Primo commit
This commit is contained in:
20
.dockerignore
Normal file
20
.dockerignore
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
__pycache__
|
||||||
|
*.pyc
|
||||||
|
*.pyo
|
||||||
|
*.pyd
|
||||||
|
.Python
|
||||||
|
env/
|
||||||
|
venv/
|
||||||
|
.venv/
|
||||||
|
pip-log.txt
|
||||||
|
pip-delete-this-directory.txt
|
||||||
|
.env
|
||||||
|
.env.example
|
||||||
|
.git/
|
||||||
|
.gitignore
|
||||||
|
data/
|
||||||
|
.vscode/
|
||||||
|
.idea/
|
||||||
|
coverage.xml
|
||||||
|
*.coverage
|
||||||
|
.pytest_cache/
|
||||||
253
CLAUDE.md
253
CLAUDE.md
@@ -1,253 +0,0 @@
|
|||||||
# CLAUDE.md - Project Intelligence
|
|
||||||
|
|
||||||
## Project Overview
|
|
||||||
|
|
||||||
**Org-Parking** is a manager-centric parking spot management system for organizations. It features fair parking assignment based on presence/parking ratio, supporting both standalone JWT authentication and Authelia/LLDAP SSO integration.
|
|
||||||
|
|
||||||
### Technology Stack
|
|
||||||
- **Backend:** FastAPI + SQLAlchemy + SQLite
|
|
||||||
- **Frontend:** Vanilla JavaScript (no frameworks)
|
|
||||||
- **Auth:** JWT tokens + Authelia SSO support
|
|
||||||
- **Containerization:** Docker + Docker Compose
|
|
||||||
- **Rate Limiting:** slowapi
|
|
||||||
|
|
||||||
### Architecture
|
|
||||||
```
|
|
||||||
app/
|
|
||||||
├── config.py → Configuration with logging and validation
|
|
||||||
└── routes/ → API endpoints (auth, users, managers, presence, parking)
|
|
||||||
services/ → Business logic (parking algorithm, auth, notifications)
|
|
||||||
database/ → SQLAlchemy models and connection
|
|
||||||
frontend/ → Static HTML pages + JS modules
|
|
||||||
utils/
|
|
||||||
├── auth_middleware.py → JWT/Authelia authentication
|
|
||||||
└── helpers.py → Shared utility functions
|
|
||||||
```
|
|
||||||
|
|
||||||
## Build & Run Commands
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Development
|
|
||||||
SECRET_KEY=dev-secret-key python -m uvicorn main:app --reload --host 0.0.0.0 --port 8000
|
|
||||||
|
|
||||||
# Docker
|
|
||||||
docker compose up -d
|
|
||||||
|
|
||||||
# Dependencies
|
|
||||||
pip install -r requirements.txt
|
|
||||||
|
|
||||||
# Initialize test database
|
|
||||||
python create_test_db.py
|
|
||||||
```
|
|
||||||
|
|
||||||
## Code Style & Conventions
|
|
||||||
|
|
||||||
### Python
|
|
||||||
- FastAPI async patterns with `Depends()` for dependency injection
|
|
||||||
- Pydantic models for request/response validation
|
|
||||||
- SQLAlchemy ORM (no raw SQL)
|
|
||||||
- Use `generate_uuid()` from `utils.helpers` for UUIDs
|
|
||||||
- Use `config.logger` for logging (not print statements)
|
|
||||||
- Dates stored as TEXT in "YYYY-MM-DD" format
|
|
||||||
|
|
||||||
### JavaScript
|
|
||||||
- ES6 modules with centralized API client (`/js/api.js`)
|
|
||||||
- Token stored in localStorage, auto-included in requests
|
|
||||||
- Utility functions in `/js/utils.js`
|
|
||||||
- Role-based navigation in `/js/nav.js`
|
|
||||||
|
|
||||||
### Authentication
|
|
||||||
- Dual mode: JWT tokens (standalone) or Authelia headers (SSO)
|
|
||||||
- LDAP users have `password_hash = None`
|
|
||||||
- Use helper: `is_ldap_user(user)` from `utils.helpers`
|
|
||||||
|
|
||||||
### Utility Functions (`utils/helpers.py`)
|
|
||||||
```python
|
|
||||||
from utils.helpers import (
|
|
||||||
generate_uuid, # Use instead of str(uuid.uuid4())
|
|
||||||
is_ldap_user, # Check if user is LDAP-managed
|
|
||||||
is_ldap_admin, # Check if user is LDAP admin
|
|
||||||
validate_password, # Returns list of validation errors
|
|
||||||
format_password_errors, # Format errors into user message
|
|
||||||
get_notification_default # Get setting value with default
|
|
||||||
)
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Configuration (`app/config.py`)
|
|
||||||
|
|
||||||
Configuration is environment-based with required validation:
|
|
||||||
|
|
||||||
### Required
|
|
||||||
- `SECRET_KEY` - **MUST** be set (app exits if missing)
|
|
||||||
|
|
||||||
### Security
|
|
||||||
- `RATE_LIMIT_REQUESTS` - Requests per window (default: 5)
|
|
||||||
- `RATE_LIMIT_WINDOW` - Window in seconds (default: 60)
|
|
||||||
|
|
||||||
### Email (org-stack pattern)
|
|
||||||
- `SMTP_ENABLED` - Set to `true` to enable SMTP sending
|
|
||||||
- When disabled, emails are logged to `EMAIL_LOG_FILE`
|
|
||||||
- Follows org-stack pattern: direct send with file fallback
|
|
||||||
|
|
||||||
### Logging
|
|
||||||
- `LOG_LEVEL` - DEBUG, INFO, WARNING, ERROR (default: INFO)
|
|
||||||
- Use `config.logger` for all logging
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Notifications (`services/notifications.py`)
|
|
||||||
|
|
||||||
Simplified notification service following org-stack pattern:
|
|
||||||
|
|
||||||
```python
|
|
||||||
from services.notifications import (
|
|
||||||
send_email, # Direct send or file fallback
|
|
||||||
notify_parking_assigned, # When spot assigned
|
|
||||||
notify_parking_released, # When spot released
|
|
||||||
notify_parking_reassigned, # When spot reassigned
|
|
||||||
send_presence_reminder, # Weekly presence reminder
|
|
||||||
send_weekly_parking_summary, # Friday parking summary
|
|
||||||
send_daily_parking_reminder, # Daily parking reminder
|
|
||||||
run_scheduled_notifications # Called by cron/scheduler
|
|
||||||
)
|
|
||||||
```
|
|
||||||
|
|
||||||
### Email Behavior
|
|
||||||
1. If `SMTP_ENABLED=true`: Send via SMTP
|
|
||||||
2. If SMTP fails or disabled: Log to `EMAIL_LOG_FILE`
|
|
||||||
3. Never throws - always returns success/failure
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Recent Improvements
|
|
||||||
|
|
||||||
### Security Enhancements
|
|
||||||
- **Required SECRET_KEY**: App exits if not set
|
|
||||||
- **Rate limiting**: Login/register endpoints limited to 5 req/min
|
|
||||||
- **Password validation**: Requires uppercase, lowercase, number, 8+ chars
|
|
||||||
- **Proper logging**: All security events logged
|
|
||||||
|
|
||||||
### Performance Optimizations
|
|
||||||
- **Fixed N+1 queries** in:
|
|
||||||
- `list_users()` - Batch query for manager names and counts
|
|
||||||
- `list_managers()` - Batch query for managed user counts
|
|
||||||
- `get_manager_guarantees()` - Batch query for user names
|
|
||||||
- `get_manager_exclusions()` - Batch query for user names
|
|
||||||
|
|
||||||
### Code Consolidation
|
|
||||||
- **Utility functions** (`utils/helpers.py`):
|
|
||||||
- `generate_uuid()` - Replaces 50+ `str(uuid.uuid4())` calls
|
|
||||||
- `is_ldap_user()` - Replaces 4+ duplicated checks
|
|
||||||
- `validate_password()` - Consistent password validation
|
|
||||||
- **Simplified notifications** - Removed queue system, direct send
|
|
||||||
|
|
||||||
### Logging Improvements
|
|
||||||
- Centralized logging via `config.logger`
|
|
||||||
- Replaced `print()` with proper logging
|
|
||||||
- Security events logged (login, password change, etc.)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## API Quick Reference
|
|
||||||
|
|
||||||
### Authentication
|
|
||||||
- `POST /api/auth/register` - Create user (rate limited)
|
|
||||||
- `POST /api/auth/login` - Get JWT token (rate limited)
|
|
||||||
- `GET /api/auth/me` - Current user (JWT or Authelia)
|
|
||||||
|
|
||||||
### Presence
|
|
||||||
- `POST /api/presence/mark` - Mark single day
|
|
||||||
- `POST /api/presence/mark-bulk` - Mark multiple days
|
|
||||||
- `GET /api/presence/team` - Team calendar with parking
|
|
||||||
|
|
||||||
### Parking
|
|
||||||
- `POST /api/parking/manual-assign` - Manager assigns spot
|
|
||||||
- `POST /api/parking/reassign-spot` - Reassign existing spot
|
|
||||||
- `GET /api/parking/eligible-users/{id}` - Users for reassignment
|
|
||||||
|
|
||||||
### Manager Settings
|
|
||||||
- `GET/POST/DELETE /api/managers/closing-days`
|
|
||||||
- `GET/POST/DELETE /api/managers/weekly-closing-days`
|
|
||||||
- `GET/POST/DELETE /api/managers/guarantees`
|
|
||||||
- `GET/POST/DELETE /api/managers/exclusions`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Development Notes
|
|
||||||
|
|
||||||
### Adding a New Route
|
|
||||||
1. Create file in `app/routes/`
|
|
||||||
2. Use `APIRouter(prefix="/api/...", tags=["..."])`
|
|
||||||
3. Register in `main.py`: `app.include_router(...)`
|
|
||||||
4. Add auth dependency: `current_user: User = Depends(get_current_user)`
|
|
||||||
5. Use `config.logger` for logging
|
|
||||||
6. Use `generate_uuid()` for new records
|
|
||||||
|
|
||||||
### Database Changes
|
|
||||||
No migration system (Alembic) configured. Schema changes require:
|
|
||||||
1. Update [database/models.py](database/models.py)
|
|
||||||
2. Delete SQLite file or write manual migration
|
|
||||||
3. Run `create_test_db.py` for fresh database
|
|
||||||
|
|
||||||
### Email Testing
|
|
||||||
With `SMTP_ENABLED=false`, check email log:
|
|
||||||
```bash
|
|
||||||
tail -f /tmp/parking-emails.log
|
|
||||||
```
|
|
||||||
|
|
||||||
### Running Scheduled Notifications
|
|
||||||
Add to cron or systemd timer:
|
|
||||||
```bash
|
|
||||||
# Every 5 minutes
|
|
||||||
*/5 * * * * cd /path/to/org-parking && python -c "
|
|
||||||
from database.connection import get_db
|
|
||||||
from services.notifications import run_scheduled_notifications
|
|
||||||
db = next(get_db())
|
|
||||||
run_scheduled_notifications(db)
|
|
||||||
"
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## File Quick Links
|
|
||||||
|
|
||||||
| Purpose | File |
|
|
||||||
|---------|------|
|
|
||||||
| Main entry | [main.py](main.py) |
|
|
||||||
| Configuration | [app/config.py](app/config.py) |
|
|
||||||
| Database models | [database/models.py](database/models.py) |
|
|
||||||
| Parking algorithm | [services/parking.py](services/parking.py) |
|
|
||||||
| Notifications | [services/notifications.py](services/notifications.py) |
|
|
||||||
| Auth middleware | [utils/auth_middleware.py](utils/auth_middleware.py) |
|
|
||||||
| Utility helpers | [utils/helpers.py](utils/helpers.py) |
|
|
||||||
| Frontend API client | [frontend/js/api.js](frontend/js/api.js) |
|
|
||||||
| CSS styles | [frontend/css/styles.css](frontend/css/styles.css) |
|
|
||||||
| Docker config | [compose.yml](compose.yml) |
|
|
||||||
| Environment template | [.env.example](.env.example) |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Deployment Notes
|
|
||||||
|
|
||||||
### Remote Server
|
|
||||||
- Host: `rocketscale.it`
|
|
||||||
- User: `rocky`
|
|
||||||
- SSH: `ssh rocky@rocketscale.it`
|
|
||||||
- Project path: `/home/rocky/org-parking`
|
|
||||||
- Related project: `/home/rocky/org-stack` (LLDAP, Authelia, etc.)
|
|
||||||
|
|
||||||
### Environment Variables
|
|
||||||
Copy `.env.example` to `.env` and configure:
|
|
||||||
```bash
|
|
||||||
# Generate secure key
|
|
||||||
openssl rand -hex 32
|
|
||||||
```
|
|
||||||
|
|
||||||
### Production Checklist
|
|
||||||
- [ ] Set strong `SECRET_KEY`
|
|
||||||
- [ ] Configure `ALLOWED_ORIGINS` (not `*`)
|
|
||||||
- [ ] Set `AUTHELIA_ENABLED=true` if using SSO
|
|
||||||
- [ ] Configure SMTP or check email log file
|
|
||||||
- [ ] Set up notification scheduler (cron/systemd)
|
|
||||||
13
Caddyfile.snippet
Normal file
13
Caddyfile.snippet
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
parking.lvh.me {
|
||||||
|
# Integrazione Authelia per autenticazione
|
||||||
|
forward_auth authelia:9091 {
|
||||||
|
uri /api/verify?rd=https://parking.lvh.me/
|
||||||
|
copy_headers Remote-User Remote-Groups Remote-Name Remote-Email
|
||||||
|
}
|
||||||
|
|
||||||
|
# Proxy inverso verso il container parking sulla porta 8000
|
||||||
|
reverse_proxy parking:8000
|
||||||
|
|
||||||
|
# Usa certificati gestiti internamente per lvh.me (locale)
|
||||||
|
tls internal
|
||||||
|
}
|
||||||
27
DEPENDENCIES.md
Normal file
27
DEPENDENCIES.md
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
# Spiegazione delle Dipendenze del Progetto
|
||||||
|
|
||||||
|
Questo documento spiega il motivo per cui ogni libreria elencata nel file `requirements.txt` è necessaria per il funzionamento dell'applicazione.
|
||||||
|
|
||||||
|
## Librerie Principali
|
||||||
|
|
||||||
|
### 1. Framework e Server Web
|
||||||
|
* **`fastapi`**: È il cuore dell'applicazione. Si tratta di un framework web moderno e veloce per costruire API con Python. Gestisce le richieste HTTP, il routing (gli URL) e la validazione dei dati.
|
||||||
|
* **`uvicorn[standard]`**: È il server ASGI necessario per eseguire l'applicazione FastAPI. FastAPI da solo non può girare; ha bisogno di un server come Uvicorn per accettare le connessioni dai client (browser, altre app). La versione `[standard]` include dipendenze extra per migliorare le prestazioni (come `uvloop`) e gestire meglio i protocolli web.
|
||||||
|
|
||||||
|
### 2. Database e Dati
|
||||||
|
* **`sqlalchemy`**: È l'ORM (Object-Relational Mapper) utilizzato per interagire con il database. Permette di scrivere codice Python per creare, leggere, aggiornare ed eliminare dati nel database invece di scrivere SQL puro.
|
||||||
|
* **`pydantic[email]`**: Usato per la validazione dei dati. Assicura che i dati inviati all'API (come i dati di registrazione utente) siano nel formato corretto. L'extra `[email]` include la libreria `email-validator` per verificare che gli indirizzi email siano validi.
|
||||||
|
* **`pydantic-settings`**: Un'estensione di Pydantic specifica per la gestione delle configurazioni. Permette di leggere le impostazioni da variabili d'ambiente in modo sicuro e tipizzato.
|
||||||
|
|
||||||
|
### 3. Autenticazione e Sicurezza
|
||||||
|
* **`python-jose[cryptography]`**: Serve per gestire i JSON Web Tokens (JWT). È fondamentale per il sistema di login, permettendo di creare token sicuri per identificare gli utenti autenticati.
|
||||||
|
* **`bcrypt`**: Una libreria per l'hashing delle password. È essenziale per la sicurezza, poiché permette di salvare le password nel database in modo criptato e non in chiaro.
|
||||||
|
* **`python-multipart`**: Necessario per supportare l'upload di form data. FastAPI lo richiede specificamente per gestire l'autenticazione OAuth2 (il login tramite form username/password).
|
||||||
|
|
||||||
|
### 4. Utilità e Configurazioni
|
||||||
|
* **`python-dotenv`**: Permette all'applicazione di leggere le variabili d'configurazione da un file `.env`. È molto utile durante lo sviluppo per gestire segreti e impostazioni locali.
|
||||||
|
* **`slowapi`**: Una libreria per implementare il "Rate Limiting". Serve a proteggere l'API da un numero eccessivo di richieste in breve tempo (ad esempio, per prevenire attacchi di forza bruta o abusi).
|
||||||
|
|
||||||
|
### 5. Dipendenze Indirette o Specifiche
|
||||||
|
* **`email-validator`**: Richiesto da Pydantic per la validazione delle email.
|
||||||
|
* **`idna`**: Supporto per nomi di dominio internazionalizzati (spesso una dipendenza di `email-validator` o librerie di rete).
|
||||||
@@ -2,6 +2,9 @@ FROM python:3.12-slim
|
|||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Prevent Python from buffering stdout and stderr
|
||||||
|
ENV PYTHONUNBUFFERED=1
|
||||||
|
|
||||||
# Install dependencies
|
# Install dependencies
|
||||||
COPY requirements.txt .
|
COPY requirements.txt .
|
||||||
RUN pip install --no-cache-dir -r requirements.txt
|
RUN pip install --no-cache-dir -r requirements.txt
|
||||||
@@ -20,4 +23,4 @@ HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
|
|||||||
CMD python -c "import urllib.request; urllib.request.urlopen('http://localhost:8000/health')" || exit 1
|
CMD python -c "import urllib.request; urllib.request.urlopen('http://localhost:8000/health')" || exit 1
|
||||||
|
|
||||||
# Run with uvicorn
|
# Run with uvicorn
|
||||||
CMD ["python", "-m", "uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]
|
CMD ["python", "-m", "uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000", "--workers", "1"]
|
||||||
|
|||||||
332
README.md
332
README.md
@@ -1,172 +1,214 @@
|
|||||||
# Parking Manager
|
# Org-Parking
|
||||||
|
|
||||||
A manager-centric parking spot management application with fair assignment algorithm.
|
Un'applicazione leggera gestionale per i parcheggi aziendali, progettata per le organizzazioni. Offre un algoritmo di assegnazione equa, tracciamento delle presenze ed è ottimizzata per basse risorse.
|
||||||
|
|
||||||
## Features
|
## Funzionalità
|
||||||
|
|
||||||
- **Manager-centric model**: Managers own parking spots, not offices
|
- **Modello Centrato sull'Ufficio**: I posti auto appartengono agli Uffici, non ai Manager o agli Utenti.
|
||||||
- **Fair assignment algorithm**: Users with lowest parking/presence ratio get priority
|
- **Algoritmo di Assegnazione Equa**: Gli utenti con il rapporto parcheggi/presenze più basso hanno la priorità.
|
||||||
- **Presence tracking**: Calendar-based presence marking (present/remote/absent)
|
- **Tracciamento Presenze**: Marcatura delle presenze basata su calendario (presente/remoto/assente).
|
||||||
- **Closing days**: Support for specific dates and weekly recurring closures
|
- **Regole di Chiusura Flessibili**: Supporto per chiusure in date specifiche e chiusure settimanali ricorrenti per ufficio.
|
||||||
- **Guarantees & exclusions**: Per-user parking rules
|
- **Garanzie ed Esclusioni**: Regole di parcheggio per utente gestite dagli amministratori dell'ufficio.
|
||||||
- **Authelia/LLDAP integration**: SSO authentication with group-based roles
|
- **Accesso Basato sui Ruoli**:
|
||||||
|
- **Admin**: Controllo completo del sistema, creazione uffici, gestione utenti.
|
||||||
|
- **Manager**: Gestisce le impostazioni del proprio ufficio e il team.
|
||||||
|
- **Impiegato**: Segna presenza, visualizza calendario, controlla stato parcheggio.
|
||||||
|
- **Basso Consumo di Memoria**: Ottimizzato per piccoli server (<500MB RAM).
|
||||||
|
- **Autenticazione**: Auth JWT integrata o integrazione SSO con Authelia.
|
||||||
|
|
||||||
## Architecture
|
## Architettura
|
||||||
|
|
||||||
```
|
```
|
||||||
├── app/
|
app/
|
||||||
│ ├── routes/ # API endpoints
|
├── routes/ # API endpoints
|
||||||
│ │ ├── auth.py # Authentication + holidays
|
│ ├── auth.py # Autenticazione
|
||||||
│ │ ├── users.py # User management
|
│ ├── users.py # Gestione utenti
|
||||||
│ │ ├── managers.py # Manager rules (closing days, guarantees)
|
│ ├── offices.py # Gestione uffici (quote, regole)
|
||||||
│ │ ├── presence.py # Presence marking
|
│ ├── presence.py # Marcatura presenze
|
||||||
│ │ └── parking.py # Parking assignments
|
│ └── parking.py # Logica di assegnazione
|
||||||
│ └── config.py # Application configuration
|
└── config.py # Configurazione
|
||||||
├── database/
|
database/
|
||||||
│ ├── models.py # SQLAlchemy ORM models
|
├── models.py # Modelli SQLAlchemy ORM
|
||||||
│ └── connection.py # Database setup
|
└── connection.py # Setup Database
|
||||||
├── services/
|
frontend/ # Frontend Vanilla JS pulito
|
||||||
│ ├── auth.py # JWT + password handling
|
├── pages/ # Viste HTML
|
||||||
│ ├── parking.py # Fair assignment algorithm
|
├── js/ # Moduli logici
|
||||||
│ ├── holidays.py # Public holiday calculation
|
└── css/ # Stili
|
||||||
│ └── notifications.py # Email notifications (TODO: scheduler)
|
|
||||||
├── frontend/
|
|
||||||
│ ├── pages/ # HTML pages
|
|
||||||
│ ├── js/ # JavaScript modules
|
|
||||||
│ └── css/ # Stylesheets
|
|
||||||
└── main.py # FastAPI application entry
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## Quick Start (Development)
|
## Guida Rapida
|
||||||
|
|
||||||
```bash
|
### Sviluppo Locale
|
||||||
# Create virtual environment
|
|
||||||
python3 -m venv .venv
|
|
||||||
source .venv/bin/activate
|
|
||||||
|
|
||||||
# Install dependencies
|
1. **Setup Ambiente**:
|
||||||
pip install -r requirements.txt
|
```bash
|
||||||
|
python3 -m venv .venv
|
||||||
|
source .venv/bin/activate
|
||||||
|
pip install -r requirements.txt
|
||||||
|
```
|
||||||
|
|
||||||
# Run development server
|
2. **Avvio Server**:
|
||||||
python main.py
|
```bash
|
||||||
|
python main.py
|
||||||
|
```
|
||||||
|
Accedi a `http://localhost:8000`
|
||||||
|
|
||||||
|
### Deployment Docker (Consigliato)
|
||||||
|
|
||||||
|
Questa applicazione è ottimizzata per ambienti con risorse limitate (es. Raspberry Pi, piccoli VPS).
|
||||||
|
|
||||||
|
1. **Build**:
|
||||||
|
```bash
|
||||||
|
docker compose build
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Run**:
|
||||||
|
```bash
|
||||||
|
docker compose up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
**Nota sull'Uso della Memoria**:
|
||||||
|
Il `Dockerfile` è configurato per eseguire `uvicorn` con `--workers 1` per minimizzare l'uso della memoria (~50MB in idle). Se in esecuzione su un server più grande, puoi rimuovere questo flag nel `Dockerfile`.
|
||||||
|
|
||||||
|
## Configurazione
|
||||||
|
|
||||||
|
Copia `.env.example` in `.env` e configura:
|
||||||
|
|
||||||
|
| Variabile | Descrizione | Default |
|
||||||
|
|-----------|-------------|---------|
|
||||||
|
| `SECRET_KEY` | **Richiesto**. Chiave cripto per JWT. | `""` (Esce se mancante) |
|
||||||
|
| `DATABASE_URL` | Vedi docs SQLAlchemy. | `sqlite:///data/parking.db` |
|
||||||
|
| `AUTHELIA_ENABLED`| Abilita supporto header SSO. | `false` |
|
||||||
|
| `SMTP_ENABLED` | Abilita notifiche email. | `false` |
|
||||||
|
| `LOG_LEVEL` | Verbosità log. | `INFO` |
|
||||||
|
|
||||||
|
## Algoritmo di Equità
|
||||||
|
|
||||||
|
I posti auto vengono assegnati giornalmente basandosi su un rapporto di equità:
|
||||||
```
|
```
|
||||||
|
Rapporto = (Giorni Parcheggiati con successo) / (Giorni di Presenza in ufficio)
|
||||||
Access at http://localhost:8000
|
|
||||||
|
|
||||||
## Docker Deployment
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Build image
|
|
||||||
docker build -t parking-manager .
|
|
||||||
|
|
||||||
# Run with environment variables
|
|
||||||
docker run -d \
|
|
||||||
-p 8000:8000 \
|
|
||||||
-v ./data:/app/data \
|
|
||||||
-e SECRET_KEY=your-secret-key \
|
|
||||||
-e AUTHELIA_ENABLED=true \
|
|
||||||
parking-manager
|
|
||||||
```
|
```
|
||||||
|
- Gli utenti **Garantiti** vengono assegnati per primi.
|
||||||
Or use Docker Compose:
|
- I posti **Rimanenti** sono distribuiti agli utenti presenti iniziando da chi ha il rapporto più **basso**.
|
||||||
|
- Gli utenti **Esclusi** non ricevono mai un posto.
|
||||||
```bash
|
|
||||||
docker compose up -d
|
|
||||||
```
|
|
||||||
|
|
||||||
## Environment Variables
|
|
||||||
|
|
||||||
| Variable | Description | Default |
|
|
||||||
|----------|-------------|---------|
|
|
||||||
| `SECRET_KEY` | JWT signing key | Random (dev only) |
|
|
||||||
| `HOST` | Bind address | `0.0.0.0` |
|
|
||||||
| `PORT` | Server port | `8000` |
|
|
||||||
| `DATABASE_URL` | SQLite path | `sqlite:///data/parking.db` |
|
|
||||||
| `AUTHELIA_ENABLED` | Enable Authelia SSO | `false` |
|
|
||||||
| `ALLOWED_ORIGINS` | CORS origins | `*` |
|
|
||||||
|
|
||||||
### SMTP (Notifications - Optional)
|
|
||||||
|
|
||||||
| Variable | Description |
|
|
||||||
|----------|-------------|
|
|
||||||
| `SMTP_HOST` | SMTP server hostname |
|
|
||||||
| `SMTP_PORT` | SMTP port (default: 587) |
|
|
||||||
| `SMTP_USER` | SMTP username |
|
|
||||||
| `SMTP_PASSWORD` | SMTP password |
|
|
||||||
| `SMTP_FROM` | From email address |
|
|
||||||
|
|
||||||
## Authentication
|
|
||||||
|
|
||||||
### Standalone Mode
|
|
||||||
Built-in JWT authentication with bcrypt password hashing. Users register/login via `/login` and `/register`.
|
|
||||||
|
|
||||||
### Authelia Mode
|
|
||||||
When `AUTHELIA_ENABLED=true`, the app trusts Authelia headers:
|
|
||||||
- `Remote-User`: User email/username
|
|
||||||
- `Remote-Name`: Display name
|
|
||||||
- `Remote-Groups`: Comma-separated group list
|
|
||||||
|
|
||||||
Group mapping (follows lldap naming convention):
|
|
||||||
- `parking_admins` → admin role
|
|
||||||
- `managers` → manager role
|
|
||||||
- Others → employee role
|
|
||||||
|
|
||||||
## User Roles
|
|
||||||
|
|
||||||
| Role | Permissions |
|
|
||||||
|------|-------------|
|
|
||||||
| **admin** | Full access, manage users and managers |
|
|
||||||
| **manager** | Manage their team, set parking rules |
|
|
||||||
| **employee** | Mark own presence, view calendar |
|
|
||||||
|
|
||||||
## API Endpoints
|
## API Endpoints
|
||||||
|
|
||||||
### Authentication
|
Di seguito la lista delle chiamate API disponibili suddivise per modulo.
|
||||||
- `POST /api/auth/login` - Login
|
|
||||||
- `POST /api/auth/register` - Register (standalone mode)
|
|
||||||
- `POST /api/auth/logout` - Logout
|
|
||||||
- `GET /api/auth/me` - Current user info
|
|
||||||
- `GET /api/auth/holidays/{year}` - Public holidays
|
|
||||||
|
|
||||||
### Users
|
### Auth (`/api/auth`)
|
||||||
- `GET /api/users` - List users (admin)
|
Gestione autenticazione e sessione.
|
||||||
- `POST /api/users` - Create user (admin)
|
|
||||||
- `PUT /api/users/{id}` - Update user (admin)
|
|
||||||
- `DELETE /api/users/{id}` - Delete user (admin)
|
|
||||||
- `GET /api/users/me/profile` - Own profile
|
|
||||||
- `PUT /api/users/me/settings` - Own settings
|
|
||||||
|
|
||||||
### Managers
|
- `POST /register`: Registrazione nuovo utente (Disabilitato se Authelia è attivo).
|
||||||
- `GET /api/managers` - List managers
|
- `POST /login`: Login con email e password (ritorna token JWT/cookie).
|
||||||
- `GET /api/managers/{id}` - Manager details
|
- `POST /logout`: Logout e invalidazione sessione.
|
||||||
- `PUT /api/managers/{id}/settings` - Update parking quota (admin)
|
- `GET /me`: Ritorna informazioni sull'utente corrente.
|
||||||
- `GET/POST/DELETE /api/managers/{id}/closing-days` - Specific closures
|
- `GET /config`: Ritorna la configurazione pubblica di autenticazione.
|
||||||
- `GET/POST/DELETE /api/managers/{id}/weekly-closing-days` - Recurring closures
|
- `GET /holidays/{year}`: Ritorna i giorni festivi per l'anno specificato.
|
||||||
- `GET/POST/DELETE /api/managers/{id}/guarantees` - Parking guarantees
|
|
||||||
- `GET/POST/DELETE /api/managers/{id}/exclusions` - Parking exclusions
|
|
||||||
|
|
||||||
### Presence
|
### Users (`/api/users`)
|
||||||
- `POST /api/presence/mark` - Mark presence
|
Gestione utenti e profili.
|
||||||
- `POST /api/presence/mark-bulk` - Bulk mark
|
|
||||||
- `GET /api/presence/my-presences` - Own presences
|
|
||||||
- `GET /api/presence/team` - Team calendar (manager/admin)
|
|
||||||
|
|
||||||
### Parking
|
- `GET /`: Lista di tutti gli utenti (Solo Admin).
|
||||||
- `GET /api/parking/assignments/{date}` - Day's assignments
|
- `POST /`: Crea un nuovo utente (Solo Admin).
|
||||||
- `GET /api/parking/my-assignments` - Own assignments
|
- `GET /{user_id}`: Dettaglio di un utente specifico (Solo Admin).
|
||||||
- `POST /api/parking/manual-assign` - Manual assignment
|
- `PUT /{user_id}`: Aggiorna dati di un utente (Solo Admin).
|
||||||
- `POST /api/parking/reassign-spot` - Reassign spot
|
- `DELETE /{user_id}`: Elimina un utente (Solo Admin).
|
||||||
|
- `GET /me/profile`: Ottieni il proprio profilo.
|
||||||
|
- `PUT /me/profile`: Aggiorna il proprio profilo.
|
||||||
|
- `GET /me/settings`: Ottieni le proprie impostazioni.
|
||||||
|
- `PUT /me/settings`: Aggiorna le proprie impostazioni.
|
||||||
|
- `POST /me/change-password`: Modifica la propria password.
|
||||||
|
|
||||||
## Fairness Algorithm
|
### Offices (`/api/offices`)
|
||||||
|
Gestione uffici, regole di chiusura e quote.
|
||||||
|
|
||||||
Parking spots are assigned based on a fairness ratio:
|
- `GET /`: Lista di tutti gli uffici.
|
||||||
|
- `POST /`: Crea un nuovo ufficio (Solo Admin).
|
||||||
|
- `GET /{office_id}`: Dettagli di un ufficio.
|
||||||
|
- `PUT /{office_id}`: Aggiorna configurazione ufficio (Solo Admin).
|
||||||
|
- `DELETE /{office_id}`: Elimina un ufficio (Solo Admin).
|
||||||
|
- `GET /{office_id}/users`: Lista utenti assegnati all'ufficio.
|
||||||
|
- `GET /{office_id}/closing-days`: Lista giorni di chiusura specifici.
|
||||||
|
- `POST /{office_id}/closing-days`: Aggiungi giorno di chiusura.
|
||||||
|
- `DELETE /{office_id}/closing-days/{id}`: Rimuovi giorno di chiusura.
|
||||||
|
- `GET /{office_id}/weekly-closing-days`: Lista giorni di chiusura settimanali (es. Sabato/Domenica).
|
||||||
|
- `POST /{office_id}/weekly-closing-days`: Aggiungi giorno di chiusura settimanale.
|
||||||
|
- `DELETE /{office_id}/weekly-closing-days/{id}`: Rimuovi giorno di chiusura settimanale.
|
||||||
|
- `GET /{office_id}/guarantees`: Lista utenti con posto garantito.
|
||||||
|
- `POST /{office_id}/guarantees`: Aggiungi garanzia posto.
|
||||||
|
- `DELETE /{office_id}/guarantees/{id}`: Rimuovi garanzia.
|
||||||
|
- `GET /{office_id}/exclusions`: Lista utenti esclusi dal parcheggio.
|
||||||
|
- `POST /{office_id}/exclusions`: Aggiungi esclusione.
|
||||||
|
- `DELETE /{office_id}/exclusions/{id}`: Rimuovi esclusione.
|
||||||
|
|
||||||
```
|
### Presence (`/api/presence`)
|
||||||
ratio = parking_days / presence_days
|
Gestione presenze giornaliere.
|
||||||
```
|
|
||||||
|
|
||||||
Users with the lowest ratio get priority. Guaranteed users are always assigned first.
|
- `POST /mark`: Segna la propria presenza per una data (Presente/Remoto/Assente).
|
||||||
|
- `GET /my-presences`: Lista delle proprie presenze.
|
||||||
|
- `DELETE /{date}`: Rimuovi la propria presenza per una data.
|
||||||
|
- `POST /admin/mark`: Segna presenza per un altro utente (Manager/Admin).
|
||||||
|
- `DELETE /admin/{user_id}/{date}`: Rimuovi presenza di un altro utente (Manager/Admin).
|
||||||
|
- `GET /team`: Visualizza presenze e stato parcheggio del team.
|
||||||
|
- `GET /admin/{user_id}`: Storico presenze di un utente.
|
||||||
|
|
||||||
## License
|
### Parking (`/api/parking`)
|
||||||
|
Gestione assegnazioni posti auto.
|
||||||
|
|
||||||
|
- `POST /init-office-pool`: Inizializza i posti disponibili per un giorno.
|
||||||
|
- `GET /assignments/{date}`: Lista assegnazioni per una data.
|
||||||
|
- `GET /my-assignments`: Le mie assegnazioni parcheggio.
|
||||||
|
- `POST /run-allocation`: Esegui manualmente l'algoritmo di assegnazione per una data.
|
||||||
|
- `POST /clear-assignments`: Cancella tutte le assegnazioni per una data.
|
||||||
|
- `POST /manual-assign`: Assegna manualmente un posto a un utente.
|
||||||
|
- `POST /reassign-spot`: Riassegna o libera un posto già assegnato.
|
||||||
|
- `POST /release-my-spot/{id}`: Rilascia il proprio posto assegnato.
|
||||||
|
- `GET /eligible-users/{id}`: Lista utenti idonei a ricevere un posto riassegnato.
|
||||||
|
|
||||||
|
## Utilizzo con AUTHELIA
|
||||||
|
|
||||||
|
Org-Parking supporta l'integrazione nativa con **Authelia** (o altri provider SSO compatibili con Forward Auth). Questo permette il Single Sign-On (SSO) e la gestione centralizzata degli utenti.
|
||||||
|
|
||||||
|
### Configurazione
|
||||||
|
|
||||||
|
1. **Abilita Authelia**:
|
||||||
|
Nel file `.env`, imposta `AUTHELIA_ENABLED=true`.
|
||||||
|
|
||||||
|
2. **Configura gli Header del Proxy**:
|
||||||
|
Assicurati che il tuo reverse proxy (es. Traefik, Nginx) passi i seguenti header all'applicazione dopo l'autenticazione:
|
||||||
|
* `Remote-User`: Username dell'utente (spesso uguale all'email).
|
||||||
|
* `Remote-Email`: Email dell'utente.
|
||||||
|
* `Remote-Name`: Nome completo dell'utente (Opzionale).
|
||||||
|
* `Remote-Groups`: Gruppi di appartenenza (separati da virgola).
|
||||||
|
|
||||||
|
3. **Gestione Admin**:
|
||||||
|
L'applicazione assegna automaticamente il ruolo di **Admin** agli utenti che appartengono al gruppo specificato nella variabile `AUTHELIA_ADMIN_GROUP` (default: `parking_admins`).
|
||||||
|
* Se un utente viene rimosso da questo gruppo su Authelia, perderà i privilegi di admin al login successivo.
|
||||||
|
* Gli altri ruoli (Manager, Employee) e l'assegnazione agli uffici vengono gestiti manualmente all'interno di Org-Parking da un amministratore.
|
||||||
|
|
||||||
|
### Comportamento
|
||||||
|
|
||||||
|
* **Creazione Utenti**: Gli utenti vengono creati automaticamente nel database di Org-Parking al loro primo accesso riuscito tramite Authelia.
|
||||||
|
* **Login/Logout**: Le pagine di login e registrazione interne vengono disabilitate o reindirizzano al portale SSO.
|
||||||
|
* **Password**: Org-Parking non gestisce le password in questa modalità; l'autenticazione è interamente delegata al provider esterno.
|
||||||
|
* **Sicurezza**: L'applicazione si fida degli header `Remote-*` solo se `AUTHELIA_ENABLED` è attivo. Assicurarsi che il container non sia esposto direttamente a internet senza passare per il proxy di autenticazione.
|
||||||
|
|
||||||
|
## Note di Deployment
|
||||||
|
|
||||||
|
- **Database**: SQLite è usato di default per semplicità e basso overhead. I dati persistono in `./data/parking.db`.
|
||||||
|
- **Sicurezza**:
|
||||||
|
- Rate limiting è attivo sugli endpoint sensibili (Login/Register).
|
||||||
|
- Le password sono hashate con Bcrypt.
|
||||||
|
- L'autenticazione via cookie è sicura di default.
|
||||||
|
|
||||||
|
### Risoluzione Problemi Comuni
|
||||||
|
|
||||||
|
**Errore: "Il reindirizzamento è stato determinato per essere non sicuro"**
|
||||||
|
|
||||||
|
Questo errore appare se si tenta di accedere all'applicazione tramite HTTP (es. `http://lvh.me:8000`) mentre Authelia è su HTTPS. Authelia blocca i reindirizzamenti verso protocolli non sicuri.
|
||||||
|
**Soluzione**: Accedere all'applicazione tramite il Reverse Proxy (Caddy) usando HTTPS, ad esempio `https://parking.lvh.me`. Assicurarsi che Caddy sia configurato per gestire il dominio e l'autenticazione.
|
||||||
|
|
||||||
|
## Licenza
|
||||||
|
|
||||||
MIT
|
MIT
|
||||||
|
|||||||
@@ -7,6 +7,10 @@ import sys
|
|||||||
import logging
|
import logging
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
|
# Paths
|
||||||
|
BASE_DIR = Path(__file__).resolve().parent.parent
|
||||||
|
FRONTEND_DIR = BASE_DIR / "frontend"
|
||||||
|
|
||||||
# Configure logging
|
# Configure logging
|
||||||
LOG_LEVEL = os.getenv("LOG_LEVEL", "INFO").upper()
|
LOG_LEVEL = os.getenv("LOG_LEVEL", "INFO").upper()
|
||||||
logging.basicConfig(
|
logging.basicConfig(
|
||||||
@@ -17,6 +21,19 @@ logger = logging.getLogger("org-parking")
|
|||||||
|
|
||||||
# Database
|
# Database
|
||||||
DATABASE_PATH = os.getenv("DATABASE_PATH", "parking.db")
|
DATABASE_PATH = os.getenv("DATABASE_PATH", "parking.db")
|
||||||
|
|
||||||
|
# Fix for local execution: if path is absolute (docker) but dir doesn't exist, fallback to local data/
|
||||||
|
if os.path.isabs(DATABASE_PATH) and not os.path.exists(os.path.dirname(DATABASE_PATH)):
|
||||||
|
# Check if we are aiming for /app/data but running locally
|
||||||
|
if str(DATABASE_PATH).startswith("/app/") or not os.access(os.path.dirname(DATABASE_PATH), os.W_OK):
|
||||||
|
logger.warning(f"Configured DATABASE_PATH '{DATABASE_PATH}' folder not found/writable. Switching to local 'data' directory.")
|
||||||
|
|
||||||
|
local_data_dir = BASE_DIR / "data"
|
||||||
|
local_data_dir.mkdir(exist_ok=True)
|
||||||
|
|
||||||
|
DATABASE_PATH = str(local_data_dir / os.path.basename(DATABASE_PATH))
|
||||||
|
logger.info(f"Using local database path: {DATABASE_PATH}")
|
||||||
|
|
||||||
DATABASE_URL = os.getenv("DATABASE_URL", f"sqlite:///{DATABASE_PATH}")
|
DATABASE_URL = os.getenv("DATABASE_URL", f"sqlite:///{DATABASE_PATH}")
|
||||||
|
|
||||||
# JWT Authentication
|
# JWT Authentication
|
||||||
@@ -35,7 +52,7 @@ HOST = os.getenv("HOST", "0.0.0.0")
|
|||||||
PORT = int(os.getenv("PORT", "8000"))
|
PORT = int(os.getenv("PORT", "8000"))
|
||||||
|
|
||||||
# CORS
|
# CORS
|
||||||
ALLOWED_ORIGINS = os.getenv("ALLOWED_ORIGINS", "http://localhost:8000,http://127.0.0.1:8000").split(",")
|
ALLOWED_ORIGINS = os.getenv("ALLOWED_ORIGINS", "http://localhost:8000,http://127.0.0.1:8000,http://lvh.me").split(",")
|
||||||
|
|
||||||
# Authelia Integration
|
# Authelia Integration
|
||||||
AUTHELIA_ENABLED = os.getenv("AUTHELIA_ENABLED", "false").lower() == "true"
|
AUTHELIA_ENABLED = os.getenv("AUTHELIA_ENABLED", "false").lower() == "true"
|
||||||
@@ -67,6 +84,4 @@ EMAIL_LOG_FILE = os.getenv("EMAIL_LOG_FILE", "/tmp/parking-emails.log")
|
|||||||
RATE_LIMIT_REQUESTS = int(os.getenv("RATE_LIMIT_REQUESTS", "5")) # requests per window
|
RATE_LIMIT_REQUESTS = int(os.getenv("RATE_LIMIT_REQUESTS", "5")) # requests per window
|
||||||
RATE_LIMIT_WINDOW = int(os.getenv("RATE_LIMIT_WINDOW", "60")) # seconds
|
RATE_LIMIT_WINDOW = int(os.getenv("RATE_LIMIT_WINDOW", "60")) # seconds
|
||||||
|
|
||||||
# Paths
|
|
||||||
BASE_DIR = Path(__file__).resolve().parent.parent
|
|
||||||
FRONTEND_DIR = BASE_DIR / "frontend"
|
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ from slowapi import Limiter
|
|||||||
from slowapi.util import get_remote_address
|
from slowapi.util import get_remote_address
|
||||||
|
|
||||||
from database.connection import get_db
|
from database.connection import get_db
|
||||||
|
from database.models import UserRole
|
||||||
from services.auth import (
|
from services.auth import (
|
||||||
create_user, authenticate_user, create_access_token,
|
create_user, authenticate_user, create_access_token,
|
||||||
get_user_by_email
|
get_user_by_email
|
||||||
@@ -25,7 +26,6 @@ class RegisterRequest(BaseModel):
|
|||||||
email: EmailStr
|
email: EmailStr
|
||||||
password: str
|
password: str
|
||||||
name: str
|
name: str
|
||||||
manager_id: str | None = None
|
|
||||||
|
|
||||||
|
|
||||||
class LoginRequest(BaseModel):
|
class LoginRequest(BaseModel):
|
||||||
@@ -42,16 +42,16 @@ class UserResponse(BaseModel):
|
|||||||
id: str
|
id: str
|
||||||
email: str
|
email: str
|
||||||
name: str | None
|
name: str | None
|
||||||
manager_id: str | None
|
office_id: str | None
|
||||||
role: str
|
office_name: str | None = None
|
||||||
manager_parking_quota: int | None = None
|
role: UserRole
|
||||||
week_start_day: int = 0
|
week_start_day: int = 0
|
||||||
# Notification preferences
|
# Notification preferences
|
||||||
notify_weekly_parking: int = 1
|
notify_weekly_parking: bool = True
|
||||||
notify_daily_parking: int = 1
|
notify_daily_parking: bool = True
|
||||||
notify_daily_parking_hour: int = 8
|
notify_daily_parking_hour: int = 8
|
||||||
notify_daily_parking_minute: int = 0
|
notify_daily_parking_minute: int = 0
|
||||||
notify_parking_changes: int = 1
|
notify_parking_changes: bool = True
|
||||||
|
|
||||||
|
|
||||||
@router.post("/register", response_model=TokenResponse)
|
@router.post("/register", response_model=TokenResponse)
|
||||||
@@ -76,8 +76,7 @@ def register(request: Request, data: RegisterRequest, db: Session = Depends(get_
|
|||||||
db=db,
|
db=db,
|
||||||
email=data.email,
|
email=data.email,
|
||||||
password=data.password,
|
password=data.password,
|
||||||
name=data.name,
|
name=data.name
|
||||||
manager_id=data.manager_id
|
|
||||||
)
|
)
|
||||||
|
|
||||||
config.logger.info(f"New user registered: {data.email}")
|
config.logger.info(f"New user registered: {data.email}")
|
||||||
@@ -126,15 +125,15 @@ def get_me(user=Depends(get_current_user)):
|
|||||||
id=user.id,
|
id=user.id,
|
||||||
email=user.email,
|
email=user.email,
|
||||||
name=user.name,
|
name=user.name,
|
||||||
manager_id=user.manager_id,
|
office_id=user.office_id,
|
||||||
|
office_name=user.office.name if user.office else None,
|
||||||
role=user.role,
|
role=user.role,
|
||||||
manager_parking_quota=user.manager_parking_quota,
|
|
||||||
week_start_day=get_notification_default(user.week_start_day, 0),
|
week_start_day=get_notification_default(user.week_start_day, 0),
|
||||||
notify_weekly_parking=get_notification_default(user.notify_weekly_parking, 1),
|
notify_weekly_parking=get_notification_default(user.notify_weekly_parking, True),
|
||||||
notify_daily_parking=get_notification_default(user.notify_daily_parking, 1),
|
notify_daily_parking=get_notification_default(user.notify_daily_parking, True),
|
||||||
notify_daily_parking_hour=get_notification_default(user.notify_daily_parking_hour, 8),
|
notify_daily_parking_hour=get_notification_default(user.notify_daily_parking_hour, 8),
|
||||||
notify_daily_parking_minute=get_notification_default(user.notify_daily_parking_minute, 0),
|
notify_daily_parking_minute=get_notification_default(user.notify_daily_parking_minute, 0),
|
||||||
notify_parking_changes=get_notification_default(user.notify_parking_changes, 1)
|
notify_parking_changes=get_notification_default(user.notify_parking_changes, True)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ Manager settings, closing days, guarantees, and exclusions
|
|||||||
Key concept: Managers own parking spots and set rules for their managed users.
|
Key concept: Managers own parking spots and set rules for their managed users.
|
||||||
Rules are set at manager level (users have manager_id pointing to their manager).
|
Rules are set at manager level (users have manager_id pointing to their manager).
|
||||||
"""
|
"""
|
||||||
from datetime import datetime
|
from datetime import datetime, date
|
||||||
from fastapi import APIRouter, Depends, HTTPException
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
@@ -26,7 +26,8 @@ router = APIRouter(prefix="/api/managers", tags=["managers"])
|
|||||||
|
|
||||||
# Request/Response Models
|
# Request/Response Models
|
||||||
class ClosingDayCreate(BaseModel):
|
class ClosingDayCreate(BaseModel):
|
||||||
date: str # YYYY-MM-DD
|
date: date # Start date
|
||||||
|
end_date: date | None = None # Optional end date (inclusive)
|
||||||
reason: str | None = None
|
reason: str | None = None
|
||||||
|
|
||||||
|
|
||||||
@@ -36,14 +37,16 @@ class WeeklyClosingDayCreate(BaseModel):
|
|||||||
|
|
||||||
class GuaranteeCreate(BaseModel):
|
class GuaranteeCreate(BaseModel):
|
||||||
user_id: str
|
user_id: str
|
||||||
start_date: str | None = None
|
start_date: date | None = None
|
||||||
end_date: str | None = None
|
end_date: date | None = None
|
||||||
|
notes: str | None = None
|
||||||
|
|
||||||
|
|
||||||
class ExclusionCreate(BaseModel):
|
class ExclusionCreate(BaseModel):
|
||||||
user_id: str
|
user_id: str
|
||||||
start_date: str | None = None
|
start_date: date | None = None
|
||||||
end_date: str | None = None
|
end_date: date | None = None
|
||||||
|
notes: str | None = None
|
||||||
|
|
||||||
|
|
||||||
class ManagerSettingsUpdate(BaseModel):
|
class ManagerSettingsUpdate(BaseModel):
|
||||||
@@ -124,7 +127,7 @@ def update_manager_settings(manager_id: str, data: ManagerSettingsUpdate, db: Se
|
|||||||
raise HTTPException(status_code=400, detail=f"Spot prefix '{data.spot_prefix}' is already used")
|
raise HTTPException(status_code=400, detail=f"Spot prefix '{data.spot_prefix}' is already used")
|
||||||
manager.manager_spot_prefix = data.spot_prefix
|
manager.manager_spot_prefix = data.spot_prefix
|
||||||
|
|
||||||
manager.updated_at = datetime.utcnow().isoformat()
|
manager.updated_at = datetime.utcnow()
|
||||||
db.commit()
|
db.commit()
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -155,7 +158,7 @@ def get_manager_closing_days(manager_id: str, db: Session = Depends(get_db), use
|
|||||||
days = db.query(ManagerClosingDay).filter(
|
days = db.query(ManagerClosingDay).filter(
|
||||||
ManagerClosingDay.manager_id == manager_id
|
ManagerClosingDay.manager_id == manager_id
|
||||||
).order_by(ManagerClosingDay.date).all()
|
).order_by(ManagerClosingDay.date).all()
|
||||||
return [{"id": d.id, "date": d.date, "reason": d.reason} for d in days]
|
return [{"id": d.id, "date": d.date, "end_date": d.end_date, "reason": d.reason} for d in days]
|
||||||
|
|
||||||
|
|
||||||
@router.post("/{manager_id}/closing-days")
|
@router.post("/{manager_id}/closing-days")
|
||||||
@@ -172,10 +175,14 @@ def add_manager_closing_day(manager_id: str, data: ClosingDayCreate, db: Session
|
|||||||
if existing:
|
if existing:
|
||||||
raise HTTPException(status_code=400, detail="Closing day already exists for this date")
|
raise HTTPException(status_code=400, detail="Closing day already exists for this date")
|
||||||
|
|
||||||
|
if data.end_date and data.end_date < data.date:
|
||||||
|
raise HTTPException(status_code=400, detail="End date must be after start date")
|
||||||
|
|
||||||
closing_day = ManagerClosingDay(
|
closing_day = ManagerClosingDay(
|
||||||
id=generate_uuid(),
|
id=generate_uuid(),
|
||||||
manager_id=manager_id,
|
manager_id=manager_id,
|
||||||
date=data.date,
|
date=data.date,
|
||||||
|
end_date=data.end_date,
|
||||||
reason=data.reason
|
reason=data.reason
|
||||||
)
|
)
|
||||||
db.add(closing_day)
|
db.add(closing_day)
|
||||||
@@ -270,7 +277,8 @@ def get_manager_guarantees(manager_id: str, db: Session = Depends(get_db), user=
|
|||||||
"user_id": g.user_id,
|
"user_id": g.user_id,
|
||||||
"user_name": user_lookup.get(g.user_id),
|
"user_name": user_lookup.get(g.user_id),
|
||||||
"start_date": g.start_date,
|
"start_date": g.start_date,
|
||||||
"end_date": g.end_date
|
"end_date": g.end_date,
|
||||||
|
"notes": g.notes
|
||||||
}
|
}
|
||||||
for g in guarantees
|
for g in guarantees
|
||||||
]
|
]
|
||||||
@@ -292,13 +300,17 @@ def add_manager_guarantee(manager_id: str, data: GuaranteeCreate, db: Session =
|
|||||||
if existing:
|
if existing:
|
||||||
raise HTTPException(status_code=400, detail="User already has a parking guarantee")
|
raise HTTPException(status_code=400, detail="User already has a parking guarantee")
|
||||||
|
|
||||||
|
if data.start_date and data.end_date and data.end_date < data.start_date:
|
||||||
|
raise HTTPException(status_code=400, detail="End date must be after start date")
|
||||||
|
|
||||||
guarantee = ParkingGuarantee(
|
guarantee = ParkingGuarantee(
|
||||||
id=generate_uuid(),
|
id=generate_uuid(),
|
||||||
manager_id=manager_id,
|
manager_id=manager_id,
|
||||||
user_id=data.user_id,
|
user_id=data.user_id,
|
||||||
start_date=data.start_date,
|
start_date=data.start_date,
|
||||||
end_date=data.end_date,
|
end_date=data.end_date,
|
||||||
created_at=datetime.utcnow().isoformat()
|
notes=data.notes,
|
||||||
|
created_at=datetime.utcnow()
|
||||||
)
|
)
|
||||||
db.add(guarantee)
|
db.add(guarantee)
|
||||||
db.commit()
|
db.commit()
|
||||||
@@ -340,7 +352,8 @@ def get_manager_exclusions(manager_id: str, db: Session = Depends(get_db), user=
|
|||||||
"user_id": e.user_id,
|
"user_id": e.user_id,
|
||||||
"user_name": user_lookup.get(e.user_id),
|
"user_name": user_lookup.get(e.user_id),
|
||||||
"start_date": e.start_date,
|
"start_date": e.start_date,
|
||||||
"end_date": e.end_date
|
"end_date": e.end_date,
|
||||||
|
"notes": e.notes
|
||||||
}
|
}
|
||||||
for e in exclusions
|
for e in exclusions
|
||||||
]
|
]
|
||||||
@@ -362,13 +375,17 @@ def add_manager_exclusion(manager_id: str, data: ExclusionCreate, db: Session =
|
|||||||
if existing:
|
if existing:
|
||||||
raise HTTPException(status_code=400, detail="User already has a parking exclusion")
|
raise HTTPException(status_code=400, detail="User already has a parking exclusion")
|
||||||
|
|
||||||
|
if data.start_date and data.end_date and data.end_date < data.start_date:
|
||||||
|
raise HTTPException(status_code=400, detail="End date must be after start date")
|
||||||
|
|
||||||
exclusion = ParkingExclusion(
|
exclusion = ParkingExclusion(
|
||||||
id=generate_uuid(),
|
id=generate_uuid(),
|
||||||
manager_id=manager_id,
|
manager_id=manager_id,
|
||||||
user_id=data.user_id,
|
user_id=data.user_id,
|
||||||
start_date=data.start_date,
|
start_date=data.start_date,
|
||||||
end_date=data.end_date,
|
end_date=data.end_date,
|
||||||
created_at=datetime.utcnow().isoformat()
|
notes=data.notes,
|
||||||
|
created_at=datetime.utcnow()
|
||||||
)
|
)
|
||||||
db.add(exclusion)
|
db.add(exclusion)
|
||||||
db.commit()
|
db.commit()
|
||||||
|
|||||||
500
app/routes/offices.py
Normal file
500
app/routes/offices.py
Normal file
@@ -0,0 +1,500 @@
|
|||||||
|
"""
|
||||||
|
Office Management Routes
|
||||||
|
Office settings, closing days, guarantees, and exclusions
|
||||||
|
"""
|
||||||
|
from datetime import datetime, date
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
|
from pydantic import BaseModel
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
from sqlalchemy import func
|
||||||
|
|
||||||
|
from database.connection import get_db
|
||||||
|
from database.models import (
|
||||||
|
User, Office,
|
||||||
|
OfficeClosingDay, OfficeWeeklyClosingDay,
|
||||||
|
ParkingGuarantee, ParkingExclusion,
|
||||||
|
UserRole
|
||||||
|
)
|
||||||
|
from utils.auth_middleware import require_admin, require_manager_or_admin, get_current_user
|
||||||
|
from utils.helpers import generate_uuid
|
||||||
|
from app import config
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api/offices", tags=["offices"])
|
||||||
|
|
||||||
|
|
||||||
|
# Request/Response Models
|
||||||
|
class ValidOfficeCreate(BaseModel):
|
||||||
|
name: str
|
||||||
|
parking_quota: int = 0
|
||||||
|
|
||||||
|
|
||||||
|
class ClosingDayCreate(BaseModel):
|
||||||
|
date: date # Start date
|
||||||
|
end_date: date | None = None # Optional end date (inclusive)
|
||||||
|
reason: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class WeeklyClosingDayCreate(BaseModel):
|
||||||
|
weekday: int # 0=Sunday, 1=Monday, ..., 6=Saturday
|
||||||
|
|
||||||
|
|
||||||
|
class GuaranteeCreate(BaseModel):
|
||||||
|
user_id: str
|
||||||
|
start_date: date | None = None
|
||||||
|
end_date: date | None = None
|
||||||
|
notes: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class ExclusionCreate(BaseModel):
|
||||||
|
user_id: str
|
||||||
|
start_date: date | None = None
|
||||||
|
end_date: date | None = None
|
||||||
|
notes: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class OfficeSettingsUpdate(BaseModel):
|
||||||
|
parking_quota: int | None = None
|
||||||
|
name: str | None = None
|
||||||
|
booking_window_enabled: bool | None = None
|
||||||
|
booking_window_end_hour: int | None = None
|
||||||
|
booking_window_end_minute: int | None = None
|
||||||
|
|
||||||
|
|
||||||
|
# Helper check
|
||||||
|
def check_office_access(user: User, office_id: str):
|
||||||
|
if user.role == UserRole.ADMIN:
|
||||||
|
return True
|
||||||
|
if user.role == UserRole.MANAGER and user.office_id == office_id:
|
||||||
|
return True
|
||||||
|
raise HTTPException(status_code=403, detail="Access denied to this office")
|
||||||
|
|
||||||
|
|
||||||
|
# Office listing and details
|
||||||
|
@router.get("")
|
||||||
|
def list_offices(db: Session = Depends(get_db), user=Depends(require_manager_or_admin)):
|
||||||
|
"""Get all offices with their user count and parking quota"""
|
||||||
|
offices = db.query(Office).all()
|
||||||
|
|
||||||
|
# Batch query user counts
|
||||||
|
counts = db.query(User.office_id, func.count(User.id)).filter(
|
||||||
|
User.office_id.isnot(None)
|
||||||
|
).group_by(User.office_id).all()
|
||||||
|
user_counts = {office_id: count for office_id, count in counts}
|
||||||
|
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"id": office.id,
|
||||||
|
"name": office.name,
|
||||||
|
"parking_quota": office.parking_quota,
|
||||||
|
"spot_prefix": office.spot_prefix,
|
||||||
|
"user_count": user_counts.get(office.id, 0)
|
||||||
|
}
|
||||||
|
for office in offices
|
||||||
|
]
|
||||||
|
|
||||||
|
def get_next_available_prefix(db: Session) -> str:
|
||||||
|
"""Find the next available office prefix (A, B, C... AA, AB...)"""
|
||||||
|
existing = db.query(Office.spot_prefix).filter(Office.spot_prefix.isnot(None)).all()
|
||||||
|
used_prefixes = {row[0] for row in existing}
|
||||||
|
|
||||||
|
# Try single letters A-Z
|
||||||
|
for i in range(26):
|
||||||
|
char = chr(65 + i)
|
||||||
|
if char not in used_prefixes:
|
||||||
|
return char
|
||||||
|
|
||||||
|
# Try double letters AA-ZZ if needed
|
||||||
|
for i in range(26):
|
||||||
|
for j in range(26):
|
||||||
|
char = chr(65 + i) + chr(65 + j)
|
||||||
|
if char not in used_prefixes:
|
||||||
|
return char
|
||||||
|
|
||||||
|
raise HTTPException(status_code=400, detail="No more office prefixes available")
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("")
|
||||||
|
def create_office(data: ValidOfficeCreate, db: Session = Depends(get_db), user=Depends(require_admin)):
|
||||||
|
"""Create a new office (admin only)"""
|
||||||
|
office = Office(
|
||||||
|
id=generate_uuid(),
|
||||||
|
name=data.name,
|
||||||
|
parking_quota=data.parking_quota,
|
||||||
|
spot_prefix=get_next_available_prefix(db),
|
||||||
|
created_at=datetime.utcnow()
|
||||||
|
)
|
||||||
|
db.add(office)
|
||||||
|
db.commit()
|
||||||
|
return office
|
||||||
|
|
||||||
|
@router.get("/{office_id}")
|
||||||
|
def get_office_details(office_id: str, db: Session = Depends(get_db), user=Depends(require_manager_or_admin)):
|
||||||
|
"""Get office details"""
|
||||||
|
office = db.query(Office).filter(Office.id == office_id).first()
|
||||||
|
if not office:
|
||||||
|
raise HTTPException(status_code=404, detail="Office not found")
|
||||||
|
|
||||||
|
check_office_access(user, office_id)
|
||||||
|
|
||||||
|
user_count = db.query(User).filter(User.office_id == office_id).count()
|
||||||
|
|
||||||
|
return {
|
||||||
|
"id": office.id,
|
||||||
|
"name": office.name,
|
||||||
|
"parking_quota": office.parking_quota,
|
||||||
|
"spot_prefix": office.spot_prefix,
|
||||||
|
"user_count": user_count,
|
||||||
|
"booking_window_enabled": office.booking_window_enabled,
|
||||||
|
"booking_window_end_hour": office.booking_window_end_hour,
|
||||||
|
"booking_window_end_minute": office.booking_window_end_minute
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/{office_id}")
|
||||||
|
def update_office_settings(office_id: str, data: OfficeSettingsUpdate, db: Session = Depends(get_db), user=Depends(require_admin)):
|
||||||
|
"""Update office settings (admin only) - Manager can view but usually Admin sets quota"""
|
||||||
|
# Verify access - currently assume admin manages quota. If manager should too, update logic.
|
||||||
|
# User request description: "Admin manage all offices with CRUD... rimodulare posti auto".
|
||||||
|
# So Managers might not edit quota? Or maybe they can?
|
||||||
|
# Keeping it simple: require_admin for structural changes.
|
||||||
|
|
||||||
|
office = db.query(Office).filter(Office.id == office_id).first()
|
||||||
|
if not office:
|
||||||
|
raise HTTPException(status_code=404, detail="Office not found")
|
||||||
|
|
||||||
|
if data.name:
|
||||||
|
office.name = data.name
|
||||||
|
|
||||||
|
if data.parking_quota is not None:
|
||||||
|
if data.parking_quota < 0:
|
||||||
|
raise HTTPException(status_code=400, detail="Parking quota must be non-negative")
|
||||||
|
office.parking_quota = data.parking_quota
|
||||||
|
|
||||||
|
if data.booking_window_enabled is not None:
|
||||||
|
office.booking_window_enabled = data.booking_window_enabled
|
||||||
|
|
||||||
|
if data.booking_window_end_hour is not None:
|
||||||
|
if not (0 <= data.booking_window_end_hour <= 23):
|
||||||
|
raise HTTPException(status_code=400, detail="Hour must be 0-23")
|
||||||
|
office.booking_window_end_hour = data.booking_window_end_hour
|
||||||
|
|
||||||
|
if data.booking_window_end_minute is not None:
|
||||||
|
if not (0 <= data.booking_window_end_minute <= 59):
|
||||||
|
raise HTTPException(status_code=400, detail="Minute must be 0-59")
|
||||||
|
office.booking_window_end_minute = data.booking_window_end_minute
|
||||||
|
|
||||||
|
office.updated_at = datetime.utcnow()
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
return {
|
||||||
|
"id": office.id,
|
||||||
|
"name": office.name,
|
||||||
|
"parking_quota": office.parking_quota,
|
||||||
|
"spot_prefix": office.spot_prefix,
|
||||||
|
"booking_window_enabled": office.booking_window_enabled,
|
||||||
|
"booking_window_end_hour": office.booking_window_end_hour,
|
||||||
|
"booking_window_end_minute": office.booking_window_end_minute
|
||||||
|
}
|
||||||
|
|
||||||
|
@router.delete("/{office_id}")
|
||||||
|
def delete_office(office_id: str, db: Session = Depends(get_db), user=Depends(require_admin)):
|
||||||
|
"""Delete an office (admin only)"""
|
||||||
|
office = db.query(Office).filter(Office.id == office_id).first()
|
||||||
|
if not office:
|
||||||
|
raise HTTPException(status_code=404, detail="Office not found")
|
||||||
|
|
||||||
|
db.delete(office)
|
||||||
|
db.commit()
|
||||||
|
return {"message": "Office deleted"}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{office_id}/users")
|
||||||
|
def get_office_users(office_id: str, db: Session = Depends(get_db), user=Depends(require_manager_or_admin)):
|
||||||
|
"""Get all users in an office"""
|
||||||
|
check_office_access(user, office_id)
|
||||||
|
|
||||||
|
office = db.query(Office).filter(Office.id == office_id).first()
|
||||||
|
if not office:
|
||||||
|
raise HTTPException(status_code=404, detail="Office not found")
|
||||||
|
|
||||||
|
users = db.query(User).filter(User.office_id == office_id).all()
|
||||||
|
return [{"id": u.id, "name": u.name, "email": u.email, "role": u.role} for u in users]
|
||||||
|
|
||||||
|
|
||||||
|
# Closing days
|
||||||
|
@router.get("/{office_id}/closing-days")
|
||||||
|
def get_office_closing_days(office_id: str, db: Session = Depends(get_db), user=Depends(get_current_user)):
|
||||||
|
"""Get closing days for an office"""
|
||||||
|
# Any user in the office can read closing days? Or just manager?
|
||||||
|
# check_office_access(user, office_id) # Let's allow read for all authenticated (frontend might need it)
|
||||||
|
|
||||||
|
days = db.query(OfficeClosingDay).filter(
|
||||||
|
OfficeClosingDay.office_id == office_id
|
||||||
|
).order_by(OfficeClosingDay.date).all()
|
||||||
|
return [{"id": d.id, "date": d.date, "end_date": d.end_date, "reason": d.reason} for d in days]
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{office_id}/closing-days")
|
||||||
|
def add_office_closing_day(office_id: str, data: ClosingDayCreate, db: Session = Depends(get_db), user=Depends(require_manager_or_admin)):
|
||||||
|
"""Add a closing day for an office"""
|
||||||
|
check_office_access(user, office_id)
|
||||||
|
|
||||||
|
office = db.query(Office).filter(Office.id == office_id).first()
|
||||||
|
if not office:
|
||||||
|
raise HTTPException(status_code=404, detail="Office not found")
|
||||||
|
|
||||||
|
existing = db.query(OfficeClosingDay).filter(
|
||||||
|
OfficeClosingDay.office_id == office_id,
|
||||||
|
OfficeClosingDay.date == data.date
|
||||||
|
).first()
|
||||||
|
if existing:
|
||||||
|
raise HTTPException(status_code=400, detail="Closing day already exists for this date")
|
||||||
|
|
||||||
|
if data.end_date and data.end_date < data.date:
|
||||||
|
raise HTTPException(status_code=400, detail="End date must be after start date")
|
||||||
|
|
||||||
|
closing_day = OfficeClosingDay(
|
||||||
|
id=generate_uuid(),
|
||||||
|
office_id=office_id,
|
||||||
|
date=data.date,
|
||||||
|
end_date=data.end_date,
|
||||||
|
reason=data.reason
|
||||||
|
)
|
||||||
|
db.add(closing_day)
|
||||||
|
db.commit()
|
||||||
|
return {"id": closing_day.id, "message": "Closing day added"}
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/{office_id}/closing-days/{closing_day_id}")
|
||||||
|
def remove_office_closing_day(office_id: str, closing_day_id: str, db: Session = Depends(get_db), user=Depends(require_manager_or_admin)):
|
||||||
|
"""Remove a closing day for an office"""
|
||||||
|
check_office_access(user, office_id)
|
||||||
|
|
||||||
|
closing_day = db.query(OfficeClosingDay).filter(
|
||||||
|
OfficeClosingDay.id == closing_day_id,
|
||||||
|
OfficeClosingDay.office_id == office_id
|
||||||
|
).first()
|
||||||
|
if not closing_day:
|
||||||
|
raise HTTPException(status_code=404, detail="Closing day not found")
|
||||||
|
|
||||||
|
db.delete(closing_day)
|
||||||
|
db.commit()
|
||||||
|
return {"message": "Closing day removed"}
|
||||||
|
|
||||||
|
|
||||||
|
# Weekly closing days
|
||||||
|
@router.get("/{office_id}/weekly-closing-days")
|
||||||
|
def get_office_weekly_closing_days(office_id: str, db: Session = Depends(get_db), user=Depends(get_current_user)):
|
||||||
|
"""Get weekly closing days for an office"""
|
||||||
|
days = db.query(OfficeWeeklyClosingDay).filter(
|
||||||
|
OfficeWeeklyClosingDay.office_id == office_id
|
||||||
|
).all()
|
||||||
|
return [{"id": d.id, "weekday": d.weekday} for d in days]
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{office_id}/weekly-closing-days")
|
||||||
|
def add_office_weekly_closing_day(office_id: str, data: WeeklyClosingDayCreate, db: Session = Depends(get_db), user=Depends(require_manager_or_admin)):
|
||||||
|
"""Add a weekly closing day for an office"""
|
||||||
|
check_office_access(user, office_id)
|
||||||
|
|
||||||
|
office = db.query(Office).filter(Office.id == office_id).first()
|
||||||
|
if not office:
|
||||||
|
raise HTTPException(status_code=404, detail="Office not found")
|
||||||
|
|
||||||
|
if data.weekday < 0 or data.weekday > 6:
|
||||||
|
raise HTTPException(status_code=400, detail="Weekday must be 0-6 (0=Sunday, 6=Saturday)")
|
||||||
|
|
||||||
|
existing = db.query(OfficeWeeklyClosingDay).filter(
|
||||||
|
OfficeWeeklyClosingDay.office_id == office_id,
|
||||||
|
OfficeWeeklyClosingDay.weekday == data.weekday
|
||||||
|
).first()
|
||||||
|
if existing:
|
||||||
|
raise HTTPException(status_code=400, detail="Weekly closing day already exists for this weekday")
|
||||||
|
|
||||||
|
weekly_closing = OfficeWeeklyClosingDay(
|
||||||
|
id=generate_uuid(),
|
||||||
|
office_id=office_id,
|
||||||
|
weekday=data.weekday
|
||||||
|
)
|
||||||
|
db.add(weekly_closing)
|
||||||
|
db.commit()
|
||||||
|
return {"id": weekly_closing.id, "message": "Weekly closing day added"}
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/{office_id}/weekly-closing-days/{weekly_id}")
|
||||||
|
def remove_office_weekly_closing_day(office_id: str, weekly_id: str, db: Session = Depends(get_db), user=Depends(require_manager_or_admin)):
|
||||||
|
"""Remove a weekly closing day for an office"""
|
||||||
|
check_office_access(user, office_id)
|
||||||
|
|
||||||
|
weekly_closing = db.query(OfficeWeeklyClosingDay).filter(
|
||||||
|
OfficeWeeklyClosingDay.id == weekly_id,
|
||||||
|
OfficeWeeklyClosingDay.office_id == office_id
|
||||||
|
).first()
|
||||||
|
if not weekly_closing:
|
||||||
|
raise HTTPException(status_code=404, detail="Weekly closing day not found")
|
||||||
|
|
||||||
|
db.delete(weekly_closing)
|
||||||
|
db.commit()
|
||||||
|
return {"message": "Weekly closing day removed"}
|
||||||
|
|
||||||
|
|
||||||
|
# Guarantees
|
||||||
|
@router.get("/{office_id}/guarantees")
|
||||||
|
def get_office_guarantees(office_id: str, db: Session = Depends(get_db), user=Depends(require_manager_or_admin)):
|
||||||
|
"""Get parking guarantees for an office"""
|
||||||
|
check_office_access(user, office_id)
|
||||||
|
|
||||||
|
guarantees = db.query(ParkingGuarantee).filter(ParkingGuarantee.office_id == office_id).all()
|
||||||
|
|
||||||
|
# Batch query to get all user names at once
|
||||||
|
user_ids = [g.user_id for g in guarantees]
|
||||||
|
if user_ids:
|
||||||
|
users = db.query(User).filter(User.id.in_(user_ids)).all()
|
||||||
|
user_lookup = {u.id: u.name for u in users}
|
||||||
|
else:
|
||||||
|
user_lookup = {}
|
||||||
|
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"id": g.id,
|
||||||
|
"user_id": g.user_id,
|
||||||
|
"user_name": user_lookup.get(g.user_id),
|
||||||
|
"start_date": g.start_date,
|
||||||
|
"end_date": g.end_date,
|
||||||
|
"notes": g.notes
|
||||||
|
}
|
||||||
|
for g in guarantees
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{office_id}/guarantees")
|
||||||
|
def add_office_guarantee(office_id: str, data: GuaranteeCreate, db: Session = Depends(get_db), user=Depends(require_manager_or_admin)):
|
||||||
|
"""Add parking guarantee for an office"""
|
||||||
|
check_office_access(user, office_id)
|
||||||
|
|
||||||
|
office = db.query(Office).filter(Office.id == office_id).first()
|
||||||
|
if not office:
|
||||||
|
raise HTTPException(status_code=404, detail="Office not found")
|
||||||
|
if not db.query(User).filter(User.id == data.user_id).first():
|
||||||
|
raise HTTPException(status_code=404, detail="User not found")
|
||||||
|
|
||||||
|
existing = db.query(ParkingGuarantee).filter(
|
||||||
|
ParkingGuarantee.office_id == office_id,
|
||||||
|
ParkingGuarantee.user_id == data.user_id
|
||||||
|
).first()
|
||||||
|
if existing:
|
||||||
|
raise HTTPException(status_code=400, detail="User already has a parking guarantee")
|
||||||
|
|
||||||
|
if data.start_date and data.end_date and data.end_date < data.start_date:
|
||||||
|
raise HTTPException(status_code=400, detail="End date must be after start date")
|
||||||
|
|
||||||
|
guarantee = ParkingGuarantee(
|
||||||
|
id=generate_uuid(),
|
||||||
|
office_id=office_id,
|
||||||
|
user_id=data.user_id,
|
||||||
|
start_date=data.start_date,
|
||||||
|
end_date=data.end_date,
|
||||||
|
notes=data.notes,
|
||||||
|
created_at=datetime.utcnow()
|
||||||
|
)
|
||||||
|
db.add(guarantee)
|
||||||
|
db.commit()
|
||||||
|
return {"id": guarantee.id, "message": "Guarantee added"}
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/{office_id}/guarantees/{guarantee_id}")
|
||||||
|
def remove_office_guarantee(office_id: str, guarantee_id: str, db: Session = Depends(get_db), user=Depends(require_manager_or_admin)):
|
||||||
|
"""Remove parking guarantee for an office"""
|
||||||
|
check_office_access(user, office_id)
|
||||||
|
|
||||||
|
guarantee = db.query(ParkingGuarantee).filter(
|
||||||
|
ParkingGuarantee.id == guarantee_id,
|
||||||
|
ParkingGuarantee.office_id == office_id
|
||||||
|
).first()
|
||||||
|
if not guarantee:
|
||||||
|
raise HTTPException(status_code=404, detail="Guarantee not found")
|
||||||
|
|
||||||
|
db.delete(guarantee)
|
||||||
|
db.commit()
|
||||||
|
return {"message": "Guarantee removed"}
|
||||||
|
|
||||||
|
|
||||||
|
# Exclusions
|
||||||
|
@router.get("/{office_id}/exclusions")
|
||||||
|
def get_office_exclusions(office_id: str, db: Session = Depends(get_db), user=Depends(require_manager_or_admin)):
|
||||||
|
"""Get parking exclusions for an office"""
|
||||||
|
check_office_access(user, office_id)
|
||||||
|
|
||||||
|
exclusions = db.query(ParkingExclusion).filter(ParkingExclusion.office_id == office_id).all()
|
||||||
|
|
||||||
|
# Batch query to get all user names at once
|
||||||
|
user_ids = [e.user_id for e in exclusions]
|
||||||
|
if user_ids:
|
||||||
|
users = db.query(User).filter(User.id.in_(user_ids)).all()
|
||||||
|
user_lookup = {u.id: u.name for u in users}
|
||||||
|
else:
|
||||||
|
user_lookup = {}
|
||||||
|
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"id": e.id,
|
||||||
|
"user_id": e.user_id,
|
||||||
|
"user_name": user_lookup.get(e.user_id),
|
||||||
|
"start_date": e.start_date,
|
||||||
|
"end_date": e.end_date,
|
||||||
|
"notes": e.notes
|
||||||
|
}
|
||||||
|
for e in exclusions
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{office_id}/exclusions")
|
||||||
|
def add_office_exclusion(office_id: str, data: ExclusionCreate, db: Session = Depends(get_db), user=Depends(require_manager_or_admin)):
|
||||||
|
"""Add parking exclusion for an office"""
|
||||||
|
check_office_access(user, office_id)
|
||||||
|
|
||||||
|
office = db.query(Office).filter(Office.id == office_id).first()
|
||||||
|
if not office:
|
||||||
|
raise HTTPException(status_code=404, detail="Office not found")
|
||||||
|
if not db.query(User).filter(User.id == data.user_id).first():
|
||||||
|
raise HTTPException(status_code=404, detail="User not found")
|
||||||
|
|
||||||
|
existing = db.query(ParkingExclusion).filter(
|
||||||
|
ParkingExclusion.office_id == office_id,
|
||||||
|
ParkingExclusion.user_id == data.user_id
|
||||||
|
).first()
|
||||||
|
if existing:
|
||||||
|
raise HTTPException(status_code=400, detail="User already has a parking exclusion")
|
||||||
|
|
||||||
|
if data.start_date and data.end_date and data.end_date < data.start_date:
|
||||||
|
raise HTTPException(status_code=400, detail="End date must be after start date")
|
||||||
|
|
||||||
|
exclusion = ParkingExclusion(
|
||||||
|
id=generate_uuid(),
|
||||||
|
office_id=office_id,
|
||||||
|
user_id=data.user_id,
|
||||||
|
start_date=data.start_date,
|
||||||
|
end_date=data.end_date,
|
||||||
|
notes=data.notes,
|
||||||
|
created_at=datetime.utcnow()
|
||||||
|
)
|
||||||
|
db.add(exclusion)
|
||||||
|
db.commit()
|
||||||
|
return {"id": exclusion.id, "message": "Exclusion added"}
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/{office_id}/exclusions/{exclusion_id}")
|
||||||
|
def remove_office_exclusion(office_id: str, exclusion_id: str, db: Session = Depends(get_db), user=Depends(require_manager_or_admin)):
|
||||||
|
"""Remove parking exclusion for an office"""
|
||||||
|
check_office_access(user, office_id)
|
||||||
|
|
||||||
|
exclusion = db.query(ParkingExclusion).filter(
|
||||||
|
ParkingExclusion.id == exclusion_id,
|
||||||
|
ParkingExclusion.office_id == office_id
|
||||||
|
).first()
|
||||||
|
if not exclusion:
|
||||||
|
raise HTTPException(status_code=404, detail="Exclusion not found")
|
||||||
|
|
||||||
|
db.delete(exclusion)
|
||||||
|
db.commit()
|
||||||
|
return {"message": "Exclusion removed"}
|
||||||
@@ -2,21 +2,33 @@
|
|||||||
Parking Management Routes
|
Parking Management Routes
|
||||||
Parking assignments, spot management, and pool initialization
|
Parking assignments, spot management, and pool initialization
|
||||||
|
|
||||||
|
Manager-centric model:
|
||||||
|
- Managers own parking spots (defined by manager_parking_quota)
|
||||||
|
- Spots are named with manager's letter prefix (A1, A2, B1, B2...)
|
||||||
|
- Assignments reference manager_id directly
|
||||||
|
"""
|
||||||
|
"""
|
||||||
|
Parking Management Routes
|
||||||
|
Parking assignments, spot management, and pool initialization
|
||||||
|
|
||||||
Manager-centric model:
|
Manager-centric model:
|
||||||
- Managers own parking spots (defined by manager_parking_quota)
|
- Managers own parking spots (defined by manager_parking_quota)
|
||||||
- Spots are named with manager's letter prefix (A1, A2, B1, B2...)
|
- Spots are named with manager's letter prefix (A1, A2, B1, B2...)
|
||||||
- Assignments reference manager_id directly
|
- Assignments reference manager_id directly
|
||||||
"""
|
"""
|
||||||
from typing import List
|
from typing import List
|
||||||
from datetime import datetime
|
from datetime import datetime, date
|
||||||
from fastapi import APIRouter, Depends, HTTPException
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from database.connection import get_db
|
from database.connection import get_db
|
||||||
from database.models import DailyParkingAssignment, User
|
from database.models import DailyParkingAssignment, User, UserRole, Office
|
||||||
from utils.auth_middleware import get_current_user, require_manager_or_admin
|
from utils.auth_middleware import get_current_user, require_manager_or_admin
|
||||||
from services.parking import initialize_parking_pool, get_spot_display_name
|
from services.parking import (
|
||||||
|
initialize_parking_pool, get_spot_display_name, release_user_spot,
|
||||||
|
run_batch_allocation, clear_assignments_for_office_date
|
||||||
|
)
|
||||||
from services.notifications import notify_parking_assigned, notify_parking_released, notify_parking_reassigned
|
from services.notifications import notify_parking_assigned, notify_parking_released, notify_parking_reassigned
|
||||||
from app import config
|
from app import config
|
||||||
|
|
||||||
@@ -25,14 +37,14 @@ router = APIRouter(prefix="/api/parking", tags=["parking"])
|
|||||||
|
|
||||||
# Request/Response Models
|
# Request/Response Models
|
||||||
class InitPoolRequest(BaseModel):
|
class InitPoolRequest(BaseModel):
|
||||||
date: str # YYYY-MM-DD
|
date: date
|
||||||
|
|
||||||
|
|
||||||
class ManualAssignRequest(BaseModel):
|
class ManualAssignRequest(BaseModel):
|
||||||
manager_id: str
|
office_id: str
|
||||||
user_id: str
|
user_id: str
|
||||||
spot_id: str
|
spot_id: str
|
||||||
date: str
|
date: date
|
||||||
|
|
||||||
|
|
||||||
class ReassignSpotRequest(BaseModel):
|
class ReassignSpotRequest(BaseModel):
|
||||||
@@ -42,50 +54,57 @@ class ReassignSpotRequest(BaseModel):
|
|||||||
|
|
||||||
class AssignmentResponse(BaseModel):
|
class AssignmentResponse(BaseModel):
|
||||||
id: str
|
id: str
|
||||||
date: str
|
date: date
|
||||||
spot_id: str
|
spot_id: str
|
||||||
spot_display_name: str | None = None
|
spot_display_name: str | None = None
|
||||||
user_id: str | None
|
user_id: str | None
|
||||||
manager_id: str
|
office_id: str
|
||||||
user_name: str | None = None
|
user_name: str | None = None
|
||||||
user_email: str | None = None
|
user_email: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class RunAllocationRequest(BaseModel):
|
||||||
|
date: date
|
||||||
|
office_id: str
|
||||||
|
|
||||||
|
|
||||||
|
class ClearAssignmentsRequest(BaseModel):
|
||||||
|
date: date
|
||||||
|
office_id: str
|
||||||
|
|
||||||
|
|
||||||
# Routes
|
# Routes
|
||||||
@router.post("/init-manager-pool")
|
@router.post("/init-office-pool")
|
||||||
def init_manager_pool(request: InitPoolRequest, db: Session = Depends(get_db), current_user=Depends(require_manager_or_admin)):
|
def init_office_pool(request: InitPoolRequest, db: Session = Depends(get_db), current_user=Depends(require_manager_or_admin)):
|
||||||
"""Initialize parking pool for a manager on a given date"""
|
"""Initialize parking pool for an office on a given date"""
|
||||||
try:
|
pool_date = request.date
|
||||||
datetime.strptime(request.date, "%Y-%m-%d")
|
|
||||||
except ValueError:
|
|
||||||
raise HTTPException(status_code=400, detail="Invalid date format")
|
|
||||||
|
|
||||||
quota = current_user.manager_parking_quota or 0
|
if not current_user.office_id:
|
||||||
if quota == 0:
|
raise HTTPException(status_code=400, detail="User does not belong to an office")
|
||||||
return {"success": True, "message": "No parking quota configured", "spots": 0}
|
|
||||||
|
office = db.query(Office).filter(Office.id == current_user.office_id).first()
|
||||||
|
if not office or not office.parking_quota:
|
||||||
|
return {"success": True, "message": "No parking quota configured", "spots": 0}
|
||||||
|
|
||||||
spots = initialize_parking_pool(current_user.id, quota, request.date, db)
|
spots = initialize_parking_pool(office.id, office.parking_quota, pool_date, db)
|
||||||
return {"success": True, "spots": spots}
|
return {"success": True, "spots": spots}
|
||||||
|
|
||||||
|
|
||||||
@router.get("/assignments/{date}", response_model=List[AssignmentResponse])
|
@router.get("/assignments/{date_val}", response_model=List[AssignmentResponse])
|
||||||
def get_assignments(date: str, manager_id: str = None, db: Session = Depends(get_db), current_user=Depends(get_current_user)):
|
def get_assignments(date_val: date, office_id: str = None, db: Session = Depends(get_db), current_user=Depends(get_current_user)):
|
||||||
"""Get parking assignments for a date, optionally filtered by manager"""
|
"""Get parking assignments for a date, optionally filtered by office"""
|
||||||
try:
|
query_date = date_val
|
||||||
datetime.strptime(date, "%Y-%m-%d")
|
|
||||||
except ValueError:
|
|
||||||
raise HTTPException(status_code=400, detail="Invalid date format")
|
|
||||||
|
|
||||||
query = db.query(DailyParkingAssignment).filter(DailyParkingAssignment.date == date)
|
query = db.query(DailyParkingAssignment).filter(DailyParkingAssignment.date == query_date)
|
||||||
if manager_id:
|
if office_id:
|
||||||
query = query.filter(DailyParkingAssignment.manager_id == manager_id)
|
query = query.filter(DailyParkingAssignment.office_id == office_id)
|
||||||
|
|
||||||
assignments = query.all()
|
assignments = query.all()
|
||||||
results = []
|
results = []
|
||||||
|
|
||||||
for assignment in assignments:
|
for assignment in assignments:
|
||||||
# Get display name using manager's spot prefix
|
# Get display name using office's spot prefix
|
||||||
spot_display_name = get_spot_display_name(assignment.spot_id, assignment.manager_id, db)
|
spot_display_name = get_spot_display_name(assignment.spot_id, assignment.office_id, db)
|
||||||
|
|
||||||
result = AssignmentResponse(
|
result = AssignmentResponse(
|
||||||
id=assignment.id,
|
id=assignment.id,
|
||||||
@@ -93,7 +112,7 @@ def get_assignments(date: str, manager_id: str = None, db: Session = Depends(get
|
|||||||
spot_id=assignment.spot_id,
|
spot_id=assignment.spot_id,
|
||||||
spot_display_name=spot_display_name,
|
spot_display_name=spot_display_name,
|
||||||
user_id=assignment.user_id,
|
user_id=assignment.user_id,
|
||||||
manager_id=assignment.manager_id
|
office_id=assignment.office_id
|
||||||
)
|
)
|
||||||
|
|
||||||
if assignment.user_id:
|
if assignment.user_id:
|
||||||
@@ -108,7 +127,7 @@ def get_assignments(date: str, manager_id: str = None, db: Session = Depends(get
|
|||||||
|
|
||||||
|
|
||||||
@router.get("/my-assignments", response_model=List[AssignmentResponse])
|
@router.get("/my-assignments", response_model=List[AssignmentResponse])
|
||||||
def get_my_assignments(start_date: str = None, end_date: str = None, db: Session = Depends(get_db), current_user=Depends(get_current_user)):
|
def get_my_assignments(start_date: date = None, end_date: date = None, db: Session = Depends(get_db), current_user=Depends(get_current_user)):
|
||||||
"""Get current user's parking assignments"""
|
"""Get current user's parking assignments"""
|
||||||
query = db.query(DailyParkingAssignment).filter(
|
query = db.query(DailyParkingAssignment).filter(
|
||||||
DailyParkingAssignment.user_id == current_user.id
|
DailyParkingAssignment.user_id == current_user.id
|
||||||
@@ -123,7 +142,7 @@ def get_my_assignments(start_date: str = None, end_date: str = None, db: Session
|
|||||||
results = []
|
results = []
|
||||||
|
|
||||||
for assignment in assignments:
|
for assignment in assignments:
|
||||||
spot_display_name = get_spot_display_name(assignment.spot_id, assignment.manager_id, db)
|
spot_display_name = get_spot_display_name(assignment.spot_id, assignment.office_id, db)
|
||||||
|
|
||||||
results.append(AssignmentResponse(
|
results.append(AssignmentResponse(
|
||||||
id=assignment.id,
|
id=assignment.id,
|
||||||
@@ -131,7 +150,7 @@ def get_my_assignments(start_date: str = None, end_date: str = None, db: Session
|
|||||||
spot_id=assignment.spot_id,
|
spot_id=assignment.spot_id,
|
||||||
spot_display_name=spot_display_name,
|
spot_display_name=spot_display_name,
|
||||||
user_id=assignment.user_id,
|
user_id=assignment.user_id,
|
||||||
manager_id=assignment.manager_id,
|
office_id=assignment.office_id,
|
||||||
user_name=current_user.name,
|
user_name=current_user.name,
|
||||||
user_email=current_user.email
|
user_email=current_user.email
|
||||||
))
|
))
|
||||||
@@ -139,27 +158,55 @@ def get_my_assignments(start_date: str = None, end_date: str = None, db: Session
|
|||||||
return results
|
return results
|
||||||
|
|
||||||
|
|
||||||
|
return results
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/run-allocation")
|
||||||
|
def run_fair_allocation(data: RunAllocationRequest, db: Session = Depends(get_db), current_user=Depends(require_manager_or_admin)):
|
||||||
|
"""Manually trigger fair allocation for a date (Test Tool)"""
|
||||||
|
# Verify office access
|
||||||
|
if current_user.role == UserRole.MANAGER and current_user.office_id != data.office_id:
|
||||||
|
raise HTTPException(status_code=403, detail="Not authorized for this office")
|
||||||
|
|
||||||
|
result = run_batch_allocation(data.office_id, data.date, db)
|
||||||
|
return {"message": "Allocation completed", "result": result}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/clear-assignments")
|
||||||
|
def clear_assignments(data: ClearAssignmentsRequest, db: Session = Depends(get_db), current_user=Depends(require_manager_or_admin)):
|
||||||
|
"""Clear all assignments for a date (Test Tool)"""
|
||||||
|
# Verify office access
|
||||||
|
if current_user.role == UserRole.MANAGER and current_user.office_id != data.office_id:
|
||||||
|
raise HTTPException(status_code=403, detail="Not authorized for this office")
|
||||||
|
|
||||||
|
count = clear_assignments_for_office_date(data.office_id, data.date, db)
|
||||||
|
return {"message": "Assignments cleared", "count": count}
|
||||||
|
|
||||||
|
|
||||||
@router.post("/manual-assign")
|
@router.post("/manual-assign")
|
||||||
def manual_assign(data: ManualAssignRequest, db: Session = Depends(get_db), current_user=Depends(require_manager_or_admin)):
|
def manual_assign(data: ManualAssignRequest, db: Session = Depends(get_db), current_user=Depends(require_manager_or_admin)):
|
||||||
"""Manually assign a spot to a user"""
|
"""Manually assign a spot to a user"""
|
||||||
|
assign_date = data.date
|
||||||
|
|
||||||
# Verify user exists
|
# Verify user exists
|
||||||
user = db.query(User).filter(User.id == data.user_id).first()
|
user = db.query(User).filter(User.id == data.user_id).first()
|
||||||
if not user:
|
if not user:
|
||||||
raise HTTPException(status_code=404, detail="User not found")
|
raise HTTPException(status_code=404, detail="User not found")
|
||||||
|
|
||||||
# Verify manager exists and check permission
|
# Verify office exists
|
||||||
manager = db.query(User).filter(User.id == data.manager_id, User.role == "manager").first()
|
office = db.query(Office).filter(Office.id == data.office_id).first()
|
||||||
if not manager:
|
if not office:
|
||||||
raise HTTPException(status_code=404, detail="Manager not found")
|
raise HTTPException(status_code=404, detail="Office not found")
|
||||||
|
|
||||||
# Only admin or the manager themselves can assign spots
|
# Only admin or the manager of that office can assign spots
|
||||||
if current_user.role != "admin" and current_user.id != data.manager_id:
|
is_manager = (current_user.role == UserRole.MANAGER and current_user.office_id == data.office_id)
|
||||||
raise HTTPException(status_code=403, detail="Not authorized to assign spots for this manager")
|
if current_user.role != UserRole.ADMIN and not is_manager:
|
||||||
|
raise HTTPException(status_code=403, detail="Not authorized to assign spots for this office")
|
||||||
|
|
||||||
# Check if spot exists and is free
|
# Check if spot exists and is free
|
||||||
spot = db.query(DailyParkingAssignment).filter(
|
spot = db.query(DailyParkingAssignment).filter(
|
||||||
DailyParkingAssignment.manager_id == data.manager_id,
|
DailyParkingAssignment.office_id == data.office_id,
|
||||||
DailyParkingAssignment.date == data.date,
|
DailyParkingAssignment.date == assign_date,
|
||||||
DailyParkingAssignment.spot_id == data.spot_id
|
DailyParkingAssignment.spot_id == data.spot_id
|
||||||
).first()
|
).first()
|
||||||
|
|
||||||
@@ -170,7 +217,7 @@ def manual_assign(data: ManualAssignRequest, db: Session = Depends(get_db), curr
|
|||||||
|
|
||||||
# Check if user already has a spot for this date (from any manager)
|
# Check if user already has a spot for this date (from any manager)
|
||||||
existing = db.query(DailyParkingAssignment).filter(
|
existing = db.query(DailyParkingAssignment).filter(
|
||||||
DailyParkingAssignment.date == data.date,
|
DailyParkingAssignment.date == assign_date,
|
||||||
DailyParkingAssignment.user_id == data.user_id
|
DailyParkingAssignment.user_id == data.user_id
|
||||||
).first()
|
).first()
|
||||||
|
|
||||||
@@ -180,7 +227,7 @@ def manual_assign(data: ManualAssignRequest, db: Session = Depends(get_db), curr
|
|||||||
spot.user_id = data.user_id
|
spot.user_id = data.user_id
|
||||||
db.commit()
|
db.commit()
|
||||||
|
|
||||||
spot_display_name = get_spot_display_name(data.spot_id, data.manager_id, db)
|
spot_display_name = get_spot_display_name(data.spot_id, data.office_id, db)
|
||||||
return {"message": "Spot assigned", "spot_id": data.spot_id, "spot_display_name": spot_display_name}
|
return {"message": "Spot assigned", "spot_id": data.spot_id, "spot_display_name": spot_display_name}
|
||||||
|
|
||||||
|
|
||||||
@@ -198,7 +245,7 @@ def release_my_spot(assignment_id: str, db: Session = Depends(get_db), current_u
|
|||||||
raise HTTPException(status_code=403, detail="You can only release your own parking spot")
|
raise HTTPException(status_code=403, detail="You can only release your own parking spot")
|
||||||
|
|
||||||
# Get spot display name for notification
|
# Get spot display name for notification
|
||||||
spot_display_name = get_spot_display_name(assignment.spot_id, assignment.manager_id, db)
|
spot_display_name = get_spot_display_name(assignment.spot_id, assignment.office_id, db)
|
||||||
|
|
||||||
assignment.user_id = None
|
assignment.user_id = None
|
||||||
db.commit()
|
db.commit()
|
||||||
@@ -223,9 +270,9 @@ def reassign_spot(data: ReassignSpotRequest, db: Session = Depends(get_db), curr
|
|||||||
raise HTTPException(status_code=404, detail="Assignment not found")
|
raise HTTPException(status_code=404, detail="Assignment not found")
|
||||||
|
|
||||||
# Check permission: admin, manager who owns the spot, or current spot holder
|
# Check permission: admin, manager who owns the spot, or current spot holder
|
||||||
is_admin = current_user.role == 'admin'
|
is_admin = current_user.role == UserRole.ADMIN
|
||||||
is_spot_owner = assignment.user_id == current_user.id
|
is_spot_owner = assignment.user_id == current_user.id
|
||||||
is_manager = current_user.id == assignment.manager_id
|
is_manager = (current_user.role == UserRole.MANAGER and current_user.office_id == assignment.office_id)
|
||||||
|
|
||||||
if not (is_admin or is_manager or is_spot_owner):
|
if not (is_admin or is_manager or is_spot_owner):
|
||||||
raise HTTPException(status_code=403, detail="Not authorized to reassign this spot")
|
raise HTTPException(status_code=403, detail="Not authorized to reassign this spot")
|
||||||
@@ -235,9 +282,17 @@ def reassign_spot(data: ReassignSpotRequest, db: Session = Depends(get_db), curr
|
|||||||
old_user = db.query(User).filter(User.id == old_user_id).first() if old_user_id else None
|
old_user = db.query(User).filter(User.id == old_user_id).first() if old_user_id else None
|
||||||
|
|
||||||
# Get spot display name for notifications
|
# Get spot display name for notifications
|
||||||
spot_display_name = get_spot_display_name(assignment.spot_id, assignment.manager_id, db)
|
spot_display_name = get_spot_display_name(assignment.spot_id, assignment.office_id, db)
|
||||||
|
|
||||||
if data.new_user_id:
|
if data.new_user_id == "auto":
|
||||||
|
# "Auto assign" means releasing the spot so the system picks the next person
|
||||||
|
# release_user_spot returns True if it released it (and potentially reassigned it)
|
||||||
|
success = release_user_spot(assignment.office_id, assignment.user_id, assignment.date, db)
|
||||||
|
if not success:
|
||||||
|
raise HTTPException(status_code=400, detail="Could not auto-reassign spot")
|
||||||
|
return {"message": "Spot released for auto-assignment"}
|
||||||
|
|
||||||
|
elif data.new_user_id:
|
||||||
# Check new user exists
|
# Check new user exists
|
||||||
new_user = db.query(User).filter(User.id == data.new_user_id).first()
|
new_user = db.query(User).filter(User.id == data.new_user_id).first()
|
||||||
if not new_user:
|
if not new_user:
|
||||||
@@ -275,7 +330,7 @@ def reassign_spot(data: ReassignSpotRequest, db: Session = Depends(get_db), curr
|
|||||||
db.refresh(assignment)
|
db.refresh(assignment)
|
||||||
|
|
||||||
# Build response
|
# Build response
|
||||||
spot_display_name = get_spot_display_name(assignment.spot_id, assignment.manager_id, db)
|
spot_display_name = get_spot_display_name(assignment.spot_id, assignment.office_id, db)
|
||||||
|
|
||||||
result = AssignmentResponse(
|
result = AssignmentResponse(
|
||||||
id=assignment.id,
|
id=assignment.id,
|
||||||
@@ -283,7 +338,7 @@ def reassign_spot(data: ReassignSpotRequest, db: Session = Depends(get_db), curr
|
|||||||
spot_id=assignment.spot_id,
|
spot_id=assignment.spot_id,
|
||||||
spot_display_name=spot_display_name,
|
spot_display_name=spot_display_name,
|
||||||
user_id=assignment.user_id,
|
user_id=assignment.user_id,
|
||||||
manager_id=assignment.manager_id
|
office_id=assignment.office_id
|
||||||
)
|
)
|
||||||
|
|
||||||
if assignment.user_id:
|
if assignment.user_id:
|
||||||
@@ -308,16 +363,16 @@ def get_eligible_users(assignment_id: str, db: Session = Depends(get_db), curren
|
|||||||
raise HTTPException(status_code=404, detail="Assignment not found")
|
raise HTTPException(status_code=404, detail="Assignment not found")
|
||||||
|
|
||||||
# Check permission: admin, manager who owns the spot, or current spot holder
|
# Check permission: admin, manager who owns the spot, or current spot holder
|
||||||
is_admin = current_user.role == 'admin'
|
is_admin = current_user.role == UserRole.ADMIN
|
||||||
is_spot_owner = assignment.user_id == current_user.id
|
is_spot_owner = assignment.user_id == current_user.id
|
||||||
is_manager = current_user.id == assignment.manager_id
|
is_manager = (current_user.role == UserRole.MANAGER and current_user.office_id == assignment.office_id)
|
||||||
|
|
||||||
if not (is_admin or is_manager or is_spot_owner):
|
if not (is_admin or is_manager or is_spot_owner):
|
||||||
raise HTTPException(status_code=403, detail="Not authorized")
|
raise HTTPException(status_code=403, detail="Not authorized")
|
||||||
|
|
||||||
# Get users in this manager's team (including the manager themselves)
|
# Get users in this office (including the manager themselves)
|
||||||
users = db.query(User).filter(
|
users = db.query(User).filter(
|
||||||
(User.manager_id == assignment.manager_id) | (User.id == assignment.manager_id),
|
User.office_id == assignment.office_id,
|
||||||
User.id != assignment.user_id # Exclude current holder
|
User.id != assignment.user_id # Exclude current holder
|
||||||
).all()
|
).all()
|
||||||
|
|
||||||
|
|||||||
@@ -3,13 +3,13 @@ Presence Management Routes
|
|||||||
User presence marking and admin management
|
User presence marking and admin management
|
||||||
"""
|
"""
|
||||||
from typing import List
|
from typing import List
|
||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta, date
|
||||||
from fastapi import APIRouter, Depends, HTTPException
|
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from database.connection import get_db
|
from database.connection import get_db
|
||||||
from database.models import UserPresence, User, DailyParkingAssignment
|
from database.models import UserPresence, User, DailyParkingAssignment, UserRole, PresenceStatus, Office
|
||||||
from utils.auth_middleware import get_current_user, require_manager_or_admin
|
from utils.auth_middleware import get_current_user, require_manager_or_admin
|
||||||
from utils.helpers import generate_uuid
|
from utils.helpers import generate_uuid
|
||||||
from services.parking import handle_presence_change, get_spot_display_name
|
from services.parking import handle_presence_change, get_spot_display_name
|
||||||
@@ -20,38 +20,26 @@ router = APIRouter(prefix="/api/presence", tags=["presence"])
|
|||||||
|
|
||||||
# Request/Response Models
|
# Request/Response Models
|
||||||
class PresenceMarkRequest(BaseModel):
|
class PresenceMarkRequest(BaseModel):
|
||||||
date: str # YYYY-MM-DD
|
date: date
|
||||||
status: str # present, remote, absent
|
status: PresenceStatus
|
||||||
|
|
||||||
|
|
||||||
class AdminPresenceMarkRequest(BaseModel):
|
class AdminPresenceMarkRequest(BaseModel):
|
||||||
user_id: str
|
user_id: str
|
||||||
date: str
|
date: date
|
||||||
status: str
|
status: PresenceStatus
|
||||||
|
|
||||||
|
|
||||||
class BulkPresenceRequest(BaseModel):
|
|
||||||
start_date: str
|
|
||||||
end_date: str
|
|
||||||
status: str
|
|
||||||
days: List[int] | None = None # Optional: [0,1,2,3,4] for Mon-Fri
|
|
||||||
|
|
||||||
|
|
||||||
class AdminBulkPresenceRequest(BaseModel):
|
|
||||||
user_id: str
|
|
||||||
start_date: str
|
|
||||||
end_date: str
|
|
||||||
status: str
|
|
||||||
days: List[int] | None = None
|
|
||||||
|
|
||||||
|
|
||||||
class PresenceResponse(BaseModel):
|
class PresenceResponse(BaseModel):
|
||||||
id: str
|
id: str
|
||||||
user_id: str
|
user_id: str
|
||||||
date: str
|
date: date
|
||||||
status: str
|
status: PresenceStatus
|
||||||
created_at: str | None
|
created_at: datetime | None
|
||||||
updated_at: str | None
|
updated_at: datetime | None
|
||||||
parking_spot_number: str | None = None
|
parking_spot_number: str | None = None
|
||||||
|
|
||||||
class Config:
|
class Config:
|
||||||
@@ -59,51 +47,38 @@ class PresenceResponse(BaseModel):
|
|||||||
|
|
||||||
|
|
||||||
# Helper functions
|
# Helper functions
|
||||||
def validate_status(status: str):
|
|
||||||
if status not in ["present", "remote", "absent"]:
|
|
||||||
raise HTTPException(status_code=400, detail="Status must be: present, remote, or absent")
|
|
||||||
|
|
||||||
|
|
||||||
def parse_date(date_str: str) -> datetime:
|
|
||||||
try:
|
|
||||||
return datetime.strptime(date_str, "%Y-%m-%d")
|
|
||||||
except ValueError:
|
|
||||||
raise HTTPException(status_code=400, detail="Invalid date format. Use YYYY-MM-DD")
|
|
||||||
|
|
||||||
|
|
||||||
def check_manager_access(current_user: User, target_user: User, db: Session):
|
def check_manager_access(current_user: User, target_user: User, db: Session):
|
||||||
"""Check if current_user has access to target_user"""
|
"""Check if current_user has access to target_user"""
|
||||||
if current_user.role == "admin":
|
if current_user.role == UserRole.ADMIN:
|
||||||
return True
|
return True
|
||||||
|
|
||||||
if current_user.role == "manager":
|
if current_user.role == UserRole.MANAGER:
|
||||||
# Manager can access users they manage
|
# Manager can access users in their Office
|
||||||
if target_user.manager_id == current_user.id:
|
if target_user.office_id == current_user.office_id:
|
||||||
return True
|
return True
|
||||||
raise HTTPException(status_code=403, detail="User is not managed by you")
|
raise HTTPException(status_code=403, detail="User is not in your office")
|
||||||
|
|
||||||
raise HTTPException(status_code=403, detail="Access denied")
|
raise HTTPException(status_code=403, detail="Access denied")
|
||||||
|
|
||||||
|
|
||||||
def _mark_presence_for_user(
|
def _mark_presence_for_user(
|
||||||
user_id: str,
|
user_id: str,
|
||||||
date: str,
|
presence_date: date,
|
||||||
status: str,
|
status: PresenceStatus,
|
||||||
db: Session,
|
db: Session,
|
||||||
target_user: User
|
target_user: User
|
||||||
) -> UserPresence:
|
) -> UserPresence:
|
||||||
"""
|
"""
|
||||||
Core presence marking logic - shared by user and admin routes.
|
Core presence marking logic - shared by user and admin routes.
|
||||||
"""
|
"""
|
||||||
validate_status(status)
|
|
||||||
parse_date(date)
|
|
||||||
|
|
||||||
existing = db.query(UserPresence).filter(
|
existing = db.query(UserPresence).filter(
|
||||||
UserPresence.user_id == user_id,
|
UserPresence.user_id == user_id,
|
||||||
UserPresence.date == date
|
UserPresence.date == presence_date
|
||||||
).first()
|
).first()
|
||||||
|
|
||||||
now = datetime.utcnow().isoformat()
|
now = datetime.utcnow()
|
||||||
old_status = existing.status if existing else None
|
old_status = existing.status if existing else None
|
||||||
|
|
||||||
if existing:
|
if existing:
|
||||||
@@ -116,7 +91,7 @@ def _mark_presence_for_user(
|
|||||||
presence = UserPresence(
|
presence = UserPresence(
|
||||||
id=generate_uuid(),
|
id=generate_uuid(),
|
||||||
user_id=user_id,
|
user_id=user_id,
|
||||||
date=date,
|
date=presence_date,
|
||||||
status=status,
|
status=status,
|
||||||
created_at=now,
|
created_at=now,
|
||||||
updated_at=now
|
updated_at=now
|
||||||
@@ -125,114 +100,36 @@ def _mark_presence_for_user(
|
|||||||
db.commit()
|
db.commit()
|
||||||
db.refresh(presence)
|
db.refresh(presence)
|
||||||
|
|
||||||
# Handle parking assignment
|
# Handle parking assignment (if user is in an office)
|
||||||
# Use manager_id if user has one, or user's own id if they are a manager
|
if target_user.office_id and old_status != status:
|
||||||
parking_manager_id = target_user.manager_id
|
|
||||||
if not parking_manager_id and target_user.role == "manager":
|
|
||||||
# Manager is part of their own team for parking purposes
|
|
||||||
parking_manager_id = target_user.id
|
|
||||||
|
|
||||||
if old_status != status and parking_manager_id:
|
|
||||||
try:
|
try:
|
||||||
handle_presence_change(
|
handle_presence_change(
|
||||||
user_id, date,
|
user_id, presence_date,
|
||||||
old_status or "absent", status,
|
old_status or PresenceStatus.ABSENT, status,
|
||||||
parking_manager_id, db
|
target_user.office_id, db
|
||||||
)
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
config.logger.warning(f"Parking handler failed for user {user_id} on {date}: {e}")
|
config.logger.warning(f"Parking handler failed for user {user_id} on {presence_date}: {e}")
|
||||||
|
|
||||||
return presence
|
return presence
|
||||||
|
|
||||||
|
|
||||||
def _bulk_mark_presence(
|
|
||||||
user_id: str,
|
|
||||||
start_date: str,
|
|
||||||
end_date: str,
|
|
||||||
status: str,
|
|
||||||
days: List[int] | None,
|
|
||||||
db: Session,
|
|
||||||
target_user: User
|
|
||||||
) -> List[UserPresence]:
|
|
||||||
"""
|
|
||||||
Core bulk presence marking logic - shared by user and admin routes.
|
|
||||||
"""
|
|
||||||
validate_status(status)
|
|
||||||
start = parse_date(start_date)
|
|
||||||
end = parse_date(end_date)
|
|
||||||
|
|
||||||
if end < start:
|
|
||||||
raise HTTPException(status_code=400, detail="End date must be after start date")
|
|
||||||
if (end - start).days > 90:
|
|
||||||
raise HTTPException(status_code=400, detail="Range cannot exceed 90 days")
|
|
||||||
|
|
||||||
results = []
|
|
||||||
current_date = start
|
|
||||||
now = datetime.utcnow().isoformat()
|
|
||||||
|
|
||||||
while current_date <= end:
|
|
||||||
if days is None or current_date.weekday() in days:
|
|
||||||
date_str = current_date.strftime("%Y-%m-%d")
|
|
||||||
|
|
||||||
existing = db.query(UserPresence).filter(
|
|
||||||
UserPresence.user_id == user_id,
|
|
||||||
UserPresence.date == date_str
|
|
||||||
).first()
|
|
||||||
|
|
||||||
old_status = existing.status if existing else None
|
|
||||||
|
|
||||||
if existing:
|
|
||||||
existing.status = status
|
|
||||||
existing.updated_at = now
|
|
||||||
results.append(existing)
|
|
||||||
else:
|
|
||||||
presence = UserPresence(
|
|
||||||
id=generate_uuid(),
|
|
||||||
user_id=user_id,
|
|
||||||
date=date_str,
|
|
||||||
status=status,
|
|
||||||
created_at=now,
|
|
||||||
updated_at=now
|
|
||||||
)
|
|
||||||
db.add(presence)
|
|
||||||
results.append(presence)
|
|
||||||
|
|
||||||
# Handle parking for each date
|
|
||||||
# Use manager_id if user has one, or user's own id if they are a manager
|
|
||||||
parking_manager_id = target_user.manager_id
|
|
||||||
if not parking_manager_id and target_user.role == "manager":
|
|
||||||
parking_manager_id = target_user.id
|
|
||||||
|
|
||||||
if old_status != status and parking_manager_id:
|
|
||||||
try:
|
|
||||||
handle_presence_change(
|
|
||||||
user_id, date_str,
|
|
||||||
old_status or "absent", status,
|
|
||||||
parking_manager_id, db
|
|
||||||
)
|
|
||||||
except Exception as e:
|
|
||||||
config.logger.warning(f"Parking handler failed for user {user_id} on {date_str}: {e}")
|
|
||||||
|
|
||||||
current_date += timedelta(days=1)
|
|
||||||
|
|
||||||
db.commit()
|
|
||||||
return results
|
|
||||||
|
|
||||||
|
|
||||||
def _delete_presence(
|
def _delete_presence(
|
||||||
user_id: str,
|
user_id: str,
|
||||||
date: str,
|
presence_date: date,
|
||||||
db: Session,
|
db: Session,
|
||||||
target_user: User
|
target_user: User
|
||||||
) -> dict:
|
) -> dict:
|
||||||
"""
|
"""
|
||||||
Core presence deletion logic - shared by user and admin routes.
|
Core presence deletion logic - shared by user and admin routes.
|
||||||
"""
|
"""
|
||||||
parse_date(date)
|
|
||||||
|
|
||||||
presence = db.query(UserPresence).filter(
|
presence = db.query(UserPresence).filter(
|
||||||
UserPresence.user_id == user_id,
|
UserPresence.user_id == user_id,
|
||||||
UserPresence.date == date
|
UserPresence.date == presence_date
|
||||||
).first()
|
).first()
|
||||||
|
|
||||||
if not presence:
|
if not presence:
|
||||||
@@ -242,20 +139,15 @@ def _delete_presence(
|
|||||||
db.delete(presence)
|
db.delete(presence)
|
||||||
db.commit()
|
db.commit()
|
||||||
|
|
||||||
# Use manager_id if user has one, or user's own id if they are a manager
|
if target_user.office_id:
|
||||||
parking_manager_id = target_user.manager_id
|
|
||||||
if not parking_manager_id and target_user.role == "manager":
|
|
||||||
parking_manager_id = target_user.id
|
|
||||||
|
|
||||||
if parking_manager_id:
|
|
||||||
try:
|
try:
|
||||||
handle_presence_change(
|
handle_presence_change(
|
||||||
user_id, date,
|
user_id, presence_date,
|
||||||
old_status, "absent",
|
old_status, PresenceStatus.ABSENT,
|
||||||
parking_manager_id, db
|
target_user.office_id, db
|
||||||
)
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
config.logger.warning(f"Parking handler failed for user {user_id} on {date}: {e}")
|
config.logger.warning(f"Parking handler failed for user {user_id} on {presence_date}: {e}")
|
||||||
|
|
||||||
return {"message": "Presence deleted"}
|
return {"message": "Presence deleted"}
|
||||||
|
|
||||||
@@ -267,34 +159,26 @@ def mark_presence(data: PresenceMarkRequest, db: Session = Depends(get_db), curr
|
|||||||
return _mark_presence_for_user(current_user.id, data.date, data.status, db, current_user)
|
return _mark_presence_for_user(current_user.id, data.date, data.status, db, current_user)
|
||||||
|
|
||||||
|
|
||||||
@router.post("/mark-bulk", response_model=List[PresenceResponse])
|
|
||||||
def mark_bulk_presence(data: BulkPresenceRequest, db: Session = Depends(get_db), current_user=Depends(get_current_user)):
|
|
||||||
"""Mark presence for a date range"""
|
|
||||||
return _bulk_mark_presence(
|
|
||||||
current_user.id, data.start_date, data.end_date,
|
|
||||||
data.status, data.days, db, current_user
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/my-presences", response_model=List[PresenceResponse])
|
@router.get("/my-presences", response_model=List[PresenceResponse])
|
||||||
def get_my_presences(start_date: str = None, end_date: str = None, db: Session = Depends(get_db), current_user=Depends(get_current_user)):
|
def get_my_presences(start_date: date = None, end_date: date = None, db: Session = Depends(get_db), current_user=Depends(get_current_user)):
|
||||||
"""Get current user's presences"""
|
"""Get current user's presences"""
|
||||||
query = db.query(UserPresence).filter(UserPresence.user_id == current_user.id)
|
query = db.query(UserPresence).filter(UserPresence.user_id == current_user.id)
|
||||||
|
|
||||||
if start_date:
|
if start_date:
|
||||||
parse_date(start_date)
|
|
||||||
query = query.filter(UserPresence.date >= start_date)
|
query = query.filter(UserPresence.date >= start_date)
|
||||||
if end_date:
|
if end_date:
|
||||||
parse_date(end_date)
|
|
||||||
query = query.filter(UserPresence.date <= end_date)
|
query = query.filter(UserPresence.date <= end_date)
|
||||||
|
|
||||||
return query.order_by(UserPresence.date.desc()).all()
|
return query.order_by(UserPresence.date.desc()).all()
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/{date}")
|
@router.delete("/{date_val}")
|
||||||
def delete_presence(date: str, db: Session = Depends(get_db), current_user=Depends(get_current_user)):
|
def delete_presence(date_val: date, db: Session = Depends(get_db), current_user=Depends(get_current_user)):
|
||||||
"""Delete presence for a date"""
|
"""Delete presence for a date"""
|
||||||
return _delete_presence(current_user.id, date, db, current_user)
|
return _delete_presence(current_user.id, date_val, db, current_user)
|
||||||
|
|
||||||
|
|
||||||
# Admin/Manager Routes
|
# Admin/Manager Routes
|
||||||
@@ -309,66 +193,47 @@ def admin_mark_presence(data: AdminPresenceMarkRequest, db: Session = Depends(ge
|
|||||||
return _mark_presence_for_user(data.user_id, data.date, data.status, db, target_user)
|
return _mark_presence_for_user(data.user_id, data.date, data.status, db, target_user)
|
||||||
|
|
||||||
|
|
||||||
@router.post("/admin/mark-bulk", response_model=List[PresenceResponse])
|
|
||||||
def admin_mark_bulk_presence(data: AdminBulkPresenceRequest, db: Session = Depends(get_db), current_user=Depends(require_manager_or_admin)):
|
|
||||||
"""Bulk mark presence for any user (manager/admin)"""
|
|
||||||
target_user = db.query(User).filter(User.id == data.user_id).first()
|
|
||||||
if not target_user:
|
|
||||||
raise HTTPException(status_code=404, detail="User not found")
|
|
||||||
|
|
||||||
check_manager_access(current_user, target_user, db)
|
|
||||||
return _bulk_mark_presence(
|
|
||||||
data.user_id, data.start_date, data.end_date,
|
|
||||||
data.status, data.days, db, target_user
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/admin/{user_id}/{date}")
|
|
||||||
def admin_delete_presence(user_id: str, date: str, db: Session = Depends(get_db), current_user=Depends(require_manager_or_admin)):
|
@router.delete("/admin/{user_id}/{date_val}")
|
||||||
|
def admin_delete_presence(user_id: str, date_val: date, db: Session = Depends(get_db), current_user=Depends(require_manager_or_admin)):
|
||||||
"""Delete presence for any user (manager/admin)"""
|
"""Delete presence for any user (manager/admin)"""
|
||||||
target_user = db.query(User).filter(User.id == user_id).first()
|
target_user = db.query(User).filter(User.id == user_id).first()
|
||||||
if not target_user:
|
if not target_user:
|
||||||
raise HTTPException(status_code=404, detail="User not found")
|
raise HTTPException(status_code=404, detail="User not found")
|
||||||
|
|
||||||
check_manager_access(current_user, target_user, db)
|
check_manager_access(current_user, target_user, db)
|
||||||
return _delete_presence(user_id, date, db, target_user)
|
return _delete_presence(user_id, date_val, db, target_user)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/team")
|
@router.get("/team")
|
||||||
def get_team_presences(start_date: str, end_date: str, manager_id: str = None, db: Session = Depends(get_db), current_user=Depends(get_current_user)):
|
def get_team_presences(start_date: date, end_date: date, office_id: str = None, db: Session = Depends(get_db), current_user=Depends(get_current_user)):
|
||||||
"""Get team presences with parking info, filtered by manager.
|
"""Get office presences with parking info.
|
||||||
- Admins can see all teams
|
- Admins can see all users (or filter by office_id)
|
||||||
- Managers see their own team
|
- Managers see their own office's users
|
||||||
- Employees can only see their own team (read-only view)
|
- Employees can see their own office's users (read-only view)
|
||||||
"""
|
"""
|
||||||
parse_date(start_date)
|
|
||||||
parse_date(end_date)
|
|
||||||
|
|
||||||
# Get users based on permissions and manager filter
|
if current_user.role == UserRole.ADMIN:
|
||||||
# Note: Manager is part of their own team (for parking assignment purposes)
|
if office_id:
|
||||||
if current_user.role == "employee":
|
users = db.query(User).filter(User.office_id == office_id).all()
|
||||||
# Employees can only see their own team (users with same manager_id + the manager)
|
else:
|
||||||
if not current_user.manager_id:
|
users = db.query(User).all()
|
||||||
return [] # No manager assigned, no team to show
|
|
||||||
users = db.query(User).filter(
|
elif current_user.office_id:
|
||||||
(User.manager_id == current_user.manager_id) | (User.id == current_user.manager_id)
|
# Non-admin users see their office members
|
||||||
).all()
|
users = db.query(User).filter(User.office_id == current_user.office_id).all()
|
||||||
elif manager_id:
|
|
||||||
# Filter by specific manager (for admins/managers) - include the manager themselves
|
|
||||||
users = db.query(User).filter(
|
|
||||||
(User.manager_id == manager_id) | (User.id == manager_id)
|
|
||||||
).all()
|
|
||||||
elif current_user.role == "admin":
|
|
||||||
# Admin sees all users
|
|
||||||
users = db.query(User).all()
|
|
||||||
else:
|
else:
|
||||||
# Manager sees their team + themselves
|
# No office assigned
|
||||||
users = db.query(User).filter(
|
return []
|
||||||
(User.manager_id == current_user.id) | (User.id == current_user.id)
|
|
||||||
).all()
|
|
||||||
|
|
||||||
# Batch query presences and parking for all users
|
# Batch query presences and parking for all selected users
|
||||||
user_ids = [u.id for u in users]
|
user_ids = [u.id for u in users]
|
||||||
|
if not user_ids:
|
||||||
|
return []
|
||||||
|
|
||||||
presences = db.query(UserPresence).filter(
|
presences = db.query(UserPresence).filter(
|
||||||
UserPresence.user_id.in_(user_ids),
|
UserPresence.user_id.in_(user_ids),
|
||||||
UserPresence.date >= start_date,
|
UserPresence.date >= start_date,
|
||||||
@@ -389,7 +254,7 @@ def get_team_presences(start_date: str, end_date: str, manager_id: str = None, d
|
|||||||
parking_lookup[p.user_id] = []
|
parking_lookup[p.user_id] = []
|
||||||
parking_info_lookup[p.user_id] = []
|
parking_info_lookup[p.user_id] = []
|
||||||
parking_lookup[p.user_id].append(p.date)
|
parking_lookup[p.user_id].append(p.date)
|
||||||
spot_display_name = get_spot_display_name(p.spot_id, p.manager_id, db)
|
spot_display_name = get_spot_display_name(p.spot_id, p.office_id, db)
|
||||||
parking_info_lookup[p.user_id].append({
|
parking_info_lookup[p.user_id].append({
|
||||||
"id": p.id,
|
"id": p.id,
|
||||||
"date": p.date,
|
"date": p.date,
|
||||||
@@ -397,10 +262,10 @@ def get_team_presences(start_date: str, end_date: str, manager_id: str = None, d
|
|||||||
"spot_display_name": spot_display_name
|
"spot_display_name": spot_display_name
|
||||||
})
|
})
|
||||||
|
|
||||||
# Build manager lookup for display
|
# Build office lookup for display (replacing old manager_lookup)
|
||||||
manager_ids = list(set(u.manager_id for u in users if u.manager_id))
|
office_ids = list(set(u.office_id for u in users if u.office_id))
|
||||||
managers = db.query(User).filter(User.id.in_(manager_ids)).all() if manager_ids else []
|
offices = db.query(Office).filter(Office.id.in_(office_ids)).all() if office_ids else []
|
||||||
manager_lookup = {m.id: m.name for m in managers}
|
office_lookup = {o.id: o.name for o in offices}
|
||||||
|
|
||||||
# Build response
|
# Build response
|
||||||
result = []
|
result = []
|
||||||
@@ -410,8 +275,8 @@ def get_team_presences(start_date: str, end_date: str, manager_id: str = None, d
|
|||||||
result.append({
|
result.append({
|
||||||
"id": user.id,
|
"id": user.id,
|
||||||
"name": user.name,
|
"name": user.name,
|
||||||
"manager_id": user.manager_id,
|
"office_id": user.office_id,
|
||||||
"manager_name": manager_lookup.get(user.manager_id),
|
"office_name": office_lookup.get(user.office_id),
|
||||||
"presences": [{"date": p.date, "status": p.status} for p in user_presences],
|
"presences": [{"date": p.date, "status": p.status} for p in user_presences],
|
||||||
"parking_dates": parking_lookup.get(user.id, []),
|
"parking_dates": parking_lookup.get(user.id, []),
|
||||||
"parking_info": parking_info_lookup.get(user.id, [])
|
"parking_info": parking_info_lookup.get(user.id, [])
|
||||||
@@ -421,7 +286,7 @@ def get_team_presences(start_date: str, end_date: str, manager_id: str = None, d
|
|||||||
|
|
||||||
|
|
||||||
@router.get("/admin/{user_id}")
|
@router.get("/admin/{user_id}")
|
||||||
def get_user_presences(user_id: str, start_date: str = None, end_date: str = None, db: Session = Depends(get_db), current_user=Depends(require_manager_or_admin)):
|
def get_user_presences(user_id: str, start_date: date = None, end_date: date = None, db: Session = Depends(get_db), current_user=Depends(require_manager_or_admin)):
|
||||||
"""Get any user's presences with parking info (manager/admin)"""
|
"""Get any user's presences with parking info (manager/admin)"""
|
||||||
target_user = db.query(User).filter(User.id == user_id).first()
|
target_user = db.query(User).filter(User.id == user_id).first()
|
||||||
if not target_user:
|
if not target_user:
|
||||||
@@ -432,24 +297,23 @@ def get_user_presences(user_id: str, start_date: str = None, end_date: str = Non
|
|||||||
query = db.query(UserPresence).filter(UserPresence.user_id == user_id)
|
query = db.query(UserPresence).filter(UserPresence.user_id == user_id)
|
||||||
|
|
||||||
if start_date:
|
if start_date:
|
||||||
parse_date(start_date)
|
|
||||||
query = query.filter(UserPresence.date >= start_date)
|
query = query.filter(UserPresence.date >= start_date)
|
||||||
if end_date:
|
if end_date:
|
||||||
parse_date(end_date)
|
|
||||||
query = query.filter(UserPresence.date <= end_date)
|
query = query.filter(UserPresence.date <= end_date)
|
||||||
|
|
||||||
presences = query.order_by(UserPresence.date.desc()).all()
|
presences = query.order_by(UserPresence.date.desc()).all()
|
||||||
|
|
||||||
# Batch query parking assignments
|
# Batch query parking assignments
|
||||||
date_strs = [p.date for p in presences]
|
dates = [p.date for p in presences]
|
||||||
parking_map = {}
|
parking_map = {}
|
||||||
if date_strs:
|
if dates:
|
||||||
|
# Note: Assignments link to user. We can find spot display name by looking up assignment -> office
|
||||||
assignments = db.query(DailyParkingAssignment).filter(
|
assignments = db.query(DailyParkingAssignment).filter(
|
||||||
DailyParkingAssignment.user_id == user_id,
|
DailyParkingAssignment.user_id == user_id,
|
||||||
DailyParkingAssignment.date.in_(date_strs)
|
DailyParkingAssignment.date.in_(dates)
|
||||||
).all()
|
).all()
|
||||||
for a in assignments:
|
for a in assignments:
|
||||||
parking_map[a.date] = get_spot_display_name(a.spot_id, a.manager_id, db)
|
parking_map[a.date] = get_spot_display_name(a.spot_id, a.office_id, db)
|
||||||
|
|
||||||
# Build response
|
# Build response
|
||||||
result = []
|
result = []
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ from pydantic import BaseModel, EmailStr
|
|||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from database.connection import get_db
|
from database.connection import get_db
|
||||||
from database.models import User
|
from database.models import User, UserRole, Office
|
||||||
from utils.auth_middleware import get_current_user, require_admin
|
from utils.auth_middleware import get_current_user, require_admin
|
||||||
from utils.helpers import (
|
from utils.helpers import (
|
||||||
generate_uuid, is_ldap_user, is_ldap_admin,
|
generate_uuid, is_ldap_user, is_ldap_admin,
|
||||||
@@ -25,16 +25,14 @@ class UserCreate(BaseModel):
|
|||||||
email: EmailStr
|
email: EmailStr
|
||||||
password: str
|
password: str
|
||||||
name: str | None = None
|
name: str | None = None
|
||||||
role: str = "employee"
|
role: UserRole = UserRole.EMPLOYEE
|
||||||
manager_id: str | None = None
|
office_id: str | None = None
|
||||||
|
|
||||||
|
|
||||||
class UserUpdate(BaseModel):
|
class UserUpdate(BaseModel):
|
||||||
name: str | None = None
|
name: str | None = None
|
||||||
role: str | None = None
|
role: UserRole | None = None
|
||||||
manager_id: str | None = None
|
office_id: str | None = None
|
||||||
manager_parking_quota: int | None = None
|
|
||||||
manager_spot_prefix: str | None = None
|
|
||||||
|
|
||||||
|
|
||||||
class ProfileUpdate(BaseModel):
|
class ProfileUpdate(BaseModel):
|
||||||
@@ -44,11 +42,11 @@ class ProfileUpdate(BaseModel):
|
|||||||
class SettingsUpdate(BaseModel):
|
class SettingsUpdate(BaseModel):
|
||||||
week_start_day: int | None = None
|
week_start_day: int | None = None
|
||||||
# Notification preferences
|
# Notification preferences
|
||||||
notify_weekly_parking: int | None = None
|
notify_weekly_parking: bool | None = None
|
||||||
notify_daily_parking: int | None = None
|
notify_daily_parking: bool | None = None
|
||||||
notify_daily_parking_hour: int | None = None
|
notify_daily_parking_hour: int | None = None
|
||||||
notify_daily_parking_minute: int | None = None
|
notify_daily_parking_minute: int | None = None
|
||||||
notify_parking_changes: int | None = None
|
notify_parking_changes: bool | None = None
|
||||||
|
|
||||||
|
|
||||||
class ChangePasswordRequest(BaseModel):
|
class ChangePasswordRequest(BaseModel):
|
||||||
@@ -60,61 +58,54 @@ class UserResponse(BaseModel):
|
|||||||
id: str
|
id: str
|
||||||
email: str
|
email: str
|
||||||
name: str | None
|
name: str | None
|
||||||
role: str
|
role: UserRole
|
||||||
manager_id: str | None = None
|
office_id: str | None = None
|
||||||
manager_name: str | None = None
|
office_name: str | None = None
|
||||||
manager_parking_quota: int | None = None
|
|
||||||
manager_spot_prefix: str | None = None
|
|
||||||
managed_user_count: int | None = None
|
|
||||||
is_ldap_user: bool = False
|
is_ldap_user: bool = False
|
||||||
is_ldap_admin: bool = False
|
is_ldap_admin: bool = False
|
||||||
created_at: str | None
|
created_at: str | None
|
||||||
|
parking_ratio: float | None = None
|
||||||
|
|
||||||
class Config:
|
class Config:
|
||||||
from_attributes = True
|
from_attributes = True
|
||||||
|
|
||||||
|
|
||||||
def user_to_response(user: User, db: Session, manager_lookup: dict = None, managed_counts: dict = None) -> dict:
|
def user_to_response(user: User, db: Session, office_lookup: dict = None) -> dict:
|
||||||
"""
|
"""
|
||||||
Convert user to response dict with computed fields.
|
Convert user to response dict with computed fields.
|
||||||
|
|
||||||
Args:
|
|
||||||
user: The user to convert
|
|
||||||
db: Database session
|
|
||||||
manager_lookup: Optional pre-fetched dict of manager_id -> name (for batch operations)
|
|
||||||
managed_counts: Optional pre-fetched dict of user_id -> managed_user_count (for batch operations)
|
|
||||||
"""
|
"""
|
||||||
# Get manager name - use lookup if available, otherwise query
|
# Get office name - use lookup if available, otherwise query
|
||||||
manager_name = None
|
office_name = None
|
||||||
if user.manager_id:
|
if user.office_id:
|
||||||
if manager_lookup is not None:
|
if office_lookup is not None:
|
||||||
manager_name = manager_lookup.get(user.manager_id)
|
office_name = office_lookup.get(user.office_id)
|
||||||
else:
|
else:
|
||||||
manager = db.query(User).filter(User.id == user.manager_id).first()
|
office = db.query(Office).filter(Office.id == user.office_id).first()
|
||||||
if manager:
|
if office:
|
||||||
manager_name = manager.name
|
office_name = office.name
|
||||||
|
|
||||||
# Count managed users if this user is a manager
|
# Calculate parking ratio (score)
|
||||||
managed_user_count = None
|
parking_ratio = None
|
||||||
if user.role == "manager":
|
if user.office_id:
|
||||||
if managed_counts is not None:
|
try:
|
||||||
managed_user_count = managed_counts.get(user.id, 0)
|
# Avoid circular import by importing inside function if needed,
|
||||||
else:
|
# or ensure services.parking doesn't import this file.
|
||||||
managed_user_count = db.query(User).filter(User.manager_id == user.id).count()
|
from services.parking import get_user_parking_ratio
|
||||||
|
parking_ratio = get_user_parking_ratio(user.id, user.office_id, db)
|
||||||
|
except ImportError:
|
||||||
|
pass
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"id": user.id,
|
"id": user.id,
|
||||||
"email": user.email,
|
"email": user.email,
|
||||||
"name": user.name,
|
"name": user.name,
|
||||||
"role": user.role,
|
"role": user.role,
|
||||||
"manager_id": user.manager_id,
|
"office_id": user.office_id,
|
||||||
"manager_name": manager_name,
|
"office_name": office_name,
|
||||||
"manager_parking_quota": user.manager_parking_quota,
|
|
||||||
"manager_spot_prefix": user.manager_spot_prefix,
|
|
||||||
"managed_user_count": managed_user_count,
|
|
||||||
"is_ldap_user": is_ldap_user(user),
|
"is_ldap_user": is_ldap_user(user),
|
||||||
"is_ldap_admin": is_ldap_admin(user),
|
"is_ldap_admin": is_ldap_admin(user),
|
||||||
"created_at": user.created_at
|
"created_at": user.created_at.isoformat() if user.created_at else None,
|
||||||
|
"parking_ratio": parking_ratio
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -125,23 +116,12 @@ def list_users(db: Session = Depends(get_db), user=Depends(require_admin)):
|
|||||||
users = db.query(User).all()
|
users = db.query(User).all()
|
||||||
|
|
||||||
# Build lookups to avoid N+1 queries
|
# Build lookups to avoid N+1 queries
|
||||||
# Manager lookup: id -> name
|
# Office lookup: id -> name
|
||||||
manager_ids = list(set(u.manager_id for u in users if u.manager_id))
|
office_ids = list(set(u.office_id for u in users if u.office_id))
|
||||||
managers = db.query(User).filter(User.id.in_(manager_ids)).all() if manager_ids else []
|
offices = db.query(Office).filter(Office.id.in_(office_ids)).all() if office_ids else []
|
||||||
manager_lookup = {m.id: m.name for m in managers}
|
office_lookup = {o.id: o.name for o in offices}
|
||||||
|
|
||||||
# Managed user counts for managers
|
return [user_to_response(u, db, office_lookup) for u in users]
|
||||||
from sqlalchemy import func
|
|
||||||
manager_user_ids = [u.id for u in users if u.role == "manager"]
|
|
||||||
if manager_user_ids:
|
|
||||||
counts = db.query(User.manager_id, func.count(User.id)).filter(
|
|
||||||
User.manager_id.in_(manager_user_ids)
|
|
||||||
).group_by(User.manager_id).all()
|
|
||||||
managed_counts = {manager_id: count for manager_id, count in counts}
|
|
||||||
else:
|
|
||||||
managed_counts = {}
|
|
||||||
|
|
||||||
return [user_to_response(u, db, manager_lookup, managed_counts) for u in users]
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/{user_id}")
|
@router.get("/{user_id}")
|
||||||
@@ -162,18 +142,17 @@ def create_user(data: UserCreate, db: Session = Depends(get_db), user=Depends(re
|
|||||||
if db.query(User).filter(User.email == data.email).first():
|
if db.query(User).filter(User.email == data.email).first():
|
||||||
raise HTTPException(status_code=400, detail="Email already registered")
|
raise HTTPException(status_code=400, detail="Email already registered")
|
||||||
|
|
||||||
if data.role not in ["admin", "manager", "employee"]:
|
# Role validation handled by Pydantic Enum
|
||||||
raise HTTPException(status_code=400, detail="Invalid role")
|
|
||||||
|
|
||||||
# Validate password strength
|
# Validate password strength
|
||||||
password_errors = validate_password(data.password)
|
password_errors = validate_password(data.password)
|
||||||
if password_errors:
|
if password_errors:
|
||||||
raise HTTPException(status_code=400, detail=format_password_errors(password_errors))
|
raise HTTPException(status_code=400, detail=format_password_errors(password_errors))
|
||||||
|
|
||||||
if data.manager_id:
|
if data.office_id:
|
||||||
manager = db.query(User).filter(User.id == data.manager_id).first()
|
office = db.query(Office).filter(Office.id == data.office_id).first()
|
||||||
if not manager or manager.role != "manager":
|
if not office:
|
||||||
raise HTTPException(status_code=400, detail="Invalid manager")
|
raise HTTPException(status_code=400, detail="Invalid office")
|
||||||
|
|
||||||
new_user = User(
|
new_user = User(
|
||||||
id=generate_uuid(),
|
id=generate_uuid(),
|
||||||
@@ -181,8 +160,8 @@ def create_user(data: UserCreate, db: Session = Depends(get_db), user=Depends(re
|
|||||||
password_hash=hash_password(data.password),
|
password_hash=hash_password(data.password),
|
||||||
name=data.name,
|
name=data.name,
|
||||||
role=data.role,
|
role=data.role,
|
||||||
manager_id=data.manager_id,
|
office_id=data.office_id,
|
||||||
created_at=datetime.utcnow().isoformat()
|
created_at=datetime.utcnow()
|
||||||
)
|
)
|
||||||
|
|
||||||
db.add(new_user)
|
db.add(new_user)
|
||||||
@@ -211,54 +190,20 @@ def update_user(user_id: str, data: UserUpdate, db: Session = Depends(get_db), u
|
|||||||
|
|
||||||
# Role update
|
# Role update
|
||||||
if data.role is not None:
|
if data.role is not None:
|
||||||
if data.role not in ["admin", "manager", "employee"]:
|
|
||||||
raise HTTPException(status_code=400, detail="Invalid role")
|
|
||||||
# Can't change admin role for LDAP admins (they get admin from parking_admins group)
|
# Can't change admin role for LDAP admins (they get admin from parking_admins group)
|
||||||
if target_is_ldap_admin and data.role != "admin":
|
if target_is_ldap_admin and data.role != UserRole.ADMIN:
|
||||||
raise HTTPException(status_code=400, detail="Admin role is managed by LDAP group (parking_admins)")
|
raise HTTPException(status_code=400, detail="Admin role is managed by LDAP group (parking_admins)")
|
||||||
# If changing from manager to another role, check for managed users
|
|
||||||
if target.role == "manager" and data.role != "manager":
|
|
||||||
managed_count = db.query(User).filter(User.manager_id == user_id).count()
|
|
||||||
if managed_count > 0:
|
|
||||||
raise HTTPException(status_code=400, detail=f"Cannot change role: {managed_count} users are assigned to this manager")
|
|
||||||
# Clear manager-specific fields
|
|
||||||
target.manager_parking_quota = 0
|
|
||||||
target.manager_spot_prefix = None
|
|
||||||
target.role = data.role
|
target.role = data.role
|
||||||
|
|
||||||
# Manager assignment (any user including admins can be assigned to a manager)
|
# Office assignment
|
||||||
if data.manager_id is not None:
|
if "office_id" in data.__fields_set__:
|
||||||
if data.manager_id:
|
if data.office_id:
|
||||||
manager = db.query(User).filter(User.id == data.manager_id).first()
|
office = db.query(Office).filter(Office.id == data.office_id).first()
|
||||||
if not manager or manager.role != "manager":
|
if not office:
|
||||||
raise HTTPException(status_code=400, detail="Invalid manager")
|
raise HTTPException(status_code=400, detail="Invalid office")
|
||||||
if data.manager_id == user_id:
|
target.office_id = data.office_id if data.office_id else None
|
||||||
raise HTTPException(status_code=400, detail="User cannot be their own manager")
|
|
||||||
target.manager_id = data.manager_id if data.manager_id else None
|
|
||||||
|
|
||||||
# Manager-specific fields
|
target.updated_at = datetime.utcnow()
|
||||||
if data.manager_parking_quota is not None:
|
|
||||||
if target.role != "manager":
|
|
||||||
raise HTTPException(status_code=400, detail="Parking quota only for managers")
|
|
||||||
target.manager_parking_quota = data.manager_parking_quota
|
|
||||||
|
|
||||||
if data.manager_spot_prefix is not None:
|
|
||||||
if target.role != "manager":
|
|
||||||
raise HTTPException(status_code=400, detail="Spot prefix only for managers")
|
|
||||||
prefix = data.manager_spot_prefix.upper() if data.manager_spot_prefix else None
|
|
||||||
if prefix and not prefix.isalpha():
|
|
||||||
raise HTTPException(status_code=400, detail="Spot prefix must be a letter")
|
|
||||||
# Check for duplicate prefix
|
|
||||||
if prefix:
|
|
||||||
existing = db.query(User).filter(
|
|
||||||
User.manager_spot_prefix == prefix,
|
|
||||||
User.id != user_id
|
|
||||||
).first()
|
|
||||||
if existing:
|
|
||||||
raise HTTPException(status_code=400, detail=f"Spot prefix '{prefix}' is already used by another manager")
|
|
||||||
target.manager_spot_prefix = prefix
|
|
||||||
|
|
||||||
target.updated_at = datetime.utcnow().isoformat()
|
|
||||||
db.commit()
|
db.commit()
|
||||||
db.refresh(target)
|
db.refresh(target)
|
||||||
return user_to_response(target, db)
|
return user_to_response(target, db)
|
||||||
@@ -274,12 +219,6 @@ def delete_user(user_id: str, db: Session = Depends(get_db), current_user=Depend
|
|||||||
if not target:
|
if not target:
|
||||||
raise HTTPException(status_code=404, detail="User not found")
|
raise HTTPException(status_code=404, detail="User not found")
|
||||||
|
|
||||||
# Check if user is a manager with managed users
|
|
||||||
if target.role == "manager":
|
|
||||||
managed_count = db.query(User).filter(User.manager_id == user_id).count()
|
|
||||||
if managed_count > 0:
|
|
||||||
raise HTTPException(status_code=400, detail=f"Cannot delete: {managed_count} users are assigned to this manager")
|
|
||||||
|
|
||||||
db.delete(target)
|
db.delete(target)
|
||||||
db.commit()
|
db.commit()
|
||||||
return {"message": "User deleted"}
|
return {"message": "User deleted"}
|
||||||
@@ -289,20 +228,20 @@ def delete_user(user_id: str, db: Session = Depends(get_db), current_user=Depend
|
|||||||
@router.get("/me/profile")
|
@router.get("/me/profile")
|
||||||
def get_profile(db: Session = Depends(get_db), current_user=Depends(get_current_user)):
|
def get_profile(db: Session = Depends(get_db), current_user=Depends(get_current_user)):
|
||||||
"""Get current user's profile"""
|
"""Get current user's profile"""
|
||||||
# Get manager name
|
# Get office name
|
||||||
manager_name = None
|
office_name = None
|
||||||
if current_user.manager_id:
|
if current_user.office_id:
|
||||||
manager = db.query(User).filter(User.id == current_user.manager_id).first()
|
office = db.query(Office).filter(Office.id == current_user.office_id).first()
|
||||||
if manager:
|
if office:
|
||||||
manager_name = manager.name
|
office_name = office.name
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"id": current_user.id,
|
"id": current_user.id,
|
||||||
"email": current_user.email,
|
"email": current_user.email,
|
||||||
"name": current_user.name,
|
"name": current_user.name,
|
||||||
"role": current_user.role,
|
"role": current_user.role,
|
||||||
"manager_id": current_user.manager_id,
|
"office_id": current_user.office_id,
|
||||||
"manager_name": manager_name,
|
"office_name": office_name,
|
||||||
"is_ldap_user": is_ldap_user(current_user)
|
"is_ldap_user": is_ldap_user(current_user)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -314,7 +253,7 @@ def update_profile(data: ProfileUpdate, db: Session = Depends(get_db), current_u
|
|||||||
if is_ldap_user(current_user):
|
if is_ldap_user(current_user):
|
||||||
raise HTTPException(status_code=400, detail="Name is managed by LDAP")
|
raise HTTPException(status_code=400, detail="Name is managed by LDAP")
|
||||||
current_user.name = data.name
|
current_user.name = data.name
|
||||||
current_user.updated_at = datetime.utcnow().isoformat()
|
current_user.updated_at = datetime.utcnow()
|
||||||
db.commit()
|
db.commit()
|
||||||
|
|
||||||
return {"message": "Profile updated"}
|
return {"message": "Profile updated"}
|
||||||
@@ -325,11 +264,11 @@ def get_settings(current_user=Depends(get_current_user)):
|
|||||||
"""Get current user's settings"""
|
"""Get current user's settings"""
|
||||||
return {
|
return {
|
||||||
"week_start_day": get_notification_default(current_user.week_start_day, 0),
|
"week_start_day": get_notification_default(current_user.week_start_day, 0),
|
||||||
"notify_weekly_parking": get_notification_default(current_user.notify_weekly_parking, 1),
|
"notify_weekly_parking": get_notification_default(current_user.notify_weekly_parking, True),
|
||||||
"notify_daily_parking": get_notification_default(current_user.notify_daily_parking, 1),
|
"notify_daily_parking": get_notification_default(current_user.notify_daily_parking, True),
|
||||||
"notify_daily_parking_hour": get_notification_default(current_user.notify_daily_parking_hour, 8),
|
"notify_daily_parking_hour": get_notification_default(current_user.notify_daily_parking_hour, 8),
|
||||||
"notify_daily_parking_minute": get_notification_default(current_user.notify_daily_parking_minute, 0),
|
"notify_daily_parking_minute": get_notification_default(current_user.notify_daily_parking_minute, 0),
|
||||||
"notify_parking_changes": get_notification_default(current_user.notify_parking_changes, 1)
|
"notify_parking_changes": get_notification_default(current_user.notify_parking_changes, True)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -337,8 +276,8 @@ def get_settings(current_user=Depends(get_current_user)):
|
|||||||
def update_settings(data: SettingsUpdate, db: Session = Depends(get_db), current_user=Depends(get_current_user)):
|
def update_settings(data: SettingsUpdate, db: Session = Depends(get_db), current_user=Depends(get_current_user)):
|
||||||
"""Update current user's settings"""
|
"""Update current user's settings"""
|
||||||
if data.week_start_day is not None:
|
if data.week_start_day is not None:
|
||||||
if data.week_start_day not in [0, 1]:
|
if data.week_start_day not in [0, 6]:
|
||||||
raise HTTPException(status_code=400, detail="Week start must be 0 (Sunday) or 1 (Monday)")
|
raise HTTPException(status_code=400, detail="Week start must be 0 (Monday) or 6 (Sunday)")
|
||||||
current_user.week_start_day = data.week_start_day
|
current_user.week_start_day = data.week_start_day
|
||||||
|
|
||||||
# Notification preferences
|
# Notification preferences
|
||||||
@@ -361,7 +300,7 @@ def update_settings(data: SettingsUpdate, db: Session = Depends(get_db), current
|
|||||||
if data.notify_parking_changes is not None:
|
if data.notify_parking_changes is not None:
|
||||||
current_user.notify_parking_changes = data.notify_parking_changes
|
current_user.notify_parking_changes = data.notify_parking_changes
|
||||||
|
|
||||||
current_user.updated_at = datetime.utcnow().isoformat()
|
current_user.updated_at = datetime.utcnow()
|
||||||
db.commit()
|
db.commit()
|
||||||
return {
|
return {
|
||||||
"message": "Settings updated",
|
"message": "Settings updated",
|
||||||
@@ -389,7 +328,7 @@ def change_password(data: ChangePasswordRequest, db: Session = Depends(get_db),
|
|||||||
raise HTTPException(status_code=400, detail=format_password_errors(password_errors))
|
raise HTTPException(status_code=400, detail=format_password_errors(password_errors))
|
||||||
|
|
||||||
current_user.password_hash = hash_password(data.new_password)
|
current_user.password_hash = hash_password(data.new_password)
|
||||||
current_user.updated_at = datetime.utcnow().isoformat()
|
current_user.updated_at = datetime.utcnow()
|
||||||
db.commit()
|
db.commit()
|
||||||
config.logger.info(f"User {current_user.email} changed password")
|
config.logger.info(f"User {current_user.email} changed password")
|
||||||
return {"message": "Password changed"}
|
return {"message": "Password changed"}
|
||||||
|
|||||||
@@ -3,8 +3,6 @@ services:
|
|||||||
build: .
|
build: .
|
||||||
container_name: parking
|
container_name: parking
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
ports:
|
|
||||||
- "8000:8000"
|
|
||||||
volumes:
|
volumes:
|
||||||
- ./data:/app/data
|
- ./data:/app/data
|
||||||
env_file:
|
env_file:
|
||||||
@@ -21,6 +19,13 @@ services:
|
|||||||
start_period: 10s
|
start_period: 10s
|
||||||
networks:
|
networks:
|
||||||
- org-network
|
- org-network
|
||||||
|
labels:
|
||||||
|
- "caddy=parking.lvh.me"
|
||||||
|
- "caddy.reverse_proxy={{upstreams 8000}}"
|
||||||
|
- "caddy.forward_auth=authelia:9091"
|
||||||
|
- "caddy.forward_auth.uri=/api/verify?rd=https://parking.lvh.me/"
|
||||||
|
- "caddy.forward_auth.copy_headers=Remote-User Remote-Groups Remote-Name Remote-Email"
|
||||||
|
|
||||||
|
|
||||||
networks:
|
networks:
|
||||||
org-network:
|
org-network:
|
||||||
|
|||||||
@@ -1,132 +0,0 @@
|
|||||||
"""
|
|
||||||
Create test database with sample data
|
|
||||||
Run: .venv/bin/python create_test_db.py
|
|
||||||
|
|
||||||
Manager-centric model:
|
|
||||||
- Users have a manager_id pointing to their manager
|
|
||||||
- Managers own parking spots (manager_parking_quota)
|
|
||||||
- Each manager has a spot prefix (A, B, C...) for display names
|
|
||||||
"""
|
|
||||||
import uuid
|
|
||||||
from datetime import datetime, timezone
|
|
||||||
from database.connection import engine, SessionLocal
|
|
||||||
from database.models import Base, User
|
|
||||||
from services.auth import hash_password
|
|
||||||
|
|
||||||
# Drop and recreate all tables for clean slate
|
|
||||||
Base.metadata.drop_all(bind=engine)
|
|
||||||
Base.metadata.create_all(bind=engine)
|
|
||||||
|
|
||||||
db = SessionLocal()
|
|
||||||
|
|
||||||
now = datetime.now(timezone.utc).isoformat()
|
|
||||||
password_hash = hash_password("password123")
|
|
||||||
|
|
||||||
# Create users with manager-centric model
|
|
||||||
# manager_id points to the user's manager
|
|
||||||
|
|
||||||
users_data = [
|
|
||||||
{
|
|
||||||
"id": "admin",
|
|
||||||
"email": "admin@example.com",
|
|
||||||
"name": "Admin User",
|
|
||||||
"role": "admin",
|
|
||||||
"manager_id": None, # Admins don't have managers
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "manager1",
|
|
||||||
"email": "manager1@example.com",
|
|
||||||
"name": "Alice Manager",
|
|
||||||
"role": "manager",
|
|
||||||
"manager_id": None, # Managers don't have managers
|
|
||||||
"manager_parking_quota": 3,
|
|
||||||
"manager_spot_prefix": "A",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "manager2",
|
|
||||||
"email": "manager2@example.com",
|
|
||||||
"name": "Bob Manager",
|
|
||||||
"role": "manager",
|
|
||||||
"manager_id": None,
|
|
||||||
"manager_parking_quota": 2,
|
|
||||||
"manager_spot_prefix": "B",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "user1",
|
|
||||||
"email": "user1@example.com",
|
|
||||||
"name": "User One",
|
|
||||||
"role": "employee",
|
|
||||||
"manager_id": "manager1", # Managed by Alice
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "user2",
|
|
||||||
"email": "user2@example.com",
|
|
||||||
"name": "User Two",
|
|
||||||
"role": "employee",
|
|
||||||
"manager_id": "manager1", # Managed by Alice
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "user3",
|
|
||||||
"email": "user3@example.com",
|
|
||||||
"name": "User Three",
|
|
||||||
"role": "employee",
|
|
||||||
"manager_id": "manager1", # Managed by Alice
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "user4",
|
|
||||||
"email": "user4@example.com",
|
|
||||||
"name": "User Four",
|
|
||||||
"role": "employee",
|
|
||||||
"manager_id": "manager2", # Managed by Bob
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "user5",
|
|
||||||
"email": "user5@example.com",
|
|
||||||
"name": "User Five",
|
|
||||||
"role": "employee",
|
|
||||||
"manager_id": "manager2", # Managed by Bob
|
|
||||||
},
|
|
||||||
]
|
|
||||||
|
|
||||||
for data in users_data:
|
|
||||||
user = User(
|
|
||||||
id=data["id"],
|
|
||||||
email=data["email"],
|
|
||||||
password_hash=password_hash,
|
|
||||||
name=data["name"],
|
|
||||||
role=data["role"],
|
|
||||||
manager_id=data.get("manager_id"),
|
|
||||||
manager_parking_quota=data.get("manager_parking_quota", 0),
|
|
||||||
manager_spot_prefix=data.get("manager_spot_prefix"),
|
|
||||||
created_at=now
|
|
||||||
)
|
|
||||||
db.add(user)
|
|
||||||
print(f"Created user: {user.email} ({user.role})")
|
|
||||||
|
|
||||||
db.commit()
|
|
||||||
db.close()
|
|
||||||
|
|
||||||
print("\n" + "="*60)
|
|
||||||
print("Test database created successfully!")
|
|
||||||
print("="*60)
|
|
||||||
print("\nTest accounts (all use password: password123):")
|
|
||||||
print("-"*60)
|
|
||||||
print(f"{'Email':<25} {'Role':<10} {'Manager':<15}")
|
|
||||||
print("-"*60)
|
|
||||||
print(f"{'admin@example.com':<25} {'admin':<10} {'-':<15}")
|
|
||||||
print(f"{'manager1@example.com':<25} {'manager':<10} {'-':<15}")
|
|
||||||
print(f"{'manager2@example.com':<25} {'manager':<10} {'-':<15}")
|
|
||||||
print(f"{'user1@example.com':<25} {'employee':<10} {'Alice':<15}")
|
|
||||||
print(f"{'user2@example.com':<25} {'employee':<10} {'Alice':<15}")
|
|
||||||
print(f"{'user3@example.com':<25} {'employee':<10} {'Alice':<15}")
|
|
||||||
print(f"{'user4@example.com':<25} {'employee':<10} {'Bob':<15}")
|
|
||||||
print(f"{'user5@example.com':<25} {'employee':<10} {'Bob':<15}")
|
|
||||||
print("-"*60)
|
|
||||||
print("\nParking pools:")
|
|
||||||
print(" Alice (manager1): 3 spots (A1,A2,A3)")
|
|
||||||
print(" -> manages: user1, user2, user3")
|
|
||||||
print(" -> 3 users, 3 spots = 100% ratio target")
|
|
||||||
print()
|
|
||||||
print(" Bob (manager2): 2 spots (B1,B2)")
|
|
||||||
print(" -> manages: user4, user5")
|
|
||||||
print(" -> 2 users, 2 spots = 100% ratio target")
|
|
||||||
@@ -2,12 +2,73 @@
|
|||||||
SQLAlchemy ORM Models
|
SQLAlchemy ORM Models
|
||||||
Clean, focused data models for parking management
|
Clean, focused data models for parking management
|
||||||
"""
|
"""
|
||||||
from sqlalchemy import Column, String, Integer, Text, ForeignKey, Index
|
import enum
|
||||||
|
from sqlalchemy import Column, String, Integer, Text, ForeignKey, Index, Enum, Date, DateTime, Boolean
|
||||||
from sqlalchemy.orm import relationship, declarative_base
|
from sqlalchemy.orm import relationship, declarative_base
|
||||||
|
from datetime import datetime, date
|
||||||
|
|
||||||
Base = declarative_base()
|
Base = declarative_base()
|
||||||
|
|
||||||
|
|
||||||
|
class UserRole(str, enum.Enum):
|
||||||
|
ADMIN = "admin"
|
||||||
|
MANAGER = "manager"
|
||||||
|
EMPLOYEE = "employee"
|
||||||
|
|
||||||
|
|
||||||
|
class PresenceStatus(str, enum.Enum):
|
||||||
|
PRESENT = "present"
|
||||||
|
REMOTE = "remote"
|
||||||
|
ABSENT = "absent"
|
||||||
|
|
||||||
|
|
||||||
|
class NotificationType(str, enum.Enum):
|
||||||
|
PRESENCE_REMINDER = "presence_reminder"
|
||||||
|
WEEKLY_PARKING = "weekly_parking"
|
||||||
|
DAILY_PARKING = "daily_parking"
|
||||||
|
PARKING_CHANGE = "parking_change"
|
||||||
|
|
||||||
|
|
||||||
|
class WeekDay(enum.IntEnum):
|
||||||
|
# Matches Python's calendar (0=Monday)? No!
|
||||||
|
# The current DB convention in ManagerWeeklyClosingDay seems to be 0=Sunday based on comment:
|
||||||
|
# "0=Sunday, 1=Monday, ..., 6=Saturday"
|
||||||
|
# To keep consistency with existing logic comments, we'll stick to that,
|
||||||
|
# OR we can switch to standard Python (0=Monday).
|
||||||
|
# Plan said: "IntEnum matching DB convention (0=Sunday, 1=Monday, ...)"
|
||||||
|
MONDAY = 0
|
||||||
|
TUESDAY = 1
|
||||||
|
WEDNESDAY = 2
|
||||||
|
THURSDAY = 3
|
||||||
|
FRIDAY = 4
|
||||||
|
SATURDAY = 5
|
||||||
|
SUNDAY = 6
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
class Office(Base):
|
||||||
|
"""Organization units that have parking spots"""
|
||||||
|
__tablename__ = "offices"
|
||||||
|
|
||||||
|
id = Column(Text, primary_key=True)
|
||||||
|
name = Column(Text, nullable=False)
|
||||||
|
parking_quota = Column(Integer, default=0)
|
||||||
|
spot_prefix = Column(Text) # Letter prefix: A, B, C
|
||||||
|
|
||||||
|
# Booking Window Settings (Batch Assignment)
|
||||||
|
booking_window_enabled = Column(Boolean, default=False)
|
||||||
|
booking_window_end_hour = Column(Integer, default=18) # 0-23
|
||||||
|
booking_window_end_minute = Column(Integer, default=0) # 0-59
|
||||||
|
|
||||||
|
created_at = Column(DateTime, default=datetime.utcnow)
|
||||||
|
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
||||||
|
|
||||||
|
# Relationships
|
||||||
|
users = relationship("User", back_populates="office")
|
||||||
|
closing_days = relationship("OfficeClosingDay", back_populates="office", cascade="all, delete-orphan")
|
||||||
|
weekly_closing_days = relationship("OfficeWeeklyClosingDay", back_populates="office", cascade="all, delete-orphan")
|
||||||
|
|
||||||
|
|
||||||
class User(Base):
|
class User(Base):
|
||||||
"""Application users"""
|
"""Application users"""
|
||||||
__tablename__ = "users"
|
__tablename__ = "users"
|
||||||
@@ -16,34 +77,30 @@ class User(Base):
|
|||||||
email = Column(Text, unique=True, nullable=False)
|
email = Column(Text, unique=True, nullable=False)
|
||||||
password_hash = Column(Text)
|
password_hash = Column(Text)
|
||||||
name = Column(Text)
|
name = Column(Text)
|
||||||
role = Column(Text, nullable=False, default="employee") # admin, manager, employee
|
role = Column(Enum(UserRole, values_callable=lambda obj: [e.value for e in obj]), nullable=False, default=UserRole.EMPLOYEE)
|
||||||
manager_id = Column(Text, ForeignKey("users.id")) # Who manages this user (any user can have a manager)
|
office_id = Column(Text, ForeignKey("offices.id")) # Which office this user belongs to
|
||||||
|
|
||||||
# Manager-specific fields (only relevant for role='manager')
|
|
||||||
manager_parking_quota = Column(Integer, default=0) # Number of spots this manager controls
|
|
||||||
manager_spot_prefix = Column(Text) # Letter prefix for spots: A, B, C, etc.
|
|
||||||
|
|
||||||
# User preferences
|
# User preferences
|
||||||
week_start_day = Column(Integer, default=0) # 0=Sunday, 1=Monday, ..., 6=Saturday
|
week_start_day = Column(Integer, default=0) # 0=Sunday, 1=Monday, ... (Matches WeekDay logic)
|
||||||
|
|
||||||
# Notification preferences
|
# Notification preferences
|
||||||
notify_weekly_parking = Column(Integer, default=1) # Weekly parking summary (Friday at 12)
|
notify_weekly_parking = Column(Boolean, default=True) # Weekly parking summary (Friday at 12)
|
||||||
notify_daily_parking = Column(Integer, default=1) # Daily parking reminder
|
notify_daily_parking = Column(Boolean, default=True) # Daily parking reminder
|
||||||
notify_daily_parking_hour = Column(Integer, default=8) # Hour for daily reminder (0-23)
|
notify_daily_parking_hour = Column(Integer, default=8) # Hour for daily reminder (0-23)
|
||||||
notify_daily_parking_minute = Column(Integer, default=0) # Minute for daily reminder (0-59)
|
notify_daily_parking_minute = Column(Integer, default=0) # Minute for daily reminder (0-59)
|
||||||
notify_parking_changes = Column(Integer, default=1) # Immediate notification on assignment changes
|
notify_parking_changes = Column(Boolean, default=True) # Immediate notification on assignment changes
|
||||||
|
|
||||||
created_at = Column(Text)
|
created_at = Column(DateTime, default=datetime.utcnow)
|
||||||
updated_at = Column(Text)
|
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
||||||
|
|
||||||
# Relationships
|
# Relationships
|
||||||
manager = relationship("User", remote_side=[id], backref="managed_users")
|
office = relationship("Office", back_populates="users")
|
||||||
presences = relationship("UserPresence", back_populates="user", cascade="all, delete-orphan")
|
presences = relationship("UserPresence", back_populates="user", cascade="all, delete-orphan")
|
||||||
assignments = relationship("DailyParkingAssignment", back_populates="user", foreign_keys="DailyParkingAssignment.user_id")
|
assignments = relationship("DailyParkingAssignment", back_populates="user", foreign_keys="DailyParkingAssignment.user_id")
|
||||||
|
|
||||||
__table_args__ = (
|
__table_args__ = (
|
||||||
Index('idx_user_email', 'email'),
|
Index('idx_user_email', 'email'),
|
||||||
Index('idx_user_manager', 'manager_id'),
|
Index('idx_user_office', 'office_id'),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -53,10 +110,10 @@ class UserPresence(Base):
|
|||||||
|
|
||||||
id = Column(Text, primary_key=True)
|
id = Column(Text, primary_key=True)
|
||||||
user_id = Column(Text, ForeignKey("users.id", ondelete="CASCADE"), nullable=False)
|
user_id = Column(Text, ForeignKey("users.id", ondelete="CASCADE"), nullable=False)
|
||||||
date = Column(Text, nullable=False) # YYYY-MM-DD
|
date = Column(Date, nullable=False)
|
||||||
status = Column(Text, nullable=False) # present, remote, absent
|
status = Column(Enum(PresenceStatus, values_callable=lambda obj: [e.value for e in obj]), nullable=False) # present, remote, absent
|
||||||
created_at = Column(Text)
|
created_at = Column(DateTime, default=datetime.utcnow)
|
||||||
updated_at = Column(Text)
|
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
||||||
|
|
||||||
# Relationships
|
# Relationships
|
||||||
user = relationship("User", back_populates="presences")
|
user = relationship("User", back_populates="presences")
|
||||||
@@ -68,97 +125,100 @@ class UserPresence(Base):
|
|||||||
|
|
||||||
|
|
||||||
class DailyParkingAssignment(Base):
|
class DailyParkingAssignment(Base):
|
||||||
"""Parking spot assignments per day - spots belong to managers"""
|
"""Parking spot assignments per day - spots belong to offices"""
|
||||||
__tablename__ = "daily_parking_assignments"
|
__tablename__ = "daily_parking_assignments"
|
||||||
|
|
||||||
id = Column(Text, primary_key=True)
|
id = Column(Text, primary_key=True)
|
||||||
date = Column(Text, nullable=False) # YYYY-MM-DD
|
date = Column(Date, nullable=False)
|
||||||
spot_id = Column(Text, nullable=False) # A1, A2, B1, B2, etc. (prefix from manager)
|
spot_id = Column(Text, nullable=False) # A1, A2, B1, B2, etc. (prefix from office)
|
||||||
user_id = Column(Text, ForeignKey("users.id", ondelete="SET NULL"))
|
user_id = Column(Text, ForeignKey("users.id", ondelete="SET NULL"))
|
||||||
manager_id = Column(Text, ForeignKey("users.id", ondelete="CASCADE"), nullable=False) # Manager who owns the spot
|
office_id = Column(Text, ForeignKey("offices.id", ondelete="CASCADE"), nullable=False) # Office that owns the spot
|
||||||
created_at = Column(Text)
|
created_at = Column(DateTime, default=datetime.utcnow)
|
||||||
|
|
||||||
# Relationships
|
# Relationships
|
||||||
user = relationship("User", back_populates="assignments", foreign_keys=[user_id])
|
user = relationship("User", back_populates="assignments", foreign_keys=[user_id])
|
||||||
manager = relationship("User", foreign_keys=[manager_id])
|
office = relationship("Office")
|
||||||
|
|
||||||
__table_args__ = (
|
__table_args__ = (
|
||||||
Index('idx_assignment_manager_date', 'manager_id', 'date'),
|
Index('idx_assignment_office_date', 'office_id', 'date'),
|
||||||
Index('idx_assignment_user', 'user_id'),
|
Index('idx_assignment_user', 'user_id'),
|
||||||
Index('idx_assignment_date_spot', 'date', 'spot_id'),
|
Index('idx_assignment_date_spot', 'date', 'spot_id'),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
class ManagerClosingDay(Base):
|
class OfficeClosingDay(Base):
|
||||||
"""Specific date closing days for a manager's parking pool (holidays, special closures)"""
|
"""Specific date closing days for an office's parking pool (holidays, special closures)"""
|
||||||
__tablename__ = "manager_closing_days"
|
__tablename__ = "office_closing_days"
|
||||||
|
|
||||||
id = Column(Text, primary_key=True)
|
id = Column(Text, primary_key=True)
|
||||||
manager_id = Column(Text, ForeignKey("users.id", ondelete="CASCADE"), nullable=False)
|
office_id = Column(Text, ForeignKey("offices.id", ondelete="CASCADE"), nullable=False)
|
||||||
date = Column(Text, nullable=False) # YYYY-MM-DD
|
date = Column(Date, nullable=False)
|
||||||
|
end_date = Column(Date)
|
||||||
reason = Column(Text)
|
reason = Column(Text)
|
||||||
|
|
||||||
# Relationships
|
# Relationships
|
||||||
manager = relationship("User")
|
office = relationship("Office", back_populates="closing_days")
|
||||||
|
|
||||||
__table_args__ = (
|
__table_args__ = (
|
||||||
Index('idx_closing_manager_date', 'manager_id', 'date', unique=True),
|
Index('idx_closing_office_date', 'office_id', 'date', unique=True),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
class ManagerWeeklyClosingDay(Base):
|
class OfficeWeeklyClosingDay(Base):
|
||||||
"""Weekly recurring closing days for a manager's parking pool (e.g., Saturday and Sunday)"""
|
"""Weekly recurring closing days for an office's parking pool (e.g., Saturday and Sunday)"""
|
||||||
__tablename__ = "manager_weekly_closing_days"
|
__tablename__ = "office_weekly_closing_days"
|
||||||
|
|
||||||
id = Column(Text, primary_key=True)
|
id = Column(Text, primary_key=True)
|
||||||
manager_id = Column(Text, ForeignKey("users.id", ondelete="CASCADE"), nullable=False)
|
office_id = Column(Text, ForeignKey("offices.id", ondelete="CASCADE"), nullable=False)
|
||||||
weekday = Column(Integer, nullable=False) # 0=Sunday, 1=Monday, ..., 6=Saturday
|
weekday = Column(Integer, nullable=False) # 0=Sunday, 1=Monday, ..., 6=Saturday (Matches WeekDay Enum logic)
|
||||||
|
|
||||||
# Relationships
|
# Relationships
|
||||||
manager = relationship("User")
|
office = relationship("Office", back_populates="weekly_closing_days")
|
||||||
|
|
||||||
__table_args__ = (
|
__table_args__ = (
|
||||||
Index('idx_weekly_closing_manager_day', 'manager_id', 'weekday', unique=True),
|
Index('idx_weekly_closing_office_day', 'office_id', 'weekday', unique=True),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
class ParkingGuarantee(Base):
|
class ParkingGuarantee(Base):
|
||||||
"""Users guaranteed a parking spot when present (set by manager)"""
|
"""Users guaranteed a parking spot when present (set by office manager)"""
|
||||||
__tablename__ = "parking_guarantees"
|
__tablename__ = "parking_guarantees"
|
||||||
|
|
||||||
id = Column(Text, primary_key=True)
|
id = Column(Text, primary_key=True)
|
||||||
manager_id = Column(Text, ForeignKey("users.id", ondelete="CASCADE"), nullable=False)
|
office_id = Column(Text, ForeignKey("offices.id", ondelete="CASCADE"), nullable=False)
|
||||||
user_id = Column(Text, ForeignKey("users.id", ondelete="CASCADE"), nullable=False)
|
user_id = Column(Text, ForeignKey("users.id", ondelete="CASCADE"), nullable=False)
|
||||||
start_date = Column(Text) # Optional: YYYY-MM-DD (null = no start limit)
|
start_date = Column(Date) # Optional (null = no start limit)
|
||||||
end_date = Column(Text) # Optional: YYYY-MM-DD (null = no end limit)
|
end_date = Column(Date) # Optional (null = no end limit)
|
||||||
created_at = Column(Text)
|
notes = Column(Text, nullable=True)
|
||||||
|
created_at = Column(DateTime, default=datetime.utcnow)
|
||||||
|
|
||||||
# Relationships
|
# Relationships
|
||||||
manager = relationship("User", foreign_keys=[manager_id])
|
office = relationship("Office")
|
||||||
user = relationship("User", foreign_keys=[user_id])
|
user = relationship("User", foreign_keys=[user_id])
|
||||||
|
|
||||||
__table_args__ = (
|
__table_args__ = (
|
||||||
Index('idx_guarantee_manager_user', 'manager_id', 'user_id', unique=True),
|
Index('idx_guarantee_office_user', 'office_id', 'user_id', unique=True),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
class ParkingExclusion(Base):
|
class ParkingExclusion(Base):
|
||||||
"""Users excluded from parking assignment (set by manager)"""
|
"""Users excluded from parking assignment (set by office manager)"""
|
||||||
__tablename__ = "parking_exclusions"
|
__tablename__ = "parking_exclusions"
|
||||||
|
|
||||||
id = Column(Text, primary_key=True)
|
id = Column(Text, primary_key=True)
|
||||||
manager_id = Column(Text, ForeignKey("users.id", ondelete="CASCADE"), nullable=False)
|
office_id = Column(Text, ForeignKey("offices.id", ondelete="CASCADE"), nullable=False)
|
||||||
user_id = Column(Text, ForeignKey("users.id", ondelete="CASCADE"), nullable=False)
|
user_id = Column(Text, ForeignKey("users.id", ondelete="CASCADE"), nullable=False)
|
||||||
start_date = Column(Text) # Optional: YYYY-MM-DD (null = no start limit)
|
start_date = Column(Date) # Optional
|
||||||
end_date = Column(Text) # Optional: YYYY-MM-DD (null = no end limit)
|
end_date = Column(Date) # Optional
|
||||||
created_at = Column(Text)
|
notes = Column(Text, nullable=True)
|
||||||
|
created_at = Column(DateTime, default=datetime.utcnow)
|
||||||
|
|
||||||
# Relationships
|
# Relationships
|
||||||
manager = relationship("User", foreign_keys=[manager_id])
|
office = relationship("Office")
|
||||||
user = relationship("User", foreign_keys=[user_id])
|
user = relationship("User", foreign_keys=[user_id])
|
||||||
|
|
||||||
__table_args__ = (
|
__table_args__ = (
|
||||||
Index('idx_exclusion_manager_user', 'manager_id', 'user_id', unique=True),
|
Index('idx_exclusion_office_user', 'office_id', 'user_id', unique=True),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -168,9 +228,9 @@ class NotificationLog(Base):
|
|||||||
|
|
||||||
id = Column(Text, primary_key=True)
|
id = Column(Text, primary_key=True)
|
||||||
user_id = Column(Text, ForeignKey("users.id", ondelete="CASCADE"), nullable=False)
|
user_id = Column(Text, ForeignKey("users.id", ondelete="CASCADE"), nullable=False)
|
||||||
notification_type = Column(Text, nullable=False) # presence_reminder, weekly_parking, daily_parking, parking_change
|
notification_type = Column(Enum(NotificationType, values_callable=lambda obj: [e.value for e in obj]), nullable=False)
|
||||||
reference_date = Column(Text) # The date/week this notification refers to (YYYY-MM-DD or YYYY-Www)
|
reference_date = Column(Text) # The date/week this notification refers to (YYYY-MM-DD or YYYY-Www) - keeping as Text for flexibility
|
||||||
sent_at = Column(Text, nullable=False)
|
sent_at = Column(DateTime, default=datetime.utcnow)
|
||||||
|
|
||||||
__table_args__ = (
|
__table_args__ = (
|
||||||
Index('idx_notification_user_type_date', 'user_id', 'notification_type', 'reference_date'),
|
Index('idx_notification_user_type_date', 'user_id', 'notification_type', 'reference_date'),
|
||||||
@@ -183,11 +243,11 @@ class NotificationQueue(Base):
|
|||||||
|
|
||||||
id = Column(Text, primary_key=True)
|
id = Column(Text, primary_key=True)
|
||||||
user_id = Column(Text, ForeignKey("users.id", ondelete="CASCADE"), nullable=False)
|
user_id = Column(Text, ForeignKey("users.id", ondelete="CASCADE"), nullable=False)
|
||||||
notification_type = Column(Text, nullable=False) # parking_change
|
notification_type = Column(Enum(NotificationType, values_callable=lambda obj: [e.value for e in obj]), nullable=False) # parking_change
|
||||||
subject = Column(Text, nullable=False)
|
subject = Column(Text, nullable=False)
|
||||||
body = Column(Text, nullable=False)
|
body = Column(Text, nullable=False)
|
||||||
created_at = Column(Text, nullable=False)
|
created_at = Column(DateTime, default=datetime.utcnow)
|
||||||
sent_at = Column(Text) # null = not sent yet
|
sent_at = Column(DateTime) # null = not sent yet
|
||||||
|
|
||||||
__table_args__ = (
|
__table_args__ = (
|
||||||
Index('idx_queue_pending', 'sent_at'),
|
Index('idx_queue_pending', 'sent_at'),
|
||||||
|
|||||||
@@ -1,8 +0,0 @@
|
|||||||
# Caddy configuration snippet for parking.rocketscale.it
|
|
||||||
# Add this block to org-stack/Caddyfile after the (auth) snippet definition
|
|
||||||
|
|
||||||
# Parking Manager - Protected by Authelia
|
|
||||||
parking.rocketscale.it {
|
|
||||||
import auth
|
|
||||||
reverse_proxy parking:8000
|
|
||||||
}
|
|
||||||
219
deploy/DEPLOY.md
219
deploy/DEPLOY.md
@@ -1,219 +0,0 @@
|
|||||||
# Deployment Guide for parking.rocketscale.it
|
|
||||||
|
|
||||||
## Prerequisites
|
|
||||||
|
|
||||||
- org-stack running on rocky@rocketscale.it
|
|
||||||
- Git repository on git.rocketscale.it (optional, can use rsync)
|
|
||||||
|
|
||||||
## Directory Structure
|
|
||||||
|
|
||||||
Parking is deployed as a **separate directory** alongside org-stack:
|
|
||||||
|
|
||||||
```
|
|
||||||
~/
|
|
||||||
├── org-stack/ # Main stack (Caddy, Authelia, LLDAP, etc.)
|
|
||||||
│ ├── compose.yml
|
|
||||||
│ ├── Caddyfile
|
|
||||||
│ ├── authelia/
|
|
||||||
│ └── .env
|
|
||||||
│
|
|
||||||
└── org-parking/ # Parking app (separate)
|
|
||||||
├── compose.yml # Production compose (connects to org-stack network)
|
|
||||||
├── .env # Own .env with PARKING_SECRET_KEY
|
|
||||||
├── Dockerfile
|
|
||||||
└── ...
|
|
||||||
```
|
|
||||||
|
|
||||||
## Step 1: Deploy to Server
|
|
||||||
|
|
||||||
Option A - Using rsync (recommended for development):
|
|
||||||
```bash
|
|
||||||
# From development machine
|
|
||||||
rsync -avz --exclude '.git' --exclude '__pycache__' --exclude '*.pyc' \
|
|
||||||
--exclude '.env' --exclude 'data/' --exclude '*.db' --exclude '.venv/' \
|
|
||||||
/path/to/org-parking/ rocky@rocketscale.it:~/org-parking/
|
|
||||||
```
|
|
||||||
|
|
||||||
Option B - Using git:
|
|
||||||
```bash
|
|
||||||
ssh rocky@rocketscale.it
|
|
||||||
cd ~
|
|
||||||
git clone git@git.rocketscale.it:rocky/parking-manager.git org-parking
|
|
||||||
```
|
|
||||||
|
|
||||||
## Step 2: Create Production compose.yml
|
|
||||||
|
|
||||||
Create `~/org-parking/compose.yml` on the server:
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
services:
|
|
||||||
parking:
|
|
||||||
build: .
|
|
||||||
container_name: parking
|
|
||||||
restart: unless-stopped
|
|
||||||
volumes:
|
|
||||||
- parking_data:/app/data
|
|
||||||
environment:
|
|
||||||
- SECRET_KEY=${PARKING_SECRET_KEY}
|
|
||||||
- DATABASE_PATH=/app/data/parking.db
|
|
||||||
- AUTHELIA_ENABLED=true
|
|
||||||
- ALLOWED_ORIGINS=https://parking.rocketscale.it
|
|
||||||
- SMTP_HOST=${SMTP_HOST:-}
|
|
||||||
- SMTP_PORT=${SMTP_PORT:-587}
|
|
||||||
- SMTP_USER=${SMTP_USER:-}
|
|
||||||
- SMTP_PASSWORD=${SMTP_PASSWORD:-}
|
|
||||||
- SMTP_FROM=${SMTP_FROM:-}
|
|
||||||
networks:
|
|
||||||
- org-network
|
|
||||||
healthcheck:
|
|
||||||
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8000/health')"]
|
|
||||||
interval: 30s
|
|
||||||
timeout: 10s
|
|
||||||
retries: 3
|
|
||||||
start_period: 10s
|
|
||||||
|
|
||||||
volumes:
|
|
||||||
parking_data:
|
|
||||||
|
|
||||||
networks:
|
|
||||||
org-network:
|
|
||||||
external: true
|
|
||||||
name: org-stack_org-network
|
|
||||||
```
|
|
||||||
|
|
||||||
## Step 3: Create .env File
|
|
||||||
|
|
||||||
Create `~/org-parking/.env` with a secret key:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd ~/org-parking
|
|
||||||
python3 -c "import secrets; print(f'PARKING_SECRET_KEY={secrets.token_hex(32)}')" > .env
|
|
||||||
```
|
|
||||||
|
|
||||||
**Note**: Each directory needs its own `.env` file since docker compose only reads from the current directory.
|
|
||||||
|
|
||||||
## Step 4: Add to Caddyfile
|
|
||||||
|
|
||||||
Add to `~/org-stack/Caddyfile`:
|
|
||||||
|
|
||||||
```
|
|
||||||
# Parking Manager - Protected by Authelia
|
|
||||||
parking.rocketscale.it {
|
|
||||||
import auth
|
|
||||||
reverse_proxy parking:8000
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## Step 5: Add Authelia Access Control Rule
|
|
||||||
|
|
||||||
**Important**: Authelia's `access_control` must include parking.rocketscale.it or you'll get 403 Forbidden.
|
|
||||||
|
|
||||||
Edit `~/org-stack/authelia/configuration.yml` and add to the `access_control.rules` section:
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
access_control:
|
|
||||||
default_policy: deny
|
|
||||||
rules:
|
|
||||||
# ... existing rules ...
|
|
||||||
|
|
||||||
# Parking Manager - require authentication
|
|
||||||
- domain: parking.rocketscale.it
|
|
||||||
policy: one_factor
|
|
||||||
```
|
|
||||||
|
|
||||||
After editing, restart Authelia:
|
|
||||||
```bash
|
|
||||||
cd ~/org-stack
|
|
||||||
docker compose restart authelia
|
|
||||||
```
|
|
||||||
|
|
||||||
## Step 6: Create LLDAP Group
|
|
||||||
|
|
||||||
In lldap (https://ldap.rocketscale.it):
|
|
||||||
|
|
||||||
1. Create group: `parking_admins`
|
|
||||||
2. Add yourself (or whoever should be admin) to this group
|
|
||||||
|
|
||||||
**Role Management:**
|
|
||||||
- `parking_admins` group → **admin** role (synced from LLDAP on each login)
|
|
||||||
- **manager** role → assigned by admin in the app UI (Manage Users page)
|
|
||||||
- **employee** role → default for all other users
|
|
||||||
|
|
||||||
The admin can promote users to manager and assign offices via the Manage Users page.
|
|
||||||
|
|
||||||
## Step 7: Build and Deploy
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Build and start parking service
|
|
||||||
cd ~/org-parking
|
|
||||||
docker compose build parking
|
|
||||||
docker compose up -d
|
|
||||||
|
|
||||||
# Reload Caddy to pick up new domain (from org-stack)
|
|
||||||
cd ~/org-stack
|
|
||||||
docker compose exec caddy caddy reload --config /etc/caddy/Caddyfile
|
|
||||||
|
|
||||||
# Check logs
|
|
||||||
cd ~/org-parking
|
|
||||||
docker compose logs -f parking
|
|
||||||
```
|
|
||||||
|
|
||||||
## Step 8: Verify
|
|
||||||
|
|
||||||
1. Go to https://parking.rocketscale.it
|
|
||||||
2. You should be redirected to Authelia for login
|
|
||||||
3. After login, you should see the parking app
|
|
||||||
4. Your user should be auto-created with `admin` role (if in parking-admins group)
|
|
||||||
|
|
||||||
## Troubleshooting
|
|
||||||
|
|
||||||
### 403 Forbidden from Authelia
|
|
||||||
- Authelia's `access_control` doesn't have a rule for parking.rocketscale.it
|
|
||||||
- Add the domain to `~/org-stack/authelia/configuration.yml` (see Step 6)
|
|
||||||
- Restart Authelia: `docker compose restart authelia`
|
|
||||||
|
|
||||||
### 401 Unauthorized
|
|
||||||
- Check Authelia headers are being passed
|
|
||||||
- Check `docker compose logs authelia`
|
|
||||||
|
|
||||||
### Login redirect loop (keeps redirecting to /login)
|
|
||||||
- Frontend JS must use async auth checking for Authelia mode
|
|
||||||
- The `api.requireAuth()` must call `/api/auth/me` endpoint (not check localStorage)
|
|
||||||
- Ensure all page JS files use: `currentUser = await api.requireAuth();`
|
|
||||||
|
|
||||||
### User has wrong role
|
|
||||||
- **Admin role**: Verify user is in `parking_admins` LLDAP group (synced on each login)
|
|
||||||
- **Manager role**: Must be assigned by admin via Manage Users page (not from LLDAP)
|
|
||||||
- **Employee role**: Default for users not in `parking_admins` group
|
|
||||||
|
|
||||||
### Database errors
|
|
||||||
- Check volume mount: `docker compose exec parking ls -la /app/data`
|
|
||||||
- Check permissions: `docker compose exec parking id`
|
|
||||||
|
|
||||||
## Architecture Notes
|
|
||||||
|
|
||||||
### Authelia Integration
|
|
||||||
The app supports two authentication modes:
|
|
||||||
1. **JWT mode** (standalone): Users login via `/login`, get JWT token stored in localStorage
|
|
||||||
2. **Authelia mode** (SSO): Authelia handles login, passes headers to backend
|
|
||||||
|
|
||||||
When `AUTHELIA_ENABLED=true`:
|
|
||||||
- Backend reads user info from headers: `Remote-User`, `Remote-Email`, `Remote-Name`, `Remote-Groups`
|
|
||||||
- Users are auto-created on first login
|
|
||||||
- Roles are synced from LLDAP groups on each request
|
|
||||||
- Frontend calls `/api/auth/me` to get user info (backend reads headers)
|
|
||||||
|
|
||||||
### Role Management
|
|
||||||
|
|
||||||
Only the **admin** role is synced from LLDAP:
|
|
||||||
```python
|
|
||||||
AUTHELIA_ADMIN_GROUP = "parking_admins" # → role: admin
|
|
||||||
```
|
|
||||||
|
|
||||||
Other roles are managed within the app:
|
|
||||||
- **manager**: Assigned by admin via Manage Users page
|
|
||||||
- **employee**: Default role for all non-admin users
|
|
||||||
|
|
||||||
This separation allows:
|
|
||||||
- LLDAP to control who has admin access
|
|
||||||
- App admin to assign manager roles and office assignments without LLDAP changes
|
|
||||||
@@ -1,32 +0,0 @@
|
|||||||
# Production compose file for org-stack integration
|
|
||||||
# This will be added to ~/org-stack/compose.yml on the server
|
|
||||||
|
|
||||||
services:
|
|
||||||
parking:
|
|
||||||
build: ./parking
|
|
||||||
container_name: parking
|
|
||||||
restart: unless-stopped
|
|
||||||
volumes:
|
|
||||||
- parking_data:/app/data
|
|
||||||
environment:
|
|
||||||
- SECRET_KEY=${PARKING_SECRET_KEY}
|
|
||||||
- DATABASE_PATH=/app/data/parking.db
|
|
||||||
- AUTHELIA_ENABLED=true
|
|
||||||
- ALLOWED_ORIGINS=https://parking.rocketscale.it
|
|
||||||
# SMTP (shared with other services)
|
|
||||||
- SMTP_HOST=${SMTP_HOST:-}
|
|
||||||
- SMTP_PORT=${SMTP_PORT:-587}
|
|
||||||
- SMTP_USER=${SMTP_USER:-}
|
|
||||||
- SMTP_PASSWORD=${SMTP_PASSWORD:-}
|
|
||||||
- SMTP_FROM=${SMTP_FROM:-}
|
|
||||||
networks:
|
|
||||||
- org-network
|
|
||||||
depends_on:
|
|
||||||
- authelia
|
|
||||||
|
|
||||||
volumes:
|
|
||||||
parking_data:
|
|
||||||
|
|
||||||
networks:
|
|
||||||
org-network:
|
|
||||||
external: true
|
|
||||||
BIN
frontend/assets/parking-map.png
Normal file
BIN
frontend/assets/parking-map.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 786 KiB |
@@ -30,7 +30,9 @@
|
|||||||
/* ============================================================================
|
/* ============================================================================
|
||||||
Reset & Base
|
Reset & Base
|
||||||
============================================================================ */
|
============================================================================ */
|
||||||
*, *::before, *::after {
|
*,
|
||||||
|
*::before,
|
||||||
|
*::after {
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
@@ -54,7 +56,9 @@ button {
|
|||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
input, select, textarea {
|
input,
|
||||||
|
select,
|
||||||
|
textarea {
|
||||||
font-family: inherit;
|
font-family: inherit;
|
||||||
font-size: inherit;
|
font-size: inherit;
|
||||||
}
|
}
|
||||||
@@ -431,11 +435,12 @@ input, select, textarea {
|
|||||||
width: 100%;
|
width: 100%;
|
||||||
max-width: 480px;
|
max-width: 480px;
|
||||||
max-height: 90vh;
|
max-height: 90vh;
|
||||||
overflow: auto;
|
overflow-y: auto;
|
||||||
|
overflow-x: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
.modal-small {
|
.modal-small {
|
||||||
max-width: 360px;
|
max-width: 420px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.modal-header {
|
.modal-header {
|
||||||
@@ -612,16 +617,23 @@ input, select, textarea {
|
|||||||
|
|
||||||
.calendar-day .parking-badge {
|
.calendar-day .parking-badge {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
bottom: 0.25rem;
|
bottom: 6px;
|
||||||
left: 50%;
|
left: 4px;
|
||||||
transform: translateX(-50%);
|
right: 4px;
|
||||||
background: #dbeafe;
|
background: #dbeafe;
|
||||||
color: #1e40af;
|
color: #1e40af;
|
||||||
font-size: 0.6rem;
|
font-size: 0.8rem;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
padding: 0.1rem 0.3rem;
|
padding: 0.3rem 0;
|
||||||
border-radius: 3px;
|
border-radius: 6px;
|
||||||
|
border: 1px solid #93c5fd;
|
||||||
line-height: 1;
|
line-height: 1;
|
||||||
|
text-align: center;
|
||||||
|
transform: none;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Status colors */
|
/* Status colors */
|
||||||
@@ -644,6 +656,28 @@ input, select, textarea {
|
|||||||
background: white;
|
background: white;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Closed Day */
|
||||||
|
.calendar-day.closed {
|
||||||
|
background: #e5e7eb;
|
||||||
|
color: #9ca3af;
|
||||||
|
cursor: not-allowed;
|
||||||
|
border-color: #d1d5db;
|
||||||
|
}
|
||||||
|
|
||||||
|
.calendar-day.closed:hover {
|
||||||
|
border-color: #d1d5db;
|
||||||
|
}
|
||||||
|
|
||||||
|
.calendar-day.closed .day-number {
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.team-calendar td.closed {
|
||||||
|
background: #e5e7eb;
|
||||||
|
color: #9ca3af;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
/* Legend */
|
/* Legend */
|
||||||
.legend {
|
.legend {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -975,11 +1009,11 @@ input, select, textarea {
|
|||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.toggle-switch input:checked + .toggle-slider {
|
.toggle-switch input:checked+.toggle-slider {
|
||||||
background-color: var(--primary);
|
background-color: var(--primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.toggle-switch input:checked + .toggle-slider:before {
|
.toggle-switch input:checked+.toggle-slider:before {
|
||||||
transform: translateX(22px);
|
transform: translateX(22px);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1747,3 +1781,24 @@ input, select, textarea {
|
|||||||
font-size: 0.75rem;
|
font-size: 0.75rem;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Toast Animations */
|
||||||
|
@keyframes slideInBottom {
|
||||||
|
from {
|
||||||
|
transform: translate(-50%, 100%);
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
transform: translate(-50%, 0);
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes fadeOut {
|
||||||
|
from {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
167
frontend/js/admin-offices.js
Normal file
167
frontend/js/admin-offices.js
Normal file
@@ -0,0 +1,167 @@
|
|||||||
|
/**
|
||||||
|
* Admin Offices Page
|
||||||
|
* Manage offices, quotas, and prefixes
|
||||||
|
*/
|
||||||
|
|
||||||
|
let currentUser = null;
|
||||||
|
let offices = [];
|
||||||
|
|
||||||
|
document.addEventListener('DOMContentLoaded', async () => {
|
||||||
|
currentUser = await api.requireAuth();
|
||||||
|
if (!currentUser) return;
|
||||||
|
|
||||||
|
if (currentUser.role !== 'admin') {
|
||||||
|
window.location.href = '/presence';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await loadOffices();
|
||||||
|
setupEventListeners();
|
||||||
|
});
|
||||||
|
|
||||||
|
async function loadOffices() {
|
||||||
|
const response = await api.get('/api/offices');
|
||||||
|
if (response && response.ok) {
|
||||||
|
offices = await response.json();
|
||||||
|
renderOffices();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderOffices() {
|
||||||
|
const tbody = document.getElementById('officesBody');
|
||||||
|
|
||||||
|
if (offices.length === 0) {
|
||||||
|
tbody.innerHTML = '<tr><td colspan="5" class="text-center">Nessun ufficio trovato</td></tr>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
tbody.innerHTML = offices.map(office => {
|
||||||
|
return `
|
||||||
|
<tr>
|
||||||
|
<td>${office.name}</td>
|
||||||
|
<td><span class="badge badge-info">${office.parking_quota} posti</span></td>
|
||||||
|
<td><strong>${office.spot_prefix || '-'}</strong></td>
|
||||||
|
<td>${office.user_count || 0} utenti</td>
|
||||||
|
<td>
|
||||||
|
<button class="btn btn-sm btn-secondary" onclick="editOffice('${office.id}')">Modifica</button>
|
||||||
|
<button class="btn btn-sm btn-danger" onclick="deleteOffice('${office.id}')" ${office.user_count > 0 ? 'title="Impossibile eliminare uffici con utenti" disabled' : ''}>Elimina</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
`;
|
||||||
|
}).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
function openModal(title) {
|
||||||
|
document.getElementById('officeModalTitle').textContent = title;
|
||||||
|
document.getElementById('officeModal').style.display = 'flex';
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeModal() {
|
||||||
|
document.getElementById('officeModal').style.display = 'none';
|
||||||
|
document.getElementById('officeForm').reset();
|
||||||
|
document.getElementById('officeId').value = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
async function editOffice(officeId) {
|
||||||
|
const office = offices.find(o => o.id === officeId);
|
||||||
|
if (!office) return;
|
||||||
|
|
||||||
|
document.getElementById('officeId').value = office.id;
|
||||||
|
document.getElementById('officeName').value = office.name;
|
||||||
|
document.getElementById('officeQuota').value = office.parking_quota;
|
||||||
|
|
||||||
|
openModal('Modifica Ufficio');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteOffice(officeId) {
|
||||||
|
const office = offices.find(o => o.id === officeId);
|
||||||
|
if (!office) return;
|
||||||
|
|
||||||
|
if (!confirm(`Eliminare l'ufficio "${office.name}"?`)) return;
|
||||||
|
|
||||||
|
const response = await api.delete(`/api/offices/${officeId}`);
|
||||||
|
if (response && response.ok) {
|
||||||
|
utils.showMessage('Ufficio eliminato', 'success');
|
||||||
|
await loadOffices();
|
||||||
|
} else {
|
||||||
|
const error = await response.json();
|
||||||
|
utils.showMessage(error.detail || 'Impossibile eliminare l\'ufficio', 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function setupEventListeners() {
|
||||||
|
// Add button
|
||||||
|
document.getElementById('addOfficeBtn').addEventListener('click', () => {
|
||||||
|
openModal('Nuovo Ufficio');
|
||||||
|
});
|
||||||
|
|
||||||
|
// Modal close
|
||||||
|
document.getElementById('closeOfficeModal').addEventListener('click', closeModal);
|
||||||
|
document.getElementById('cancelOffice').addEventListener('click', closeModal);
|
||||||
|
utils.setupModalClose('officeModal');
|
||||||
|
|
||||||
|
// Debug tracking for save button
|
||||||
|
const saveBtn = document.getElementById('saveOfficeBtn');
|
||||||
|
if (saveBtn) {
|
||||||
|
saveBtn.addEventListener('click', () => console.log('Save button clicked'));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Form submit
|
||||||
|
const form = document.getElementById('officeForm');
|
||||||
|
form.addEventListener('submit', handleOfficeSubmit);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleOfficeSubmit(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
console.log('Form submitting...');
|
||||||
|
|
||||||
|
const saveBtn = document.getElementById('saveOfficeBtn');
|
||||||
|
const originalText = saveBtn.innerHTML;
|
||||||
|
saveBtn.disabled = true;
|
||||||
|
saveBtn.innerHTML = 'Salvataggio...';
|
||||||
|
|
||||||
|
const officeId = document.getElementById('officeId').value;
|
||||||
|
const data = {
|
||||||
|
name: document.getElementById('officeName').value,
|
||||||
|
parking_quota: parseInt(document.getElementById('officeQuota').value) || 0
|
||||||
|
};
|
||||||
|
|
||||||
|
console.log('Payload:', data);
|
||||||
|
|
||||||
|
try {
|
||||||
|
let response;
|
||||||
|
if (officeId) {
|
||||||
|
response = await api.put(`/api/offices/${officeId}`, data);
|
||||||
|
} else {
|
||||||
|
response = await api.post('/api/offices', data);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('Response status:', response ? response.status : 'null');
|
||||||
|
|
||||||
|
if (response && response.ok) {
|
||||||
|
closeModal();
|
||||||
|
utils.showMessage(officeId ? 'Ufficio aggiornato' : 'Ufficio creato', 'success');
|
||||||
|
await loadOffices();
|
||||||
|
} else {
|
||||||
|
let errorMessage = 'Errore operazione';
|
||||||
|
try {
|
||||||
|
const error = await response.json();
|
||||||
|
errorMessage = error.detail || errorMessage;
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Error parsing JSON error:', e);
|
||||||
|
errorMessage = 'Errore server imprevisto (' + (response ? response.status : 'network') + ')';
|
||||||
|
}
|
||||||
|
utils.showMessage(errorMessage, 'error');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Form submit exception:', error);
|
||||||
|
utils.showMessage('Errore di connessione: ' + error.message, 'error');
|
||||||
|
} finally {
|
||||||
|
saveBtn.disabled = false;
|
||||||
|
saveBtn.innerHTML = originalText;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Global functions
|
||||||
|
window.editOffice = editOffice;
|
||||||
|
window.deleteOffice = deleteOffice;
|
||||||
@@ -1,11 +1,12 @@
|
|||||||
/**
|
/**
|
||||||
* Admin Users Page
|
* Admin Users Page
|
||||||
* Manage users with LDAP-aware editing
|
* Manage users with LDAP-aware editing and Office assignment
|
||||||
*/
|
*/
|
||||||
|
|
||||||
let currentUser = null;
|
let currentUser = null;
|
||||||
let users = [];
|
let users = [];
|
||||||
let managers = [];
|
let offices = [];
|
||||||
|
let currentSort = { column: 'name', direction: 'asc' };
|
||||||
|
|
||||||
document.addEventListener('DOMContentLoaded', async () => {
|
document.addEventListener('DOMContentLoaded', async () => {
|
||||||
currentUser = await api.requireAuth();
|
currentUser = await api.requireAuth();
|
||||||
@@ -16,15 +17,15 @@ document.addEventListener('DOMContentLoaded', async () => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
await loadManagers();
|
await loadOffices();
|
||||||
await loadUsers();
|
await loadUsers();
|
||||||
setupEventListeners();
|
setupEventListeners();
|
||||||
});
|
});
|
||||||
|
|
||||||
async function loadManagers() {
|
async function loadOffices() {
|
||||||
const response = await api.get('/api/managers');
|
const response = await api.get('/api/offices');
|
||||||
if (response && response.ok) {
|
if (response && response.ok) {
|
||||||
managers = await response.json();
|
offices = await response.json();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -46,30 +47,60 @@ function renderUsers(filter = '') {
|
|||||||
(u.name || '').toLowerCase().includes(filterLower) ||
|
(u.name || '').toLowerCase().includes(filterLower) ||
|
||||||
(u.email || '').toLowerCase().includes(filterLower) ||
|
(u.email || '').toLowerCase().includes(filterLower) ||
|
||||||
(u.role || '').toLowerCase().includes(filterLower) ||
|
(u.role || '').toLowerCase().includes(filterLower) ||
|
||||||
(u.manager_name || '').toLowerCase().includes(filterLower)
|
(u.office_name || '').toLowerCase().includes(filterLower)
|
||||||
);
|
);
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort
|
||||||
|
filtered.sort((a, b) => {
|
||||||
|
let valA = a[currentSort.column];
|
||||||
|
let valB = b[currentSort.column];
|
||||||
|
|
||||||
|
// Handle nulls for ratio
|
||||||
|
if (currentSort.column === 'parking_ratio') {
|
||||||
|
valA = valA !== null ? valA : 999; // Null ratio (new users) -> low priority? No, new users have ratio 0.
|
||||||
|
// Actually get_user_parking_ratio returns 0.0 for new users.
|
||||||
|
// If office_id is missing, it's None. Treat as high val to push to bottom?
|
||||||
|
valA = (valA === undefined || valA === null) ? 999 : valA;
|
||||||
|
valB = (valB === undefined || valB === null) ? 999 : valB;
|
||||||
|
} else {
|
||||||
|
valA = (valA || '').toString().toLowerCase();
|
||||||
|
valB = (valB || '').toString().toLowerCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (valA < valB) return currentSort.direction === 'asc' ? -1 : 1;
|
||||||
|
if (valA > valB) return currentSort.direction === 'asc' ? 1 : -1;
|
||||||
|
return 0;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Update icons
|
||||||
|
document.querySelectorAll('th.sortable .sort-icon').forEach(icon => icon.textContent = '');
|
||||||
|
const activeTh = document.querySelector(`th[data-sort="${currentSort.column}"]`);
|
||||||
|
if (activeTh) {
|
||||||
|
const icon = activeTh.querySelector('.sort-icon');
|
||||||
|
if (icon) icon.textContent = currentSort.direction === 'asc' ? ' ▲' : ' ▼';
|
||||||
}
|
}
|
||||||
|
|
||||||
if (filtered.length === 0) {
|
if (filtered.length === 0) {
|
||||||
tbody.innerHTML = '<tr><td colspan="5" class="text-center">No users found</td></tr>';
|
tbody.innerHTML = '<tr><td colspan="5" class="text-center">Nessun utente trovato</td></tr>';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
tbody.innerHTML = filtered.map(user => {
|
tbody.innerHTML = filtered.map(user => {
|
||||||
const ldapBadge = user.is_ldap_user ? '<span class="badge badge-info">LDAP</span>' : '';
|
const ldapBadge = user.is_ldap_user ? '<span class="badge badge-info">LDAP</span>' : '';
|
||||||
const managerInfo = user.role === 'manager'
|
const officeInfo = user.office_name || '-';
|
||||||
? `<span class="badge badge-success">${user.managed_user_count || 0} users</span>`
|
|
||||||
: (user.manager_name || '-');
|
|
||||||
|
|
||||||
return `
|
return `
|
||||||
<tr>
|
<tr>
|
||||||
<td>${user.name || '-'} ${ldapBadge}</td>
|
<td>${user.name || '-'} ${ldapBadge}</td>
|
||||||
<td>${user.email}</td>
|
<td>${user.email}</td>
|
||||||
<td><span class="badge badge-${getRoleBadgeClass(user.role)}">${user.role}</span></td>
|
<td><span class="badge badge-${getRoleBadgeClass(user.role)}">${user.role}</span></td>
|
||||||
<td>${managerInfo}</td>
|
<td>${officeInfo}</td>
|
||||||
|
<td>${user.parking_ratio !== null ? user.parking_ratio.toFixed(2) : '-'}</td>
|
||||||
<td>
|
<td>
|
||||||
<button class="btn btn-sm btn-secondary" onclick="editUser('${user.id}')">Edit</button>
|
<button class="btn btn-sm btn-secondary" onclick="editUser('${user.id}')">Modifica</button>
|
||||||
<button class="btn btn-sm btn-danger" onclick="deleteUser('${user.id}')" ${user.id === currentUser.id ? 'disabled' : ''}>Delete</button>
|
<button class="btn btn-sm btn-danger" onclick="deleteUser('${user.id}')" ${user.id === currentUser.id ? 'disabled' : ''}>Elimina</button>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
`;
|
`;
|
||||||
@@ -93,20 +124,16 @@ async function editUser(userId) {
|
|||||||
document.getElementById('editName').value = user.name || '';
|
document.getElementById('editName').value = user.name || '';
|
||||||
document.getElementById('editEmail').value = user.email;
|
document.getElementById('editEmail').value = user.email;
|
||||||
document.getElementById('editRole').value = user.role;
|
document.getElementById('editRole').value = user.role;
|
||||||
document.getElementById('editQuota').value = user.manager_parking_quota || 0;
|
|
||||||
document.getElementById('editPrefix').value = user.manager_spot_prefix || '';
|
|
||||||
|
|
||||||
// Populate manager dropdown
|
// Populate office dropdown
|
||||||
const managerSelect = document.getElementById('editManager');
|
const officeSelect = document.getElementById('editOffice');
|
||||||
managerSelect.innerHTML = '<option value="">No manager</option>';
|
officeSelect.innerHTML = '<option value="">Nessun ufficio</option>';
|
||||||
managers.forEach(m => {
|
offices.forEach(o => {
|
||||||
if (m.id !== userId) { // Can't be own manager
|
const option = document.createElement('option');
|
||||||
const option = document.createElement('option');
|
option.value = o.id;
|
||||||
option.value = m.id;
|
option.textContent = o.name;
|
||||||
option.textContent = m.name;
|
if (o.id === user.office_id) option.selected = true;
|
||||||
if (m.id === user.manager_id) option.selected = true;
|
officeSelect.appendChild(option);
|
||||||
managerSelect.appendChild(option);
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Handle LDAP restrictions
|
// Handle LDAP restrictions
|
||||||
@@ -126,13 +153,7 @@ async function editUser(userId) {
|
|||||||
roleSelect.disabled = isLdapAdmin;
|
roleSelect.disabled = isLdapAdmin;
|
||||||
document.getElementById('roleHelp').style.display = isLdapAdmin ? 'block' : 'none';
|
document.getElementById('roleHelp').style.display = isLdapAdmin ? 'block' : 'none';
|
||||||
|
|
||||||
// Manager group - show for all users (admins can also be assigned to a manager)
|
document.getElementById('userModalTitle').textContent = 'Modifica Utente';
|
||||||
document.getElementById('managerGroup').style.display = 'block';
|
|
||||||
|
|
||||||
// Manager fields - show only for managers
|
|
||||||
document.getElementById('managerFields').style.display = user.role === 'manager' ? 'block' : 'none';
|
|
||||||
|
|
||||||
document.getElementById('userModalTitle').textContent = 'Edit User';
|
|
||||||
document.getElementById('userModal').style.display = 'flex';
|
document.getElementById('userModal').style.display = 'flex';
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -140,15 +161,15 @@ async function deleteUser(userId) {
|
|||||||
const user = users.find(u => u.id === userId);
|
const user = users.find(u => u.id === userId);
|
||||||
if (!user) return;
|
if (!user) return;
|
||||||
|
|
||||||
if (!confirm(`Delete user "${user.name || user.email}"?`)) return;
|
if (!confirm(`Eliminare l'utente "${user.name || user.email}"?`)) return;
|
||||||
|
|
||||||
const response = await api.delete(`/api/users/${userId}`);
|
const response = await api.delete(`/api/users/${userId}`);
|
||||||
if (response && response.ok) {
|
if (response && response.ok) {
|
||||||
utils.showMessage('User deleted', 'success');
|
utils.showMessage('Utente eliminato', 'success');
|
||||||
await loadUsers();
|
await loadUsers();
|
||||||
} else {
|
} else {
|
||||||
const error = await response.json();
|
const error = await response.json();
|
||||||
utils.showMessage(error.detail || 'Failed to delete user', 'error');
|
utils.showMessage(error.detail || 'Impossibile eliminare l\'utente', 'error');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -158,13 +179,6 @@ function setupEventListeners() {
|
|||||||
renderUsers(e.target.value);
|
renderUsers(e.target.value);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Role change - toggle manager fields (manager group always visible since any user can have a manager)
|
|
||||||
document.getElementById('editRole').addEventListener('change', (e) => {
|
|
||||||
const role = e.target.value;
|
|
||||||
document.getElementById('managerFields').style.display = role === 'manager' ? 'block' : 'none';
|
|
||||||
// Manager group stays visible - any user (including admins) can have a manager assigned
|
|
||||||
});
|
|
||||||
|
|
||||||
// Modal close
|
// Modal close
|
||||||
document.getElementById('closeUserModal').addEventListener('click', () => {
|
document.getElementById('closeUserModal').addEventListener('click', () => {
|
||||||
document.getElementById('userModal').style.display = 'none';
|
document.getElementById('userModal').style.display = 'none';
|
||||||
@@ -183,7 +197,7 @@ function setupEventListeners() {
|
|||||||
|
|
||||||
const data = {
|
const data = {
|
||||||
role: role,
|
role: role,
|
||||||
manager_id: document.getElementById('editManager').value || null
|
office_id: document.getElementById('editOffice').value || null
|
||||||
};
|
};
|
||||||
|
|
||||||
// Only include name if not disabled (LDAP users can't change name)
|
// Only include name if not disabled (LDAP users can't change name)
|
||||||
@@ -192,23 +206,31 @@ function setupEventListeners() {
|
|||||||
data.name = nameInput.value;
|
data.name = nameInput.value;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Manager-specific fields
|
|
||||||
if (role === 'manager') {
|
|
||||||
data.manager_parking_quota = parseInt(document.getElementById('editQuota').value) || 0;
|
|
||||||
data.manager_spot_prefix = document.getElementById('editPrefix').value || null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const response = await api.put(`/api/users/${userId}`, data);
|
const response = await api.put(`/api/users/${userId}`, data);
|
||||||
if (response && response.ok) {
|
if (response && response.ok) {
|
||||||
document.getElementById('userModal').style.display = 'none';
|
document.getElementById('userModal').style.display = 'none';
|
||||||
utils.showMessage('User updated', 'success');
|
utils.showMessage('Utente aggiornato', 'success');
|
||||||
await loadManagers(); // Reload in case role changed
|
|
||||||
await loadUsers();
|
await loadUsers();
|
||||||
} else {
|
} else {
|
||||||
const error = await response.json();
|
const error = await response.json();
|
||||||
utils.showMessage(error.detail || 'Failed to update user', 'error');
|
utils.showMessage(error.detail || 'Impossibile aggiornare l\'utente', 'error');
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
|
// Sort headers
|
||||||
|
document.querySelectorAll('th.sortable').forEach(th => {
|
||||||
|
th.addEventListener('click', () => {
|
||||||
|
const column = th.dataset.sort;
|
||||||
|
if (currentSort.column === column) {
|
||||||
|
currentSort.direction = currentSort.direction === 'asc' ? 'desc' : 'asc';
|
||||||
|
} else {
|
||||||
|
currentSort.column = column;
|
||||||
|
currentSort.direction = 'asc';
|
||||||
|
}
|
||||||
|
renderUsers(document.getElementById('searchInput').value);
|
||||||
|
});
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Make functions available globally for onclick handlers
|
// Make functions available globally for onclick handlers
|
||||||
|
|||||||
@@ -158,7 +158,7 @@ const api = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const error = await response.json();
|
const error = await response.json();
|
||||||
return { success: false, error: error.detail || 'Login failed' };
|
return { success: false, error: error.detail || 'Login fallito' };
|
||||||
},
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -178,7 +178,7 @@ const api = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const error = await response.json();
|
const error = await response.json();
|
||||||
return { success: false, error: error.detail || 'Registration failed' };
|
return { success: false, error: error.detail || 'Registrazione fallita' };
|
||||||
},
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
188
frontend/js/modal-logic.js
Normal file
188
frontend/js/modal-logic.js
Normal file
@@ -0,0 +1,188 @@
|
|||||||
|
/**
|
||||||
|
* Shared logic for the Day/Presence Modal
|
||||||
|
* Handles UI interactions, assignments, and integrated reassign form.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const ModalLogic = {
|
||||||
|
// Configuration holding callbacks
|
||||||
|
config: {
|
||||||
|
onMarkPresence: async (status, date, userId) => { },
|
||||||
|
onClearPresence: async (date, userId) => { },
|
||||||
|
onReleaseParking: async (assignmentId) => { },
|
||||||
|
onReassignParking: async (assignmentId, newUserId) => { },
|
||||||
|
onReload: async () => { } // Callback to reload calendar data
|
||||||
|
},
|
||||||
|
|
||||||
|
// State
|
||||||
|
currentAssignmentId: null,
|
||||||
|
currentDate: null,
|
||||||
|
currentUserId: null,
|
||||||
|
|
||||||
|
init(config) {
|
||||||
|
this.config = { ...this.config, ...config };
|
||||||
|
this.setupEventListeners();
|
||||||
|
},
|
||||||
|
|
||||||
|
setupEventListeners() {
|
||||||
|
// Close buttons
|
||||||
|
document.getElementById('closeDayModal')?.addEventListener('click', () => {
|
||||||
|
document.getElementById('dayModal').style.display = 'none';
|
||||||
|
});
|
||||||
|
|
||||||
|
// Status buttons
|
||||||
|
document.querySelectorAll('#dayModal .status-btn').forEach(btn => {
|
||||||
|
btn.addEventListener('click', () => {
|
||||||
|
if (this.currentDate) {
|
||||||
|
this.config.onMarkPresence(btn.dataset.status, this.currentDate, this.currentUserId);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Actions
|
||||||
|
document.getElementById('clearDayBtn')?.addEventListener('click', () => {
|
||||||
|
if (this.currentDate) {
|
||||||
|
this.config.onClearPresence(this.currentDate, this.currentUserId);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('releaseParkingBtn')?.addEventListener('click', () => {
|
||||||
|
if (this.currentAssignmentId) {
|
||||||
|
this.config.onReleaseParking(this.currentAssignmentId);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('reassignParkingBtn')?.addEventListener('click', () => {
|
||||||
|
this.showReassignForm();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Reassign Form
|
||||||
|
document.getElementById('cancelReassign')?.addEventListener('click', () => {
|
||||||
|
this.hideReassignForm();
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('confirmReassign')?.addEventListener('click', () => {
|
||||||
|
const select = document.getElementById('reassignUser');
|
||||||
|
this.config.onReassignParking(this.currentAssignmentId, select.value);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Close on click outside
|
||||||
|
window.addEventListener('click', (e) => {
|
||||||
|
const modal = document.getElementById('dayModal');
|
||||||
|
if (e.target === modal) {
|
||||||
|
modal.style.display = 'none';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
openModal(data) {
|
||||||
|
const { dateStr, userName, presence, parking, userId, isReadOnly } = data;
|
||||||
|
|
||||||
|
this.currentDate = dateStr;
|
||||||
|
this.currentUserId = userId; // Optional, for team view
|
||||||
|
this.currentAssignmentId = parking ? parking.id : null;
|
||||||
|
|
||||||
|
const modal = document.getElementById('dayModal');
|
||||||
|
const title = document.getElementById('dayModalTitle');
|
||||||
|
const userLabel = document.getElementById('dayModalUser');
|
||||||
|
|
||||||
|
title.textContent = utils.formatDateDisplay(dateStr);
|
||||||
|
|
||||||
|
// Show/Hide User Name (for Team Calendar)
|
||||||
|
if (userName && userLabel) {
|
||||||
|
userLabel.textContent = userName;
|
||||||
|
userLabel.style.display = 'block';
|
||||||
|
} else if (userLabel) {
|
||||||
|
userLabel.style.display = 'none';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Highlight status
|
||||||
|
document.querySelectorAll('#dayModal .status-btn').forEach(btn => {
|
||||||
|
const status = btn.dataset.status;
|
||||||
|
if (presence && presence.status === status) {
|
||||||
|
btn.classList.add('active');
|
||||||
|
} else {
|
||||||
|
btn.classList.remove('active');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Clear button visibility
|
||||||
|
const clearBtn = document.getElementById('clearDayBtn');
|
||||||
|
if (presence) {
|
||||||
|
clearBtn.style.display = 'block';
|
||||||
|
} else {
|
||||||
|
clearBtn.style.display = 'none';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parking Section & Reset Form
|
||||||
|
this.hideReassignForm(); // Reset view to actions
|
||||||
|
|
||||||
|
const parkingSection = document.getElementById('parkingSection');
|
||||||
|
const parkingInfo = document.getElementById('parkingInfo');
|
||||||
|
|
||||||
|
if (parking) {
|
||||||
|
parkingSection.style.display = 'block';
|
||||||
|
const spotName = parking.spot_display_name || parking.spot_id;
|
||||||
|
parkingInfo.innerHTML = `<strong>Parcheggio:</strong> Posto ${spotName}`;
|
||||||
|
} else {
|
||||||
|
parkingSection.style.display = 'none';
|
||||||
|
}
|
||||||
|
|
||||||
|
modal.style.display = 'flex';
|
||||||
|
},
|
||||||
|
|
||||||
|
async showReassignForm() {
|
||||||
|
if (!this.currentAssignmentId) return;
|
||||||
|
|
||||||
|
const actionsDiv = document.getElementById('parkingActions');
|
||||||
|
const formDiv = document.getElementById('reassignForm');
|
||||||
|
|
||||||
|
actionsDiv.style.display = 'none';
|
||||||
|
formDiv.style.display = 'flex';
|
||||||
|
|
||||||
|
const select = document.getElementById('reassignUser');
|
||||||
|
select.innerHTML = '<option value="">Caricamento utenti...</option>';
|
||||||
|
|
||||||
|
// Load eligible users (Global API function assumed available)
|
||||||
|
try {
|
||||||
|
const response = await api.get(`/api/parking/eligible-users/${this.currentAssignmentId}`);
|
||||||
|
if (!response || !response.ok) {
|
||||||
|
const error = await response.json();
|
||||||
|
alert(error.detail || 'Impossibile caricare gli utenti idonei');
|
||||||
|
this.hideReassignForm();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const users = await response.json();
|
||||||
|
select.innerHTML = '<option value="">Seleziona utente...</option>';
|
||||||
|
select.innerHTML += '<option value="auto">Assegna automaticamente</option>';
|
||||||
|
|
||||||
|
if (users.length === 0) {
|
||||||
|
const option = document.createElement('option');
|
||||||
|
option.disabled = true;
|
||||||
|
option.textContent = "Nessun altro utente disponibile";
|
||||||
|
select.appendChild(option);
|
||||||
|
} else {
|
||||||
|
users.forEach(user => {
|
||||||
|
const option = document.createElement('option');
|
||||||
|
option.value = user.id;
|
||||||
|
const officeInfo = user.office_name ? ` (${user.office_name})` : '';
|
||||||
|
option.textContent = user.name + officeInfo;
|
||||||
|
select.appendChild(option);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
alert('Errore di rete');
|
||||||
|
this.hideReassignForm();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
hideReassignForm() {
|
||||||
|
document.getElementById('reassignForm').style.display = 'none';
|
||||||
|
document.getElementById('parkingActions').style.display = 'flex';
|
||||||
|
},
|
||||||
|
|
||||||
|
closeModal() {
|
||||||
|
document.getElementById('dayModal').style.display = 'none';
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -38,14 +38,20 @@ const ICONS = {
|
|||||||
<rect x="13.5" y="11" width="1.5" height="1.5" fill="currentColor"></rect>
|
<rect x="13.5" y="11" width="1.5" height="1.5" fill="currentColor"></rect>
|
||||||
<rect x="9" y="16" width="1.5" height="1.5" fill="currentColor"></rect>
|
<rect x="9" y="16" width="1.5" height="1.5" fill="currentColor"></rect>
|
||||||
<rect x="13.5" y="16" width="1.5" height="1.5" fill="currentColor"></rect>
|
<rect x="13.5" y="16" width="1.5" height="1.5" fill="currentColor"></rect>
|
||||||
|
</svg>`,
|
||||||
|
settings: `<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
|
<circle cx="12" cy="12" r="3"></circle>
|
||||||
|
<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"></path>
|
||||||
</svg>`
|
</svg>`
|
||||||
};
|
};
|
||||||
|
|
||||||
const NAV_ITEMS = [
|
const NAV_ITEMS = [
|
||||||
{ href: '/presence', icon: 'calendar', label: 'My Presence' },
|
{ href: '/presence', icon: 'calendar', label: 'La mia presenza' },
|
||||||
{ href: '/team-calendar', icon: 'users', label: 'Team Calendar' },
|
{ href: '/team-calendar', icon: 'users', label: 'Calendario del team' },
|
||||||
{ href: '/team-rules', icon: 'rules', label: 'Team Rules', roles: ['admin', 'manager'] },
|
{ href: '/team-rules', icon: 'rules', label: 'Regole parcheggio', roles: ['admin', 'manager'] },
|
||||||
{ href: '/admin/users', icon: 'user', label: 'Manage Users', roles: ['admin'] }
|
{ href: '/admin/users', icon: 'user', label: 'Gestione Utenti', roles: ['admin'] },
|
||||||
|
{ href: '/admin/offices', icon: 'building', label: 'Gestione Uffici', roles: ['admin'] },
|
||||||
|
{ href: '/parking-settings', icon: 'settings', label: 'Impostazioni Ufficio', roles: ['admin', 'manager'] }
|
||||||
];
|
];
|
||||||
|
|
||||||
function getIcon(name) {
|
function getIcon(name) {
|
||||||
@@ -108,7 +114,7 @@ function setupMobileMenu() {
|
|||||||
const menuToggle = document.createElement('button');
|
const menuToggle = document.createElement('button');
|
||||||
menuToggle.className = 'menu-toggle';
|
menuToggle.className = 'menu-toggle';
|
||||||
menuToggle.innerHTML = MENU_ICON;
|
menuToggle.innerHTML = MENU_ICON;
|
||||||
menuToggle.setAttribute('aria-label', 'Toggle menu');
|
menuToggle.setAttribute('aria-label', 'Apri/Chiudi menu');
|
||||||
pageHeader.insertBefore(menuToggle, pageHeader.firstChild);
|
pageHeader.insertBefore(menuToggle, pageHeader.firstChild);
|
||||||
|
|
||||||
// Add overlay
|
// Add overlay
|
||||||
|
|||||||
226
frontend/js/parking-settings.js
Normal file
226
frontend/js/parking-settings.js
Normal file
@@ -0,0 +1,226 @@
|
|||||||
|
|
||||||
|
let currentUser = null;
|
||||||
|
let currentOffice = null;
|
||||||
|
|
||||||
|
document.addEventListener('DOMContentLoaded', async () => {
|
||||||
|
if (!api.requireAuth()) return;
|
||||||
|
|
||||||
|
currentUser = await api.getCurrentUser();
|
||||||
|
if (!currentUser) return;
|
||||||
|
|
||||||
|
// Only Manager or Admin
|
||||||
|
if (!['admin', 'manager'].includes(currentUser.role)) {
|
||||||
|
window.location.href = '/';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize UI
|
||||||
|
populateHourSelect();
|
||||||
|
|
||||||
|
// Set default date to tomorrow
|
||||||
|
const tomorrow = new Date();
|
||||||
|
tomorrow.setDate(tomorrow.getDate() + 1);
|
||||||
|
document.getElementById('testDateStart').valueAsDate = tomorrow;
|
||||||
|
|
||||||
|
await loadOffices();
|
||||||
|
setupEventListeners();
|
||||||
|
});
|
||||||
|
|
||||||
|
async function loadOffices() {
|
||||||
|
const select = document.getElementById('officeSelect');
|
||||||
|
const card = document.getElementById('officeSelectionCard');
|
||||||
|
const content = document.getElementById('settingsContent');
|
||||||
|
|
||||||
|
// Only Admins see the selector
|
||||||
|
if (currentUser.role === 'admin') {
|
||||||
|
card.style.display = 'block';
|
||||||
|
content.style.display = 'none'; // Hide until selected
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await api.get('/api/offices');
|
||||||
|
if (response && response.ok) {
|
||||||
|
const offices = await response.json();
|
||||||
|
offices.forEach(office => {
|
||||||
|
const option = document.createElement('option');
|
||||||
|
option.value = office.id;
|
||||||
|
option.textContent = office.name;
|
||||||
|
select.appendChild(option);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
utils.showMessage('Errore caricamento uffici', 'error');
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Manager uses their own office
|
||||||
|
card.style.display = 'none';
|
||||||
|
content.style.display = 'block';
|
||||||
|
if (currentUser.office_id) {
|
||||||
|
await loadOfficeSettings(currentUser.office_id);
|
||||||
|
} else {
|
||||||
|
utils.showMessage('Nessun ufficio assegnato al manager', 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function populateHourSelect() {
|
||||||
|
const select = document.getElementById('bookingWindowHour');
|
||||||
|
select.innerHTML = '';
|
||||||
|
for (let h = 0; h < 24; h++) {
|
||||||
|
const option = document.createElement('option');
|
||||||
|
option.value = h;
|
||||||
|
option.textContent = h.toString().padStart(2, '0');
|
||||||
|
select.appendChild(option);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadOfficeSettings(id) {
|
||||||
|
const officeId = id;
|
||||||
|
if (!officeId) {
|
||||||
|
utils.showMessage('Nessun ufficio selezionato', 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await api.get(`/api/offices/${officeId}`);
|
||||||
|
if (!response.ok) throw new Error('Failed to load office');
|
||||||
|
|
||||||
|
const office = await response.json();
|
||||||
|
currentOffice = office;
|
||||||
|
|
||||||
|
// Populate form
|
||||||
|
document.getElementById('bookingWindowEnabled').checked = office.booking_window_enabled || false;
|
||||||
|
document.getElementById('bookingWindowHour').value = office.booking_window_end_hour ?? 18; // Default 18
|
||||||
|
document.getElementById('bookingWindowMinute').value = office.booking_window_end_minute ?? 0;
|
||||||
|
|
||||||
|
updateVisibility();
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
utils.showMessage('Errore nel caricamento impostazioni', 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateVisibility() {
|
||||||
|
const enabled = document.getElementById('bookingWindowEnabled').checked;
|
||||||
|
document.getElementById('cutoffTimeGroup').style.display = enabled ? 'block' : 'none';
|
||||||
|
}
|
||||||
|
|
||||||
|
function setupEventListeners() {
|
||||||
|
// Office Select
|
||||||
|
document.getElementById('officeSelect').addEventListener('change', (e) => {
|
||||||
|
const id = e.target.value;
|
||||||
|
if (id) {
|
||||||
|
document.getElementById('settingsContent').style.display = 'block';
|
||||||
|
loadOfficeSettings(id);
|
||||||
|
} else {
|
||||||
|
document.getElementById('settingsContent').style.display = 'none';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Toggle visibility
|
||||||
|
document.getElementById('bookingWindowEnabled').addEventListener('change', updateVisibility);
|
||||||
|
|
||||||
|
// Save Settings
|
||||||
|
document.getElementById('scheduleForm').addEventListener('submit', async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
if (!currentOffice) return;
|
||||||
|
|
||||||
|
const data = {
|
||||||
|
booking_window_enabled: document.getElementById('bookingWindowEnabled').checked,
|
||||||
|
booking_window_end_hour: parseInt(document.getElementById('bookingWindowHour').value),
|
||||||
|
booking_window_end_minute: parseInt(document.getElementById('bookingWindowMinute').value)
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await api.put(`/api/offices/${currentOffice.id}`, data);
|
||||||
|
if (res) {
|
||||||
|
utils.showMessage('Impostazioni salvate con successo', 'success');
|
||||||
|
currentOffice = res;
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
utils.showMessage('Errore nel salvataggio', 'error');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Test Tools
|
||||||
|
document.getElementById('runAllocationBtn').addEventListener('click', async () => {
|
||||||
|
if (!confirm('Sei sicuro di voler avviare l\'assegnazione ORA? Questo potrebbe sovrascrivere le assegnazioni esistenti per la data selezionata.')) return;
|
||||||
|
|
||||||
|
const dateStart = document.getElementById('testDateStart').value;
|
||||||
|
const dateEnd = document.getElementById('testDateEnd').value;
|
||||||
|
|
||||||
|
if (!dateStart) return utils.showMessage('Seleziona una data di inizio', 'error');
|
||||||
|
|
||||||
|
let start = new Date(dateStart);
|
||||||
|
let end = dateEnd ? new Date(dateEnd) : new Date(dateStart);
|
||||||
|
|
||||||
|
if (end < start) {
|
||||||
|
return utils.showMessage('La data di fine deve essere successiva alla data di inizio', 'error');
|
||||||
|
}
|
||||||
|
|
||||||
|
let current = new Date(start);
|
||||||
|
let successCount = 0;
|
||||||
|
let errorCount = 0;
|
||||||
|
|
||||||
|
utils.showMessage('Avvio assegnazione...', 'success');
|
||||||
|
|
||||||
|
while (current <= end) {
|
||||||
|
const dateStr = current.toISOString().split('T')[0];
|
||||||
|
try {
|
||||||
|
await api.post('/api/parking/run-allocation', {
|
||||||
|
date: dateStr,
|
||||||
|
office_id: currentOffice.id
|
||||||
|
});
|
||||||
|
successCount++;
|
||||||
|
} catch (e) {
|
||||||
|
console.error(`Error for ${dateStr}`, e);
|
||||||
|
errorCount++;
|
||||||
|
}
|
||||||
|
current.setDate(current.getDate() + 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (errorCount === 0) {
|
||||||
|
utils.showMessage(`Assegnazione completata per ${successCount} giorni.`, 'success');
|
||||||
|
} else {
|
||||||
|
utils.showMessage(`Completato con errori: ${successCount} successi, ${errorCount} errori.`, 'warning');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('clearAssignmentsBtn').addEventListener('click', async () => {
|
||||||
|
if (!confirm('ATTENZIONE: Stai per eliminare TUTTE le assegnazioni per il periodo selezionato. Procedere?')) return;
|
||||||
|
|
||||||
|
const dateStart = document.getElementById('testDateStart').value;
|
||||||
|
const dateEnd = document.getElementById('testDateEnd').value;
|
||||||
|
|
||||||
|
if (!dateStart) return utils.showMessage('Seleziona una data di inizio', 'error');
|
||||||
|
|
||||||
|
let start = new Date(dateStart);
|
||||||
|
let end = dateEnd ? new Date(dateEnd) : new Date(dateStart);
|
||||||
|
|
||||||
|
if (end < start) {
|
||||||
|
return utils.showMessage('La data di fine deve essere successiva alla data di inizio', 'error');
|
||||||
|
}
|
||||||
|
|
||||||
|
let current = new Date(start);
|
||||||
|
let totalRemoved = 0;
|
||||||
|
|
||||||
|
utils.showMessage('Rimozione in corso...', 'warning');
|
||||||
|
|
||||||
|
while (current <= end) {
|
||||||
|
const dateStr = current.toISOString().split('T')[0];
|
||||||
|
try {
|
||||||
|
const res = await api.post('/api/parking/clear-assignments', {
|
||||||
|
date: dateStr,
|
||||||
|
office_id: currentOffice.id
|
||||||
|
});
|
||||||
|
totalRemoved += (res.count || 0);
|
||||||
|
} catch (e) {
|
||||||
|
console.error(`Error clearing ${dateStr}`, e);
|
||||||
|
}
|
||||||
|
current.setDate(current.getDate() + 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
utils.showMessage(`Operazione eseguita. ${totalRemoved} assegnazioni rimosse in totale.`, 'warning');
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -8,14 +8,31 @@ let currentDate = new Date();
|
|||||||
let presenceData = {};
|
let presenceData = {};
|
||||||
let parkingData = {};
|
let parkingData = {};
|
||||||
let currentAssignmentId = null;
|
let currentAssignmentId = null;
|
||||||
|
let weeklyClosingDays = [];
|
||||||
|
let specificClosingDays = [];
|
||||||
|
let statusDate = new Date();
|
||||||
|
let statusViewMode = 'daily';
|
||||||
|
|
||||||
document.addEventListener('DOMContentLoaded', async () => {
|
document.addEventListener('DOMContentLoaded', async () => {
|
||||||
currentUser = await api.requireAuth();
|
currentUser = await api.requireAuth();
|
||||||
if (!currentUser) return;
|
if (!currentUser) return;
|
||||||
|
|
||||||
await Promise.all([loadPresences(), loadParkingAssignments()]);
|
await Promise.all([loadPresences(), loadParkingAssignments(), loadClosingDays()]);
|
||||||
|
|
||||||
|
// Initialize Modal Logic
|
||||||
|
ModalLogic.init({
|
||||||
|
onMarkPresence: handleMarkPresence,
|
||||||
|
onClearPresence: handleClearPresence,
|
||||||
|
onReleaseParking: handleReleaseParking,
|
||||||
|
onReassignParking: handleReassignParking
|
||||||
|
});
|
||||||
|
|
||||||
renderCalendar();
|
renderCalendar();
|
||||||
setupEventListeners();
|
setupEventListeners();
|
||||||
|
|
||||||
|
// Initialize Parking Status
|
||||||
|
initParkingStatus();
|
||||||
|
setupStatusListeners();
|
||||||
});
|
});
|
||||||
|
|
||||||
async function loadPresences() {
|
async function loadPresences() {
|
||||||
@@ -56,10 +73,32 @@ async function loadParkingAssignments() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async function loadClosingDays() {
|
||||||
|
if (!currentUser.office_id) return;
|
||||||
|
try {
|
||||||
|
const [weeklyRes, specificRes] = await Promise.all([
|
||||||
|
api.get(`/api/offices/${currentUser.office_id}/weekly-closing-days`),
|
||||||
|
api.get(`/api/offices/${currentUser.office_id}/closing-days`)
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (weeklyRes && weeklyRes.ok) {
|
||||||
|
const days = await weeklyRes.json();
|
||||||
|
weeklyClosingDays = days.map(d => d.weekday);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (specificRes && specificRes.ok) {
|
||||||
|
specificClosingDays = await specificRes.json();
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Error loading closing days:', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function renderCalendar() {
|
function renderCalendar() {
|
||||||
const year = currentDate.getFullYear();
|
const year = currentDate.getFullYear();
|
||||||
const month = currentDate.getMonth();
|
const month = currentDate.getMonth();
|
||||||
const weekStartDay = currentUser.week_start_day || 0; // 0=Sunday, 1=Monday
|
const weekStartDay = currentUser.week_start_day || 1; // 0=Sunday, 1=Monday (default to Monday)
|
||||||
|
|
||||||
// Update month header
|
// Update month header
|
||||||
document.getElementById('currentMonth').textContent = `${utils.getMonthName(month)} ${year}`;
|
document.getElementById('currentMonth').textContent = `${utils.getMonthName(month)} ${year}`;
|
||||||
@@ -78,7 +117,7 @@ function renderCalendar() {
|
|||||||
grid.innerHTML = '';
|
grid.innerHTML = '';
|
||||||
|
|
||||||
// Day headers - reorder based on week start day
|
// Day headers - reorder based on week start day
|
||||||
const allDayNames = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
|
const allDayNames = ['Dom', 'Lun', 'Mar', 'Mer', 'Gio', 'Ven', 'Sab'];
|
||||||
const dayNames = [];
|
const dayNames = [];
|
||||||
for (let i = 0; i < 7; i++) {
|
for (let i = 0; i < 7; i++) {
|
||||||
dayNames.push(allDayNames[(weekStartDay + i) % 7]);
|
dayNames.push(allDayNames[(weekStartDay + i) % 7]);
|
||||||
@@ -120,7 +159,25 @@ function renderCalendar() {
|
|||||||
if (isHoliday) cell.classList.add('holiday');
|
if (isHoliday) cell.classList.add('holiday');
|
||||||
if (isToday) cell.classList.add('today');
|
if (isToday) cell.classList.add('today');
|
||||||
|
|
||||||
if (presence) {
|
// Check closing days
|
||||||
|
// Note: JS getDay(): 0=Sunday, 1=Monday...
|
||||||
|
// DB WeekDay: 0=Sunday, etc. (They match)
|
||||||
|
const isWeeklyClosed = weeklyClosingDays.includes(dayOfWeek);
|
||||||
|
const isSpecificClosed = specificClosingDays.some(d => {
|
||||||
|
const start = new Date(d.date);
|
||||||
|
const end = d.end_date ? new Date(d.end_date) : start;
|
||||||
|
// Reset times for strict date comparison
|
||||||
|
start.setHours(0, 0, 0, 0);
|
||||||
|
end.setHours(0, 0, 0, 0);
|
||||||
|
return date >= start && date <= end;
|
||||||
|
});
|
||||||
|
|
||||||
|
const isClosed = isWeeklyClosed || isSpecificClosed;
|
||||||
|
|
||||||
|
if (isClosed) {
|
||||||
|
cell.classList.add('closed');
|
||||||
|
cell.title = "Ufficio Chiuso";
|
||||||
|
} else if (presence) {
|
||||||
cell.classList.add(`status-${presence.status}`);
|
cell.classList.add(`status-${presence.status}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -134,140 +191,60 @@ function renderCalendar() {
|
|||||||
${parkingBadge}
|
${parkingBadge}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
cell.addEventListener('click', () => openDayModal(dateStr, presence, parking));
|
if (!isClosed) {
|
||||||
|
cell.addEventListener('click', () => openDayModal(dateStr, presence, parking));
|
||||||
|
}
|
||||||
grid.appendChild(cell);
|
grid.appendChild(cell);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function openDayModal(dateStr, presence, parking) {
|
function openDayModal(dateStr, presence, parking) {
|
||||||
const modal = document.getElementById('dayModal');
|
ModalLogic.openModal({
|
||||||
const title = document.getElementById('dayModalTitle');
|
dateStr,
|
||||||
|
presence,
|
||||||
title.textContent = utils.formatDateDisplay(dateStr);
|
parking
|
||||||
|
|
||||||
// Highlight current status
|
|
||||||
document.querySelectorAll('.status-btn').forEach(btn => {
|
|
||||||
const status = btn.dataset.status;
|
|
||||||
if (presence && presence.status === status) {
|
|
||||||
btn.classList.add('active');
|
|
||||||
} else {
|
|
||||||
btn.classList.remove('active');
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Update parking section
|
|
||||||
const parkingSection = document.getElementById('parkingSection');
|
|
||||||
const parkingInfo = document.getElementById('parkingInfo');
|
|
||||||
const releaseBtn = document.getElementById('releaseParkingBtn');
|
|
||||||
|
|
||||||
if (parking) {
|
|
||||||
parkingSection.style.display = 'block';
|
|
||||||
const spotName = parking.spot_display_name || parking.spot_id;
|
|
||||||
parkingInfo.innerHTML = `<strong>Parking:</strong> Spot ${spotName}`;
|
|
||||||
releaseBtn.dataset.assignmentId = parking.id;
|
|
||||||
document.getElementById('reassignParkingBtn').dataset.assignmentId = parking.id;
|
|
||||||
currentAssignmentId = parking.id;
|
|
||||||
} else {
|
|
||||||
parkingSection.style.display = 'none';
|
|
||||||
}
|
|
||||||
|
|
||||||
modal.dataset.date = dateStr;
|
|
||||||
modal.style.display = 'flex';
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function markPresence(status) {
|
async function handleMarkPresence(status, date) {
|
||||||
const modal = document.getElementById('dayModal');
|
|
||||||
const date = modal.dataset.date;
|
|
||||||
|
|
||||||
const response = await api.post('/api/presence/mark', { date, status });
|
const response = await api.post('/api/presence/mark', { date, status });
|
||||||
if (response && response.ok) {
|
if (response && response.ok) {
|
||||||
await Promise.all([loadPresences(), loadParkingAssignments()]);
|
await Promise.all([loadPresences(), loadParkingAssignments()]);
|
||||||
renderCalendar();
|
renderCalendar();
|
||||||
modal.style.display = 'none';
|
ModalLogic.closeModal();
|
||||||
} else {
|
} else {
|
||||||
const error = await response.json();
|
const error = await response.json();
|
||||||
alert(error.detail || 'Failed to mark presence');
|
alert(error.detail || 'Impossibile segnare la presenza');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function clearPresence() {
|
async function handleClearPresence(date) {
|
||||||
const modal = document.getElementById('dayModal');
|
|
||||||
const date = modal.dataset.date;
|
|
||||||
|
|
||||||
if (!confirm('Clear presence for this date?')) return;
|
|
||||||
|
|
||||||
const response = await api.delete(`/api/presence/${date}`);
|
const response = await api.delete(`/api/presence/${date}`);
|
||||||
if (response && response.ok) {
|
if (response && response.ok) {
|
||||||
await Promise.all([loadPresences(), loadParkingAssignments()]);
|
await Promise.all([loadPresences(), loadParkingAssignments()]);
|
||||||
renderCalendar();
|
renderCalendar();
|
||||||
modal.style.display = 'none';
|
ModalLogic.closeModal();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function releaseParking() {
|
async function handleReleaseParking(assignmentId) {
|
||||||
const modal = document.getElementById('dayModal');
|
if (!confirm('Rilasciare il parcheggio per questa data?')) return;
|
||||||
const releaseBtn = document.getElementById('releaseParkingBtn');
|
|
||||||
const assignmentId = releaseBtn.dataset.assignmentId;
|
|
||||||
|
|
||||||
if (!assignmentId) return;
|
|
||||||
if (!confirm('Release your parking spot for this date?')) return;
|
|
||||||
|
|
||||||
const response = await api.post(`/api/parking/release-my-spot/${assignmentId}`);
|
const response = await api.post(`/api/parking/release-my-spot/${assignmentId}`);
|
||||||
|
|
||||||
if (response && response.ok) {
|
if (response && response.ok) {
|
||||||
await loadParkingAssignments();
|
await loadParkingAssignments();
|
||||||
renderCalendar();
|
renderCalendar();
|
||||||
modal.style.display = 'none';
|
ModalLogic.closeModal();
|
||||||
} else {
|
} else {
|
||||||
const error = await response.json();
|
const error = await response.json();
|
||||||
alert(error.detail || 'Failed to release parking spot');
|
alert(error.detail || 'Impossibile rilasciare il parcheggio');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function openReassignModal() {
|
async function handleReassignParking(assignmentId, newUserId) {
|
||||||
const assignmentId = currentAssignmentId;
|
// Basic validation handled by select; confirm
|
||||||
if (!assignmentId) return;
|
|
||||||
|
|
||||||
// Load eligible users
|
|
||||||
const response = await api.get(`/api/parking/eligible-users/${assignmentId}`);
|
|
||||||
if (!response || !response.ok) {
|
|
||||||
const error = await response.json();
|
|
||||||
alert(error.detail || 'Failed to load eligible users');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const users = await response.json();
|
|
||||||
const select = document.getElementById('reassignUser');
|
|
||||||
select.innerHTML = '<option value="">Select user...</option>';
|
|
||||||
|
|
||||||
if (users.length === 0) {
|
|
||||||
select.innerHTML = '<option value="">No eligible users available</option>';
|
|
||||||
} else {
|
|
||||||
users.forEach(user => {
|
|
||||||
const option = document.createElement('option');
|
|
||||||
option.value = user.id;
|
|
||||||
option.textContent = user.name;
|
|
||||||
select.appendChild(option);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get spot info from parking data
|
|
||||||
const parking = Object.values(parkingData).find(p => p.id === assignmentId);
|
|
||||||
if (parking) {
|
|
||||||
const spotName = parking.spot_display_name || parking.spot_id;
|
|
||||||
document.getElementById('reassignSpotInfo').textContent = `Spot ${spotName}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
document.getElementById('dayModal').style.display = 'none';
|
|
||||||
document.getElementById('reassignModal').style.display = 'flex';
|
|
||||||
}
|
|
||||||
|
|
||||||
async function confirmReassign() {
|
|
||||||
const assignmentId = currentAssignmentId;
|
|
||||||
const newUserId = document.getElementById('reassignUser').value;
|
|
||||||
|
|
||||||
if (!assignmentId || !newUserId) {
|
if (!assignmentId || !newUserId) {
|
||||||
alert('Please select a user');
|
alert('Seleziona un utente');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -279,13 +256,15 @@ async function confirmReassign() {
|
|||||||
if (response && response.ok) {
|
if (response && response.ok) {
|
||||||
await loadParkingAssignments();
|
await loadParkingAssignments();
|
||||||
renderCalendar();
|
renderCalendar();
|
||||||
document.getElementById('reassignModal').style.display = 'none';
|
ModalLogic.closeModal();
|
||||||
} else {
|
} else {
|
||||||
const error = await response.json();
|
const error = await response.json();
|
||||||
alert(error.detail || 'Failed to reassign parking spot');
|
alert(error.detail || 'Impossibile riassegnare il parcheggio');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
function setupEventListeners() {
|
function setupEventListeners() {
|
||||||
// Month navigation
|
// Month navigation
|
||||||
document.getElementById('prevMonth').addEventListener('click', async () => {
|
document.getElementById('prevMonth').addEventListener('click', async () => {
|
||||||
@@ -300,69 +279,255 @@ function setupEventListeners() {
|
|||||||
renderCalendar();
|
renderCalendar();
|
||||||
});
|
});
|
||||||
|
|
||||||
// Day modal
|
// Quick Entry Logic
|
||||||
document.getElementById('closeDayModal').addEventListener('click', () => {
|
const quickEntryModal = document.getElementById('quickEntryModal');
|
||||||
document.getElementById('dayModal').style.display = 'none';
|
const quickEntryBtn = document.getElementById('quickEntryBtn');
|
||||||
|
const closeQuickEntryBtn = document.getElementById('closeQuickEntryModal');
|
||||||
|
const cancelQuickEntryBtn = document.getElementById('cancelQuickEntry');
|
||||||
|
const quickEntryForm = document.getElementById('quickEntryForm');
|
||||||
|
|
||||||
|
if (quickEntryBtn) {
|
||||||
|
quickEntryBtn.addEventListener('click', () => {
|
||||||
|
// Default dates: tomorrow
|
||||||
|
const tomorrow = new Date();
|
||||||
|
tomorrow.setDate(tomorrow.getDate() + 1);
|
||||||
|
|
||||||
|
document.getElementById('qeStartDate').valueAsDate = tomorrow;
|
||||||
|
document.getElementById('qeEndDate').valueAsDate = tomorrow;
|
||||||
|
document.getElementById('qeStatus').value = '';
|
||||||
|
|
||||||
|
// Clear selections
|
||||||
|
document.querySelectorAll('.qe-status-btn').forEach(btn => btn.classList.remove('active'));
|
||||||
|
|
||||||
|
quickEntryModal.style.display = 'flex';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (closeQuickEntryBtn) closeQuickEntryBtn.addEventListener('click', () => quickEntryModal.style.display = 'none');
|
||||||
|
if (cancelQuickEntryBtn) cancelQuickEntryBtn.addEventListener('click', () => quickEntryModal.style.display = 'none');
|
||||||
|
|
||||||
|
// Status selection in QE
|
||||||
|
document.querySelectorAll('.qe-status-btn').forEach(btn => {
|
||||||
|
btn.addEventListener('click', () => {
|
||||||
|
document.querySelectorAll('.qe-status-btn').forEach(b => b.classList.remove('active'));
|
||||||
|
btn.classList.add('active');
|
||||||
|
document.getElementById('qeStatus').value = btn.dataset.status;
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
document.querySelectorAll('.status-btn').forEach(btn => {
|
if (quickEntryForm) {
|
||||||
btn.addEventListener('click', () => markPresence(btn.dataset.status));
|
quickEntryForm.addEventListener('submit', async (e) => {
|
||||||
});
|
e.preventDefault();
|
||||||
|
|
||||||
document.getElementById('clearDayBtn').addEventListener('click', clearPresence);
|
const startStr = document.getElementById('qeStartDate').value;
|
||||||
document.getElementById('releaseParkingBtn').addEventListener('click', releaseParking);
|
const endStr = document.getElementById('qeEndDate').value;
|
||||||
document.getElementById('reassignParkingBtn').addEventListener('click', openReassignModal);
|
const status = document.getElementById('qeStatus').value;
|
||||||
|
|
||||||
utils.setupModalClose('dayModal');
|
if (!status) return utils.showMessage('Seleziona uno stato', 'error');
|
||||||
|
if (!startStr || !endStr) return utils.showMessage('Seleziona le date', 'error');
|
||||||
|
|
||||||
// Reassign modal
|
const startDate = new Date(startStr);
|
||||||
document.getElementById('closeReassignModal').addEventListener('click', () => {
|
const endDate = new Date(endStr);
|
||||||
document.getElementById('reassignModal').style.display = 'none';
|
|
||||||
});
|
|
||||||
document.getElementById('cancelReassign').addEventListener('click', () => {
|
|
||||||
document.getElementById('reassignModal').style.display = 'none';
|
|
||||||
});
|
|
||||||
document.getElementById('confirmReassign').addEventListener('click', confirmReassign);
|
|
||||||
utils.setupModalClose('reassignModal');
|
|
||||||
|
|
||||||
// Bulk mark
|
if (endDate < startDate) return utils.showMessage('La data di fine non può essere precedente alla data di inizio', 'error');
|
||||||
document.getElementById('bulkMarkBtn').addEventListener('click', () => {
|
|
||||||
document.getElementById('bulkMarkModal').style.display = 'flex';
|
|
||||||
});
|
|
||||||
|
|
||||||
document.getElementById('closeBulkModal').addEventListener('click', () => {
|
quickEntryModal.style.display = 'none';
|
||||||
document.getElementById('bulkMarkModal').style.display = 'none';
|
utils.showMessage('Inserimento in corso...', 'warning');
|
||||||
});
|
|
||||||
|
|
||||||
document.getElementById('cancelBulk').addEventListener('click', () => {
|
const promises = [];
|
||||||
document.getElementById('bulkMarkModal').style.display = 'none';
|
let current = new Date(startDate);
|
||||||
});
|
|
||||||
|
|
||||||
utils.setupModalClose('bulkMarkModal');
|
while (current <= endDate) {
|
||||||
|
const dStr = current.toISOString().split('T')[0];
|
||||||
|
if (status === 'clear') {
|
||||||
|
promises.push(api.delete(`/api/presence/${dStr}`));
|
||||||
|
} else {
|
||||||
|
promises.push(api.post('/api/presence/mark', { date: dStr, status: status }));
|
||||||
|
}
|
||||||
|
current.setDate(current.getDate() + 1);
|
||||||
|
}
|
||||||
|
|
||||||
document.getElementById('bulkMarkForm').addEventListener('submit', async (e) => {
|
try {
|
||||||
e.preventDefault();
|
await Promise.all(promises);
|
||||||
|
utils.showMessage('Inserimento completato!', 'success');
|
||||||
|
await Promise.all([loadPresences(), loadParkingAssignments()]);
|
||||||
|
renderCalendar();
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
utils.showMessage('Errore durante l\'inserimento. Alcuni giorni potrebbero non essere stati aggiornati.', 'error');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const startDate = document.getElementById('startDate').value;
|
// ----------------------------------------------------------------------------
|
||||||
const endDate = document.getElementById('endDate').value;
|
// Parking Status Logic
|
||||||
const status = document.getElementById('bulkStatus').value;
|
// ----------------------------------------------------------------------------
|
||||||
const weekdaysOnly = document.getElementById('weekdaysOnly').checked;
|
|
||||||
|
|
||||||
const data = { start_date: startDate, end_date: endDate, status };
|
function initParkingStatus() {
|
||||||
if (weekdaysOnly) {
|
updateStatusHeader();
|
||||||
data.days = [1, 2, 3, 4, 5]; // Mon-Fri (JS weekday)
|
loadDailyStatus();
|
||||||
}
|
|
||||||
|
|
||||||
const response = await api.post('/api/presence/mark-bulk', data);
|
// Update office name if available
|
||||||
|
if (currentUser && currentUser.office_name) {
|
||||||
|
const nameDisplay = document.getElementById('statusOfficeName');
|
||||||
|
if (nameDisplay) nameDisplay.textContent = currentUser.office_name;
|
||||||
|
|
||||||
|
const headerDisplay = document.getElementById('currentOfficeDisplay');
|
||||||
|
if (headerDisplay) headerDisplay.textContent = currentUser.office_name;
|
||||||
|
} else {
|
||||||
|
const nameDisplay = document.getElementById('statusOfficeName');
|
||||||
|
if (nameDisplay) nameDisplay.textContent = 'Tuo Ufficio';
|
||||||
|
|
||||||
|
const headerDisplay = document.getElementById('currentOfficeDisplay');
|
||||||
|
if (headerDisplay) headerDisplay.textContent = 'Tuo Ufficio';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateStatusHeader() {
|
||||||
|
const options = { weekday: 'long', year: 'numeric', month: 'long', day: 'numeric' };
|
||||||
|
const dateStr = statusDate.toLocaleDateString('it-IT', options);
|
||||||
|
const capitalizedDate = dateStr.charAt(0).toUpperCase() + dateStr.slice(1);
|
||||||
|
|
||||||
|
const statusDateDisplay = document.getElementById('statusDateDisplay');
|
||||||
|
if (statusDateDisplay) statusDateDisplay.textContent = capitalizedDate;
|
||||||
|
|
||||||
|
const pickerDateDisplay = document.getElementById('pickerDateDisplay');
|
||||||
|
if (pickerDateDisplay) pickerDateDisplay.textContent = utils.formatDate(statusDate);
|
||||||
|
|
||||||
|
const summaryDateDisplay = document.getElementById('summaryDateDisplay');
|
||||||
|
if (summaryDateDisplay) summaryDateDisplay.textContent = dateStr;
|
||||||
|
|
||||||
|
const picker = document.getElementById('statusDatePicker');
|
||||||
|
if (picker) {
|
||||||
|
const yyyy = statusDate.getFullYear();
|
||||||
|
const mm = String(statusDate.getMonth() + 1).padStart(2, '0');
|
||||||
|
const dd = String(statusDate.getDate()).padStart(2, '0');
|
||||||
|
picker.value = `${yyyy}-${mm}-${dd}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadDailyStatus() {
|
||||||
|
if (!currentUser || !currentUser.office_id) return;
|
||||||
|
|
||||||
|
const dateStr = utils.formatDate(statusDate);
|
||||||
|
const officeId = currentUser.office_id;
|
||||||
|
|
||||||
|
const grid = document.getElementById('spotsGrid');
|
||||||
|
// Keep grid height to avoid jump if possible, or just loading styling
|
||||||
|
if (grid) grid.innerHTML = '<div style="width:100%; text-align:center; padding:2rem; color:var(--text-secondary);">Caricamento...</div>';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await api.get(`/api/parking/assignments/${dateStr}?office_id=${officeId}`);
|
||||||
if (response && response.ok) {
|
if (response && response.ok) {
|
||||||
const results = await response.json();
|
const assignments = await response.json();
|
||||||
alert(`Marked ${results.length} dates`);
|
renderParkingStatus(assignments);
|
||||||
document.getElementById('bulkMarkModal').style.display = 'none';
|
|
||||||
await Promise.all([loadPresences(), loadParkingAssignments()]);
|
|
||||||
renderCalendar();
|
|
||||||
} else {
|
} else {
|
||||||
const error = await response.json();
|
if (grid) grid.innerHTML = '<div style="width:100%; text-align:center; padding:1rem;">Impossibile caricare i dati.</div>';
|
||||||
alert(error.detail || 'Failed to bulk mark');
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error("Error loading parking status", e);
|
||||||
|
if (grid) grid.innerHTML = '<div style="width:100%; text-align:center; padding:1rem;">Errore di caricamento.</div>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderParkingStatus(assignments) {
|
||||||
|
const grid = document.getElementById('spotsGrid');
|
||||||
|
if (!grid) return;
|
||||||
|
|
||||||
|
grid.innerHTML = '';
|
||||||
|
|
||||||
|
if (!assignments || assignments.length === 0) {
|
||||||
|
grid.innerHTML = '<div style="width:100%; text-align:center; padding:1rem;">Nessun posto configurato o disponibile.</div>';
|
||||||
|
const badge = document.getElementById('spotsCountBadge');
|
||||||
|
if (badge) badge.textContent = `Liberi: 0/0`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort
|
||||||
|
assignments.sort((a, b) => {
|
||||||
|
const nameA = a.spot_display_name || a.spot_id;
|
||||||
|
const nameB = b.spot_display_name || b.spot_id;
|
||||||
|
return nameA.localeCompare(nameB, undefined, { numeric: true, sensitivity: 'base' });
|
||||||
|
});
|
||||||
|
|
||||||
|
let total = assignments.length;
|
||||||
|
let free = 0;
|
||||||
|
|
||||||
|
assignments.forEach(a => {
|
||||||
|
const isFree = !a.user_id;
|
||||||
|
if (isFree) free++;
|
||||||
|
|
||||||
|
const spotName = a.spot_display_name || a.spot_id;
|
||||||
|
const statusText = isFree ? 'Libero' : (a.user_name || 'Occupato');
|
||||||
|
|
||||||
|
// Colors: Free = Green (default), Occupied = Yellow (requested)
|
||||||
|
// Yellow palette: Border #eab308, bg #fefce8, text #a16207, icon #eab308
|
||||||
|
|
||||||
|
const borderColor = isFree ? '#22c55e' : '#eab308';
|
||||||
|
const bgColor = isFree ? '#f0fdf4' : '#fefce8';
|
||||||
|
const textColor = isFree ? '#15803d' : '#a16207';
|
||||||
|
const iconColor = isFree ? '#22c55e' : '#eab308';
|
||||||
|
|
||||||
|
const el = document.createElement('div');
|
||||||
|
el.className = 'spot-card';
|
||||||
|
el.style.cssText = `
|
||||||
|
border: 1px solid ${borderColor};
|
||||||
|
background: ${bgColor};
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 1rem;
|
||||||
|
width: 140px;
|
||||||
|
min-width: 120px;
|
||||||
|
text-align: center;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
transition: all 0.2s;
|
||||||
|
`;
|
||||||
|
|
||||||
|
// New Car Icon (Front Facing Sedan style or similar simple shape)
|
||||||
|
// Using a cleaner SVG path
|
||||||
|
el.innerHTML = `
|
||||||
|
<div style="font-weight: 600; font-size: 1.1rem; color: #1f2937;">${spotName}</div>
|
||||||
|
<div style="color: ${textColor}; font-size: 0.85rem; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; max-width: 100%;" title="${statusText}">
|
||||||
|
${statusText}
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
grid.appendChild(el);
|
||||||
|
});
|
||||||
|
|
||||||
|
const badge = document.getElementById('spotsCountBadge');
|
||||||
|
if (badge) badge.textContent = `Liberi: ${free}/${total}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
function setupStatusListeners() {
|
||||||
|
const prevDay = document.getElementById('statusPrevDay');
|
||||||
|
if (prevDay) prevDay.addEventListener('click', () => {
|
||||||
|
statusDate.setDate(statusDate.getDate() - 1);
|
||||||
|
updateStatusHeader();
|
||||||
|
loadDailyStatus();
|
||||||
|
});
|
||||||
|
|
||||||
|
const nextDay = document.getElementById('statusNextDay');
|
||||||
|
if (nextDay) nextDay.addEventListener('click', () => {
|
||||||
|
statusDate.setDate(statusDate.getDate() + 1);
|
||||||
|
updateStatusHeader();
|
||||||
|
loadDailyStatus();
|
||||||
|
});
|
||||||
|
|
||||||
|
const datePicker = document.getElementById('statusDatePicker');
|
||||||
|
if (datePicker) datePicker.addEventListener('change', (e) => {
|
||||||
|
if (e.target.value) {
|
||||||
|
statusDate = new Date(e.target.value);
|
||||||
|
updateStatusHeader();
|
||||||
|
loadDailyStatus();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,72 +1,118 @@
|
|||||||
/**
|
/**
|
||||||
* Team Calendar Page
|
* Team Calendar Page
|
||||||
* Shows presence and parking for all team members
|
* Shows presence and parking for all team members
|
||||||
* Filtered by manager (manager-centric model)
|
* Filtered by office (office-centric model)
|
||||||
*/
|
*/
|
||||||
|
|
||||||
let currentUser = null;
|
let currentUser = null;
|
||||||
let currentStartDate = null;
|
let currentStartDate = null;
|
||||||
let viewMode = 'week'; // 'week' or 'month'
|
let viewMode = 'week'; // 'week' or 'month'
|
||||||
let managers = [];
|
let offices = [];
|
||||||
let teamData = [];
|
let teamData = [];
|
||||||
let parkingDataLookup = {};
|
let parkingDataLookup = {};
|
||||||
let parkingAssignmentLookup = {};
|
let parkingAssignmentLookup = {};
|
||||||
let selectedUserId = null;
|
let selectedUserId = null;
|
||||||
let selectedDate = null;
|
let selectedDate = null;
|
||||||
let currentAssignmentId = null;
|
let currentAssignmentId = null;
|
||||||
|
let officeClosingRules = {}; // { officeId: { weekly: [], specific: [] } }
|
||||||
|
|
||||||
document.addEventListener('DOMContentLoaded', async () => {
|
document.addEventListener('DOMContentLoaded', async () => {
|
||||||
currentUser = await api.requireAuth();
|
currentUser = await api.requireAuth();
|
||||||
if (!currentUser) return;
|
if (!currentUser) return;
|
||||||
|
|
||||||
// Initialize start date based on week start preference
|
// Initialize start date based on week start preference
|
||||||
const weekStartDay = currentUser.week_start_day || 0;
|
const weekStartDay = currentUser.week_start_day || 1;
|
||||||
currentStartDate = utils.getWeekStart(new Date(), weekStartDay);
|
currentStartDate = utils.getWeekStart(new Date(), weekStartDay);
|
||||||
|
|
||||||
await loadManagers();
|
await loadOffices();
|
||||||
await loadTeamData();
|
await loadTeamData();
|
||||||
|
await loadTeamData();
|
||||||
|
|
||||||
|
// Initialize Modal Logic
|
||||||
|
ModalLogic.init({
|
||||||
|
onMarkPresence: handleMarkPresence,
|
||||||
|
onClearPresence: handleClearPresence,
|
||||||
|
onReleaseParking: handleReleaseParking,
|
||||||
|
onReassignParking: handleReassignParking
|
||||||
|
});
|
||||||
|
|
||||||
renderCalendar();
|
renderCalendar();
|
||||||
setupEventListeners();
|
setupEventListeners();
|
||||||
});
|
});
|
||||||
|
|
||||||
async function loadManagers() {
|
function updateOfficeDisplay() {
|
||||||
const response = await api.get('/api/managers');
|
const display = document.getElementById('currentOfficeNameDisplay');
|
||||||
if (response && response.ok) {
|
if (!display) return;
|
||||||
managers = await response.json();
|
|
||||||
const select = document.getElementById('managerFilter');
|
|
||||||
|
|
||||||
// Filter managers based on user role
|
const select = document.getElementById('officeFilter');
|
||||||
let filteredManagers = managers;
|
|
||||||
|
// If user is employee, show their office name directly
|
||||||
|
if (currentUser.role === 'employee') {
|
||||||
|
display.textContent = currentUser.office_name || "Mio Ufficio";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// For admin/manager, show selected
|
||||||
|
if (select && select.value) {
|
||||||
|
// Find name in options
|
||||||
|
const option = select.options[select.selectedIndex];
|
||||||
|
if (option) {
|
||||||
|
// Remove the count (xx utenti) part if desired, or keep it.
|
||||||
|
// User requested "nome del'ufficio", let's keep it simple.
|
||||||
|
// Option text is "Name (Count users)"
|
||||||
|
// let text = option.textContent.split('(')[0].trim();
|
||||||
|
display.textContent = option.textContent;
|
||||||
|
} else {
|
||||||
|
display.textContent = "Tutti gli Uffici";
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
display.textContent = "Tutti gli Uffici";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadOffices() {
|
||||||
|
const select = document.getElementById('officeFilter');
|
||||||
|
|
||||||
|
// Only Admins and Managers can list offices
|
||||||
|
// Employees will just see their own office logic handled in loadTeamData
|
||||||
|
// Only Admins can see the office selector
|
||||||
|
if (currentUser.role !== 'admin') {
|
||||||
|
select.style.display = 'none';
|
||||||
|
// Employees stop here, Managers continue to allow auto-selection logic below
|
||||||
|
if (currentUser.role === 'employee') return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await api.get('/api/offices');
|
||||||
|
if (response && response.ok) {
|
||||||
|
offices = await response.json();
|
||||||
|
|
||||||
|
let filteredOffices = offices;
|
||||||
if (currentUser.role === 'manager') {
|
if (currentUser.role === 'manager') {
|
||||||
// Manager only sees themselves
|
// Manager only sees their own office in the filter?
|
||||||
filteredManagers = managers.filter(m => m.id === currentUser.id);
|
// Actually managers might want to filter if they (hypothetically) managed multiple,
|
||||||
} else if (currentUser.role === 'employee') {
|
// but currently User has 1 office.
|
||||||
// Employee only sees their own manager
|
if (currentUser.office_id) {
|
||||||
if (currentUser.manager_id) {
|
filteredOffices = offices.filter(o => o.id === currentUser.office_id);
|
||||||
filteredManagers = managers.filter(m => m.id === currentUser.manager_id);
|
|
||||||
} else {
|
} else {
|
||||||
filteredManagers = [];
|
filteredOffices = [];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
filteredManagers.forEach(manager => {
|
filteredOffices.forEach(office => {
|
||||||
const option = document.createElement('option');
|
const option = document.createElement('option');
|
||||||
option.value = manager.id;
|
option.value = office.id;
|
||||||
const userCount = manager.managed_user_count || 0;
|
option.textContent = `${office.name} (${office.user_count || 0} utenti)`;
|
||||||
option.textContent = `${manager.name} (${userCount} users)`;
|
|
||||||
select.appendChild(option);
|
select.appendChild(option);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Auto-select for managers and employees (they only see their team)
|
// Auto-select for managers
|
||||||
if (filteredManagers.length === 1) {
|
if (currentUser.role === 'manager' && filteredOffices.length === 1) {
|
||||||
select.value = filteredManagers[0].id;
|
select.value = filteredOffices[0].id;
|
||||||
}
|
|
||||||
|
|
||||||
// Hide manager filter for employees (they can only see their team)
|
|
||||||
if (currentUser.role === 'employee') {
|
|
||||||
select.style.display = 'none';
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Initial update of office display
|
||||||
|
updateOfficeDisplay();
|
||||||
}
|
}
|
||||||
|
|
||||||
function getDateRange() {
|
function getDateRange() {
|
||||||
@@ -85,15 +131,16 @@ function getDateRange() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function loadTeamData() {
|
async function loadTeamData() {
|
||||||
|
await loadClosingData();
|
||||||
const { startDate, endDate } = getDateRange();
|
const { startDate, endDate } = getDateRange();
|
||||||
const startStr = utils.formatDate(startDate);
|
const startStr = utils.formatDate(startDate);
|
||||||
const endStr = utils.formatDate(endDate);
|
const endStr = utils.formatDate(endDate);
|
||||||
|
|
||||||
let url = `/api/presence/team?start_date=${startStr}&end_date=${endStr}`;
|
let url = `/api/presence/team?start_date=${startStr}&end_date=${endStr}`;
|
||||||
|
|
||||||
const managerFilter = document.getElementById('managerFilter').value;
|
const officeFilter = document.getElementById('officeFilter').value;
|
||||||
if (managerFilter) {
|
if (officeFilter) {
|
||||||
url += `&manager_id=${managerFilter}`;
|
url += `&office_id=${officeFilter}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
const response = await api.get(url);
|
const response = await api.get(url);
|
||||||
@@ -114,6 +161,68 @@ async function loadTeamData() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async function loadClosingData() {
|
||||||
|
officeClosingRules = {};
|
||||||
|
let officeIdsToLoad = [];
|
||||||
|
|
||||||
|
const selectedOfficeId = document.getElementById('officeFilter').value;
|
||||||
|
|
||||||
|
if (selectedOfficeId) {
|
||||||
|
officeIdsToLoad = [selectedOfficeId];
|
||||||
|
} else if (currentUser.role === 'employee' || (currentUser.role === 'manager' && currentUser.office_id)) {
|
||||||
|
officeIdsToLoad = [currentUser.office_id];
|
||||||
|
} else if (offices.length > 0) {
|
||||||
|
// Admin viewing all or Manager with access to list
|
||||||
|
officeIdsToLoad = offices.map(o => o.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (officeIdsToLoad.length === 0) return;
|
||||||
|
|
||||||
|
// Fetch in parallel
|
||||||
|
const promises = officeIdsToLoad.map(async (oid) => {
|
||||||
|
try {
|
||||||
|
const [weeklyRes, specificRes] = await Promise.all([
|
||||||
|
api.get(`/api/offices/${oid}/weekly-closing-days`),
|
||||||
|
api.get(`/api/offices/${oid}/closing-days`)
|
||||||
|
]);
|
||||||
|
|
||||||
|
officeClosingRules[oid] = { weekly: [], specific: [] };
|
||||||
|
|
||||||
|
if (weeklyRes && weeklyRes.ok) {
|
||||||
|
const days = await weeklyRes.json();
|
||||||
|
officeClosingRules[oid].weekly = days.map(d => d.weekday);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (specificRes && specificRes.ok) {
|
||||||
|
officeClosingRules[oid].specific = await specificRes.json();
|
||||||
|
|
||||||
|
// OPTIMIZATION: Pre-calculate all specific closed dates into a Set
|
||||||
|
const closedSet = new Set();
|
||||||
|
officeClosingRules[oid].specific.forEach(range => {
|
||||||
|
let start = new Date(range.date);
|
||||||
|
let end = range.end_date ? new Date(range.end_date) : new Date(range.date);
|
||||||
|
|
||||||
|
// Normalize to noon to avoid timezone issues when stepping
|
||||||
|
start.setHours(12, 0, 0, 0);
|
||||||
|
end.setHours(12, 0, 0, 0);
|
||||||
|
|
||||||
|
let current = new Date(start);
|
||||||
|
while (current <= end) {
|
||||||
|
closedSet.add(utils.formatDate(current));
|
||||||
|
current.setDate(current.getDate() + 1);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
officeClosingRules[oid].closedDatesSet = closedSet;
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error(`Error loading closing days for office ${oid}:`, e);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
await Promise.all(promises);
|
||||||
|
}
|
||||||
|
|
||||||
function renderCalendar() {
|
function renderCalendar() {
|
||||||
const header = document.getElementById('calendarHeader');
|
const header = document.getElementById('calendarHeader');
|
||||||
const body = document.getElementById('calendarBody');
|
const body = document.getElementById('calendarBody');
|
||||||
@@ -132,8 +241,8 @@ function renderCalendar() {
|
|||||||
const dayCount = Math.round((endDate - startDate) / (1000 * 60 * 60 * 24)) + 1;
|
const dayCount = Math.round((endDate - startDate) / (1000 * 60 * 60 * 24)) + 1;
|
||||||
|
|
||||||
// Build header row
|
// Build header row
|
||||||
const dayNames = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
|
const dayNames = ['Dom', 'Lun', 'Mar', 'Mer', 'Gio', 'Ven', 'Sab'];
|
||||||
let headerHtml = '<th>Name</th><th>Manager</th>';
|
let headerHtml = '<th>Nome</th><th>Ufficio</th>';
|
||||||
|
|
||||||
for (let i = 0; i < dayCount; i++) {
|
for (let i = 0; i < dayCount; i++) {
|
||||||
const date = new Date(startDate);
|
const date = new Date(startDate);
|
||||||
@@ -141,13 +250,19 @@ function renderCalendar() {
|
|||||||
const dayOfWeek = date.getDay();
|
const dayOfWeek = date.getDay();
|
||||||
const isWeekend = dayOfWeek === 0 || dayOfWeek === 6;
|
const isWeekend = dayOfWeek === 0 || dayOfWeek === 6;
|
||||||
const isHoliday = utils.isItalianHoliday(date);
|
const isHoliday = utils.isItalianHoliday(date);
|
||||||
const isToday = date.toDateString() === new Date().toDateString();
|
const isToday = utils.formatDate(date) === utils.formatDate(new Date());
|
||||||
|
|
||||||
let classes = [];
|
let classes = [];
|
||||||
if (isWeekend) classes.push('weekend');
|
if (isWeekend) classes.push('weekend');
|
||||||
if (isHoliday) classes.push('holiday');
|
if (isHoliday) classes.push('holiday');
|
||||||
if (isToday) classes.push('today');
|
if (isToday) classes.push('today');
|
||||||
|
|
||||||
|
if (isToday) classes.push('today');
|
||||||
|
|
||||||
|
// Header doesn't show closed status in multi-office view
|
||||||
|
// unless we want to check if ALL are closed?
|
||||||
|
// For now, simpler to leave header clean.
|
||||||
|
|
||||||
headerHtml += `<th class="${classes.join(' ')}">
|
headerHtml += `<th class="${classes.join(' ')}">
|
||||||
<div>${dayNames[dayOfWeek].charAt(0)}</div>
|
<div>${dayNames[dayOfWeek].charAt(0)}</div>
|
||||||
<div class="day-number">${date.getDate()}</div>
|
<div class="day-number">${date.getDate()}</div>
|
||||||
@@ -157,7 +272,7 @@ function renderCalendar() {
|
|||||||
|
|
||||||
// Build body rows
|
// Build body rows
|
||||||
if (teamData.length === 0) {
|
if (teamData.length === 0) {
|
||||||
body.innerHTML = `<tr><td colspan="${dayCount + 2}" class="text-center">No team members found</td></tr>`;
|
body.innerHTML = `<tr><td colspan="${dayCount + 2}" class="text-center">Nessun membro del team trovato</td></tr>`;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -165,7 +280,7 @@ function renderCalendar() {
|
|||||||
teamData.forEach(member => {
|
teamData.forEach(member => {
|
||||||
bodyHtml += `<tr>
|
bodyHtml += `<tr>
|
||||||
<td class="member-name">${member.name || 'Unknown'}</td>
|
<td class="member-name">${member.name || 'Unknown'}</td>
|
||||||
<td class="member-manager">${member.manager_name || '-'}</td>`;
|
<td class="member-manager">${member.office_name || '-'}</td>`;
|
||||||
|
|
||||||
for (let i = 0; i < dayCount; i++) {
|
for (let i = 0; i < dayCount; i++) {
|
||||||
const date = new Date(startDate);
|
const date = new Date(startDate);
|
||||||
@@ -179,7 +294,7 @@ function renderCalendar() {
|
|||||||
const parkingKey = `${member.id}_${dateStr}`;
|
const parkingKey = `${member.id}_${dateStr}`;
|
||||||
const parkingSpot = parkingDataLookup[parkingKey];
|
const parkingSpot = parkingDataLookup[parkingKey];
|
||||||
const hasParking = member.parking_dates && member.parking_dates.includes(dateStr);
|
const hasParking = member.parking_dates && member.parking_dates.includes(dateStr);
|
||||||
const isToday = date.toDateString() === new Date().toDateString();
|
const isToday = dateStr === utils.formatDate(new Date());
|
||||||
|
|
||||||
let cellClasses = ['calendar-cell'];
|
let cellClasses = ['calendar-cell'];
|
||||||
if (isWeekend) cellClasses.push('weekend');
|
if (isWeekend) cellClasses.push('weekend');
|
||||||
@@ -187,6 +302,47 @@ function renderCalendar() {
|
|||||||
if (isToday) cellClasses.push('today');
|
if (isToday) cellClasses.push('today');
|
||||||
if (presence) cellClasses.push(`status-${presence.status}`);
|
if (presence) cellClasses.push(`status-${presence.status}`);
|
||||||
|
|
||||||
|
if (isToday) cellClasses.push('today');
|
||||||
|
if (presence) cellClasses.push(`status-${presence.status}`);
|
||||||
|
|
||||||
|
// Optimized closing day check
|
||||||
|
// Pre-calculate loop-invariant sets outside if not already done, but here we do it per-cell because of date dependency?
|
||||||
|
// BETTER: We should pre-calculate a "closedMap" for the viewed range for each office?
|
||||||
|
// OR: Just optimize the inner check.
|
||||||
|
|
||||||
|
// Optimization: Create a lookup string for the current date once
|
||||||
|
// (Already have dateStr)
|
||||||
|
|
||||||
|
const memberRules = officeClosingRules[member.office_id];
|
||||||
|
let isClosed = false;
|
||||||
|
|
||||||
|
if (memberRules) {
|
||||||
|
// Check weekly
|
||||||
|
if (memberRules.weekly.includes(dayOfWeek)) {
|
||||||
|
isClosed = true;
|
||||||
|
} else if (memberRules.specific && memberRules.specific.length > 0) {
|
||||||
|
// Check specific
|
||||||
|
// Optimization: Use the string date lookup if we had a Set, but we have ranges.
|
||||||
|
// We can optimize by converting ranges to Sets ONCE when loading data,
|
||||||
|
// OR just stick to this check if N is small.
|
||||||
|
// Given the "optimization" task, let's just make sure we don't do new Date() inside.
|
||||||
|
// The `specific` array contains objects with `date` and `end_date` strings.
|
||||||
|
// We can compare strings directly if format is YYYY-MM-DD and we are careful.
|
||||||
|
|
||||||
|
// Optimization: check if dateStr is in a Set of closed dates for this office?
|
||||||
|
// Let's implement the Set lookup logic in `loadClosingData` or `renderCalendar` start.
|
||||||
|
// For now, let's assume `memberRules.closedDatesSet` exists.
|
||||||
|
|
||||||
|
if (memberRules.closedDatesSet && memberRules.closedDatesSet.has(dateStr)) {
|
||||||
|
isClosed = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isClosed) {
|
||||||
|
cellClasses.push('closed');
|
||||||
|
}
|
||||||
|
|
||||||
// Show parking badge instead of just 'P'
|
// Show parking badge instead of just 'P'
|
||||||
let parkingBadge = '';
|
let parkingBadge = '';
|
||||||
if (hasParking) {
|
if (hasParking) {
|
||||||
@@ -194,7 +350,7 @@ function renderCalendar() {
|
|||||||
parkingBadge = `<span class="parking-badge-sm">${spotName}</span>`;
|
parkingBadge = `<span class="parking-badge-sm">${spotName}</span>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
bodyHtml += `<td class="${cellClasses.join(' ')}" data-user-id="${member.id}" data-date="${dateStr}" data-user-name="${member.name || ''}">${parkingBadge}</td>`;
|
bodyHtml += `<td class="${cellClasses.join(' ')}" data-user-id="${member.id}" data-date="${dateStr}" data-user-name="${member.name || ''}" ${isClosed ? 'data-closed="true"' : ''}>${parkingBadge}</td>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
bodyHtml += '</tr>';
|
bodyHtml += '</tr>';
|
||||||
@@ -205,12 +361,14 @@ function renderCalendar() {
|
|||||||
if (currentUser.role === 'admin' || currentUser.role === 'manager') {
|
if (currentUser.role === 'admin' || currentUser.role === 'manager') {
|
||||||
body.querySelectorAll('.calendar-cell').forEach(cell => {
|
body.querySelectorAll('.calendar-cell').forEach(cell => {
|
||||||
cell.style.cursor = 'pointer';
|
cell.style.cursor = 'pointer';
|
||||||
cell.addEventListener('click', () => {
|
if (cell.dataset.closed !== 'true') {
|
||||||
const userId = cell.dataset.userId;
|
cell.addEventListener('click', () => {
|
||||||
const date = cell.dataset.date;
|
const userId = cell.dataset.userId;
|
||||||
const userName = cell.dataset.userName;
|
const date = cell.dataset.date;
|
||||||
openDayModal(userId, date, userName);
|
const userName = cell.dataset.userName;
|
||||||
});
|
openDayModal(userId, date, userName);
|
||||||
|
});
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -219,129 +377,107 @@ function openDayModal(userId, dateStr, userName) {
|
|||||||
selectedUserId = userId;
|
selectedUserId = userId;
|
||||||
selectedDate = dateStr;
|
selectedDate = dateStr;
|
||||||
|
|
||||||
const modal = document.getElementById('dayModal');
|
|
||||||
document.getElementById('dayModalTitle').textContent = utils.formatDateDisplay(dateStr);
|
|
||||||
document.getElementById('dayModalUser').textContent = userName;
|
|
||||||
|
|
||||||
// Find current status and parking
|
// Find current status and parking
|
||||||
const member = teamData.find(m => m.id === userId);
|
const member = teamData.find(m => m.id === userId);
|
||||||
const presence = member?.presences.find(p => p.date === dateStr);
|
const presence = member?.presences.find(p => p.date === dateStr);
|
||||||
const parkingKey = `${userId}_${dateStr}`;
|
const parkingKey = `${userId}_${dateStr}`;
|
||||||
const parkingSpot = parkingDataLookup[parkingKey];
|
const parkingSpot = parkingDataLookup[parkingKey];
|
||||||
const assignmentId = parkingAssignmentLookup[parkingKey];
|
const assignmentId = parkingAssignmentLookup[parkingKey];
|
||||||
|
currentAssignmentId = assignmentId; // Ensure this is set for modal logic
|
||||||
|
|
||||||
// Highlight current status
|
const parkingObj = assignmentId ? {
|
||||||
document.querySelectorAll('#dayModal .status-btn').forEach(btn => {
|
id: assignmentId,
|
||||||
const status = btn.dataset.status;
|
spot_display_name: parkingSpot,
|
||||||
if (presence && presence.status === status) {
|
spot_id: parkingSpot
|
||||||
btn.classList.add('active');
|
} : null;
|
||||||
} else {
|
|
||||||
btn.classList.remove('active');
|
ModalLogic.openModal({
|
||||||
}
|
dateStr,
|
||||||
|
userName,
|
||||||
|
presence,
|
||||||
|
parking: parkingObj,
|
||||||
|
userId
|
||||||
});
|
});
|
||||||
|
|
||||||
// Update parking section
|
|
||||||
const parkingSection = document.getElementById('parkingSection');
|
|
||||||
const parkingInfo = document.getElementById('parkingInfo');
|
|
||||||
|
|
||||||
if (parkingSpot) {
|
|
||||||
parkingSection.style.display = 'block';
|
|
||||||
parkingInfo.innerHTML = `<strong>Parking:</strong> Spot ${parkingSpot}`;
|
|
||||||
currentAssignmentId = assignmentId;
|
|
||||||
} else {
|
|
||||||
parkingSection.style.display = 'none';
|
|
||||||
currentAssignmentId = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
modal.style.display = 'flex';
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function markPresence(status) {
|
async function handleMarkPresence(status, date, userId) {
|
||||||
if (!selectedUserId || !selectedDate) return;
|
// userId passed from ModalLogic if provided, or use selectedUserId
|
||||||
|
const targetUserId = userId || selectedUserId;
|
||||||
|
if (!targetUserId) return;
|
||||||
|
|
||||||
const response = await api.post('/api/presence/admin/mark', {
|
const response = await api.post('/api/presence/admin/mark', {
|
||||||
user_id: selectedUserId,
|
user_id: targetUserId,
|
||||||
date: selectedDate,
|
date: date,
|
||||||
status: status
|
status: status
|
||||||
});
|
});
|
||||||
|
|
||||||
if (response && response.ok) {
|
if (response && response.ok) {
|
||||||
document.getElementById('dayModal').style.display = 'none';
|
ModalLogic.closeModal();
|
||||||
await loadTeamData();
|
await loadTeamData();
|
||||||
renderCalendar();
|
renderCalendar();
|
||||||
} else {
|
} else {
|
||||||
const error = await response.json();
|
const error = await response.json();
|
||||||
alert(error.detail || 'Failed to mark presence');
|
alert(error.detail || 'Impossibile segnare la presenza');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function clearPresence() {
|
async function handleClearPresence(date, userId) {
|
||||||
if (!selectedUserId || !selectedDate) return;
|
const targetUserId = userId || selectedUserId;
|
||||||
if (!confirm('Clear presence for this date?')) return;
|
if (!targetUserId) return;
|
||||||
|
|
||||||
const response = await api.delete(`/api/presence/admin/${selectedUserId}/${selectedDate}`);
|
// confirm is not needed here if ModalLogic doesn't mandate it, but keeping logic
|
||||||
|
// ModalLogic buttons usually trigger this directly.
|
||||||
|
|
||||||
|
const response = await api.delete(`/api/presence/admin/${targetUserId}/${date}`);
|
||||||
if (response && response.ok) {
|
if (response && response.ok) {
|
||||||
document.getElementById('dayModal').style.display = 'none';
|
ModalLogic.closeModal();
|
||||||
await loadTeamData();
|
await loadTeamData();
|
||||||
renderCalendar();
|
renderCalendar();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function openReassignModal() {
|
async function handleReleaseParking(assignmentId) {
|
||||||
if (!currentAssignmentId) return;
|
if (!confirm('Rilasciare il parcheggio per questa data?')) return;
|
||||||
|
|
||||||
// Load eligible users
|
// Note: Admin endpoint for releasing ANY spot vs "my spot"
|
||||||
const response = await api.get(`/api/parking/eligible-users/${currentAssignmentId}`);
|
// Since we are admin/manager here, we might need a general release endpoint or use reassign with null?
|
||||||
if (!response || !response.ok) {
|
// The current 'release_my_spot' is only for self.
|
||||||
const error = await response.json();
|
// 'reassign_spot' with null user_id is the way for admins.
|
||||||
alert(error.detail || 'Failed to load eligible users');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const users = await response.json();
|
const response = await api.post('/api/parking/reassign-spot', {
|
||||||
const select = document.getElementById('reassignUser');
|
assignment_id: assignmentId,
|
||||||
select.innerHTML = '<option value="">Select user...</option>';
|
new_user_id: null // Release
|
||||||
|
});
|
||||||
|
|
||||||
if (users.length === 0) {
|
if (response && response.ok) {
|
||||||
select.innerHTML = '<option value="">No eligible users available</option>';
|
ModalLogic.closeModal();
|
||||||
|
await loadTeamData();
|
||||||
|
renderCalendar();
|
||||||
} else {
|
} else {
|
||||||
users.forEach(user => {
|
const error = await response.json();
|
||||||
const option = document.createElement('option');
|
alert(error.detail || 'Impossibile rilasciare il parcheggio');
|
||||||
option.value = user.id;
|
|
||||||
option.textContent = user.name;
|
|
||||||
select.appendChild(option);
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get spot info
|
|
||||||
const parkingKey = `${selectedUserId}_${selectedDate}`;
|
|
||||||
const spotName = parkingDataLookup[parkingKey] || 'Unknown';
|
|
||||||
document.getElementById('reassignSpotInfo').textContent = `Spot ${spotName}`;
|
|
||||||
|
|
||||||
document.getElementById('dayModal').style.display = 'none';
|
|
||||||
document.getElementById('reassignModal').style.display = 'flex';
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function confirmReassign() {
|
|
||||||
const newUserId = document.getElementById('reassignUser').value;
|
|
||||||
|
|
||||||
if (!currentAssignmentId || !newUserId) {
|
async function handleReassignParking(assignmentId, newUserId) {
|
||||||
alert('Please select a user');
|
if (!assignmentId || !newUserId) {
|
||||||
|
alert('Seleziona un utente');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const response = await api.post('/api/parking/reassign-spot', {
|
const response = await api.post('/api/parking/reassign-spot', {
|
||||||
assignment_id: currentAssignmentId,
|
assignment_id: assignmentId,
|
||||||
new_user_id: newUserId
|
new_user_id: newUserId
|
||||||
});
|
});
|
||||||
|
|
||||||
if (response && response.ok) {
|
if (response && response.ok) {
|
||||||
await loadTeamData();
|
await loadTeamData();
|
||||||
renderCalendar();
|
renderCalendar();
|
||||||
document.getElementById('reassignModal').style.display = 'none';
|
ModalLogic.closeModal();
|
||||||
} else {
|
} else {
|
||||||
const error = await response.json();
|
const error = await response.json();
|
||||||
alert(error.detail || 'Failed to reassign parking spot');
|
alert(error.detail || 'Impossibile riassegnare il parcheggio');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -375,40 +511,20 @@ function setupEventListeners() {
|
|||||||
currentStartDate = new Date(currentStartDate.getFullYear(), currentStartDate.getMonth(), 1);
|
currentStartDate = new Date(currentStartDate.getFullYear(), currentStartDate.getMonth(), 1);
|
||||||
} else {
|
} else {
|
||||||
// Set to current week start
|
// Set to current week start
|
||||||
const weekStartDay = currentUser.week_start_day || 0;
|
const weekStartDay = currentUser.week_start_day || 1;
|
||||||
currentStartDate = utils.getWeekStart(new Date(), weekStartDay);
|
currentStartDate = utils.getWeekStart(new Date(), weekStartDay);
|
||||||
}
|
}
|
||||||
await loadTeamData();
|
await loadTeamData();
|
||||||
renderCalendar();
|
renderCalendar();
|
||||||
});
|
});
|
||||||
|
|
||||||
// Manager filter
|
|
||||||
document.getElementById('managerFilter').addEventListener('change', async () => {
|
// Office filter
|
||||||
|
document.getElementById('officeFilter').addEventListener('change', async () => {
|
||||||
|
updateOfficeDisplay(); // Update label on change
|
||||||
await loadTeamData();
|
await loadTeamData();
|
||||||
renderCalendar();
|
renderCalendar();
|
||||||
});
|
});
|
||||||
|
|
||||||
// Day modal
|
|
||||||
document.getElementById('closeDayModal').addEventListener('click', () => {
|
|
||||||
document.getElementById('dayModal').style.display = 'none';
|
|
||||||
});
|
|
||||||
|
|
||||||
document.querySelectorAll('#dayModal .status-btn').forEach(btn => {
|
|
||||||
btn.addEventListener('click', () => markPresence(btn.dataset.status));
|
|
||||||
});
|
|
||||||
|
|
||||||
document.getElementById('clearDayBtn').addEventListener('click', clearPresence);
|
|
||||||
document.getElementById('reassignParkingBtn').addEventListener('click', openReassignModal);
|
|
||||||
|
|
||||||
utils.setupModalClose('dayModal');
|
utils.setupModalClose('dayModal');
|
||||||
|
|
||||||
// Reassign modal
|
|
||||||
document.getElementById('closeReassignModal').addEventListener('click', () => {
|
|
||||||
document.getElementById('reassignModal').style.display = 'none';
|
|
||||||
});
|
|
||||||
document.getElementById('cancelReassign').addEventListener('click', () => {
|
|
||||||
document.getElementById('reassignModal').style.display = 'none';
|
|
||||||
});
|
|
||||||
document.getElementById('confirmReassign').addEventListener('click', confirmReassign);
|
|
||||||
utils.setupModalClose('reassignModal');
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,370 +1,383 @@
|
|||||||
/**
|
/**
|
||||||
* Team Rules Page
|
* Team Rules Page
|
||||||
* Manage closing days, parking guarantees, and exclusions
|
* Manage closing days, guarantees, and exclusions
|
||||||
*
|
* Office-centric model
|
||||||
* Rules are set at manager level for their parking pool.
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
let currentUser = null;
|
let currentUser = null;
|
||||||
let selectedManagerId = null;
|
let offices = [];
|
||||||
let managerUsers = [];
|
let currentOfficeId = null;
|
||||||
|
let officeUsers = [];
|
||||||
|
let currentWeeklyClosingDays = [];
|
||||||
|
|
||||||
document.addEventListener('DOMContentLoaded', async () => {
|
document.addEventListener('DOMContentLoaded', async () => {
|
||||||
currentUser = await api.requireAuth();
|
currentUser = await api.requireAuth();
|
||||||
if (!currentUser) return;
|
if (!currentUser) return;
|
||||||
|
|
||||||
// Only managers and admins can access
|
// Only admins and managers can access this page
|
||||||
if (currentUser.role === 'employee') {
|
if (currentUser.role !== 'admin' && currentUser.role !== 'manager') {
|
||||||
window.location.href = '/presence';
|
window.location.href = '/presence';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
await loadManagers();
|
await loadOffices();
|
||||||
setupEventListeners();
|
setupEventListeners();
|
||||||
});
|
});
|
||||||
|
|
||||||
async function loadManagers() {
|
async function loadOffices() {
|
||||||
const response = await api.get('/api/managers');
|
const select = document.getElementById('officeSelect');
|
||||||
if (response && response.ok) {
|
const card = document.getElementById('officeSelectionCard');
|
||||||
const managers = await response.json();
|
|
||||||
const select = document.getElementById('managerSelect');
|
|
||||||
|
|
||||||
// Filter to managers this user can see
|
// Only Admins can see the office selector
|
||||||
let filteredManagers = managers;
|
if (currentUser.role !== 'admin') {
|
||||||
|
if (card) card.style.display = 'none';
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await api.get('/api/offices');
|
||||||
|
if (response && response.ok) {
|
||||||
|
offices = await response.json();
|
||||||
|
|
||||||
|
let filteredOffices = offices;
|
||||||
if (currentUser.role === 'manager') {
|
if (currentUser.role === 'manager') {
|
||||||
// Manager only sees themselves
|
// Manager only sees their own office
|
||||||
filteredManagers = managers.filter(m => m.id === currentUser.id);
|
if (currentUser.office_id) {
|
||||||
|
filteredOffices = offices.filter(o => o.id === currentUser.office_id);
|
||||||
|
} else {
|
||||||
|
filteredOffices = [];
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Show managers in dropdown
|
filteredOffices.forEach(office => {
|
||||||
let totalManagers = 0;
|
|
||||||
let firstManagerId = null;
|
|
||||||
|
|
||||||
filteredManagers.forEach(manager => {
|
|
||||||
const option = document.createElement('option');
|
const option = document.createElement('option');
|
||||||
option.value = manager.id;
|
option.value = office.id;
|
||||||
// Show manager name with user count and parking quota
|
option.textContent = office.name;
|
||||||
const userCount = manager.managed_user_count || 0;
|
|
||||||
const quota = manager.parking_quota || 0;
|
|
||||||
option.textContent = `${manager.name} (${userCount} users, ${quota} spots)`;
|
|
||||||
select.appendChild(option);
|
select.appendChild(option);
|
||||||
totalManagers++;
|
|
||||||
if (!firstManagerId) firstManagerId = manager.id;
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Auto-select if only one manager
|
// Auto-select for managers
|
||||||
if (totalManagers === 1 && firstManagerId) {
|
if (currentUser.role === 'manager' && filteredOffices.length === 1) {
|
||||||
select.value = firstManagerId;
|
select.value = filteredOffices[0].id;
|
||||||
await selectManager(firstManagerId);
|
loadOfficeRules(filteredOffices[0].id);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function selectManager(managerId) {
|
async function loadOfficeRules(officeId) {
|
||||||
selectedManagerId = managerId;
|
if (!officeId) {
|
||||||
|
|
||||||
if (!managerId) {
|
|
||||||
document.getElementById('rulesContent').style.display = 'none';
|
document.getElementById('rulesContent').style.display = 'none';
|
||||||
document.getElementById('noManagerMessage').style.display = 'block';
|
document.getElementById('noOfficeMessage').style.display = 'block';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
currentOfficeId = officeId;
|
||||||
document.getElementById('rulesContent').style.display = 'block';
|
document.getElementById('rulesContent').style.display = 'block';
|
||||||
document.getElementById('noManagerMessage').style.display = 'none';
|
document.getElementById('noOfficeMessage').style.display = 'none';
|
||||||
|
|
||||||
|
// Load users for this office (for dropdowns)
|
||||||
|
await loadOfficeUsers(officeId);
|
||||||
|
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
loadWeeklyClosingDays(),
|
loadWeeklyClosingDays(officeId),
|
||||||
loadClosingDays(),
|
loadClosingDays(officeId),
|
||||||
loadGuarantees(),
|
loadGuarantees(officeId),
|
||||||
loadExclusions(),
|
loadExclusions(officeId)
|
||||||
loadManagerUsers()
|
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadWeeklyClosingDays() {
|
async function loadOfficeUsers(officeId) {
|
||||||
const response = await api.get(`/api/managers/${selectedManagerId}/weekly-closing-days`);
|
const response = await api.get(`/api/offices/${officeId}/users`);
|
||||||
|
if (response && response.ok) {
|
||||||
|
officeUsers = await response.json();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Weekly Closing Days
|
||||||
|
async function loadWeeklyClosingDays(officeId) {
|
||||||
|
const response = await api.get(`/api/offices/${officeId}/weekly-closing-days`);
|
||||||
if (response && response.ok) {
|
if (response && response.ok) {
|
||||||
const days = await response.json();
|
const days = await response.json();
|
||||||
const weekdays = days.map(d => d.weekday);
|
currentWeeklyClosingDays = days;
|
||||||
|
const activeWeekdays = days.map(d => d.weekday);
|
||||||
|
|
||||||
// Update checkboxes
|
|
||||||
document.querySelectorAll('#weeklyClosingDays input[type="checkbox"]').forEach(cb => {
|
document.querySelectorAll('#weeklyClosingDays input[type="checkbox"]').forEach(cb => {
|
||||||
const weekday = parseInt(cb.dataset.weekday);
|
const weekday = parseInt(cb.dataset.weekday);
|
||||||
cb.checked = weekdays.includes(weekday);
|
cb.checked = activeWeekdays.includes(weekday);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadManagerUsers() {
|
async function saveWeeklyClosingDays() {
|
||||||
const response = await api.get(`/api/managers/${selectedManagerId}/users`);
|
const btn = document.getElementById('saveWeeklyClosingDaysBtn');
|
||||||
if (response && response.ok) {
|
if (!btn) return;
|
||||||
managerUsers = await response.json();
|
|
||||||
updateUserSelects();
|
const originalText = btn.textContent;
|
||||||
|
btn.disabled = true;
|
||||||
|
btn.textContent = 'Salvataggio...';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const promises = [];
|
||||||
|
const checkboxes = document.querySelectorAll('#weeklyClosingDays input[type="checkbox"]');
|
||||||
|
|
||||||
|
for (const cb of checkboxes) {
|
||||||
|
const weekday = parseInt(cb.dataset.weekday);
|
||||||
|
const isChecked = cb.checked;
|
||||||
|
const existingEntry = currentWeeklyClosingDays.find(d => d.weekday === weekday);
|
||||||
|
|
||||||
|
if (isChecked && !existingEntry) {
|
||||||
|
// Add
|
||||||
|
promises.push(api.post(`/api/offices/${currentOfficeId}/weekly-closing-days`, { weekday }));
|
||||||
|
} else if (!isChecked && existingEntry) {
|
||||||
|
// Remove
|
||||||
|
promises.push(api.delete(`/api/offices/${currentOfficeId}/weekly-closing-days/${existingEntry.id}`));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await Promise.all(promises);
|
||||||
|
utils.showMessage('Giorni di chiusura aggiornati', 'success');
|
||||||
|
await loadWeeklyClosingDays(currentOfficeId);
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
utils.showMessage('Errore durante il salvataggio', 'error');
|
||||||
|
} finally {
|
||||||
|
btn.disabled = false;
|
||||||
|
btn.textContent = originalText;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateUserSelects() {
|
|
||||||
['guaranteeUser', 'exclusionUser'].forEach(selectId => {
|
|
||||||
const select = document.getElementById(selectId);
|
|
||||||
select.innerHTML = '<option value="">Select user...</option>';
|
|
||||||
managerUsers.forEach(user => {
|
|
||||||
const option = document.createElement('option');
|
|
||||||
option.value = user.id;
|
|
||||||
option.textContent = user.name;
|
|
||||||
select.appendChild(option);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async function loadClosingDays() {
|
|
||||||
const response = await api.get(`/api/managers/${selectedManagerId}/closing-days`);
|
// Closing Days
|
||||||
|
async function loadClosingDays(officeId) {
|
||||||
|
const response = await api.get(`/api/offices/${officeId}/closing-days`);
|
||||||
const container = document.getElementById('closingDaysList');
|
const container = document.getElementById('closingDaysList');
|
||||||
|
|
||||||
if (response && response.ok) {
|
if (response && response.ok) {
|
||||||
const days = await response.json();
|
const days = await response.json();
|
||||||
|
|
||||||
if (days.length === 0) {
|
if (days.length === 0) {
|
||||||
container.innerHTML = '';
|
container.innerHTML = '<p class="text-muted">Nessun giorno di chiusura specifico.</p>';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
container.innerHTML = days.map(day => `
|
container.innerHTML = days.map(day => `
|
||||||
<div class="rule-item">
|
<div class="rule-item">
|
||||||
<div class="rule-info">
|
<div class="rule-info">
|
||||||
<span class="rule-date">${utils.formatDateDisplay(day.date)}</span>
|
<strong>${utils.formatDateDisplay(day.date)}${day.end_date ? ' - ' + utils.formatDateDisplay(day.end_date) : ''}</strong>
|
||||||
${day.reason ? `<span class="rule-reason">${day.reason}</span>` : ''}
|
${day.reason ? `<span class="rule-note">${day.reason}</span>` : ''}
|
||||||
</div>
|
</div>
|
||||||
<button class="btn-icon btn-danger" onclick="deleteClosingDay('${day.id}')">
|
<button class="btn-icon btn-danger" onclick="deleteClosingDay('${day.id}')">
|
||||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
×
|
||||||
<polyline points="3 6 5 6 21 6"></polyline>
|
|
||||||
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"></path>
|
|
||||||
</svg>
|
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
`).join('');
|
`).join('');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatDateRange(startDate, endDate) {
|
async function addClosingDay(data) {
|
||||||
if (!startDate && !endDate) return '';
|
const response = await api.post(`/api/offices/${currentOfficeId}/closing-days`, data);
|
||||||
if (startDate && !endDate) return `From ${utils.formatDateDisplay(startDate)}`;
|
if (response && response.ok) {
|
||||||
if (!startDate && endDate) return `Until ${utils.formatDateDisplay(endDate)}`;
|
await loadClosingDays(currentOfficeId);
|
||||||
return `${utils.formatDateDisplay(startDate)} - ${utils.formatDateDisplay(endDate)}`;
|
document.getElementById('closingDayModal').style.display = 'none';
|
||||||
|
document.getElementById('closingDayForm').reset();
|
||||||
|
} else {
|
||||||
|
const error = await response.json();
|
||||||
|
alert(error.detail || 'Impossibile aggiungere il giorno di chiusura');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadGuarantees() {
|
async function deleteClosingDay(id) {
|
||||||
const response = await api.get(`/api/managers/${selectedManagerId}/guarantees`);
|
if (!confirm('Eliminare questo giorno di chiusura?')) return;
|
||||||
|
const response = await api.delete(`/api/offices/${currentOfficeId}/closing-days/${id}`);
|
||||||
|
if (response && response.ok) {
|
||||||
|
await loadClosingDays(currentOfficeId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Guarantees
|
||||||
|
async function loadGuarantees(officeId) {
|
||||||
|
const response = await api.get(`/api/offices/${officeId}/guarantees`);
|
||||||
const container = document.getElementById('guaranteesList');
|
const container = document.getElementById('guaranteesList');
|
||||||
|
|
||||||
if (response && response.ok) {
|
if (response && response.ok) {
|
||||||
const guarantees = await response.json();
|
const guarantees = await response.json();
|
||||||
|
|
||||||
if (guarantees.length === 0) {
|
if (guarantees.length === 0) {
|
||||||
container.innerHTML = '';
|
container.innerHTML = '<p class="text-muted">Nessuna garanzia di parcheggio attiva.</p>';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
container.innerHTML = guarantees.map(g => {
|
container.innerHTML = guarantees.map(g => `
|
||||||
const dateRange = formatDateRange(g.start_date, g.end_date);
|
|
||||||
return `
|
|
||||||
<div class="rule-item">
|
<div class="rule-item">
|
||||||
<div class="rule-info">
|
<div class="rule-info">
|
||||||
<span class="rule-name">${g.user_name}</span>
|
<strong>${g.user_name || 'Utente sconosciuto'}</strong>
|
||||||
${dateRange ? `<span class="rule-date-range">${dateRange}</span>` : ''}
|
<span class="rule-dates">
|
||||||
|
${g.start_date ? 'Dal ' + utils.formatDateDisplay(g.start_date) : 'Da sempre'}
|
||||||
|
${g.end_date ? ' al ' + utils.formatDateDisplay(g.end_date) : ''}
|
||||||
|
</span>
|
||||||
|
${g.notes ? `<span class="rule-note">${g.notes}</span>` : ''}
|
||||||
</div>
|
</div>
|
||||||
<button class="btn-icon btn-danger" onclick="deleteGuarantee('${g.id}')">
|
<button class="btn-icon btn-danger" onclick="deleteGuarantee('${g.id}')">
|
||||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
×
|
||||||
<polyline points="3 6 5 6 21 6"></polyline>
|
|
||||||
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"></path>
|
|
||||||
</svg>
|
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
`}).join('');
|
`).join('');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadExclusions() {
|
async function addGuarantee(data) {
|
||||||
const response = await api.get(`/api/managers/${selectedManagerId}/exclusions`);
|
const response = await api.post(`/api/offices/${currentOfficeId}/guarantees`, data);
|
||||||
const container = document.getElementById('exclusionsList');
|
|
||||||
|
|
||||||
if (response && response.ok) {
|
if (response && response.ok) {
|
||||||
const exclusions = await response.json();
|
await loadGuarantees(currentOfficeId);
|
||||||
if (exclusions.length === 0) {
|
document.getElementById('guaranteeModal').style.display = 'none';
|
||||||
container.innerHTML = '';
|
document.getElementById('guaranteeForm').reset();
|
||||||
return;
|
} else {
|
||||||
}
|
const error = await response.json();
|
||||||
|
alert(error.detail || 'Impossibile aggiungere la garanzia');
|
||||||
container.innerHTML = exclusions.map(e => {
|
|
||||||
const dateRange = formatDateRange(e.start_date, e.end_date);
|
|
||||||
return `
|
|
||||||
<div class="rule-item">
|
|
||||||
<div class="rule-info">
|
|
||||||
<span class="rule-name">${e.user_name}</span>
|
|
||||||
${dateRange ? `<span class="rule-date-range">${dateRange}</span>` : ''}
|
|
||||||
</div>
|
|
||||||
<button class="btn-icon btn-danger" onclick="deleteExclusion('${e.id}')">
|
|
||||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
|
||||||
<polyline points="3 6 5 6 21 6"></polyline>
|
|
||||||
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"></path>
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
`}).join('');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Delete functions
|
|
||||||
async function deleteClosingDay(id) {
|
|
||||||
if (!confirm('Delete this closing day?')) return;
|
|
||||||
const response = await api.delete(`/api/managers/${selectedManagerId}/closing-days/${id}`);
|
|
||||||
if (response && response.ok) {
|
|
||||||
await loadClosingDays();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function deleteGuarantee(id) {
|
async function deleteGuarantee(id) {
|
||||||
if (!confirm('Remove this parking guarantee?')) return;
|
if (!confirm('Eliminare questa garanzia?')) return;
|
||||||
const response = await api.delete(`/api/managers/${selectedManagerId}/guarantees/${id}`);
|
const response = await api.delete(`/api/offices/${currentOfficeId}/guarantees/${id}`);
|
||||||
if (response && response.ok) {
|
if (response && response.ok) {
|
||||||
await loadGuarantees();
|
await loadGuarantees(currentOfficeId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Exclusions
|
||||||
|
async function loadExclusions(officeId) {
|
||||||
|
const response = await api.get(`/api/offices/${officeId}/exclusions`);
|
||||||
|
const container = document.getElementById('exclusionsList');
|
||||||
|
|
||||||
|
if (response && response.ok) {
|
||||||
|
const exclusions = await response.json();
|
||||||
|
|
||||||
|
if (exclusions.length === 0) {
|
||||||
|
container.innerHTML = '<p class="text-muted">Nessuna esclusione attiva.</p>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
container.innerHTML = exclusions.map(e => `
|
||||||
|
<div class="rule-item">
|
||||||
|
<div class="rule-info">
|
||||||
|
<strong>${e.user_name || 'Utente sconosciuto'}</strong>
|
||||||
|
<span class="rule-dates">
|
||||||
|
${e.start_date ? 'Dal ' + utils.formatDateDisplay(e.start_date) : 'Da sempre'}
|
||||||
|
${e.end_date ? ' al ' + utils.formatDateDisplay(e.end_date) : ''}
|
||||||
|
</span>
|
||||||
|
${e.notes ? `<span class="rule-note">${e.notes}</span>` : ''}
|
||||||
|
</div>
|
||||||
|
<button class="btn-icon btn-danger" onclick="deleteExclusion('${e.id}')">
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
`).join('');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function addExclusion(data) {
|
||||||
|
const response = await api.post(`/api/offices/${currentOfficeId}/exclusions`, data);
|
||||||
|
if (response && response.ok) {
|
||||||
|
await loadExclusions(currentOfficeId);
|
||||||
|
document.getElementById('exclusionModal').style.display = 'none';
|
||||||
|
document.getElementById('exclusionForm').reset();
|
||||||
|
} else {
|
||||||
|
const error = await response.json();
|
||||||
|
alert(error.detail || 'Impossibile aggiungere l\'esclusione');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function deleteExclusion(id) {
|
async function deleteExclusion(id) {
|
||||||
if (!confirm('Remove this parking exclusion?')) return;
|
if (!confirm('Eliminare questa esclusione?')) return;
|
||||||
const response = await api.delete(`/api/managers/${selectedManagerId}/exclusions/${id}`);
|
const response = await api.delete(`/api/offices/${currentOfficeId}/exclusions/${id}`);
|
||||||
if (response && response.ok) {
|
if (response && response.ok) {
|
||||||
await loadExclusions();
|
await loadExclusions(currentOfficeId);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function setupEventListeners() {
|
function populateUserSelects() {
|
||||||
// Manager selection
|
const selects = ['guaranteeUser', 'exclusionUser'];
|
||||||
document.getElementById('managerSelect').addEventListener('change', (e) => {
|
selects.forEach(id => {
|
||||||
selectManager(e.target.value);
|
const select = document.getElementById(id);
|
||||||
});
|
const currentVal = select.value;
|
||||||
|
select.innerHTML = '<option value="">Seleziona utente...</option>';
|
||||||
|
|
||||||
// Weekly closing day checkboxes
|
officeUsers.forEach(user => {
|
||||||
document.querySelectorAll('#weeklyClosingDays input[type="checkbox"]').forEach(cb => {
|
const option = document.createElement('option');
|
||||||
cb.addEventListener('change', async (e) => {
|
option.value = user.id;
|
||||||
const weekday = parseInt(e.target.dataset.weekday);
|
option.textContent = user.name;
|
||||||
|
select.appendChild(option);
|
||||||
if (e.target.checked) {
|
|
||||||
// Add weekly closing day
|
|
||||||
const response = await api.post(`/api/managers/${selectedManagerId}/weekly-closing-days`, { weekday });
|
|
||||||
if (!response || !response.ok) {
|
|
||||||
e.target.checked = false;
|
|
||||||
const error = await response.json();
|
|
||||||
alert(error.detail || 'Failed to add weekly closing day');
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// Remove weekly closing day - need to find the ID first
|
|
||||||
const getResponse = await api.get(`/api/managers/${selectedManagerId}/weekly-closing-days`);
|
|
||||||
if (getResponse && getResponse.ok) {
|
|
||||||
const days = await getResponse.json();
|
|
||||||
const day = days.find(d => d.weekday === weekday);
|
|
||||||
if (day) {
|
|
||||||
const deleteResponse = await api.delete(`/api/managers/${selectedManagerId}/weekly-closing-days/${day.id}`);
|
|
||||||
if (!deleteResponse || !deleteResponse.ok) {
|
|
||||||
e.target.checked = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
});
|
|
||||||
|
|
||||||
// Modal openers
|
if (currentVal) select.value = currentVal;
|
||||||
document.getElementById('addClosingDayBtn').addEventListener('click', () => {
|
|
||||||
document.getElementById('closingDayForm').reset();
|
|
||||||
document.getElementById('closingDayModal').style.display = 'flex';
|
|
||||||
});
|
});
|
||||||
|
|
||||||
document.getElementById('addGuaranteeBtn').addEventListener('click', () => {
|
|
||||||
document.getElementById('guaranteeForm').reset();
|
|
||||||
document.getElementById('guaranteeModal').style.display = 'flex';
|
|
||||||
});
|
|
||||||
|
|
||||||
document.getElementById('addExclusionBtn').addEventListener('click', () => {
|
|
||||||
document.getElementById('exclusionForm').reset();
|
|
||||||
document.getElementById('exclusionModal').style.display = 'flex';
|
|
||||||
});
|
|
||||||
|
|
||||||
// Modal closers
|
|
||||||
['closeClosingDayModal', 'cancelClosingDay'].forEach(id => {
|
|
||||||
document.getElementById(id).addEventListener('click', () => {
|
|
||||||
document.getElementById('closingDayModal').style.display = 'none';
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
['closeGuaranteeModal', 'cancelGuarantee'].forEach(id => {
|
|
||||||
document.getElementById(id).addEventListener('click', () => {
|
|
||||||
document.getElementById('guaranteeModal').style.display = 'none';
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
['closeExclusionModal', 'cancelExclusion'].forEach(id => {
|
|
||||||
document.getElementById(id).addEventListener('click', () => {
|
|
||||||
document.getElementById('exclusionModal').style.display = 'none';
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// Form submissions
|
|
||||||
document.getElementById('closingDayForm').addEventListener('submit', async (e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
const data = {
|
|
||||||
date: document.getElementById('closingDate').value,
|
|
||||||
reason: document.getElementById('closingReason').value || null
|
|
||||||
};
|
|
||||||
const response = await api.post(`/api/managers/${selectedManagerId}/closing-days`, data);
|
|
||||||
if (response && response.ok) {
|
|
||||||
document.getElementById('closingDayModal').style.display = 'none';
|
|
||||||
await loadClosingDays();
|
|
||||||
} else {
|
|
||||||
const error = await response.json();
|
|
||||||
alert(error.detail || 'Failed to add closing day');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
document.getElementById('guaranteeForm').addEventListener('submit', async (e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
const data = {
|
|
||||||
user_id: document.getElementById('guaranteeUser').value,
|
|
||||||
start_date: document.getElementById('guaranteeStartDate').value || null,
|
|
||||||
end_date: document.getElementById('guaranteeEndDate').value || null
|
|
||||||
};
|
|
||||||
const response = await api.post(`/api/managers/${selectedManagerId}/guarantees`, data);
|
|
||||||
if (response && response.ok) {
|
|
||||||
document.getElementById('guaranteeModal').style.display = 'none';
|
|
||||||
await loadGuarantees();
|
|
||||||
} else {
|
|
||||||
const error = await response.json();
|
|
||||||
alert(error.detail || 'Failed to add guarantee');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
document.getElementById('exclusionForm').addEventListener('submit', async (e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
const data = {
|
|
||||||
user_id: document.getElementById('exclusionUser').value,
|
|
||||||
start_date: document.getElementById('exclusionStartDate').value || null,
|
|
||||||
end_date: document.getElementById('exclusionEndDate').value || null
|
|
||||||
};
|
|
||||||
const response = await api.post(`/api/managers/${selectedManagerId}/exclusions`, data);
|
|
||||||
if (response && response.ok) {
|
|
||||||
document.getElementById('exclusionModal').style.display = 'none';
|
|
||||||
await loadExclusions();
|
|
||||||
} else {
|
|
||||||
const error = await response.json();
|
|
||||||
alert(error.detail || 'Failed to add exclusion');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Modal background clicks
|
|
||||||
utils.setupModalClose('closingDayModal');
|
|
||||||
utils.setupModalClose('guaranteeModal');
|
|
||||||
utils.setupModalClose('exclusionModal');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Make delete functions globally accessible
|
function setupEventListeners() {
|
||||||
|
// Office select
|
||||||
|
document.getElementById('officeSelect').addEventListener('change', (e) => {
|
||||||
|
loadOfficeRules(e.target.value);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Save Weekly closing days
|
||||||
|
const saveBtn = document.getElementById('saveWeeklyClosingDaysBtn');
|
||||||
|
if (saveBtn) {
|
||||||
|
saveBtn.addEventListener('click', saveWeeklyClosingDays);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Modals
|
||||||
|
const modals = [
|
||||||
|
{ id: 'closingDayModal', btn: 'addClosingDayBtn', close: 'closeClosingDayModal', cancel: 'cancelClosingDay' },
|
||||||
|
{ id: 'guaranteeModal', btn: 'addGuaranteeBtn', close: 'closeGuaranteeModal', cancel: 'cancelGuarantee' },
|
||||||
|
{ id: 'exclusionModal', btn: 'addExclusionBtn', close: 'closeExclusionModal', cancel: 'cancelExclusion' }
|
||||||
|
];
|
||||||
|
|
||||||
|
modals.forEach(m => {
|
||||||
|
document.getElementById(m.btn).addEventListener('click', () => {
|
||||||
|
if (m.id !== 'closingDayModal') populateUserSelects();
|
||||||
|
document.getElementById(m.id).style.display = 'flex';
|
||||||
|
});
|
||||||
|
document.getElementById(m.close).addEventListener('click', () => {
|
||||||
|
document.getElementById(m.id).style.display = 'none';
|
||||||
|
});
|
||||||
|
document.getElementById(m.cancel).addEventListener('click', () => {
|
||||||
|
document.getElementById(m.id).style.display = 'none';
|
||||||
|
});
|
||||||
|
utils.setupModalClose(m.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Forms
|
||||||
|
document.getElementById('closingDayForm').addEventListener('submit', (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
addClosingDay({
|
||||||
|
date: document.getElementById('closingDate').value,
|
||||||
|
end_date: document.getElementById('closingEndDate').value || null,
|
||||||
|
reason: document.getElementById('closingReason').value || null
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('guaranteeForm').addEventListener('submit', (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
addGuarantee({
|
||||||
|
user_id: document.getElementById('guaranteeUser').value,
|
||||||
|
start_date: document.getElementById('guaranteeStartDate').value || null,
|
||||||
|
end_date: document.getElementById('guaranteeEndDate').value || null,
|
||||||
|
notes: document.getElementById('guaranteeNotes').value || null
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('exclusionForm').addEventListener('submit', (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
addExclusion({
|
||||||
|
user_id: document.getElementById('exclusionUser').value,
|
||||||
|
start_date: document.getElementById('exclusionStartDate').value || null,
|
||||||
|
end_date: document.getElementById('exclusionEndDate').value || null,
|
||||||
|
notes: document.getElementById('exclusionNotes').value || null
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Global functions
|
||||||
window.deleteClosingDay = deleteClosingDay;
|
window.deleteClosingDay = deleteClosingDay;
|
||||||
window.deleteGuarantee = deleteGuarantee;
|
window.deleteGuarantee = deleteGuarantee;
|
||||||
window.deleteExclusion = deleteExclusion;
|
window.deleteExclusion = deleteExclusion;
|
||||||
|
|||||||
@@ -97,7 +97,7 @@ function formatDate(date) {
|
|||||||
*/
|
*/
|
||||||
function formatDateDisplay(dateStr) {
|
function formatDateDisplay(dateStr) {
|
||||||
const date = new Date(dateStr + 'T12:00:00');
|
const date = new Date(dateStr + 'T12:00:00');
|
||||||
return date.toLocaleDateString('en-US', {
|
return date.toLocaleDateString('it-IT', {
|
||||||
weekday: 'short',
|
weekday: 'short',
|
||||||
month: 'short',
|
month: 'short',
|
||||||
day: 'numeric'
|
day: 'numeric'
|
||||||
@@ -109,8 +109,8 @@ function formatDateDisplay(dateStr) {
|
|||||||
*/
|
*/
|
||||||
function getMonthName(month) {
|
function getMonthName(month) {
|
||||||
const months = [
|
const months = [
|
||||||
'January', 'February', 'March', 'April', 'May', 'June',
|
'Gennaio', 'Febbraio', 'Marzo', 'Aprile', 'Maggio', 'Giugno',
|
||||||
'July', 'August', 'September', 'October', 'November', 'December'
|
'Luglio', 'Agosto', 'Settembre', 'Ottobre', 'Novembre', 'Dicembre'
|
||||||
];
|
];
|
||||||
return months[month];
|
return months[month];
|
||||||
}
|
}
|
||||||
@@ -119,7 +119,7 @@ function getMonthName(month) {
|
|||||||
* Get day name
|
* Get day name
|
||||||
*/
|
*/
|
||||||
function getDayName(dayIndex) {
|
function getDayName(dayIndex) {
|
||||||
const days = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
|
const days = ['Dom', 'Lun', 'Mar', 'Mer', 'Gio', 'Ven', 'Sab'];
|
||||||
return days[dayIndex];
|
return days[dayIndex];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -133,7 +133,7 @@ function getDaysInMonth(year, month) {
|
|||||||
/**
|
/**
|
||||||
* Get start of week for a date
|
* Get start of week for a date
|
||||||
*/
|
*/
|
||||||
function getWeekStart(date, weekStartDay = 0) {
|
function getWeekStart(date, weekStartDay = 1) {
|
||||||
const d = new Date(date);
|
const d = new Date(date);
|
||||||
const day = d.getDay();
|
const day = d.getDay();
|
||||||
const diff = (day - weekStartDay + 7) % 7;
|
const diff = (day - weekStartDay + 7) % 7;
|
||||||
@@ -146,7 +146,7 @@ function getWeekStart(date, weekStartDay = 0) {
|
|||||||
* Format date as short display (e.g., "Nov 26")
|
* Format date as short display (e.g., "Nov 26")
|
||||||
*/
|
*/
|
||||||
function formatDateShort(date) {
|
function formatDateShort(date) {
|
||||||
return date.toLocaleDateString('en-US', {
|
return date.toLocaleDateString('it-IT', {
|
||||||
month: 'short',
|
month: 'short',
|
||||||
day: 'numeric'
|
day: 'numeric'
|
||||||
});
|
});
|
||||||
@@ -163,12 +163,14 @@ function showMessage(message, type = 'success', duration = 3000) {
|
|||||||
toastContainer.id = 'toastContainer';
|
toastContainer.id = 'toastContainer';
|
||||||
toastContainer.style.cssText = `
|
toastContainer.style.cssText = `
|
||||||
position: fixed;
|
position: fixed;
|
||||||
top: 1rem;
|
bottom: 2rem;
|
||||||
right: 1rem;
|
left: 50%;
|
||||||
|
transform: translateX(-50%);
|
||||||
z-index: 9999;
|
z-index: 9999;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 0.5rem;
|
gap: 1rem;
|
||||||
|
align-items: center;
|
||||||
`;
|
`;
|
||||||
document.body.appendChild(toastContainer);
|
document.body.appendChild(toastContainer);
|
||||||
}
|
}
|
||||||
@@ -176,17 +178,21 @@ function showMessage(message, type = 'success', duration = 3000) {
|
|||||||
const toast = document.createElement('div');
|
const toast = document.createElement('div');
|
||||||
toast.className = `message ${type}`;
|
toast.className = `message ${type}`;
|
||||||
toast.style.cssText = `
|
toast.style.cssText = `
|
||||||
padding: 0.75rem 1rem;
|
padding: 1rem 1.5rem;
|
||||||
border-radius: 6px;
|
border-radius: 8px;
|
||||||
box-shadow: 0 2px 8px rgba(0,0,0,0.15);
|
box-shadow: 0 4px 12px rgba(0,0,0,0.2);
|
||||||
animation: slideIn 0.2s ease;
|
animation: slideInBottom 0.3s ease;
|
||||||
|
font-size: 1.1rem;
|
||||||
|
font-weight: 500;
|
||||||
|
min-width: 300px;
|
||||||
|
text-align: center;
|
||||||
`;
|
`;
|
||||||
toast.textContent = message;
|
toast.textContent = message;
|
||||||
toastContainer.appendChild(toast);
|
toastContainer.appendChild(toast);
|
||||||
|
|
||||||
if (duration > 0) {
|
if (duration > 0) {
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
toast.style.animation = 'slideOut 0.2s ease';
|
toast.style.animation = 'fadeOut 0.3s ease';
|
||||||
setTimeout(() => toast.remove(), 200);
|
setTimeout(() => toast.remove(), 200);
|
||||||
}, duration);
|
}, duration);
|
||||||
}
|
}
|
||||||
@@ -196,14 +202,7 @@ function showMessage(message, type = 'success', duration = 3000) {
|
|||||||
* Close modal when clicking outside
|
* Close modal when clicking outside
|
||||||
*/
|
*/
|
||||||
function setupModalClose(modalId) {
|
function setupModalClose(modalId) {
|
||||||
const modal = document.getElementById(modalId);
|
// Behavior disabled: clicking outside does not close modal
|
||||||
if (modal) {
|
|
||||||
modal.addEventListener('click', (e) => {
|
|
||||||
if (e.target.id === modalId) {
|
|
||||||
modal.style.display = 'none';
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Export utilities
|
// Export utilities
|
||||||
|
|||||||
113
frontend/pages/admin-offices.html
Normal file
113
frontend/pages/admin-offices.html
Normal file
@@ -0,0 +1,113 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Gestione Uffici - Parking Manager</title>
|
||||||
|
<link rel="icon" type="image/svg+xml" href="/favicon.svg">
|
||||||
|
<link rel="stylesheet" href="/css/styles.css">
|
||||||
|
</head>
|
||||||
|
|
||||||
|
<body>
|
||||||
|
<aside class="sidebar">
|
||||||
|
<div class="sidebar-header">
|
||||||
|
<h1>Gestione Parcheggi</h1>
|
||||||
|
</div>
|
||||||
|
<nav class="sidebar-nav"></nav>
|
||||||
|
<div class="sidebar-footer">
|
||||||
|
<div class="user-menu">
|
||||||
|
<button class="user-button" id="userMenuButton">
|
||||||
|
<div class="user-avatar">
|
||||||
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor"
|
||||||
|
stroke-width="2">
|
||||||
|
<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"></path>
|
||||||
|
<circle cx="12" cy="7" r="4"></circle>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div class="user-info">
|
||||||
|
<div class="user-name" id="userName">Caricamento...</div>
|
||||||
|
<div class="user-role" id="userRole">-</div>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
<div class="user-dropdown" id="userDropdown" style="display: none;">
|
||||||
|
<a href="/profile" class="dropdown-item">Profilo</a>
|
||||||
|
<a href="/settings" class="dropdown-item">Impostazioni</a>
|
||||||
|
<hr class="dropdown-divider">
|
||||||
|
<button class="dropdown-item" id="logoutButton">Esci</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<main class="main-content">
|
||||||
|
<header class="page-header">
|
||||||
|
<h2>Gestione Uffici</h2>
|
||||||
|
<div class="header-actions">
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="content-wrapper">
|
||||||
|
<div class="card">
|
||||||
|
<div style="padding-bottom: 1rem; display: flex; justify-content: space-between; align-items: center;">
|
||||||
|
<h3 style="margin: 0;">Lista Uffici</h3>
|
||||||
|
<button class="btn btn-dark" id="addOfficeBtn">Nuovo Ufficio</button>
|
||||||
|
</div>
|
||||||
|
<div class="data-table-container">
|
||||||
|
<table class="data-table" id="officesTable">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Nome</th>
|
||||||
|
<th>Quota Posti</th>
|
||||||
|
<th>Prefisso</th>
|
||||||
|
<th>Utenti</th>
|
||||||
|
<th>Azioni</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="officesBody"></tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<!-- Office Modal -->
|
||||||
|
<div class="modal" id="officeModal" style="display: none;">
|
||||||
|
<div class="modal-content">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h3 id="officeModalTitle">Nuovo Ufficio</h3>
|
||||||
|
<button class="modal-close" id="closeOfficeModal">×</button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<form id="officeForm">
|
||||||
|
<input type="hidden" id="officeId">
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="officeName">Nome Ufficio</label>
|
||||||
|
<input type="text" id="officeName" required>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="officeQuota">Quota Parcheggio</label>
|
||||||
|
<input type="number" id="officeQuota" min="0" value="0" required>
|
||||||
|
<small class="text-muted">Numero totale di posti auto assegnati a questo ufficio</small>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
<div class="form-actions">
|
||||||
|
<button type="button" class="btn btn-secondary" id="cancelOffice">Annulla</button>
|
||||||
|
<button type="submit" class="btn btn-dark" id="saveOfficeBtn">Salva</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script src="/js/api.js"></script>
|
||||||
|
<script src="/js/utils.js"></script>
|
||||||
|
<script src="/js/nav.js"></script>
|
||||||
|
<script src="/js/admin-offices.js"></script>
|
||||||
|
</body>
|
||||||
|
|
||||||
|
</html>
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html lang="en">
|
<html lang="en">
|
||||||
|
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
@@ -7,31 +8,33 @@
|
|||||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg">
|
<link rel="icon" type="image/svg+xml" href="/favicon.svg">
|
||||||
<link rel="stylesheet" href="/css/styles.css">
|
<link rel="stylesheet" href="/css/styles.css">
|
||||||
</head>
|
</head>
|
||||||
|
|
||||||
<body>
|
<body>
|
||||||
<aside class="sidebar">
|
<aside class="sidebar">
|
||||||
<div class="sidebar-header">
|
<div class="sidebar-header">
|
||||||
<h1>Parking Manager</h1>
|
<h1>Gestione Parcheggi</h1>
|
||||||
</div>
|
</div>
|
||||||
<nav class="sidebar-nav"></nav>
|
<nav class="sidebar-nav"></nav>
|
||||||
<div class="sidebar-footer">
|
<div class="sidebar-footer">
|
||||||
<div class="user-menu">
|
<div class="user-menu">
|
||||||
<button class="user-button" id="userMenuButton">
|
<button class="user-button" id="userMenuButton">
|
||||||
<div class="user-avatar">
|
<div class="user-avatar">
|
||||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor"
|
||||||
|
stroke-width="2">
|
||||||
<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"></path>
|
<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"></path>
|
||||||
<circle cx="12" cy="7" r="4"></circle>
|
<circle cx="12" cy="7" r="4"></circle>
|
||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
<div class="user-info">
|
<div class="user-info">
|
||||||
<div class="user-name" id="userName">Loading...</div>
|
<div class="user-name" id="userName">Caricamento...</div>
|
||||||
<div class="user-role" id="userRole">-</div>
|
<div class="user-role" id="userRole">-</div>
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
<div class="user-dropdown" id="userDropdown" style="display: none;">
|
<div class="user-dropdown" id="userDropdown" style="display: none;">
|
||||||
<a href="/profile" class="dropdown-item">Profile</a>
|
<a href="/profile" class="dropdown-item">Profilo</a>
|
||||||
<a href="/settings" class="dropdown-item">Settings</a>
|
<a href="/settings" class="dropdown-item">Impostazioni</a>
|
||||||
<hr class="dropdown-divider">
|
<hr class="dropdown-divider">
|
||||||
<button class="dropdown-item" id="logoutButton">Logout</button>
|
<button class="dropdown-item" id="logoutButton">Esci</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -39,23 +42,33 @@
|
|||||||
|
|
||||||
<main class="main-content">
|
<main class="main-content">
|
||||||
<header class="page-header">
|
<header class="page-header">
|
||||||
<h2>Manage Users</h2>
|
<h2>Gestione Utenti</h2>
|
||||||
<div class="header-actions">
|
<div class="header-actions">
|
||||||
<input type="text" id="searchInput" class="form-input" placeholder="Search users...">
|
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div class="content-wrapper">
|
<div class="content-wrapper">
|
||||||
<div class="card">
|
<div class="card">
|
||||||
|
<div style="padding-bottom: 1rem; display: flex; justify-content: space-between; align-items: center;">
|
||||||
|
<h3 style="margin: 0;">Lista Utenti</h3>
|
||||||
|
<input type="text" id="searchInput" class="form-input" placeholder="Cerca utenti..."
|
||||||
|
style="max-width: 300px;">
|
||||||
|
</div>
|
||||||
<div class="data-table-container">
|
<div class="data-table-container">
|
||||||
<table class="data-table" id="usersTable">
|
<table class="data-table" id="usersTable">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>Name</th>
|
<th class="sortable" data-sort="name" style="cursor: pointer;">Nome <span
|
||||||
<th>Email</th>
|
class="sort-icon"></span></th>
|
||||||
<th>Role</th>
|
<th class="sortable" data-sort="email" style="cursor: pointer;">Email <span
|
||||||
<th>Manager</th>
|
class="sort-icon"></span></th>
|
||||||
<th>Actions</th>
|
<th class="sortable" data-sort="role" style="cursor: pointer;">Ruolo <span
|
||||||
|
class="sort-icon"></span></th>
|
||||||
|
<th class="sortable" data-sort="office_name" style="cursor: pointer;">Ufficio <span
|
||||||
|
class="sort-icon"></span></th>
|
||||||
|
<th class="sortable" data-sort="parking_ratio" style="cursor: pointer;">Punteggio <span
|
||||||
|
class="sort-icon"></span></th>
|
||||||
|
<th>Azioni</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody id="usersBody"></tbody>
|
<tbody id="usersBody"></tbody>
|
||||||
@@ -69,7 +82,7 @@
|
|||||||
<div class="modal" id="userModal" style="display: none;">
|
<div class="modal" id="userModal" style="display: none;">
|
||||||
<div class="modal-content">
|
<div class="modal-content">
|
||||||
<div class="modal-header">
|
<div class="modal-header">
|
||||||
<h3 id="userModalTitle">Edit User</h3>
|
<h3 id="userModalTitle">Modifica Utente</h3>
|
||||||
<button class="modal-close" id="closeUserModal">×</button>
|
<button class="modal-close" id="closeUserModal">×</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-body">
|
<div class="modal-body">
|
||||||
@@ -78,54 +91,42 @@
|
|||||||
|
|
||||||
<!-- LDAP notice -->
|
<!-- LDAP notice -->
|
||||||
<div id="ldapNotice" class="form-notice" style="display: none;">
|
<div id="ldapNotice" class="form-notice" style="display: none;">
|
||||||
<small>This user is managed by LDAP. Some fields cannot be edited.</small>
|
<small>Questo utente è gestito da LDAP. Alcuni campi non possono essere modificati.</small>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="editName">Name</label>
|
<label for="editName">Nome</label>
|
||||||
<input type="text" id="editName" required>
|
<input type="text" id="editName" required>
|
||||||
<small id="nameHelp" class="text-muted" style="display: none;">Managed by LDAP</small>
|
<small id="nameHelp" class="text-muted" style="display: none;">Gestito da LDAP</small>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="editEmail">Email</label>
|
<label for="editEmail">Email</label>
|
||||||
<input type="email" id="editEmail" disabled>
|
<input type="email" id="editEmail" disabled>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="editRole">Role</label>
|
<label for="editRole">Ruolo</label>
|
||||||
<select id="editRole" required>
|
<select id="editRole" required>
|
||||||
<option value="employee">Employee</option>
|
<option value="employee">Dipendente</option>
|
||||||
<option value="manager">Manager</option>
|
<option value="manager">Manager</option>
|
||||||
<option value="admin">Admin</option>
|
<option value="admin">Admin</option>
|
||||||
</select>
|
</select>
|
||||||
<small id="roleHelp" class="text-muted" style="display: none;">Admin role is managed by LDAP group</small>
|
<small id="roleHelp" class="text-muted" style="display: none;">Il ruolo admin è gestito dal
|
||||||
|
gruppo LDAP</small>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group" id="managerGroup">
|
<div class="form-group" id="officeGroup">
|
||||||
<label for="editManager">Manager</label>
|
<label for="editOffice">Ufficio</label>
|
||||||
<select id="editManager">
|
<select id="editOffice">
|
||||||
<option value="">No manager</option>
|
<option value="">Nessun ufficio</option>
|
||||||
</select>
|
</select>
|
||||||
<small class="text-muted">Who manages this user</small>
|
<small class="text-muted">Ufficio di appartenenza</small>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Manager-specific fields -->
|
<!-- Manager-specific fields -->
|
||||||
<div id="managerFields" style="display: none;">
|
|
||||||
<hr>
|
|
||||||
<h4>Manager Settings</h4>
|
|
||||||
<div class="form-group">
|
|
||||||
<label for="editQuota">Parking Quota</label>
|
|
||||||
<input type="number" id="editQuota" min="0" value="0">
|
|
||||||
<small class="text-muted">Number of parking spots this manager controls</small>
|
|
||||||
</div>
|
|
||||||
<div class="form-group">
|
|
||||||
<label for="editPrefix">Spot Prefix</label>
|
|
||||||
<input type="text" id="editPrefix" maxlength="2" placeholder="A, B, C...">
|
|
||||||
<small class="text-muted">Letter prefix for parking spots (e.g., A for spots A1, A2...)</small>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="form-actions">
|
<div class="form-actions">
|
||||||
<button type="button" class="btn btn-secondary" id="cancelUser">Cancel</button>
|
<button type="button" class="btn btn-secondary" id="cancelUser">Annulla</button>
|
||||||
<button type="submit" class="btn btn-dark">Save</button>
|
<button type="submit" class="btn btn-dark">Salva</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
@@ -137,4 +138,5 @@
|
|||||||
<script src="/js/nav.js"></script>
|
<script src="/js/nav.js"></script>
|
||||||
<script src="/js/admin-users.js"></script>
|
<script src="/js/admin-users.js"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
|
||||||
|
</html>
|
||||||
@@ -11,13 +11,13 @@
|
|||||||
<div class="auth-container">
|
<div class="auth-container">
|
||||||
<div class="auth-card">
|
<div class="auth-card">
|
||||||
<div class="auth-header">
|
<div class="auth-header">
|
||||||
<h1>Parking Manager</h1>
|
<h1>Gestione Parcheggi</h1>
|
||||||
<p>Manage team presence and parking assignments</p>
|
<p>Gestisci la presenza del team e le assegnazioni dei parcheggi</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div id="authButtons" style="display: flex; flex-direction: column; gap: 1rem;">
|
<div id="authButtons" style="display: flex; flex-direction: column; gap: 1rem;">
|
||||||
<!-- Buttons will be populated by JavaScript based on auth mode -->
|
<!-- Buttons will be populated by JavaScript based on auth mode -->
|
||||||
<div class="loading">Loading...</div>
|
<div class="loading">Caricamento...</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -56,30 +56,30 @@
|
|||||||
if (config.login_url) {
|
if (config.login_url) {
|
||||||
// Redirect to Authelia login with return URL
|
// Redirect to Authelia login with return URL
|
||||||
const returnUrl = encodeURIComponent(window.location.origin + '/presence');
|
const returnUrl = encodeURIComponent(window.location.origin + '/presence');
|
||||||
buttons += `<a href="${config.login_url}?rd=${returnUrl}" class="btn btn-dark btn-full">Sign In</a>`;
|
buttons += `<a href="${config.login_url}?rd=${returnUrl}" class="btn btn-dark btn-full">Accedi</a>`;
|
||||||
} else {
|
} else {
|
||||||
// No login URL configured - just try to access the app (Authelia will intercept)
|
// No login URL configured - just try to access the app (Authelia will intercept)
|
||||||
buttons += `<a href="/presence" class="btn btn-dark btn-full">Sign In</a>`;
|
buttons += `<a href="/presence" class="btn btn-dark btn-full">Accedi</a>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (config.registration_url) {
|
if (config.registration_url) {
|
||||||
buttons += `<a href="${config.registration_url}" class="btn btn-secondary btn-full" target="_blank">Create Account</a>`;
|
buttons += `<a href="${config.registration_url}" class="btn btn-secondary btn-full" target="_blank">Crea Account</a>`;
|
||||||
buttons += `<p class="auth-footer" style="margin-top: 0.5rem; text-align: center; font-size: 0.875rem; color: #666;">Registration requires admin approval</p>`;
|
buttons += `<p class="auth-footer" style="margin-top: 0.5rem; text-align: center; font-size: 0.875rem; color: #666;">La registrazione richiede l'approvazione dell'amministratore</p>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
buttonsDiv.innerHTML = buttons;
|
buttonsDiv.innerHTML = buttons;
|
||||||
} else {
|
} else {
|
||||||
// Standalone mode: Local login and registration
|
// Standalone mode: Local login and registration
|
||||||
buttonsDiv.innerHTML = `
|
buttonsDiv.innerHTML = `
|
||||||
<a href="/login" class="btn btn-dark btn-full">Sign In</a>
|
<a href="/login" class="btn btn-dark btn-full">Accedi</a>
|
||||||
<a href="/register" class="btn btn-secondary btn-full">Create Account</a>
|
<a href="/register" class="btn btn-secondary btn-full">Crea Account</a>
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// Fallback to standalone mode
|
// Fallback to standalone mode
|
||||||
buttonsDiv.innerHTML = `
|
buttonsDiv.innerHTML = `
|
||||||
<a href="/login" class="btn btn-dark btn-full">Sign In</a>
|
<a href="/login" class="btn btn-dark btn-full">Accedi</a>
|
||||||
<a href="/register" class="btn btn-secondary btn-full">Create Account</a>
|
<a href="/register" class="btn btn-secondary btn-full">Crea Account</a>
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html lang="en">
|
<html lang="en">
|
||||||
|
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
@@ -7,12 +8,13 @@
|
|||||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg">
|
<link rel="icon" type="image/svg+xml" href="/favicon.svg">
|
||||||
<link rel="stylesheet" href="/css/styles.css">
|
<link rel="stylesheet" href="/css/styles.css">
|
||||||
</head>
|
</head>
|
||||||
|
|
||||||
<body>
|
<body>
|
||||||
<div class="auth-container">
|
<div class="auth-container">
|
||||||
<div class="auth-card">
|
<div class="auth-card">
|
||||||
<div class="auth-header">
|
<div class="auth-header">
|
||||||
<h1>Welcome Back</h1>
|
<h1>Bentornato</h1>
|
||||||
<p>Sign in to your account</p>
|
<p>Accedi al tuo account</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div id="errorMessage"></div>
|
<div id="errorMessage"></div>
|
||||||
@@ -26,11 +28,11 @@
|
|||||||
<label for="password">Password</label>
|
<label for="password">Password</label>
|
||||||
<input type="password" id="password" required autocomplete="current-password">
|
<input type="password" id="password" required autocomplete="current-password">
|
||||||
</div>
|
</div>
|
||||||
<button type="submit" class="btn btn-dark btn-full">Sign In</button>
|
<button type="submit" class="btn btn-dark btn-full">Accedi</button>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
<div class="auth-footer">
|
<div class="auth-footer">
|
||||||
Don't have an account? <a href="/register">Sign up</a>
|
Non hai un account? <a href="/register">Registrati</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -80,4 +82,5 @@
|
|||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
|
||||||
|
</html>
|
||||||
155
frontend/pages/parking-settings.html
Normal file
155
frontend/pages/parking-settings.html
Normal file
@@ -0,0 +1,155 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Office Settings - Parking Manager</title>
|
||||||
|
<link rel="icon" type="image/svg+xml" href="/favicon.svg">
|
||||||
|
<link rel="stylesheet" href="/css/styles.css">
|
||||||
|
</head>
|
||||||
|
|
||||||
|
<body>
|
||||||
|
<aside class="sidebar">
|
||||||
|
<div class="sidebar-header">
|
||||||
|
<h1>Gestione Parcheggi</h1>
|
||||||
|
</div>
|
||||||
|
<nav class="sidebar-nav"></nav>
|
||||||
|
<div class="sidebar-footer">
|
||||||
|
<div class="user-menu">
|
||||||
|
<button class="user-button" id="userMenuButton">
|
||||||
|
<div class="user-avatar">
|
||||||
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor"
|
||||||
|
stroke-width="2">
|
||||||
|
<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"></path>
|
||||||
|
<circle cx="12" cy="7" r="4"></circle>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div class="user-info">
|
||||||
|
<div class="user-name" id="userName">Caricamento...</div>
|
||||||
|
<div class="user-role" id="userRole">-</div>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
<div class="user-dropdown" id="userDropdown" style="display: none;">
|
||||||
|
<a href="/profile" class="dropdown-item">Profilo</a>
|
||||||
|
<a href="/settings" class="dropdown-item">Impostazioni</a>
|
||||||
|
<hr class="dropdown-divider">
|
||||||
|
<button class="dropdown-item" id="logoutButton">Esci</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<main class="main-content">
|
||||||
|
<header class="page-header">
|
||||||
|
<h2>Impostazioni Ufficio</h2>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="content-wrapper">
|
||||||
|
|
||||||
|
<!-- Office Selection Card (Admin Only) -->
|
||||||
|
<div class="card" id="officeSelectionCard" style="margin-bottom: 1.5rem; display: none;">
|
||||||
|
<div style="display: flex; align-items: center; gap: 1rem;">
|
||||||
|
<label for="officeSelect" style="font-weight: 500; min-width: max-content;">Seleziona
|
||||||
|
Ufficio:</label>
|
||||||
|
<select id="officeSelect" class="form-select" style="flex: 1; max-width: 300px;">
|
||||||
|
<option value="">Seleziona Ufficio</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="settingsContent" style="display: none;">
|
||||||
|
|
||||||
|
<!-- Card 1: Batch Scheduling Settings -->
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header">
|
||||||
|
<h3>Schedulazione Automatica</h3>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<form id="scheduleForm">
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="toggle-label">
|
||||||
|
<span>Abilita Assegnazione Batch</span>
|
||||||
|
<label class="toggle-switch">
|
||||||
|
<input type="checkbox" id="bookingWindowEnabled">
|
||||||
|
<span class="toggle-slider"></span>
|
||||||
|
</label>
|
||||||
|
</label>
|
||||||
|
<small class="text-muted">Se abilitato, l'assegnazione dei posti avverrà solo dopo
|
||||||
|
l'orario
|
||||||
|
di cut-off del giorno precedente.</small>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group" id="cutoffTimeGroup">
|
||||||
|
<label>Orario di Cut-off (Giorno Precedente)</label>
|
||||||
|
<div style="display: flex; gap: 0.5rem; align-items: center;">
|
||||||
|
<select id="bookingWindowHour" style="width: 80px;">
|
||||||
|
<!-- Populated by JS -->
|
||||||
|
</select>
|
||||||
|
<span>:</span>
|
||||||
|
<select id="bookingWindowMinute" style="width: 80px;">
|
||||||
|
<option value="0">00</option>
|
||||||
|
<option value="15">15</option>
|
||||||
|
<option value="30">30</option>
|
||||||
|
<option value="45">45</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<small class="text-muted">Le presenze inserite prima di questo orario saranno messe in
|
||||||
|
attesa.</small>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-actions">
|
||||||
|
<button type="submit" class="btn btn-dark">Salva Impostazioni</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Card 2: Testing Tools -->
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header"
|
||||||
|
style="display: flex; justify-content: space-between; align-items: center;">
|
||||||
|
<h3>Strumenti di Test</h3>
|
||||||
|
<span class="badge badge-warning">Testing Only</span>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<p class="text-muted" style="margin-bottom: 1rem;">Usa questi strumenti per verificare il
|
||||||
|
funzionamento dell'assegnazione automatica.</p>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Range di Date di Test</label>
|
||||||
|
<div style="display: flex; gap: 1rem;">
|
||||||
|
<div>
|
||||||
|
<small>Da:</small>
|
||||||
|
<input type="date" id="testDateStart" class="form-control" style="width: 160px;">
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<small>A (incluso):</small>
|
||||||
|
<input type="date" id="testDateEnd" class="form-control" style="width: 160px;">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<small class="text-muted">Lascia "A" vuoto per eseguire su un singolo giorno.</small>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="display: flex; gap: 1rem; margin-top: 1rem;">
|
||||||
|
<button id="runAllocationBtn" class="btn btn-primary">
|
||||||
|
Esegui Assegnazione Ora
|
||||||
|
</button>
|
||||||
|
<button id="clearAssignmentsBtn" class="btn btn-danger">
|
||||||
|
Elimina Tutte le Assegnazioni
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div> <!-- End settingsContent -->
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<script src="/js/api.js"></script>
|
||||||
|
<script src="/js/utils.js"></script>
|
||||||
|
<script src="/js/nav.js"></script>
|
||||||
|
<script src="/js/parking-settings.js"></script>
|
||||||
|
</body>
|
||||||
|
|
||||||
|
</html>
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html lang="en">
|
<html lang="en">
|
||||||
|
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
@@ -7,31 +8,33 @@
|
|||||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg">
|
<link rel="icon" type="image/svg+xml" href="/favicon.svg">
|
||||||
<link rel="stylesheet" href="/css/styles.css">
|
<link rel="stylesheet" href="/css/styles.css">
|
||||||
</head>
|
</head>
|
||||||
|
|
||||||
<body>
|
<body>
|
||||||
<aside class="sidebar">
|
<aside class="sidebar">
|
||||||
<div class="sidebar-header">
|
<div class="sidebar-header">
|
||||||
<h1>Parking Manager</h1>
|
<h1>Gestione Parcheggi</h1>
|
||||||
</div>
|
</div>
|
||||||
<nav class="sidebar-nav"></nav>
|
<nav class="sidebar-nav"></nav>
|
||||||
<div class="sidebar-footer">
|
<div class="sidebar-footer">
|
||||||
<div class="user-menu">
|
<div class="user-menu">
|
||||||
<button class="user-button" id="userMenuButton">
|
<button class="user-button" id="userMenuButton">
|
||||||
<div class="user-avatar">
|
<div class="user-avatar">
|
||||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor"
|
||||||
|
stroke-width="2">
|
||||||
<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"></path>
|
<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"></path>
|
||||||
<circle cx="12" cy="7" r="4"></circle>
|
<circle cx="12" cy="7" r="4"></circle>
|
||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
<div class="user-info">
|
<div class="user-info">
|
||||||
<div class="user-name" id="userName">Loading...</div>
|
<div class="user-name" id="userName">Caricamento...</div>
|
||||||
<div class="user-role" id="userRole">-</div>
|
<div class="user-role" id="userRole">-</div>
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
<div class="user-dropdown" id="userDropdown" style="display: none;">
|
<div class="user-dropdown" id="userDropdown" style="display: none;">
|
||||||
<a href="/profile" class="dropdown-item">Profile</a>
|
<a href="/profile" class="dropdown-item">Profilo</a>
|
||||||
<a href="/settings" class="dropdown-item">Settings</a>
|
<a href="/settings" class="dropdown-item">Impostazioni</a>
|
||||||
<hr class="dropdown-divider">
|
<hr class="dropdown-divider">
|
||||||
<button class="dropdown-item" id="logoutButton">Logout</button>
|
<button class="dropdown-item" id="logoutButton">Esci</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -39,26 +42,36 @@
|
|||||||
|
|
||||||
<main class="main-content">
|
<main class="main-content">
|
||||||
<header class="page-header">
|
<header class="page-header">
|
||||||
<h2>My Presence</h2>
|
<h2>Dashboard</h2>
|
||||||
<div class="header-actions">
|
<div class="header-actions">
|
||||||
<button class="btn btn-secondary" id="bulkMarkBtn">Bulk Mark</button>
|
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div class="content-wrapper">
|
<div class="content-wrapper">
|
||||||
<div class="card presence-card">
|
<div class="card presence-card">
|
||||||
|
<div style="margin-bottom: 1.5rem;">
|
||||||
|
<h3>Calendario</h3>
|
||||||
|
</div>
|
||||||
<div class="calendar-header">
|
<div class="calendar-header">
|
||||||
<button class="btn-icon" id="prevMonth">
|
<button class="btn-icon" id="prevMonth">
|
||||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor"
|
||||||
|
stroke-width="2">
|
||||||
<polyline points="15 18 9 12 15 6"></polyline>
|
<polyline points="15 18 9 12 15 6"></polyline>
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
<h3 id="currentMonth">Loading...</h3>
|
<h3 id="currentMonth">Caricamento...</h3>
|
||||||
<button class="btn-icon" id="nextMonth">
|
<button class="btn-icon" id="nextMonth">
|
||||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor"
|
||||||
|
stroke-width="2">
|
||||||
<polyline points="9 18 15 12 9 6"></polyline>
|
<polyline points="9 18 15 12 9 6"></polyline>
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
|
<div style="display: flex; gap: 0.5rem; align-items: center;">
|
||||||
|
<button class="btn btn-dark btn-sm" id="quickEntryBtn" style="font-size: 0.85rem;">
|
||||||
|
Inserimento Veloce
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="calendar-grid" id="calendarGrid"></div>
|
<div class="calendar-grid" id="calendarGrid"></div>
|
||||||
@@ -66,121 +79,211 @@
|
|||||||
<div class="legend">
|
<div class="legend">
|
||||||
<div class="legend-item">
|
<div class="legend-item">
|
||||||
<div class="legend-color status-present"></div>
|
<div class="legend-color status-present"></div>
|
||||||
<span>Present (Office)</span>
|
<span>In sede</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="legend-item">
|
<div class="legend-item">
|
||||||
<div class="legend-color status-remote"></div>
|
<div class="legend-color status-remote"></div>
|
||||||
<span>Remote</span>
|
<span>Remoto</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="legend-item">
|
<div class="legend-item">
|
||||||
<div class="legend-color status-absent"></div>
|
<div class="legend-color status-absent"></div>
|
||||||
<span>Absent</span>
|
<span>Assente</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Parking Status Section -->
|
||||||
|
<div class="card" id="parkingStatusCard" style="margin-top: 2rem;">
|
||||||
|
<div style="margin-bottom: 1.5rem;">
|
||||||
|
<h3>Stato Parcheggio</h3>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Daily View Controls -->
|
||||||
|
<div id="dailyViewControls">
|
||||||
|
|
||||||
|
<!-- Date Navigation (Centered) -->
|
||||||
|
<div style="display: flex; justify-content: center; margin-bottom: 2rem;">
|
||||||
|
<div
|
||||||
|
style="display: flex; align-items: center; gap: 0.5rem; background: white; padding: 0.5rem; border-radius: 8px; border: 1px solid var(--border);">
|
||||||
|
<button class="btn-icon" id="statusPrevDay"
|
||||||
|
style="border: none; width: 32px; height: 32px;">
|
||||||
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor"
|
||||||
|
stroke-width="2">
|
||||||
|
<polyline points="15 18 9 12 15 6"></polyline>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div style="position: relative; text-align: center; min-width: 200px;">
|
||||||
|
<div id="statusDateDisplay"
|
||||||
|
style="font-weight: 600; font-size: 1rem; text-transform: capitalize;"></div>
|
||||||
|
<input type="date" id="statusDatePicker"
|
||||||
|
style="position: absolute; inset: 0; opacity: 0; cursor: pointer;">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button class="btn-icon" id="statusNextDay"
|
||||||
|
style="border: none; width: 32px; height: 32px;">
|
||||||
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor"
|
||||||
|
stroke-width="2">
|
||||||
|
<polyline points="9 18 15 12 9 6"></polyline>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Office Header (No Logo) -->
|
||||||
|
<!-- Office Header (No Logo) -->
|
||||||
|
<div
|
||||||
|
style="display: flex; align-items: center; justify-content: flex-start; gap: 1rem; margin-bottom: 1.5rem;">
|
||||||
|
<div style="font-weight: 600; font-size: 1.1rem; color: var(--text);">
|
||||||
|
Ufficio: <span id="currentOfficeDisplay" style="color: var(--primary);">...</span>
|
||||||
|
</div>
|
||||||
|
<span class="badge"
|
||||||
|
style="background: #eff6ff; color: #1d4ed8; border: 1px solid #dbeafe; padding: 0.35rem 0.75rem; font-size: 0.85rem;">
|
||||||
|
Liberi: <span id="spotsCountBadge">0/0</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Spots Grid -->
|
||||||
|
<div id="spotsGrid" style="display: flex; gap: 1rem; flex-wrap: wrap; margin-bottom: 0;">
|
||||||
|
<!-- Spots injected here -->
|
||||||
|
<div style="width: 100%; text-align: center; color: var(--text-secondary); padding: 2rem;">
|
||||||
|
Caricamento posti...
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card parking-map-card" style="margin-top: 2rem;">
|
||||||
|
<h3>Mappa Parcheggio</h3>
|
||||||
|
<img src="/assets/parking-map.png" alt="Mappa Parcheggio"
|
||||||
|
style="width: 100%; height: auto; border-radius: 4px; border: 1px solid var(--border);">
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
<!-- Day Modal -->
|
<!-- Quick Entry Modal -->
|
||||||
<div class="modal" id="dayModal" style="display: none;">
|
<div class="modal" id="quickEntryModal" style="display: none;">
|
||||||
<div class="modal-content modal-small">
|
<div class="modal-content modal-small">
|
||||||
<div class="modal-header">
|
<div class="modal-header">
|
||||||
<h3 id="dayModalTitle">Mark Presence</h3>
|
<h3>Inserimento Veloce</h3>
|
||||||
<button class="modal-close" id="closeDayModal">×</button>
|
<button class="modal-close" id="closeQuickEntryModal">×</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-body">
|
<div class="modal-body">
|
||||||
<div class="status-buttons">
|
<form id="quickEntryForm">
|
||||||
<button class="status-btn" data-status="present">
|
<div class="form-group">
|
||||||
<div class="status-icon status-present"></div>
|
<label>Range di Date</label>
|
||||||
<span>Present</span>
|
<div style="display: flex; gap: 1rem;">
|
||||||
</button>
|
<div style="flex: 1;">
|
||||||
<button class="status-btn" data-status="remote">
|
<small>Da:</small>
|
||||||
<div class="status-icon status-remote"></div>
|
<input type="date" id="qeStartDate" class="form-control" required>
|
||||||
<span>Remote</span>
|
</div>
|
||||||
</button>
|
<div style="flex: 1;">
|
||||||
<button class="status-btn" data-status="absent">
|
<small>A (incluso):</small>
|
||||||
<div class="status-icon status-absent"></div>
|
<input type="date" id="qeEndDate" class="form-control" required>
|
||||||
<span>Absent</span>
|
</div>
|
||||||
</button>
|
</div>
|
||||||
</div>
|
|
||||||
<div id="parkingSection" style="display: none; margin-top: 1rem; padding-top: 1rem; border-top: 1px solid var(--border);">
|
|
||||||
<div id="parkingInfo" style="margin-bottom: 0.75rem; font-size: 0.9rem;"></div>
|
|
||||||
<div style="display: flex; gap: 0.5rem;">
|
|
||||||
<button class="btn btn-secondary" style="flex: 1;" id="reassignParkingBtn">Reassign</button>
|
|
||||||
<button class="btn btn-secondary" style="flex: 1;" id="releaseParkingBtn">Release</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
<button class="btn btn-secondary btn-full" id="clearDayBtn" style="margin-top: 1rem;">Clear Presence</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Reassign Parking Modal -->
|
<div class="form-group">
|
||||||
<div class="modal" id="reassignModal" style="display: none;">
|
<label>Stato da applicare</label>
|
||||||
<div class="modal-content modal-small">
|
<div class="status-buttons">
|
||||||
<div class="modal-header">
|
<button type="button" class="status-btn qe-status-btn" data-status="present">
|
||||||
<h3>Reassign Parking Spot</h3>
|
<div class="status-icon status-present"></div>
|
||||||
<button class="modal-close" id="closeReassignModal">×</button>
|
<span>In sede</span>
|
||||||
</div>
|
</button>
|
||||||
<div class="modal-body">
|
<button type="button" class="status-btn qe-status-btn" data-status="remote">
|
||||||
<p id="reassignSpotInfo" style="margin-bottom: 1rem; font-size: 0.9rem;"></p>
|
<div class="status-icon status-remote"></div>
|
||||||
<div class="form-group">
|
<span>Remoto</span>
|
||||||
<label for="reassignUser">Assign to</label>
|
</button>
|
||||||
<select id="reassignUser" required>
|
<button type="button" class="status-btn qe-status-btn" data-status="absent">
|
||||||
<option value="">Select user...</option>
|
<div class="status-icon status-absent"></div>
|
||||||
</select>
|
<span>Assente</span>
|
||||||
</div>
|
</button>
|
||||||
<div class="form-actions">
|
<button type="button" class="status-btn qe-status-btn" data-status="clear">
|
||||||
<button type="button" class="btn btn-secondary" id="cancelReassign">Cancel</button>
|
<div class="status-icon"
|
||||||
<button type="button" class="btn btn-dark" id="confirmReassign">Reassign</button>
|
style="border: 2px solid #ef4444; background: #fee2e2; display: flex; align-items: center; justify-content: center;">
|
||||||
</div>
|
<span style="color: #ef4444; font-weight: bold; font-size: 1.2rem;">×</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<span>Rimuovi</span>
|
||||||
</div>
|
</button>
|
||||||
|
</div>
|
||||||
|
<input type="hidden" id="qeStatus" required>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Bulk Mark Modal -->
|
|
||||||
<div class="modal" id="bulkMarkModal" style="display: none;">
|
|
||||||
<div class="modal-content">
|
|
||||||
<div class="modal-header">
|
|
||||||
<h3>Bulk Mark Presence</h3>
|
|
||||||
<button class="modal-close" id="closeBulkModal">×</button>
|
|
||||||
</div>
|
|
||||||
<div class="modal-body">
|
|
||||||
<form id="bulkMarkForm">
|
|
||||||
<div class="form-group">
|
|
||||||
<label for="startDate">Start Date</label>
|
|
||||||
<input type="date" id="startDate" required>
|
|
||||||
</div>
|
|
||||||
<div class="form-group">
|
|
||||||
<label for="endDate">End Date</label>
|
|
||||||
<input type="date" id="endDate" required>
|
|
||||||
</div>
|
|
||||||
<div class="form-group">
|
|
||||||
<label for="bulkStatus">Status</label>
|
|
||||||
<select id="bulkStatus" required>
|
|
||||||
<option value="present">Present (Office)</option>
|
|
||||||
<option value="remote">Remote</option>
|
|
||||||
<option value="absent">Absent</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div class="form-group checkbox-group">
|
|
||||||
<label class="checkbox-label">
|
|
||||||
<input type="checkbox" id="weekdaysOnly">
|
|
||||||
<span>Weekdays only (Mon-Fri)</span>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
<div class="form-actions">
|
<div class="form-actions">
|
||||||
<button type="button" class="btn btn-secondary" id="cancelBulk">Cancel</button>
|
<button type="button" class="btn btn-secondary" id="cancelQuickEntry">Annulla</button>
|
||||||
<button type="submit" class="btn btn-dark">Mark Dates</button>
|
<button type="submit" class="btn btn-dark">Applica</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Day Modal -->
|
||||||
|
<div class="modal" id="dayModal" style="display: none;">
|
||||||
|
<div class="modal-content modal-small">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h3 id="dayModalTitle">Segna presenza</h3>
|
||||||
|
<button class="modal-close" id="closeDayModal">×</button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<p id="dayModalUser" style="display:none; margin-bottom: 1rem; font-weight: 500;"></p>
|
||||||
|
<div class="status-buttons">
|
||||||
|
<button class="status-btn" data-status="present">
|
||||||
|
<div class="status-icon status-present"></div>
|
||||||
|
<span>In sede</span>
|
||||||
|
</button>
|
||||||
|
<button class="status-btn" data-status="remote">
|
||||||
|
<div class="status-icon status-remote"></div>
|
||||||
|
<span>Remoto</span>
|
||||||
|
</button>
|
||||||
|
<button class="status-btn" data-status="absent">
|
||||||
|
<div class="status-icon status-absent"></div>
|
||||||
|
<span>Assente</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button class="btn btn-secondary btn-full" id="clearDayBtn" style="margin-top: 1rem;">Cancella
|
||||||
|
presenza</button>
|
||||||
|
|
||||||
|
<div id="parkingSection"
|
||||||
|
style="display: none; margin-top: 1rem; padding-top: 1rem; border-top: 1px solid var(--border);">
|
||||||
|
<div id="parkingInfo" style="margin-bottom: 0.75rem; font-size: 0.9rem;"></div>
|
||||||
|
|
||||||
|
<div id="parkingActions" style="display: flex; gap: 0.5rem;">
|
||||||
|
<button class="btn btn-secondary" style="flex: 1;" id="reassignParkingBtn">Cedi posto</button>
|
||||||
|
<button class="btn btn-secondary" style="flex: 1;" id="releaseParkingBtn">Lascia libero</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="reassignForm"
|
||||||
|
style="display: none; flex-direction: column; gap: 0.5rem; margin-top: 0.5rem;">
|
||||||
|
<div class="form-group" style="margin-bottom: 0.5rem;">
|
||||||
|
<label for="reassignUser" style="font-size: 0.9rem;">Assegna a</label>
|
||||||
|
<select id="reassignUser" class="form-control" style="width: 100%;">
|
||||||
|
<option value="">Seleziona utente...</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div style="display: flex; gap: 0.5rem;">
|
||||||
|
<button type="button" class="btn btn-secondary" style="flex: 1;"
|
||||||
|
id="cancelReassign">Annulla</button>
|
||||||
|
<button type="button" class="btn btn-dark" style="flex: 1;"
|
||||||
|
id="confirmReassign">Riassegna</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
<script src="/js/api.js"></script>
|
<script src="/js/api.js"></script>
|
||||||
<script src="/js/utils.js"></script>
|
<script src="/js/utils.js"></script>
|
||||||
<script src="/js/nav.js"></script>
|
<script src="/js/nav.js"></script>
|
||||||
|
<script src="/js/modal-logic.js"></script>
|
||||||
<script src="/js/presence.js"></script>
|
<script src="/js/presence.js"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
|
||||||
|
</html>
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html lang="en">
|
<html lang="en">
|
||||||
|
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
@@ -7,31 +8,33 @@
|
|||||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg">
|
<link rel="icon" type="image/svg+xml" href="/favicon.svg">
|
||||||
<link rel="stylesheet" href="/css/styles.css">
|
<link rel="stylesheet" href="/css/styles.css">
|
||||||
</head>
|
</head>
|
||||||
|
|
||||||
<body>
|
<body>
|
||||||
<aside class="sidebar">
|
<aside class="sidebar">
|
||||||
<div class="sidebar-header">
|
<div class="sidebar-header">
|
||||||
<h1>Parking Manager</h1>
|
<h1>Gestione Parcheggi</h1>
|
||||||
</div>
|
</div>
|
||||||
<nav class="sidebar-nav"></nav>
|
<nav class="sidebar-nav"></nav>
|
||||||
<div class="sidebar-footer">
|
<div class="sidebar-footer">
|
||||||
<div class="user-menu">
|
<div class="user-menu">
|
||||||
<button class="user-button" id="userMenuButton">
|
<button class="user-button" id="userMenuButton">
|
||||||
<div class="user-avatar">
|
<div class="user-avatar">
|
||||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor"
|
||||||
|
stroke-width="2">
|
||||||
<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"></path>
|
<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"></path>
|
||||||
<circle cx="12" cy="7" r="4"></circle>
|
<circle cx="12" cy="7" r="4"></circle>
|
||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
<div class="user-info">
|
<div class="user-info">
|
||||||
<div class="user-name" id="userName">Loading...</div>
|
<div class="user-name" id="userName">Caricamento...</div>
|
||||||
<div class="user-role" id="userRole">-</div>
|
<div class="user-role" id="userRole">-</div>
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
<div class="user-dropdown" id="userDropdown" style="display: none;">
|
<div class="user-dropdown" id="userDropdown" style="display: none;">
|
||||||
<a href="/profile" class="dropdown-item">Profile</a>
|
<a href="/profile" class="dropdown-item">Profilo</a>
|
||||||
<a href="/settings" class="dropdown-item">Settings</a>
|
<a href="/settings" class="dropdown-item">Impostazioni</a>
|
||||||
<hr class="dropdown-divider">
|
<hr class="dropdown-divider">
|
||||||
<button class="dropdown-item" id="logoutButton">Logout</button>
|
<button class="dropdown-item" id="logoutButton">Esci</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -39,43 +42,44 @@
|
|||||||
|
|
||||||
<main class="main-content">
|
<main class="main-content">
|
||||||
<header class="page-header">
|
<header class="page-header">
|
||||||
<h2>Profile</h2>
|
<h2>Profilo</h2>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div class="content-wrapper">
|
<div class="content-wrapper">
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<div class="card-header">
|
<div class="card-header">
|
||||||
<h3>Personal Information</h3>
|
<h3>Informazioni Personali</h3>
|
||||||
</div>
|
</div>
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<!-- LDAP Notice -->
|
<!-- LDAP Notice -->
|
||||||
<div id="ldapNotice" class="form-notice" style="display: none; margin-bottom: 1rem;">
|
<div id="ldapNotice" class="form-notice" style="display: none; margin-bottom: 1rem;">
|
||||||
<small>Your account is managed by LDAP. Some information cannot be changed here.</small>
|
<small>Il tuo account è gestito da LDAP. Alcune informazioni non possono essere modificate
|
||||||
|
qui.</small>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<form id="profileForm">
|
<form id="profileForm">
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="name">Full Name</label>
|
<label for="name">Nome Completo</label>
|
||||||
<input type="text" id="name" required>
|
<input type="text" id="name" required>
|
||||||
<small id="nameHelp" class="text-muted" style="display: none;">Managed by LDAP</small>
|
<small id="nameHelp" class="text-muted" style="display: none;">Gestito da LDAP</small>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="email">Email</label>
|
<label for="email">Email</label>
|
||||||
<input type="email" id="email" disabled>
|
<input type="email" id="email" disabled>
|
||||||
<small class="text-muted">Email cannot be changed</small>
|
<small class="text-muted">L'email non può essere modificata</small>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="role">Role</label>
|
<label for="role">Ruolo</label>
|
||||||
<input type="text" id="role" disabled>
|
<input type="text" id="role" disabled>
|
||||||
<small class="text-muted">Role is assigned by your administrator</small>
|
<small class="text-muted">Il ruolo è assegnato dal tuo amministratore</small>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="manager">Manager</label>
|
<label for="manager">Manager</label>
|
||||||
<input type="text" id="manager" disabled>
|
<input type="text" id="manager" disabled>
|
||||||
<small class="text-muted">Your manager is assigned by the administrator</small>
|
<small class="text-muted">Il tuo manager è assegnato dall'amministratore</small>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-actions" id="profileActions">
|
<div class="form-actions" id="profileActions">
|
||||||
<button type="submit" class="btn btn-dark">Save Changes</button>
|
<button type="submit" class="btn btn-dark">Salva Modifiche</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
@@ -84,25 +88,25 @@
|
|||||||
<!-- Password section - hidden for LDAP users -->
|
<!-- Password section - hidden for LDAP users -->
|
||||||
<div class="card" id="passwordCard">
|
<div class="card" id="passwordCard">
|
||||||
<div class="card-header">
|
<div class="card-header">
|
||||||
<h3>Change Password</h3>
|
<h3>Cambia Password</h3>
|
||||||
</div>
|
</div>
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<form id="passwordForm">
|
<form id="passwordForm">
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="currentPassword">Current Password</label>
|
<label for="currentPassword">Password Attuale</label>
|
||||||
<input type="password" id="currentPassword" required>
|
<input type="password" id="currentPassword" required>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="newPassword">New Password</label>
|
<label for="newPassword">Nuova Password</label>
|
||||||
<input type="password" id="newPassword" required minlength="8">
|
<input type="password" id="newPassword" required minlength="8">
|
||||||
<small>Minimum 8 characters</small>
|
<small>Minimo 8 caratteri</small>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="confirmPassword">Confirm New Password</label>
|
<label for="confirmPassword">Conferma Nuova Password</label>
|
||||||
<input type="password" id="confirmPassword" required>
|
<input type="password" id="confirmPassword" required>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-actions">
|
<div class="form-actions">
|
||||||
<button type="submit" class="btn btn-dark">Change Password</button>
|
<button type="submit" class="btn btn-dark">Cambia Password</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
@@ -135,7 +139,7 @@
|
|||||||
document.getElementById('name').value = profile.name || '';
|
document.getElementById('name').value = profile.name || '';
|
||||||
document.getElementById('email').value = profile.email;
|
document.getElementById('email').value = profile.email;
|
||||||
document.getElementById('role').value = profile.role;
|
document.getElementById('role').value = profile.role;
|
||||||
document.getElementById('manager').value = profile.manager_name || 'None';
|
document.getElementById('manager').value = profile.manager_name || 'Nessuno';
|
||||||
|
|
||||||
// LDAP mode adjustments
|
// LDAP mode adjustments
|
||||||
if (isLdapUser) {
|
if (isLdapUser) {
|
||||||
@@ -154,7 +158,7 @@
|
|||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|
||||||
if (isLdapUser) {
|
if (isLdapUser) {
|
||||||
utils.showMessage('Profile is managed by LDAP', 'error');
|
utils.showMessage('Il profilo è gestito da LDAP', 'error');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -164,13 +168,13 @@
|
|||||||
|
|
||||||
const response = await api.put('/api/users/me/profile', data);
|
const response = await api.put('/api/users/me/profile', data);
|
||||||
if (response && response.ok) {
|
if (response && response.ok) {
|
||||||
utils.showMessage('Profile updated successfully', 'success');
|
utils.showMessage('Profilo aggiornato con successo', 'success');
|
||||||
// Update nav display
|
// Update nav display
|
||||||
const nameEl = document.getElementById('userName');
|
const nameEl = document.getElementById('userName');
|
||||||
if (nameEl) nameEl.textContent = data.name;
|
if (nameEl) nameEl.textContent = data.name;
|
||||||
} else {
|
} else {
|
||||||
const error = await response.json();
|
const error = await response.json();
|
||||||
utils.showMessage(error.detail || 'Failed to update profile', 'error');
|
utils.showMessage(error.detail || 'Impossibile aggiornare il profilo', 'error');
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -182,7 +186,7 @@
|
|||||||
const confirmPassword = document.getElementById('confirmPassword').value;
|
const confirmPassword = document.getElementById('confirmPassword').value;
|
||||||
|
|
||||||
if (newPassword !== confirmPassword) {
|
if (newPassword !== confirmPassword) {
|
||||||
utils.showMessage('Passwords do not match', 'error');
|
utils.showMessage('Le password non corrispondono', 'error');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -193,14 +197,15 @@
|
|||||||
|
|
||||||
const response = await api.post('/api/users/me/change-password', data);
|
const response = await api.post('/api/users/me/change-password', data);
|
||||||
if (response && response.ok) {
|
if (response && response.ok) {
|
||||||
utils.showMessage('Password changed successfully', 'success');
|
utils.showMessage('Password cambiata con successo', 'success');
|
||||||
document.getElementById('passwordForm').reset();
|
document.getElementById('passwordForm').reset();
|
||||||
} else {
|
} else {
|
||||||
const error = await response.json();
|
const error = await response.json();
|
||||||
utils.showMessage(error.detail || 'Failed to change password', 'error');
|
utils.showMessage(error.detail || 'Impossibile cambiare la password', 'error');
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
|
||||||
|
</html>
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html lang="en">
|
<html lang="en">
|
||||||
|
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
@@ -7,19 +8,20 @@
|
|||||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg">
|
<link rel="icon" type="image/svg+xml" href="/favicon.svg">
|
||||||
<link rel="stylesheet" href="/css/styles.css">
|
<link rel="stylesheet" href="/css/styles.css">
|
||||||
</head>
|
</head>
|
||||||
|
|
||||||
<body>
|
<body>
|
||||||
<div class="auth-container">
|
<div class="auth-container">
|
||||||
<div class="auth-card">
|
<div class="auth-card">
|
||||||
<div class="auth-header">
|
<div class="auth-header">
|
||||||
<h1>Create Account</h1>
|
<h1>Crea Account</h1>
|
||||||
<p>Sign up for a new account</p>
|
<p>Registrati per un nuovo account</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div id="errorMessage"></div>
|
<div id="errorMessage"></div>
|
||||||
|
|
||||||
<form id="registerForm">
|
<form id="registerForm">
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="name">Full Name</label>
|
<label for="name">Nome Completo</label>
|
||||||
<input type="text" id="name" required autocomplete="name">
|
<input type="text" id="name" required autocomplete="name">
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
@@ -29,13 +31,13 @@
|
|||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="password">Password</label>
|
<label for="password">Password</label>
|
||||||
<input type="password" id="password" required autocomplete="new-password" minlength="8">
|
<input type="password" id="password" required autocomplete="new-password" minlength="8">
|
||||||
<small>Minimum 8 characters</small>
|
<small>Minimo 8 caratteri</small>
|
||||||
</div>
|
</div>
|
||||||
<button type="submit" class="btn btn-dark btn-full">Create Account</button>
|
<button type="submit" class="btn btn-dark btn-full">Crea Account</button>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
<div class="auth-footer">
|
<div class="auth-footer">
|
||||||
Already have an account? <a href="/login">Sign in</a>
|
Hai già un account? <a href="/login">Accedi</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -85,4 +87,5 @@
|
|||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
|
||||||
|
</html>
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html lang="en">
|
<html lang="en">
|
||||||
|
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
@@ -7,31 +8,33 @@
|
|||||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg">
|
<link rel="icon" type="image/svg+xml" href="/favicon.svg">
|
||||||
<link rel="stylesheet" href="/css/styles.css">
|
<link rel="stylesheet" href="/css/styles.css">
|
||||||
</head>
|
</head>
|
||||||
|
|
||||||
<body>
|
<body>
|
||||||
<aside class="sidebar">
|
<aside class="sidebar">
|
||||||
<div class="sidebar-header">
|
<div class="sidebar-header">
|
||||||
<h1>Parking Manager</h1>
|
<h1>Gestione Parcheggi</h1>
|
||||||
</div>
|
</div>
|
||||||
<nav class="sidebar-nav"></nav>
|
<nav class="sidebar-nav"></nav>
|
||||||
<div class="sidebar-footer">
|
<div class="sidebar-footer">
|
||||||
<div class="user-menu">
|
<div class="user-menu">
|
||||||
<button class="user-button" id="userMenuButton">
|
<button class="user-button" id="userMenuButton">
|
||||||
<div class="user-avatar">
|
<div class="user-avatar">
|
||||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor"
|
||||||
|
stroke-width="2">
|
||||||
<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"></path>
|
<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"></path>
|
||||||
<circle cx="12" cy="7" r="4"></circle>
|
<circle cx="12" cy="7" r="4"></circle>
|
||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
<div class="user-info">
|
<div class="user-info">
|
||||||
<div class="user-name" id="userName">Loading...</div>
|
<div class="user-name" id="userName">Caricamento...</div>
|
||||||
<div class="user-role" id="userRole">-</div>
|
<div class="user-role" id="userRole">-</div>
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
<div class="user-dropdown" id="userDropdown" style="display: none;">
|
<div class="user-dropdown" id="userDropdown" style="display: none;">
|
||||||
<a href="/profile" class="dropdown-item">Profile</a>
|
<a href="/profile" class="dropdown-item">Profilo</a>
|
||||||
<a href="/settings" class="dropdown-item">Settings</a>
|
<a href="/settings" class="dropdown-item">Impostazioni</a>
|
||||||
<hr class="dropdown-divider">
|
<hr class="dropdown-divider">
|
||||||
<button class="dropdown-item" id="logoutButton">Logout</button>
|
<button class="dropdown-item" id="logoutButton">Esci</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -39,58 +42,41 @@
|
|||||||
|
|
||||||
<main class="main-content">
|
<main class="main-content">
|
||||||
<header class="page-header">
|
<header class="page-header">
|
||||||
<h2>Settings</h2>
|
<h2>Impostazioni</h2>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div class="content-wrapper">
|
<div class="content-wrapper">
|
||||||
<div class="card">
|
|
||||||
<div class="card-header">
|
|
||||||
<h3>Preferences</h3>
|
|
||||||
</div>
|
|
||||||
<div class="card-body">
|
|
||||||
<form id="settingsForm">
|
|
||||||
<div class="form-group">
|
|
||||||
<label for="weekStartDay">Week Starts On</label>
|
|
||||||
<select id="weekStartDay">
|
|
||||||
<option value="0">Sunday</option>
|
|
||||||
<option value="1">Monday</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div class="form-actions">
|
|
||||||
<button type="submit" class="btn btn-dark">Save Settings</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<div class="card-header">
|
<div class="card-header">
|
||||||
<h3>Parking Notifications</h3>
|
<h3>Notifiche Parcheggio</h3>
|
||||||
</div>
|
</div>
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<form id="notificationForm">
|
<form id="notificationForm">
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label class="toggle-label">
|
<label class="toggle-label">
|
||||||
<span>Weekly Summary</span>
|
<span>Riepilogo Settimanale</span>
|
||||||
<label class="toggle-switch">
|
<label class="toggle-switch">
|
||||||
<input type="checkbox" id="notifyWeeklyParking">
|
<input type="checkbox" id="notifyWeeklyParking">
|
||||||
<span class="toggle-slider"></span>
|
<span class="toggle-slider"></span>
|
||||||
</label>
|
</label>
|
||||||
</label>
|
</label>
|
||||||
<small class="text-muted">Receive weekly parking assignments summary every Friday at 12:00</small>
|
<small class="text-muted">Ricevi il riepilogo settimanale delle assegnazioni parcheggio ogni
|
||||||
|
Venerdì alle 12:00</small>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label class="toggle-label">
|
<label class="toggle-label">
|
||||||
<span>Daily Reminder</span>
|
<span>Promemoria Giornaliero</span>
|
||||||
<label class="toggle-switch">
|
<label class="toggle-switch">
|
||||||
<input type="checkbox" id="notifyDailyParking">
|
<input type="checkbox" id="notifyDailyParking">
|
||||||
<span class="toggle-slider"></span>
|
<span class="toggle-slider"></span>
|
||||||
</label>
|
</label>
|
||||||
</label>
|
</label>
|
||||||
<small class="text-muted">Receive daily parking reminder on working days</small>
|
<small class="text-muted">Ricevi promemoria giornaliero nei giorni lavorativi</small>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group" id="dailyTimeGroup" style="margin-left: 1rem;">
|
<div class="form-group" id="dailyTimeGroup" style="margin-left: 1rem;">
|
||||||
<label>Reminder Time</label>
|
<label>Orario Promemoria</label>
|
||||||
<div style="display: flex; gap: 0.5rem; align-items: center;">
|
<div style="display: flex; gap: 0.5rem; align-items: center;">
|
||||||
<select id="notifyDailyHour" style="width: 80px;">
|
<select id="notifyDailyHour" style="width: 80px;">
|
||||||
<!-- Hours populated by JS -->
|
<!-- Hours populated by JS -->
|
||||||
@@ -106,16 +92,17 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label class="toggle-label">
|
<label class="toggle-label">
|
||||||
<span>Assignment Changes</span>
|
<span>Cambiamenti Assegnazione</span>
|
||||||
<label class="toggle-switch">
|
<label class="toggle-switch">
|
||||||
<input type="checkbox" id="notifyParkingChanges">
|
<input type="checkbox" id="notifyParkingChanges">
|
||||||
<span class="toggle-slider"></span>
|
<span class="toggle-slider"></span>
|
||||||
</label>
|
</label>
|
||||||
</label>
|
</label>
|
||||||
<small class="text-muted">Receive immediate notifications when your parking assignment changes</small>
|
<small class="text-muted">Ricevi notifiche immediate quando la tua assegnazione del
|
||||||
|
parcheggio cambia</small>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-actions">
|
<div class="form-actions">
|
||||||
<button type="submit" class="btn btn-dark">Save Notifications</button>
|
<button type="submit" class="btn btn-dark">Salva Notifiche</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
@@ -151,7 +138,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function populateForm() {
|
function populateForm() {
|
||||||
document.getElementById('weekStartDay').value = currentUser.week_start_day || 0;
|
// Notification settings
|
||||||
|
|
||||||
// Notification settings
|
// Notification settings
|
||||||
document.getElementById('notifyWeeklyParking').checked = currentUser.notify_weekly_parking !== 0;
|
document.getElementById('notifyWeeklyParking').checked = currentUser.notify_weekly_parking !== 0;
|
||||||
@@ -170,22 +157,7 @@
|
|||||||
|
|
||||||
function setupEventListeners() {
|
function setupEventListeners() {
|
||||||
// Settings form
|
// Settings form
|
||||||
document.getElementById('settingsForm').addEventListener('submit', async (e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
|
|
||||||
const data = {
|
|
||||||
week_start_day: parseInt(document.getElementById('weekStartDay').value)
|
|
||||||
};
|
|
||||||
|
|
||||||
const response = await api.put('/api/users/me/settings', data);
|
|
||||||
if (response && response.ok) {
|
|
||||||
utils.showMessage('Settings saved successfully', 'success');
|
|
||||||
currentUser = await api.getCurrentUser();
|
|
||||||
} else {
|
|
||||||
const error = await response.json();
|
|
||||||
utils.showMessage(error.detail || 'Failed to save settings', 'error');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Notification form
|
// Notification form
|
||||||
document.getElementById('notificationForm').addEventListener('submit', async (e) => {
|
document.getElementById('notificationForm').addEventListener('submit', async (e) => {
|
||||||
@@ -201,11 +173,11 @@
|
|||||||
|
|
||||||
const response = await api.put('/api/users/me/settings', data);
|
const response = await api.put('/api/users/me/settings', data);
|
||||||
if (response && response.ok) {
|
if (response && response.ok) {
|
||||||
utils.showMessage('Notification settings saved', 'success');
|
utils.showMessage('Impostazioni notifiche salvate', 'success');
|
||||||
currentUser = await api.getCurrentUser();
|
currentUser = await api.getCurrentUser();
|
||||||
} else {
|
} else {
|
||||||
const error = await response.json();
|
const error = await response.json();
|
||||||
utils.showMessage(error.detail || 'Failed to save notifications', 'error');
|
utils.showMessage(error.detail || 'Impossibile salvare le notifiche', 'error');
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -214,4 +186,5 @@
|
|||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
|
||||||
|
</html>
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html lang="en">
|
<html lang="en">
|
||||||
|
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
@@ -7,31 +8,33 @@
|
|||||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg">
|
<link rel="icon" type="image/svg+xml" href="/favicon.svg">
|
||||||
<link rel="stylesheet" href="/css/styles.css">
|
<link rel="stylesheet" href="/css/styles.css">
|
||||||
</head>
|
</head>
|
||||||
|
|
||||||
<body>
|
<body>
|
||||||
<aside class="sidebar">
|
<aside class="sidebar">
|
||||||
<div class="sidebar-header">
|
<div class="sidebar-header">
|
||||||
<h1>Parking Manager</h1>
|
<h1>Gestione Parcheggi</h1>
|
||||||
</div>
|
</div>
|
||||||
<nav class="sidebar-nav"></nav>
|
<nav class="sidebar-nav"></nav>
|
||||||
<div class="sidebar-footer">
|
<div class="sidebar-footer">
|
||||||
<div class="user-menu">
|
<div class="user-menu">
|
||||||
<button class="user-button" id="userMenuButton">
|
<button class="user-button" id="userMenuButton">
|
||||||
<div class="user-avatar">
|
<div class="user-avatar">
|
||||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor"
|
||||||
|
stroke-width="2">
|
||||||
<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"></path>
|
<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"></path>
|
||||||
<circle cx="12" cy="7" r="4"></circle>
|
<circle cx="12" cy="7" r="4"></circle>
|
||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
<div class="user-info">
|
<div class="user-info">
|
||||||
<div class="user-name" id="userName">Loading...</div>
|
<div class="user-name" id="userName">Caricamento...</div>
|
||||||
<div class="user-role" id="userRole">-</div>
|
<div class="user-role" id="userRole">-</div>
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
<div class="user-dropdown" id="userDropdown" style="display: none;">
|
<div class="user-dropdown" id="userDropdown" style="display: none;">
|
||||||
<a href="/profile" class="dropdown-item">Profile</a>
|
<a href="/profile" class="dropdown-item">Profilo</a>
|
||||||
<a href="/settings" class="dropdown-item">Settings</a>
|
<a href="/settings" class="dropdown-item">Impostazioni</a>
|
||||||
<hr class="dropdown-divider">
|
<hr class="dropdown-divider">
|
||||||
<button class="dropdown-item" id="logoutButton">Logout</button>
|
<button class="dropdown-item" id="logoutButton">Esci</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -39,36 +42,46 @@
|
|||||||
|
|
||||||
<main class="main-content">
|
<main class="main-content">
|
||||||
<header class="page-header">
|
<header class="page-header">
|
||||||
<h2>Team Calendar</h2>
|
<h2>Calendario del Team</h2>
|
||||||
<div class="header-actions">
|
<div class="header-actions">
|
||||||
<select id="viewToggle" class="form-select" style="min-width: 100px;">
|
|
||||||
<option value="week">Week</option>
|
|
||||||
<option value="month">Month</option>
|
|
||||||
</select>
|
|
||||||
<select id="managerFilter" class="form-select">
|
|
||||||
<option value="">All Managers</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div class="content-wrapper">
|
<div class="content-wrapper">
|
||||||
<div class="card">
|
<div class="card">
|
||||||
|
<div style="display: flex; gap: 1rem; margin-bottom: 1.5rem;">
|
||||||
|
<select id="viewToggle" class="form-select" style="min-width: 150px;">
|
||||||
|
<option value="week">Settimana</option>
|
||||||
|
<option value="month">Mese</option>
|
||||||
|
</select>
|
||||||
|
<select id="officeFilter" class="form-select" style="min-width: 200px;">
|
||||||
|
<option value="">Tutti gli Uffici</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="office-display-header"
|
||||||
|
style="margin-bottom: 1rem; font-weight: 600; font-size: 1.1rem; color: var(--text);">
|
||||||
|
Ufficio: <span id="currentOfficeNameDisplay" style="color: var(--primary);">Loading...</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="calendar-header">
|
<div class="calendar-header">
|
||||||
<button class="btn-icon" id="prevWeek">
|
<button class="btn-icon" id="prevWeek">
|
||||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor"
|
||||||
|
stroke-width="2">
|
||||||
<polyline points="15 18 9 12 15 6"></polyline>
|
<polyline points="15 18 9 12 15 6"></polyline>
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
<h3 id="currentWeek">Loading...</h3>
|
<h3 id="currentWeek">Caricamento...</h3>
|
||||||
<button class="btn-icon" id="nextWeek">
|
<button class="btn-icon" id="nextWeek">
|
||||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor"
|
||||||
|
stroke-width="2">
|
||||||
<polyline points="9 18 15 12 9 6"></polyline>
|
<polyline points="9 18 15 12 9 6"></polyline>
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="team-calendar-container">
|
<div class="team-calendar-container">
|
||||||
<table class="team-calendar-table" id="teamCalendarTable">
|
<table class="team-calendar team-calendar-table" id="teamCalendarTable">
|
||||||
<thead>
|
<thead>
|
||||||
<tr id="calendarHeader"></tr>
|
<tr id="calendarHeader"></tr>
|
||||||
</thead>
|
</thead>
|
||||||
@@ -79,71 +92,71 @@
|
|||||||
<div class="legend">
|
<div class="legend">
|
||||||
<div class="legend-item">
|
<div class="legend-item">
|
||||||
<div class="legend-color status-present"></div>
|
<div class="legend-color status-present"></div>
|
||||||
<span>Present</span>
|
<span>In sede</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="legend-item">
|
<div class="legend-item">
|
||||||
<div class="legend-color status-remote"></div>
|
<div class="legend-color status-remote"></div>
|
||||||
<span>Remote</span>
|
<span>Remoto</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="legend-item">
|
<div class="legend-item">
|
||||||
<div class="legend-color status-absent"></div>
|
<div class="legend-color status-absent"></div>
|
||||||
<span>Absent</span>
|
<span>Assente</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
<!-- Day Status Modal -->
|
<!-- Day Status Modal (Shared Structure) -->
|
||||||
<div class="modal" id="dayModal" style="display: none;">
|
<div class="modal" id="dayModal" style="display: none;">
|
||||||
<div class="modal-content modal-small">
|
<div class="modal-content modal-small">
|
||||||
<div class="modal-header">
|
<div class="modal-header">
|
||||||
<h3 id="dayModalTitle">Mark Presence</h3>
|
<h3 id="dayModalTitle">Segna Presenza</h3>
|
||||||
<button class="modal-close" id="closeDayModal">×</button>
|
<button class="modal-close" id="closeDayModal">×</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-body">
|
<div class="modal-body">
|
||||||
<p id="dayModalUser" style="margin-bottom: 1rem; font-weight: 500;"></p>
|
<p id="dayModalUser" style="display:none; margin-bottom: 1rem; font-weight: 500;"></p>
|
||||||
<div class="status-buttons">
|
<div class="status-buttons">
|
||||||
<button class="status-btn" data-status="present">
|
<button class="status-btn" data-status="present">
|
||||||
<div class="status-icon status-present"></div>
|
<div class="status-icon status-present"></div>
|
||||||
<span>Present</span>
|
<span>In sede</span>
|
||||||
</button>
|
</button>
|
||||||
<button class="status-btn" data-status="remote">
|
<button class="status-btn" data-status="remote">
|
||||||
<div class="status-icon status-remote"></div>
|
<div class="status-icon status-remote"></div>
|
||||||
<span>Remote</span>
|
<span>Remoto</span>
|
||||||
</button>
|
</button>
|
||||||
<button class="status-btn" data-status="absent">
|
<button class="status-btn" data-status="absent">
|
||||||
<div class="status-icon status-absent"></div>
|
<div class="status-icon status-absent"></div>
|
||||||
<span>Absent</span>
|
<span>Assente</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div id="parkingSection" style="display: none; margin-top: 1rem; padding-top: 1rem; border-top: 1px solid var(--border);">
|
<button class="btn btn-secondary btn-full" id="clearDayBtn" style="margin-top: 1rem;">Cancella
|
||||||
<div id="parkingInfo" style="margin-bottom: 0.75rem; font-size: 0.9rem;"></div>
|
Presenza</button>
|
||||||
<button class="btn btn-secondary btn-full" id="reassignParkingBtn">Reassign Spot</button>
|
|
||||||
</div>
|
|
||||||
<button class="btn btn-secondary btn-full" id="clearDayBtn" style="margin-top: 1rem;">Clear Presence</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Reassign Parking Modal -->
|
<div id="parkingSection"
|
||||||
<div class="modal" id="reassignModal" style="display: none;">
|
style="display: none; margin-top: 1rem; padding-top: 1rem; border-top: 1px solid var(--border);">
|
||||||
<div class="modal-content modal-small">
|
<div id="parkingInfo" style="margin-bottom: 0.75rem; font-size: 0.9rem;"></div>
|
||||||
<div class="modal-header">
|
|
||||||
<h3>Reassign Parking Spot</h3>
|
<div id="parkingActions" style="display: flex; gap: 0.5rem;">
|
||||||
<button class="modal-close" id="closeReassignModal">×</button>
|
<button class="btn btn-secondary" style="flex: 1;" id="reassignParkingBtn">Cedi posto</button>
|
||||||
</div>
|
<button class="btn btn-secondary" style="flex: 1;" id="releaseParkingBtn">Lascia libero</button>
|
||||||
<div class="modal-body">
|
</div>
|
||||||
<p id="reassignSpotInfo" style="margin-bottom: 1rem; font-size: 0.9rem;"></p>
|
|
||||||
<div class="form-group">
|
<div id="reassignForm"
|
||||||
<label for="reassignUser">Assign to</label>
|
style="display: none; flex-direction: column; gap: 0.5rem; margin-top: 0.5rem;">
|
||||||
<select id="reassignUser" required>
|
<div class="form-group" style="margin-bottom: 0.5rem;">
|
||||||
<option value="">Select user...</option>
|
<label for="reassignUser" style="font-size: 0.9rem;">Assegna a</label>
|
||||||
</select>
|
<select id="reassignUser" class="form-control" style="width: 100%;">
|
||||||
</div>
|
<option value="">Seleziona utente...</option>
|
||||||
<div class="form-actions">
|
</select>
|
||||||
<button type="button" class="btn btn-secondary" id="cancelReassign">Cancel</button>
|
</div>
|
||||||
<button type="button" class="btn btn-dark" id="confirmReassign">Reassign</button>
|
<div style="display: flex; gap: 0.5rem;">
|
||||||
|
<button type="button" class="btn btn-secondary" style="flex: 1;"
|
||||||
|
id="cancelReassign">Annulla</button>
|
||||||
|
<button type="button" class="btn btn-dark" style="flex: 1;"
|
||||||
|
id="confirmReassign">Riassegna</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -152,6 +165,8 @@
|
|||||||
<script src="/js/api.js"></script>
|
<script src="/js/api.js"></script>
|
||||||
<script src="/js/utils.js"></script>
|
<script src="/js/utils.js"></script>
|
||||||
<script src="/js/nav.js"></script>
|
<script src="/js/nav.js"></script>
|
||||||
|
<script src="/js/modal-logic.js"></script>
|
||||||
<script src="/js/team-calendar.js"></script>
|
<script src="/js/team-calendar.js"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
|
||||||
|
</html>
|
||||||
@@ -1,37 +1,40 @@
|
|||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html lang="en">
|
<html lang="en">
|
||||||
|
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>Team Rules - Parking Manager</title>
|
<title>Regole Parcheggio - Parking Manager</title>
|
||||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg">
|
<link rel="icon" type="image/svg+xml" href="/favicon.svg">
|
||||||
<link rel="stylesheet" href="/css/styles.css">
|
<link rel="stylesheet" href="/css/styles.css">
|
||||||
</head>
|
</head>
|
||||||
|
|
||||||
<body>
|
<body>
|
||||||
<aside class="sidebar">
|
<aside class="sidebar">
|
||||||
<div class="sidebar-header">
|
<div class="sidebar-header">
|
||||||
<h1>Parking Manager</h1>
|
<h1>Gestione Parcheggi</h1>
|
||||||
</div>
|
</div>
|
||||||
<nav class="sidebar-nav"></nav>
|
<nav class="sidebar-nav"></nav>
|
||||||
<div class="sidebar-footer">
|
<div class="sidebar-footer">
|
||||||
<div class="user-menu">
|
<div class="user-menu">
|
||||||
<button class="user-button" id="userMenuButton">
|
<button class="user-button" id="userMenuButton">
|
||||||
<div class="user-avatar">
|
<div class="user-avatar">
|
||||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor"
|
||||||
|
stroke-width="2">
|
||||||
<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"></path>
|
<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"></path>
|
||||||
<circle cx="12" cy="7" r="4"></circle>
|
<circle cx="12" cy="7" r="4"></circle>
|
||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
<div class="user-info">
|
<div class="user-info">
|
||||||
<div class="user-name" id="userName">Loading...</div>
|
<div class="user-name" id="userName">Caricamento...</div>
|
||||||
<div class="user-role" id="userRole">-</div>
|
<div class="user-role" id="userRole">-</div>
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
<div class="user-dropdown" id="userDropdown" style="display: none;">
|
<div class="user-dropdown" id="userDropdown" style="display: none;">
|
||||||
<a href="/profile" class="dropdown-item">Profile</a>
|
<a href="/profile" class="dropdown-item">Profilo</a>
|
||||||
<a href="/settings" class="dropdown-item">Settings</a>
|
<a href="/settings" class="dropdown-item">Impostazioni</a>
|
||||||
<hr class="dropdown-divider">
|
<hr class="dropdown-divider">
|
||||||
<button class="dropdown-item" id="logoutButton">Logout</button>
|
<button class="dropdown-item" id="logoutButton">Esci</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -39,100 +42,120 @@
|
|||||||
|
|
||||||
<main class="main-content">
|
<main class="main-content">
|
||||||
<header class="page-header">
|
<header class="page-header">
|
||||||
<h2>Team Rules</h2>
|
<h2>Regole Parcheggio</h2>
|
||||||
<div class="header-actions">
|
<div class="header-actions">
|
||||||
<select id="managerSelect" class="form-select">
|
|
||||||
<option value="">Select Manager</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div class="content-wrapper" id="rulesContent" style="display: none;">
|
<div class="content-wrapper">
|
||||||
<!-- Weekly Closing Days -->
|
<!-- Office Selection Card -->
|
||||||
<div class="card">
|
<div class="card" id="officeSelectionCard" style="margin-bottom: 1.5rem;">
|
||||||
<div class="card-header">
|
<div style="display: flex; align-items: center; gap: 1rem;">
|
||||||
<h3>Weekly Closing Days</h3>
|
<label for="officeSelect" style="font-weight: 500; min-width: max-content;">Seleziona
|
||||||
|
Ufficio:</label>
|
||||||
|
<select id="officeSelect" class="form-select" style="flex: 1; max-width: 300px;">
|
||||||
|
<option value="">Seleziona Ufficio</option>
|
||||||
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div class="card-body">
|
</div>
|
||||||
<p class="text-muted" style="margin-bottom: 1rem;">Days of the week parking is unavailable</p>
|
|
||||||
<div class="weekday-checkboxes" id="weeklyClosingDays">
|
<div id="rulesContent" style="display: none;">
|
||||||
<label><input type="checkbox" data-weekday="0"> Sunday</label>
|
<!-- Weekly Closing Days -->
|
||||||
<label><input type="checkbox" data-weekday="1"> Monday</label>
|
<div class="card">
|
||||||
<label><input type="checkbox" data-weekday="2"> Tuesday</label>
|
<div class="card-header">
|
||||||
<label><input type="checkbox" data-weekday="3"> Wednesday</label>
|
<h3>Giorni di Chiusura Settimanale</h3>
|
||||||
<label><input type="checkbox" data-weekday="4"> Thursday</label>
|
<button class="btn btn-primary btn-sm" id="saveWeeklyClosingDaysBtn">Salva</button>
|
||||||
<label><input type="checkbox" data-weekday="5"> Friday</label>
|
</div>
|
||||||
<label><input type="checkbox" data-weekday="6"> Saturday</label>
|
<div class="card-body">
|
||||||
|
<p class="text-muted" style="margin-bottom: 1rem;">Giorni della settimana in cui il parcheggio
|
||||||
|
non è
|
||||||
|
disponibile</p>
|
||||||
|
<div class="weekday-checkboxes" id="weeklyClosingDays">
|
||||||
|
<label><input type="checkbox" data-weekday="1"> Lunedì</label>
|
||||||
|
<label><input type="checkbox" data-weekday="2"> Martedì</label>
|
||||||
|
<label><input type="checkbox" data-weekday="3"> Mercoledì</label>
|
||||||
|
<label><input type="checkbox" data-weekday="4"> Giovedì</label>
|
||||||
|
<label><input type="checkbox" data-weekday="5"> Venerdì</label>
|
||||||
|
<label><input type="checkbox" data-weekday="6"> Sabato</label>
|
||||||
|
<label><input type="checkbox" data-weekday="0"> Domenica</label>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Specific Closing Days -->
|
<!-- Specific Closing Days -->
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<div class="card-header">
|
<div class="card-header">
|
||||||
<h3>Specific Closing Days</h3>
|
<h3>Giorni di Chiusura Specifici</h3>
|
||||||
<button class="btn btn-secondary btn-sm" id="addClosingDayBtn">Add</button>
|
<button class="btn btn-secondary btn-sm" id="addClosingDayBtn">Aggiungi</button>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<p class="text-muted">Date specifiche in cui il parcheggio non è disponibile (festività, ecc.)
|
||||||
|
</p>
|
||||||
|
<div id="closingDaysList" class="rules-list"></div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="card-body">
|
|
||||||
<p class="text-muted">Specific dates when parking is unavailable (holidays, etc.)</p>
|
|
||||||
<div id="closingDaysList" class="rules-list"></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Parking Guarantees -->
|
<!-- Parking Guarantees -->
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<div class="card-header">
|
<div class="card-header">
|
||||||
<h3>Parking Guarantees</h3>
|
<h3>Garanzie di Parcheggio</h3>
|
||||||
<button class="btn btn-secondary btn-sm" id="addGuaranteeBtn">Add</button>
|
<button class="btn btn-secondary btn-sm" id="addGuaranteeBtn">Aggiungi</button>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<p class="text-muted">Utenti a cui è garantito un posto auto quando sono presenti</p>
|
||||||
|
<div id="guaranteesList" class="rules-list"></div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="card-body">
|
|
||||||
<p class="text-muted">Users guaranteed a parking spot when present</p>
|
|
||||||
<div id="guaranteesList" class="rules-list"></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Parking Exclusions -->
|
<!-- Parking Exclusions -->
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<div class="card-header">
|
<div class="card-header">
|
||||||
<h3>Parking Exclusions</h3>
|
<h3>Esclusioni Parcheggio</h3>
|
||||||
<button class="btn btn-secondary btn-sm" id="addExclusionBtn">Add</button>
|
<button class="btn btn-secondary btn-sm" id="addExclusionBtn">Aggiungi</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<p class="text-muted">Users excluded from parking assignment</p>
|
<p class="text-muted">Utenti esclusi dall'assegnazione del parcheggio</p>
|
||||||
<div id="exclusionsList" class="rules-list"></div>
|
<div id="exclusionsList" class="rules-list"></div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="content-wrapper" id="noManagerMessage">
|
<div id="noOfficeMessage">
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<div class="card-body text-center">
|
<div class="card-body text-center">
|
||||||
<p>Select a manager to manage their parking rules</p>
|
<p>Seleziona un ufficio sopra per gestirne le regole di parcheggio</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
<!-- Add Closing Day Modal -->
|
<!-- Add Closing Day Modal -->
|
||||||
<div class="modal" id="closingDayModal" style="display: none;">
|
<div class="modal" id="closingDayModal" style="display: none;">
|
||||||
<div class="modal-content modal-small">
|
<div class="modal-content modal-small">
|
||||||
<div class="modal-header">
|
<div class="modal-header">
|
||||||
<h3>Add Closing Day</h3>
|
<h3>Aggiungi Giorno di Chiusura</h3>
|
||||||
<button class="modal-close" id="closeClosingDayModal">×</button>
|
<button class="modal-close" id="closeClosingDayModal">×</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-body">
|
<div class="modal-body">
|
||||||
<form id="closingDayForm">
|
<form id="closingDayForm">
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="closingDate">Date</label>
|
<label for="closingDate">Data Inizio</label>
|
||||||
<input type="date" id="closingDate" required>
|
<input type="date" id="closingDate" required>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="closingReason">Reason (optional)</label>
|
<label for="closingEndDate">Data Fine (opzionale)</label>
|
||||||
<input type="text" id="closingReason" placeholder="e.g., Company holiday">
|
<input type="date" id="closingEndDate">
|
||||||
|
<small>Lascia vuoto per singolo giorno</small>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="closingReason">Motivo (opzionale)</label>
|
||||||
|
<input type="text" id="closingReason" placeholder="es. Ferie aziendali">
|
||||||
</div>
|
</div>
|
||||||
<div class="form-actions">
|
<div class="form-actions">
|
||||||
<button type="button" class="btn btn-secondary" id="cancelClosingDay">Cancel</button>
|
<button type="button" class="btn btn-secondary" id="cancelClosingDay">Annulla</button>
|
||||||
<button type="submit" class="btn btn-dark">Add</button>
|
<button type="submit" class="btn btn-dark">Aggiungi</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
@@ -143,30 +166,33 @@
|
|||||||
<div class="modal" id="guaranteeModal" style="display: none;">
|
<div class="modal" id="guaranteeModal" style="display: none;">
|
||||||
<div class="modal-content modal-small">
|
<div class="modal-content modal-small">
|
||||||
<div class="modal-header">
|
<div class="modal-header">
|
||||||
<h3>Add Parking Guarantee</h3>
|
<h3>Aggiungi Garanzia Parcheggio</h3>
|
||||||
<button class="modal-close" id="closeGuaranteeModal">×</button>
|
<button class="modal-close" id="closeGuaranteeModal">×</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-body">
|
<div class="modal-body">
|
||||||
<form id="guaranteeForm">
|
<form id="guaranteeForm">
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="guaranteeUser">User</label>
|
<label for="guaranteeUser">Utente</label>
|
||||||
<select id="guaranteeUser" required>
|
<select id="guaranteeUser" required>
|
||||||
<option value="">Select user...</option>
|
<option value="">Seleziona utente...</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="guaranteeStartDate">Start Date (optional)</label>
|
<label for="guaranteeStartDate">Data Inizio (opzionale)</label>
|
||||||
<input type="date" id="guaranteeStartDate">
|
<input type="date" id="guaranteeStartDate">
|
||||||
<small>Leave empty for no start limit</small>
|
<small>Lascia vuoto per nessun limite inziale</small>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="guaranteeEndDate">End Date (optional)</label>
|
|
||||||
<input type="date" id="guaranteeEndDate">
|
<input type="date" id="guaranteeEndDate">
|
||||||
<small>Leave empty for no end limit</small>
|
<small>Lascia vuoto per nessun limite finale</small>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="guaranteeNotes">Note (opzionale)</label>
|
||||||
|
<textarea id="guaranteeNotes" class="form-control" rows="2"></textarea>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-actions">
|
<div class="form-actions">
|
||||||
<button type="button" class="btn btn-secondary" id="cancelGuarantee">Cancel</button>
|
<button type="button" class="btn btn-secondary" id="cancelGuarantee">Annulla</button>
|
||||||
<button type="submit" class="btn btn-dark">Add</button>
|
<button type="submit" class="btn btn-dark">Aggiungi</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
@@ -177,30 +203,33 @@
|
|||||||
<div class="modal" id="exclusionModal" style="display: none;">
|
<div class="modal" id="exclusionModal" style="display: none;">
|
||||||
<div class="modal-content modal-small">
|
<div class="modal-content modal-small">
|
||||||
<div class="modal-header">
|
<div class="modal-header">
|
||||||
<h3>Add Parking Exclusion</h3>
|
<h3>Aggiungi Esclusione Parcheggio</h3>
|
||||||
<button class="modal-close" id="closeExclusionModal">×</button>
|
<button class="modal-close" id="closeExclusionModal">×</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-body">
|
<div class="modal-body">
|
||||||
<form id="exclusionForm">
|
<form id="exclusionForm">
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="exclusionUser">User</label>
|
<label for="exclusionUser">Utente</label>
|
||||||
<select id="exclusionUser" required>
|
<select id="exclusionUser" required>
|
||||||
<option value="">Select user...</option>
|
<option value="">Seleziona utente...</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="exclusionStartDate">Start Date (optional)</label>
|
<label for="exclusionStartDate">Data Inizio (opzionale)</label>
|
||||||
<input type="date" id="exclusionStartDate">
|
<input type="date" id="exclusionStartDate">
|
||||||
<small>Leave empty for no start limit</small>
|
<small>Lascia vuoto per nessun limite iniziale</small>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="exclusionEndDate">End Date (optional)</label>
|
|
||||||
<input type="date" id="exclusionEndDate">
|
<input type="date" id="exclusionEndDate">
|
||||||
<small>Leave empty for no end limit</small>
|
<small>Lascia vuoto per nessun limite finale</small>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="exclusionNotes">Note (opzionale)</label>
|
||||||
|
<textarea id="exclusionNotes" class="form-control" rows="2"></textarea>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-actions">
|
<div class="form-actions">
|
||||||
<button type="button" class="btn btn-secondary" id="cancelExclusion">Cancel</button>
|
<button type="button" class="btn btn-secondary" id="cancelExclusion">Annulla</button>
|
||||||
<button type="submit" class="btn btn-dark">Add</button>
|
<button type="submit" class="btn btn-dark">Aggiungi</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
@@ -212,4 +241,5 @@
|
|||||||
<script src="/js/nav.js"></script>
|
<script src="/js/nav.js"></script>
|
||||||
<script src="/js/team-rules.js"></script>
|
<script src="/js/team-rules.js"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
|
||||||
|
</html>
|
||||||
66
main.py
66
main.py
@@ -1,3 +1,6 @@
|
|||||||
|
from dotenv import load_dotenv
|
||||||
|
|
||||||
|
load_dotenv() # Carica le variabili dal file .env
|
||||||
"""
|
"""
|
||||||
Parking Manager Application
|
Parking Manager Application
|
||||||
FastAPI + SQLite + Vanilla JS
|
FastAPI + SQLite + Vanilla JS
|
||||||
@@ -10,11 +13,12 @@ from contextlib import asynccontextmanager
|
|||||||
from slowapi import Limiter, _rate_limit_exceeded_handler
|
from slowapi import Limiter, _rate_limit_exceeded_handler
|
||||||
from slowapi.util import get_remote_address
|
from slowapi.util import get_remote_address
|
||||||
from slowapi.errors import RateLimitExceeded
|
from slowapi.errors import RateLimitExceeded
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
from app import config
|
from app import config
|
||||||
from app.routes.auth import router as auth_router
|
from app.routes.auth import router as auth_router
|
||||||
from app.routes.users import router as users_router
|
from app.routes.users import router as users_router
|
||||||
from app.routes.managers import router as managers_router
|
from app.routes.offices import router as offices_router
|
||||||
from app.routes.presence import router as presence_router
|
from app.routes.presence import router as presence_router
|
||||||
from app.routes.parking import router as parking_router
|
from app.routes.parking import router as parking_router
|
||||||
from database.connection import init_db
|
from database.connection import init_db
|
||||||
@@ -26,11 +30,50 @@ limiter = Limiter(key_func=get_remote_address)
|
|||||||
@asynccontextmanager
|
@asynccontextmanager
|
||||||
async def lifespan(app: FastAPI):
|
async def lifespan(app: FastAPI):
|
||||||
"""Initialize database on startup"""
|
"""Initialize database on startup"""
|
||||||
config.logger.info("Starting Parking Manager application")
|
def log(msg):
|
||||||
|
config.logger.info(f"[{datetime.now().strftime('%Y-%m-%d %H:%M:%S')}] {msg}")
|
||||||
|
|
||||||
|
log("Starting Parking Manager application")
|
||||||
|
|
||||||
|
# Caddy Integration Logs
|
||||||
|
log("--- Caddy Integration & Handshake ---")
|
||||||
|
|
||||||
|
# Step 1: Auth / Forward Auth
|
||||||
|
log("1. Checking Caddy Forward Auth Configuration...")
|
||||||
|
status = "ENABLED (Authelia)" if config.AUTHELIA_ENABLED else "DISABLED (Internal Auth)"
|
||||||
|
log(f" - Auth Mode: {status}")
|
||||||
|
|
||||||
|
if config.AUTHELIA_ENABLED:
|
||||||
|
log(" - Configuring Trusted Headers from Caddy:")
|
||||||
|
log(f" * User: {config.AUTHELIA_HEADER_USER}")
|
||||||
|
log(f" * Name: {config.AUTHELIA_HEADER_NAME}")
|
||||||
|
log(f" * Email: {config.AUTHELIA_HEADER_EMAIL}")
|
||||||
|
log(f" * Groups: {config.AUTHELIA_HEADER_GROUPS}")
|
||||||
|
else:
|
||||||
|
log(" - No trusted headers configured (Standalone mode)")
|
||||||
|
|
||||||
|
# Step 2: CORS / Origins
|
||||||
|
log("2. Configuring Caddy CORS / Origins...")
|
||||||
|
for origin in config.ALLOWED_ORIGINS:
|
||||||
|
log(f" - Trusted Origin: {origin}")
|
||||||
|
|
||||||
init_db()
|
init_db()
|
||||||
config.logger.info("Database initialized")
|
log("3. Database Connection: ESTABLISHED")
|
||||||
|
|
||||||
|
# Step 3: Network / Reachability
|
||||||
|
local_url = f"http://{config.HOST}:{config.PORT}"
|
||||||
|
# Try to find a public URL from allowed origins (excluding localhost/ips)
|
||||||
|
public_candidates = [o for o in config.ALLOWED_ORIGINS if "localhost" not in o and "127.0.0.1" not in o and not o.startswith("*")]
|
||||||
|
reachable_url = public_candidates[0] if public_candidates else local_url.replace("0.0.0.0", "localhost")
|
||||||
|
|
||||||
|
log("4. Finalizing Caddy Handshake...")
|
||||||
|
log(f" - Listening on: {config.HOST}:{config.PORT}")
|
||||||
|
log("--- Handshake Complete ---")
|
||||||
|
|
||||||
|
log(f"feedback: App reachable via Caddy at {reachable_url}")
|
||||||
|
|
||||||
yield
|
yield
|
||||||
config.logger.info("Shutting down Parking Manager application")
|
log("Shutting down Parking Manager application")
|
||||||
|
|
||||||
|
|
||||||
app = FastAPI(title="Parking Manager", version="1.0.0", lifespan=lifespan)
|
app = FastAPI(title="Parking Manager", version="1.0.0", lifespan=lifespan)
|
||||||
@@ -51,13 +94,14 @@ app.add_middleware(
|
|||||||
# API Routes
|
# API Routes
|
||||||
app.include_router(auth_router)
|
app.include_router(auth_router)
|
||||||
app.include_router(users_router)
|
app.include_router(users_router)
|
||||||
app.include_router(managers_router)
|
app.include_router(offices_router)
|
||||||
app.include_router(presence_router)
|
app.include_router(presence_router)
|
||||||
app.include_router(parking_router)
|
app.include_router(parking_router)
|
||||||
|
|
||||||
# Static Files
|
# Static Files
|
||||||
app.mount("/css", StaticFiles(directory=str(config.FRONTEND_DIR / "css")), name="css")
|
app.mount("/css", StaticFiles(directory=str(config.FRONTEND_DIR / "css")), name="css")
|
||||||
app.mount("/js", StaticFiles(directory=str(config.FRONTEND_DIR / "js")), name="js")
|
app.mount("/js", StaticFiles(directory=str(config.FRONTEND_DIR / "js")), name="js")
|
||||||
|
app.mount("/assets", StaticFiles(directory=str(config.FRONTEND_DIR / "assets")), name="assets")
|
||||||
|
|
||||||
|
|
||||||
# Page Routes
|
# Page Routes
|
||||||
@@ -109,6 +153,12 @@ async def admin_users_page():
|
|||||||
return FileResponse(config.FRONTEND_DIR / "pages" / "admin-users.html")
|
return FileResponse(config.FRONTEND_DIR / "pages" / "admin-users.html")
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/admin/offices")
|
||||||
|
async def admin_offices_page():
|
||||||
|
"""Admin Offices page"""
|
||||||
|
return FileResponse(config.FRONTEND_DIR / "pages" / "admin-offices.html")
|
||||||
|
|
||||||
|
|
||||||
@app.get("/profile")
|
@app.get("/profile")
|
||||||
async def profile_page():
|
async def profile_page():
|
||||||
"""Profile page"""
|
"""Profile page"""
|
||||||
@@ -121,6 +171,12 @@ async def settings_page():
|
|||||||
return FileResponse(config.FRONTEND_DIR / "pages" / "settings.html")
|
return FileResponse(config.FRONTEND_DIR / "pages" / "settings.html")
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/parking-settings")
|
||||||
|
async def parking_settings_page():
|
||||||
|
"""Parking Settings page"""
|
||||||
|
return FileResponse(config.FRONTEND_DIR / "pages" / "parking-settings.html")
|
||||||
|
|
||||||
|
|
||||||
@app.get("/favicon.svg")
|
@app.get("/favicon.svg")
|
||||||
async def favicon():
|
async def favicon():
|
||||||
"""Favicon"""
|
"""Favicon"""
|
||||||
|
|||||||
@@ -1,7 +1,12 @@
|
|||||||
fastapi==0.115.5
|
fastapi==0.109.2
|
||||||
uvicorn[standard]==0.32.1
|
uvicorn[standard]==0.27.1
|
||||||
pydantic[email]==2.10.3
|
sqlalchemy==2.0.27
|
||||||
sqlalchemy==2.0.36
|
pydantic[email]==2.6.1
|
||||||
python-jose[cryptography]==3.3.0
|
pydantic-settings==2.2.1
|
||||||
bcrypt==4.2.1
|
python-dotenv==1.0.1
|
||||||
slowapi==0.1.9
|
python-jose[cryptography]==3.3.0
|
||||||
|
bcrypt==4.1.2
|
||||||
|
slowapi==0.1.9
|
||||||
|
python-multipart==0.0.9
|
||||||
|
idna<4,>=2.5
|
||||||
|
email-validator>=2.1.0.post1
|
||||||
|
|||||||
@@ -1,32 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
"""
|
|
||||||
Notification Scheduler Script
|
|
||||||
Run this script via cron every 5 minutes:
|
|
||||||
*/5 * * * * cd /path/to/app && .venv/bin/python run_notifications.py
|
|
||||||
|
|
||||||
This will:
|
|
||||||
- Send presence reminders on Thursday at 12:00 (repeat daily until compiled)
|
|
||||||
- Send weekly parking summaries on Friday at 12:00
|
|
||||||
- Send daily parking reminders at user-configured times
|
|
||||||
- Process queued parking change notifications
|
|
||||||
"""
|
|
||||||
import sys
|
|
||||||
from database.connection import SessionLocal
|
|
||||||
from services.notifications import run_scheduled_notifications
|
|
||||||
|
|
||||||
|
|
||||||
def main():
|
|
||||||
db = SessionLocal()
|
|
||||||
try:
|
|
||||||
print("Running scheduled notifications...")
|
|
||||||
run_scheduled_notifications(db)
|
|
||||||
print("Done.")
|
|
||||||
except Exception as e:
|
|
||||||
print(f"Error: {e}", file=sys.stderr)
|
|
||||||
sys.exit(1)
|
|
||||||
finally:
|
|
||||||
db.close()
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
main()
|
|
||||||
@@ -61,17 +61,16 @@ def authenticate_user(db: Session, email: str, password: str) -> User | None:
|
|||||||
return user
|
return user
|
||||||
|
|
||||||
|
|
||||||
def create_user(db: Session, email: str, password: str, name: str, manager_id: str = None, role: str = "employee") -> User:
|
def create_user(db: Session, email: str, password: str, name: str, role: str = "employee") -> User:
|
||||||
"""Create a new user"""
|
"""Create a new user"""
|
||||||
user = User(
|
user = User(
|
||||||
id=str(uuid.uuid4()),
|
id=str(uuid.uuid4()),
|
||||||
email=email,
|
email=email,
|
||||||
password_hash=hash_password(password),
|
password_hash=hash_password(password),
|
||||||
name=name,
|
name=name,
|
||||||
manager_id=manager_id,
|
|
||||||
role=role,
|
role=role,
|
||||||
created_at=datetime.utcnow().isoformat(),
|
created_at=datetime.utcnow(),
|
||||||
updated_at=datetime.utcnow().isoformat()
|
updated_at=datetime.utcnow()
|
||||||
)
|
)
|
||||||
db.add(user)
|
db.add(user)
|
||||||
db.commit()
|
db.commit()
|
||||||
|
|||||||
@@ -6,11 +6,12 @@ Follows org-stack pattern: direct SMTP send with file fallback when disabled.
|
|||||||
import smtplib
|
import smtplib
|
||||||
from email.mime.text import MIMEText
|
from email.mime.text import MIMEText
|
||||||
from email.mime.multipart import MIMEMultipart
|
from email.mime.multipart import MIMEMultipart
|
||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta, date
|
||||||
from typing import TYPE_CHECKING
|
from typing import TYPE_CHECKING, List
|
||||||
|
|
||||||
from app import config
|
from app import config
|
||||||
from utils.helpers import generate_uuid
|
from utils.helpers import generate_uuid
|
||||||
|
from database.models import NotificationType
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
@@ -72,35 +73,34 @@ def send_email(to_email: str, subject: str, body_html: str, body_text: str = Non
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
def get_week_dates(reference_date: datetime) -> list[datetime]:
|
def get_week_dates(reference_date: date) -> list[date]:
|
||||||
"""Get Monday-Sunday dates for the week containing reference_date"""
|
"""Get Monday-Sunday dates for the week containing reference_date"""
|
||||||
monday = reference_date - timedelta(days=reference_date.weekday())
|
monday = reference_date - timedelta(days=reference_date.weekday())
|
||||||
return [monday + timedelta(days=i) for i in range(7)]
|
return [monday + timedelta(days=i) for i in range(7)]
|
||||||
|
|
||||||
|
|
||||||
def get_next_week_dates(reference_date: datetime) -> list[datetime]:
|
def get_next_week_dates(reference_date: date) -> list[date]:
|
||||||
"""Get Monday-Sunday dates for the week after reference_date"""
|
"""Get Monday-Sunday dates for the week after reference_date"""
|
||||||
days_until_next_monday = 7 - reference_date.weekday()
|
days_until_next_monday = 7 - reference_date.weekday()
|
||||||
next_monday = reference_date + timedelta(days=days_until_next_monday)
|
next_monday = reference_date + timedelta(days=days_until_next_monday)
|
||||||
return [next_monday + timedelta(days=i) for i in range(7)]
|
return [next_monday + timedelta(days=i) for i in range(7)]
|
||||||
|
|
||||||
|
|
||||||
def get_week_reference(date: datetime) -> str:
|
def get_week_reference(date_obj: date) -> str:
|
||||||
"""Get ISO week reference string (e.g., 2024-W48)"""
|
"""Get ISO week reference string (e.g., 2024-W48)"""
|
||||||
return date.strftime("%Y-W%W")
|
return date_obj.strftime("%Y-W%W")
|
||||||
|
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
# Notification sending functions
|
# Notification sending functions
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|
||||||
def notify_parking_assigned(user: "User", date: str, spot_name: str):
|
def notify_parking_assigned(user: "User", assignment_date: date, spot_name: str):
|
||||||
"""Send notification when parking spot is assigned"""
|
"""Send notification when parking spot is assigned"""
|
||||||
if not user.notify_parking_changes:
|
if not user.notify_parking_changes:
|
||||||
return
|
return
|
||||||
|
|
||||||
date_obj = datetime.strptime(date, "%Y-%m-%d")
|
day_name = assignment_date.strftime("%A, %B %d")
|
||||||
day_name = date_obj.strftime("%A, %B %d")
|
|
||||||
|
|
||||||
subject = f"Parking spot assigned for {day_name}"
|
subject = f"Parking spot assigned for {day_name}"
|
||||||
body_html = f"""
|
body_html = f"""
|
||||||
@@ -117,13 +117,12 @@ def notify_parking_assigned(user: "User", date: str, spot_name: str):
|
|||||||
send_email(user.email, subject, body_html)
|
send_email(user.email, subject, body_html)
|
||||||
|
|
||||||
|
|
||||||
def notify_parking_released(user: "User", date: str, spot_name: str):
|
def notify_parking_released(user: "User", assignment_date: date, spot_name: str):
|
||||||
"""Send notification when parking spot is released"""
|
"""Send notification when parking spot is released"""
|
||||||
if not user.notify_parking_changes:
|
if not user.notify_parking_changes:
|
||||||
return
|
return
|
||||||
|
|
||||||
date_obj = datetime.strptime(date, "%Y-%m-%d")
|
day_name = assignment_date.strftime("%A, %B %d")
|
||||||
day_name = date_obj.strftime("%A, %B %d")
|
|
||||||
|
|
||||||
subject = f"Parking spot released for {day_name}"
|
subject = f"Parking spot released for {day_name}"
|
||||||
body_html = f"""
|
body_html = f"""
|
||||||
@@ -139,13 +138,12 @@ def notify_parking_released(user: "User", date: str, spot_name: str):
|
|||||||
send_email(user.email, subject, body_html)
|
send_email(user.email, subject, body_html)
|
||||||
|
|
||||||
|
|
||||||
def notify_parking_reassigned(user: "User", date: str, spot_name: str, new_user_name: str):
|
def notify_parking_reassigned(user: "User", assignment_date: date, spot_name: str, new_user_name: str):
|
||||||
"""Send notification when parking spot is reassigned to someone else"""
|
"""Send notification when parking spot is reassigned to someone else"""
|
||||||
if not user.notify_parking_changes:
|
if not user.notify_parking_changes:
|
||||||
return
|
return
|
||||||
|
|
||||||
date_obj = datetime.strptime(date, "%Y-%m-%d")
|
day_name = assignment_date.strftime("%A, %B %d")
|
||||||
day_name = date_obj.strftime("%A, %B %d")
|
|
||||||
|
|
||||||
subject = f"Parking spot reassigned for {day_name}"
|
subject = f"Parking spot reassigned for {day_name}"
|
||||||
body_html = f"""
|
body_html = f"""
|
||||||
@@ -161,29 +159,29 @@ def notify_parking_reassigned(user: "User", date: str, spot_name: str, new_user_
|
|||||||
send_email(user.email, subject, body_html)
|
send_email(user.email, subject, body_html)
|
||||||
|
|
||||||
|
|
||||||
def send_presence_reminder(user: "User", next_week_dates: list[datetime], db: "Session") -> bool:
|
def send_presence_reminder(user: "User", next_week_dates: List[date], db: "Session") -> bool:
|
||||||
"""Send presence compilation reminder for next week"""
|
"""Send presence compilation reminder for next week"""
|
||||||
from database.models import UserPresence, NotificationLog
|
from database.models import UserPresence, NotificationLog
|
||||||
|
|
||||||
week_ref = get_week_reference(next_week_dates[0])
|
week_ref = get_week_reference(next_week_dates[0])
|
||||||
|
|
||||||
# Check if already sent today for this week
|
# Check if already sent today for this week
|
||||||
today = datetime.now().strftime("%Y-%m-%d")
|
today = datetime.now().date()
|
||||||
existing = db.query(NotificationLog).filter(
|
existing = db.query(NotificationLog).filter(
|
||||||
NotificationLog.user_id == user.id,
|
NotificationLog.user_id == user.id,
|
||||||
NotificationLog.notification_type == "presence_reminder",
|
NotificationLog.notification_type == NotificationType.PRESENCE_REMINDER,
|
||||||
NotificationLog.reference_date == week_ref,
|
NotificationLog.reference_date == week_ref,
|
||||||
NotificationLog.sent_at >= today
|
NotificationLog.sent_at >= datetime.combine(today, datetime.min.time())
|
||||||
).first()
|
).first()
|
||||||
|
|
||||||
if existing:
|
if existing:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
# Check if week is compiled (at least 5 days marked)
|
# Check if week is compiled (at least 5 days marked)
|
||||||
date_strs = [d.strftime("%Y-%m-%d") for d in next_week_dates]
|
# DB stores dates as Date objects now
|
||||||
presences = db.query(UserPresence).filter(
|
presences = db.query(UserPresence).filter(
|
||||||
UserPresence.user_id == user.id,
|
UserPresence.user_id == user.id,
|
||||||
UserPresence.date.in_(date_strs)
|
UserPresence.date.in_(next_week_dates)
|
||||||
).all()
|
).all()
|
||||||
|
|
||||||
if len(presences) >= 5:
|
if len(presences) >= 5:
|
||||||
@@ -211,9 +209,9 @@ def send_presence_reminder(user: "User", next_week_dates: list[datetime], db: "S
|
|||||||
log = NotificationLog(
|
log = NotificationLog(
|
||||||
id=generate_uuid(),
|
id=generate_uuid(),
|
||||||
user_id=user.id,
|
user_id=user.id,
|
||||||
notification_type="presence_reminder",
|
notification_type=NotificationType.PRESENCE_REMINDER,
|
||||||
reference_date=week_ref,
|
reference_date=week_ref,
|
||||||
sent_at=datetime.now().isoformat()
|
sent_at=datetime.utcnow()
|
||||||
)
|
)
|
||||||
db.add(log)
|
db.add(log)
|
||||||
db.commit()
|
db.commit()
|
||||||
@@ -222,7 +220,7 @@ def send_presence_reminder(user: "User", next_week_dates: list[datetime], db: "S
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
def send_weekly_parking_summary(user: "User", next_week_dates: list[datetime], db: "Session") -> bool:
|
def send_weekly_parking_summary(user: "User", next_week_dates: List[date], db: "Session") -> bool:
|
||||||
"""Send weekly parking assignment summary for next week (Friday at 12)"""
|
"""Send weekly parking assignment summary for next week (Friday at 12)"""
|
||||||
from database.models import DailyParkingAssignment, NotificationLog
|
from database.models import DailyParkingAssignment, NotificationLog
|
||||||
from services.parking import get_spot_display_name
|
from services.parking import get_spot_display_name
|
||||||
@@ -235,7 +233,7 @@ def send_weekly_parking_summary(user: "User", next_week_dates: list[datetime], d
|
|||||||
# Check if already sent for this week
|
# Check if already sent for this week
|
||||||
existing = db.query(NotificationLog).filter(
|
existing = db.query(NotificationLog).filter(
|
||||||
NotificationLog.user_id == user.id,
|
NotificationLog.user_id == user.id,
|
||||||
NotificationLog.notification_type == "weekly_parking",
|
NotificationLog.notification_type == NotificationType.WEEKLY_PARKING,
|
||||||
NotificationLog.reference_date == week_ref
|
NotificationLog.reference_date == week_ref
|
||||||
).first()
|
).first()
|
||||||
|
|
||||||
@@ -243,10 +241,9 @@ def send_weekly_parking_summary(user: "User", next_week_dates: list[datetime], d
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
# Get parking assignments for next week
|
# Get parking assignments for next week
|
||||||
date_strs = [d.strftime("%Y-%m-%d") for d in next_week_dates]
|
|
||||||
assignments = db.query(DailyParkingAssignment).filter(
|
assignments = db.query(DailyParkingAssignment).filter(
|
||||||
DailyParkingAssignment.user_id == user.id,
|
DailyParkingAssignment.user_id == user.id,
|
||||||
DailyParkingAssignment.date.in_(date_strs)
|
DailyParkingAssignment.date.in_(next_week_dates)
|
||||||
).all()
|
).all()
|
||||||
|
|
||||||
if not assignments:
|
if not assignments:
|
||||||
@@ -254,11 +251,11 @@ def send_weekly_parking_summary(user: "User", next_week_dates: list[datetime], d
|
|||||||
|
|
||||||
# Build assignment list
|
# Build assignment list
|
||||||
assignment_lines = []
|
assignment_lines = []
|
||||||
|
# a.date is now a date object
|
||||||
for a in sorted(assignments, key=lambda x: x.date):
|
for a in sorted(assignments, key=lambda x: x.date):
|
||||||
date_obj = datetime.strptime(a.date, "%Y-%m-%d")
|
day_name = a.date.strftime("%A")
|
||||||
day_name = date_obj.strftime("%A")
|
spot_name = get_spot_display_name(a.spot_id, a.office_id, db)
|
||||||
spot_name = get_spot_display_name(a.spot_id, a.manager_id, db)
|
assignment_lines.append(f"<li>{day_name}, {a.date.strftime('%B %d')}: Spot {spot_name}</li>")
|
||||||
assignment_lines.append(f"<li>{day_name}, {date_obj.strftime('%B %d')}: Spot {spot_name}</li>")
|
|
||||||
|
|
||||||
start_date = next_week_dates[0].strftime("%B %d")
|
start_date = next_week_dates[0].strftime("%B %d")
|
||||||
end_date = next_week_dates[-1].strftime("%B %d, %Y")
|
end_date = next_week_dates[-1].strftime("%B %d, %Y")
|
||||||
@@ -283,9 +280,9 @@ def send_weekly_parking_summary(user: "User", next_week_dates: list[datetime], d
|
|||||||
log = NotificationLog(
|
log = NotificationLog(
|
||||||
id=generate_uuid(),
|
id=generate_uuid(),
|
||||||
user_id=user.id,
|
user_id=user.id,
|
||||||
notification_type="weekly_parking",
|
notification_type=NotificationType.WEEKLY_PARKING,
|
||||||
reference_date=week_ref,
|
reference_date=week_ref,
|
||||||
sent_at=datetime.now().isoformat()
|
sent_at=datetime.utcnow()
|
||||||
)
|
)
|
||||||
db.add(log)
|
db.add(log)
|
||||||
db.commit()
|
db.commit()
|
||||||
@@ -294,7 +291,7 @@ def send_weekly_parking_summary(user: "User", next_week_dates: list[datetime], d
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
def send_daily_parking_reminder(user: "User", date: datetime, db: "Session") -> bool:
|
def send_daily_parking_reminder(user: "User", date_obj: datetime, db: "Session") -> bool:
|
||||||
"""Send daily parking reminder for a specific date"""
|
"""Send daily parking reminder for a specific date"""
|
||||||
from database.models import DailyParkingAssignment, NotificationLog
|
from database.models import DailyParkingAssignment, NotificationLog
|
||||||
from services.parking import get_spot_display_name
|
from services.parking import get_spot_display_name
|
||||||
@@ -302,12 +299,13 @@ def send_daily_parking_reminder(user: "User", date: datetime, db: "Session") ->
|
|||||||
if not user.notify_daily_parking:
|
if not user.notify_daily_parking:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
date_str = date.strftime("%Y-%m-%d")
|
date_str = date_obj.strftime("%Y-%m-%d")
|
||||||
|
assignment_date = date_obj.date()
|
||||||
|
|
||||||
# Check if already sent for this date
|
# Check if already sent for this date
|
||||||
existing = db.query(NotificationLog).filter(
|
existing = db.query(NotificationLog).filter(
|
||||||
NotificationLog.user_id == user.id,
|
NotificationLog.user_id == user.id,
|
||||||
NotificationLog.notification_type == "daily_parking",
|
NotificationLog.notification_type == NotificationType.DAILY_PARKING,
|
||||||
NotificationLog.reference_date == date_str
|
NotificationLog.reference_date == date_str
|
||||||
).first()
|
).first()
|
||||||
|
|
||||||
@@ -317,14 +315,14 @@ def send_daily_parking_reminder(user: "User", date: datetime, db: "Session") ->
|
|||||||
# Get parking assignment for this date
|
# Get parking assignment for this date
|
||||||
assignment = db.query(DailyParkingAssignment).filter(
|
assignment = db.query(DailyParkingAssignment).filter(
|
||||||
DailyParkingAssignment.user_id == user.id,
|
DailyParkingAssignment.user_id == user.id,
|
||||||
DailyParkingAssignment.date == date_str
|
DailyParkingAssignment.date == assignment_date
|
||||||
).first()
|
).first()
|
||||||
|
|
||||||
if not assignment:
|
if not assignment:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
spot_name = get_spot_display_name(assignment.spot_id, assignment.manager_id, db)
|
spot_name = get_spot_display_name(assignment.spot_id, assignment.office_id, db)
|
||||||
day_name = date.strftime("%A, %B %d")
|
day_name = date_obj.strftime("%A, %B %d")
|
||||||
|
|
||||||
subject = f"Parking reminder for {day_name}"
|
subject = f"Parking reminder for {day_name}"
|
||||||
body_html = f"""
|
body_html = f"""
|
||||||
@@ -343,9 +341,9 @@ def send_daily_parking_reminder(user: "User", date: datetime, db: "Session") ->
|
|||||||
log = NotificationLog(
|
log = NotificationLog(
|
||||||
id=generate_uuid(),
|
id=generate_uuid(),
|
||||||
user_id=user.id,
|
user_id=user.id,
|
||||||
notification_type="daily_parking",
|
notification_type=NotificationType.DAILY_PARKING,
|
||||||
reference_date=date_str,
|
reference_date=date_str,
|
||||||
sent_at=datetime.now().isoformat()
|
sent_at=datetime.utcnow()
|
||||||
)
|
)
|
||||||
db.add(log)
|
db.add(log)
|
||||||
db.commit()
|
db.commit()
|
||||||
@@ -369,18 +367,19 @@ def run_scheduled_notifications(db: "Session"):
|
|||||||
current_hour = now.hour
|
current_hour = now.hour
|
||||||
current_minute = now.minute
|
current_minute = now.minute
|
||||||
current_weekday = now.weekday() # 0=Monday, 6=Sunday
|
current_weekday = now.weekday() # 0=Monday, 6=Sunday
|
||||||
|
today_date = now.date()
|
||||||
|
|
||||||
users = db.query(User).all()
|
users = db.query(User).all()
|
||||||
|
|
||||||
for user in users:
|
for user in users:
|
||||||
# Thursday at 12: Presence reminder
|
# Thursday at 12: Presence reminder
|
||||||
if current_weekday == 3 and current_hour == 12 and current_minute < 5:
|
if current_weekday == 3 and current_hour == 12 and current_minute < 5:
|
||||||
next_week = get_next_week_dates(now)
|
next_week = get_next_week_dates(today_date)
|
||||||
send_presence_reminder(user, next_week, db)
|
send_presence_reminder(user, next_week, db)
|
||||||
|
|
||||||
# Friday at 12: Weekly parking summary
|
# Friday at 12: Weekly parking summary
|
||||||
if current_weekday == 4 and current_hour == 12 and current_minute < 5:
|
if current_weekday == 4 and current_hour == 12 and current_minute < 5:
|
||||||
next_week = get_next_week_dates(now)
|
next_week = get_next_week_dates(today_date)
|
||||||
send_weekly_parking_summary(user, next_week, db)
|
send_weekly_parking_summary(user, next_week, db)
|
||||||
|
|
||||||
# Daily parking reminder at user's preferred time (working days only)
|
# Daily parking reminder at user's preferred time (working days only)
|
||||||
|
|||||||
@@ -1,49 +1,48 @@
|
|||||||
"""
|
"""
|
||||||
Parking Assignment Service
|
Parking Assignment Service
|
||||||
Manager-centric parking spot management with fairness algorithm
|
Office-centric parking spot management with fairness algorithm
|
||||||
|
|
||||||
Key concepts:
|
Key concepts:
|
||||||
- Managers own parking spots (defined by manager_parking_quota)
|
- Offices own parking spots (defined by Office.parking_quota)
|
||||||
- Each manager has a spot prefix (A, B, C...) for display names
|
- Each office has a spot prefix (A, B, C...) for display names
|
||||||
- Spots are named like A1, A2, B1, B2 based on manager prefix
|
- Spots are named like A1, A2, B1, B2 based on office prefix
|
||||||
- Fairness: users with lowest parking_days/presence_days ratio get priority
|
- Fairness: users with lowest parking_days/presence_days ratio get priority
|
||||||
"""
|
"""
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, date, timezone, timedelta
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
from sqlalchemy import or_
|
from sqlalchemy import or_
|
||||||
|
|
||||||
from database.models import (
|
from database.models import (
|
||||||
DailyParkingAssignment, User, UserPresence,
|
DailyParkingAssignment, User, UserPresence, Office,
|
||||||
ParkingGuarantee, ParkingExclusion, ManagerClosingDay, ManagerWeeklyClosingDay
|
ParkingGuarantee, ParkingExclusion, OfficeClosingDay, OfficeWeeklyClosingDay,
|
||||||
|
UserRole, PresenceStatus
|
||||||
)
|
)
|
||||||
from utils.helpers import generate_uuid
|
from utils.helpers import generate_uuid
|
||||||
from app import config
|
from app import config
|
||||||
|
|
||||||
|
|
||||||
def get_spot_prefix(manager: User, db: Session) -> str:
|
def get_spot_prefix(office: Office, db: Session) -> str:
|
||||||
"""Get the spot prefix for a manager (from manager_spot_prefix or auto-assign)"""
|
"""Get the spot prefix for an office (from office.spot_prefix or auto-assign)"""
|
||||||
if manager.manager_spot_prefix:
|
if office.spot_prefix:
|
||||||
return manager.manager_spot_prefix
|
return office.spot_prefix
|
||||||
|
|
||||||
# Auto-assign based on alphabetical order of managers without prefix
|
# Auto-assign based on alphabetical order of offices without prefix
|
||||||
managers = db.query(User).filter(
|
offices = db.query(Office).filter(
|
||||||
User.role == "manager",
|
Office.spot_prefix == None
|
||||||
User.manager_spot_prefix == None
|
).order_by(Office.name).all()
|
||||||
).order_by(User.name).all()
|
|
||||||
|
|
||||||
# Find existing prefixes
|
# Find existing prefixes
|
||||||
existing_prefixes = set(
|
existing_prefixes = set(
|
||||||
m.manager_spot_prefix for m in db.query(User).filter(
|
o.spot_prefix for o in db.query(Office).filter(
|
||||||
User.role == "manager",
|
Office.spot_prefix != None
|
||||||
User.manager_spot_prefix != None
|
|
||||||
).all()
|
).all()
|
||||||
)
|
)
|
||||||
|
|
||||||
# Find first available letter
|
# Find first available letter
|
||||||
manager_index = next((i for i, m in enumerate(managers) if m.id == manager.id), 0)
|
office_index = next((i for i, o in enumerate(offices) if o.id == office.id), 0)
|
||||||
letter = 'A'
|
letter = 'A'
|
||||||
count = 0
|
count = 0
|
||||||
while letter in existing_prefixes or count < manager_index:
|
while letter in existing_prefixes or count < office_index:
|
||||||
if letter not in existing_prefixes:
|
if letter not in existing_prefixes:
|
||||||
count += 1
|
count += 1
|
||||||
letter = chr(ord(letter) + 1)
|
letter = chr(ord(letter) + 1)
|
||||||
@@ -54,55 +53,58 @@ def get_spot_prefix(manager: User, db: Session) -> str:
|
|||||||
return letter
|
return letter
|
||||||
|
|
||||||
|
|
||||||
def get_spot_display_name(spot_id: str, manager_id: str, db: Session) -> str:
|
def get_spot_display_name(spot_id: str, office_id: str, db: Session) -> str:
|
||||||
"""Get display name for a spot (e.g., 'A3' instead of 'spot-3')"""
|
"""Get display name for a spot (e.g., 'A3' instead of 'spot-3')"""
|
||||||
manager = db.query(User).filter(User.id == manager_id).first()
|
office = db.query(Office).filter(Office.id == office_id).first()
|
||||||
if not manager:
|
if not office:
|
||||||
return spot_id
|
return spot_id
|
||||||
|
|
||||||
prefix = get_spot_prefix(manager, db)
|
prefix = get_spot_prefix(office, db)
|
||||||
spot_number = spot_id.replace("spot-", "")
|
spot_number = spot_id.replace("spot-", "")
|
||||||
return f"{prefix}{spot_number}"
|
return f"{prefix}{spot_number}"
|
||||||
|
|
||||||
|
|
||||||
def is_closing_day(manager_id: str, date: str, db: Session) -> bool:
|
def is_closing_day(office_id: str, check_date: date, db: Session) -> bool:
|
||||||
"""
|
"""
|
||||||
Check if date is a closing day for this manager.
|
Check if date is a closing day for this office.
|
||||||
Checks both specific closing days and weekly recurring closing days.
|
Checks both specific closing days and weekly recurring closing days.
|
||||||
"""
|
"""
|
||||||
# Check specific closing day
|
# Check specific closing day (single day or range)
|
||||||
specific = db.query(ManagerClosingDay).filter(
|
specific = db.query(OfficeClosingDay).filter(
|
||||||
ManagerClosingDay.manager_id == manager_id,
|
OfficeClosingDay.office_id == office_id,
|
||||||
ManagerClosingDay.date == date
|
or_(
|
||||||
|
OfficeClosingDay.date == check_date,
|
||||||
|
(OfficeClosingDay.end_date != None) & (OfficeClosingDay.date <= check_date) & (OfficeClosingDay.end_date >= check_date)
|
||||||
|
)
|
||||||
).first()
|
).first()
|
||||||
if specific:
|
if specific:
|
||||||
return True
|
return True
|
||||||
|
|
||||||
# Check weekly closing day
|
# Check weekly closing day
|
||||||
date_obj = datetime.strptime(date, "%Y-%m-%d")
|
# Python: 0=Monday, 6=Sunday
|
||||||
weekday = date_obj.weekday() # 0=Monday in Python
|
# DB/API: 0=Sunday, 1=Monday... (Legacy convention)
|
||||||
# Convert to our format: 0=Sunday, 1=Monday, ..., 6=Saturday
|
python_weekday = check_date.weekday()
|
||||||
weekday_sunday_start = (weekday + 1) % 7
|
db_weekday = (python_weekday + 1) % 7
|
||||||
|
|
||||||
weekly = db.query(ManagerWeeklyClosingDay).filter(
|
weekly = db.query(OfficeWeeklyClosingDay).filter(
|
||||||
ManagerWeeklyClosingDay.manager_id == manager_id,
|
OfficeWeeklyClosingDay.office_id == office_id,
|
||||||
ManagerWeeklyClosingDay.weekday == weekday_sunday_start
|
OfficeWeeklyClosingDay.weekday == db_weekday
|
||||||
).first()
|
).first()
|
||||||
|
|
||||||
return weekly is not None
|
return weekly is not None
|
||||||
|
|
||||||
|
|
||||||
def initialize_parking_pool(manager_id: str, quota: int, date: str, db: Session) -> int:
|
def initialize_parking_pool(office_id: str, quota: int, pool_date: date, db: Session) -> int:
|
||||||
"""Initialize empty parking spots for a manager's pool on a given date.
|
"""Initialize empty parking spots for an office's pool on a given date.
|
||||||
Returns 0 if it's a closing day (no parking available).
|
Returns 0 if it's a closing day (no parking available).
|
||||||
"""
|
"""
|
||||||
# Don't create pool on closing days
|
# Don't create pool on closing days
|
||||||
if is_closing_day(manager_id, date, db):
|
if is_closing_day(office_id, pool_date, db):
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
existing = db.query(DailyParkingAssignment).filter(
|
existing = db.query(DailyParkingAssignment).filter(
|
||||||
DailyParkingAssignment.manager_id == manager_id,
|
DailyParkingAssignment.office_id == office_id,
|
||||||
DailyParkingAssignment.date == date
|
DailyParkingAssignment.date == pool_date
|
||||||
).count()
|
).count()
|
||||||
|
|
||||||
if existing > 0:
|
if existing > 0:
|
||||||
@@ -111,20 +113,20 @@ def initialize_parking_pool(manager_id: str, quota: int, date: str, db: Session)
|
|||||||
for i in range(1, quota + 1):
|
for i in range(1, quota + 1):
|
||||||
spot = DailyParkingAssignment(
|
spot = DailyParkingAssignment(
|
||||||
id=generate_uuid(),
|
id=generate_uuid(),
|
||||||
date=date,
|
date=pool_date,
|
||||||
spot_id=f"spot-{i}",
|
spot_id=f"spot-{i}",
|
||||||
user_id=None,
|
user_id=None,
|
||||||
manager_id=manager_id,
|
office_id=office_id,
|
||||||
created_at=datetime.now(timezone.utc).isoformat()
|
created_at=datetime.now(timezone.utc)
|
||||||
)
|
)
|
||||||
db.add(spot)
|
db.add(spot)
|
||||||
|
|
||||||
db.commit()
|
db.commit()
|
||||||
config.logger.debug(f"Initialized {quota} parking spots for manager {manager_id} on {date}")
|
config.logger.debug(f"Initialized {quota} parking spots for office {office_id} on {pool_date}")
|
||||||
return quota
|
return quota
|
||||||
|
|
||||||
|
|
||||||
def get_user_parking_ratio(user_id: str, manager_id: str, db: Session) -> float:
|
def get_user_parking_ratio(user_id: str, office_id: str, db: Session) -> float:
|
||||||
"""
|
"""
|
||||||
Calculate user's parking ratio: parking_days / presence_days
|
Calculate user's parking ratio: parking_days / presence_days
|
||||||
Lower ratio = higher priority for next parking spot
|
Lower ratio = higher priority for next parking spot
|
||||||
@@ -132,7 +134,7 @@ def get_user_parking_ratio(user_id: str, manager_id: str, db: Session) -> float:
|
|||||||
# Count days user was present
|
# Count days user was present
|
||||||
presence_days = db.query(UserPresence).filter(
|
presence_days = db.query(UserPresence).filter(
|
||||||
UserPresence.user_id == user_id,
|
UserPresence.user_id == user_id,
|
||||||
UserPresence.status == "present"
|
UserPresence.status == PresenceStatus.PRESENT
|
||||||
).count()
|
).count()
|
||||||
|
|
||||||
if presence_days == 0:
|
if presence_days == 0:
|
||||||
@@ -141,16 +143,16 @@ def get_user_parking_ratio(user_id: str, manager_id: str, db: Session) -> float:
|
|||||||
# Count days user got parking
|
# Count days user got parking
|
||||||
parking_days = db.query(DailyParkingAssignment).filter(
|
parking_days = db.query(DailyParkingAssignment).filter(
|
||||||
DailyParkingAssignment.user_id == user_id,
|
DailyParkingAssignment.user_id == user_id,
|
||||||
DailyParkingAssignment.manager_id == manager_id
|
DailyParkingAssignment.office_id == office_id
|
||||||
).count()
|
).count()
|
||||||
|
|
||||||
return parking_days / presence_days
|
return parking_days / presence_days
|
||||||
|
|
||||||
|
|
||||||
def is_user_excluded(user_id: str, manager_id: str, date: str, db: Session) -> bool:
|
def is_user_excluded(user_id: str, office_id: str, check_date: date, db: Session) -> bool:
|
||||||
"""Check if user is excluded from parking for this date"""
|
"""Check if user is excluded from parking for this date"""
|
||||||
exclusion = db.query(ParkingExclusion).filter(
|
exclusion = db.query(ParkingExclusion).filter(
|
||||||
ParkingExclusion.manager_id == manager_id,
|
ParkingExclusion.office_id == office_id,
|
||||||
ParkingExclusion.user_id == user_id
|
ParkingExclusion.user_id == user_id
|
||||||
).first()
|
).first()
|
||||||
|
|
||||||
@@ -158,18 +160,18 @@ def is_user_excluded(user_id: str, manager_id: str, date: str, db: Session) -> b
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
# Check date range
|
# Check date range
|
||||||
if exclusion.start_date and date < exclusion.start_date:
|
if exclusion.start_date and check_date < exclusion.start_date:
|
||||||
return False
|
return False
|
||||||
if exclusion.end_date and date > exclusion.end_date:
|
if exclusion.end_date and check_date > exclusion.end_date:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
|
||||||
def has_guarantee(user_id: str, manager_id: str, date: str, db: Session) -> bool:
|
def has_guarantee(user_id: str, office_id: str, check_date: date, db: Session) -> bool:
|
||||||
"""Check if user has a parking guarantee for this date"""
|
"""Check if user has a parking guarantee for this date"""
|
||||||
guarantee = db.query(ParkingGuarantee).filter(
|
guarantee = db.query(ParkingGuarantee).filter(
|
||||||
ParkingGuarantee.manager_id == manager_id,
|
ParkingGuarantee.office_id == office_id,
|
||||||
ParkingGuarantee.user_id == user_id
|
ParkingGuarantee.user_id == user_id
|
||||||
).first()
|
).first()
|
||||||
|
|
||||||
@@ -177,28 +179,25 @@ def has_guarantee(user_id: str, manager_id: str, date: str, db: Session) -> bool
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
# Check date range
|
# Check date range
|
||||||
if guarantee.start_date and date < guarantee.start_date:
|
if guarantee.start_date and check_date < guarantee.start_date:
|
||||||
return False
|
return False
|
||||||
if guarantee.end_date and date > guarantee.end_date:
|
if guarantee.end_date and check_date > guarantee.end_date:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
|
||||||
def get_users_wanting_parking(manager_id: str, date: str, db: Session) -> list[dict]:
|
def get_users_wanting_parking(office_id: str, pool_date: date, db: Session) -> list[dict]:
|
||||||
"""
|
"""
|
||||||
Get all users who want parking for this date, sorted by fairness priority.
|
Get all users who want parking for this date, sorted by fairness priority.
|
||||||
Returns list of {user_id, has_guarantee, ratio}
|
Returns list of {user_id, has_guarantee, ratio}
|
||||||
|
|
||||||
Note: Manager is part of their own team and can get parking from their pool.
|
|
||||||
"""
|
"""
|
||||||
# Get users who marked "present" for this date:
|
# Get users who marked "present" for this date:
|
||||||
# - Users managed by this manager (User.manager_id == manager_id)
|
# - Users belonging to this office
|
||||||
# - The manager themselves (User.id == manager_id)
|
|
||||||
present_users = db.query(UserPresence).join(User).filter(
|
present_users = db.query(UserPresence).join(User).filter(
|
||||||
UserPresence.date == date,
|
UserPresence.date == pool_date,
|
||||||
UserPresence.status == "present",
|
UserPresence.status == PresenceStatus.PRESENT,
|
||||||
or_(User.manager_id == manager_id, User.id == manager_id)
|
User.office_id == office_id
|
||||||
).all()
|
).all()
|
||||||
|
|
||||||
candidates = []
|
candidates = []
|
||||||
@@ -206,12 +205,12 @@ def get_users_wanting_parking(manager_id: str, date: str, db: Session) -> list[d
|
|||||||
user_id = presence.user_id
|
user_id = presence.user_id
|
||||||
|
|
||||||
# Skip excluded users
|
# Skip excluded users
|
||||||
if is_user_excluded(user_id, manager_id, date, db):
|
if is_user_excluded(user_id, office_id, pool_date, db):
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# Skip users who already have a spot
|
# Skip users who already have a spot
|
||||||
existing = db.query(DailyParkingAssignment).filter(
|
existing = db.query(DailyParkingAssignment).filter(
|
||||||
DailyParkingAssignment.date == date,
|
DailyParkingAssignment.date == pool_date,
|
||||||
DailyParkingAssignment.user_id == user_id
|
DailyParkingAssignment.user_id == user_id
|
||||||
).first()
|
).first()
|
||||||
if existing:
|
if existing:
|
||||||
@@ -219,8 +218,8 @@ def get_users_wanting_parking(manager_id: str, date: str, db: Session) -> list[d
|
|||||||
|
|
||||||
candidates.append({
|
candidates.append({
|
||||||
"user_id": user_id,
|
"user_id": user_id,
|
||||||
"has_guarantee": has_guarantee(user_id, manager_id, date, db),
|
"has_guarantee": has_guarantee(user_id, office_id, pool_date, db),
|
||||||
"ratio": get_user_parking_ratio(user_id, manager_id, db)
|
"ratio": get_user_parking_ratio(user_id, office_id, db)
|
||||||
})
|
})
|
||||||
|
|
||||||
# Sort: guaranteed users first, then by ratio (lowest first for fairness)
|
# Sort: guaranteed users first, then by ratio (lowest first for fairness)
|
||||||
@@ -229,30 +228,30 @@ def get_users_wanting_parking(manager_id: str, date: str, db: Session) -> list[d
|
|||||||
return candidates
|
return candidates
|
||||||
|
|
||||||
|
|
||||||
def assign_parking_fairly(manager_id: str, date: str, db: Session) -> dict:
|
def assign_parking_fairly(office_id: str, pool_date: date, db: Session) -> dict:
|
||||||
"""
|
"""
|
||||||
Assign parking spots fairly based on parking ratio.
|
Assign parking spots fairly based on parking ratio.
|
||||||
Called after presence is set for a date.
|
Called after presence is set for a date.
|
||||||
Returns {assigned: [...], waitlist: [...]}
|
Returns {assigned: [...], waitlist: [...]}
|
||||||
"""
|
"""
|
||||||
manager = db.query(User).filter(User.id == manager_id).first()
|
office = db.query(Office).filter(Office.id == office_id).first()
|
||||||
if not manager or not manager.manager_parking_quota:
|
if not office or not office.parking_quota:
|
||||||
return {"assigned": [], "waitlist": []}
|
return {"assigned": [], "waitlist": []}
|
||||||
|
|
||||||
# No parking on closing days
|
# No parking on closing days
|
||||||
if is_closing_day(manager_id, date, db):
|
if is_closing_day(office_id, pool_date, db):
|
||||||
return {"assigned": [], "waitlist": [], "closed": True}
|
return {"assigned": [], "waitlist": [], "closed": True}
|
||||||
|
|
||||||
# Initialize pool
|
# Initialize pool
|
||||||
initialize_parking_pool(manager_id, manager.manager_parking_quota, date, db)
|
initialize_parking_pool(office_id, office.parking_quota, pool_date, db)
|
||||||
|
|
||||||
# Get candidates sorted by fairness
|
# Get candidates sorted by fairness
|
||||||
candidates = get_users_wanting_parking(manager_id, date, db)
|
candidates = get_users_wanting_parking(office_id, pool_date, db)
|
||||||
|
|
||||||
# Get available spots
|
# Get available spots
|
||||||
free_spots = db.query(DailyParkingAssignment).filter(
|
free_spots = db.query(DailyParkingAssignment).filter(
|
||||||
DailyParkingAssignment.manager_id == manager_id,
|
DailyParkingAssignment.office_id == office_id,
|
||||||
DailyParkingAssignment.date == date,
|
DailyParkingAssignment.date == pool_date,
|
||||||
DailyParkingAssignment.user_id == None
|
DailyParkingAssignment.user_id == None
|
||||||
).all()
|
).all()
|
||||||
|
|
||||||
@@ -272,11 +271,11 @@ def assign_parking_fairly(manager_id: str, date: str, db: Session) -> dict:
|
|||||||
return {"assigned": assigned, "waitlist": waitlist}
|
return {"assigned": assigned, "waitlist": waitlist}
|
||||||
|
|
||||||
|
|
||||||
def release_user_spot(manager_id: str, user_id: str, date: str, db: Session) -> bool:
|
def release_user_spot(office_id: str, user_id: str, pool_date: date, db: Session) -> bool:
|
||||||
"""Release a user's parking spot and reassign to next in fairness queue"""
|
"""Release a user's parking spot and reassign to next in fairness queue"""
|
||||||
assignment = db.query(DailyParkingAssignment).filter(
|
assignment = db.query(DailyParkingAssignment).filter(
|
||||||
DailyParkingAssignment.manager_id == manager_id,
|
DailyParkingAssignment.office_id == office_id,
|
||||||
DailyParkingAssignment.date == date,
|
DailyParkingAssignment.date == pool_date,
|
||||||
DailyParkingAssignment.user_id == user_id
|
DailyParkingAssignment.user_id == user_id
|
||||||
).first()
|
).first()
|
||||||
|
|
||||||
@@ -288,7 +287,7 @@ def release_user_spot(manager_id: str, user_id: str, date: str, db: Session) ->
|
|||||||
db.commit()
|
db.commit()
|
||||||
|
|
||||||
# Try to assign to next user in fairness queue
|
# Try to assign to next user in fairness queue
|
||||||
candidates = get_users_wanting_parking(manager_id, date, db)
|
candidates = get_users_wanting_parking(office_id, pool_date, db)
|
||||||
if candidates:
|
if candidates:
|
||||||
assignment.user_id = candidates[0]["user_id"]
|
assignment.user_id = candidates[0]["user_id"]
|
||||||
db.commit()
|
db.commit()
|
||||||
@@ -296,29 +295,74 @@ def release_user_spot(manager_id: str, user_id: str, date: str, db: Session) ->
|
|||||||
return True
|
return True
|
||||||
|
|
||||||
|
|
||||||
def handle_presence_change(user_id: str, date: str, old_status: str, new_status: str, manager_id: str, db: Session):
|
def handle_presence_change(user_id: str, change_date: date, old_status: PresenceStatus, new_status: PresenceStatus, office_id: str, db: Session):
|
||||||
"""
|
"""
|
||||||
Handle presence status change and update parking accordingly.
|
Handle presence status change and update parking accordingly.
|
||||||
Uses fairness algorithm for assignment.
|
Uses fairness algorithm for assignment.
|
||||||
manager_id is the user's manager (from User.manager_id).
|
|
||||||
"""
|
"""
|
||||||
# Don't process past dates
|
# Don't process past dates
|
||||||
target_date = datetime.strptime(date, "%Y-%m-%d").date()
|
if change_date < datetime.utcnow().date():
|
||||||
if target_date < datetime.now().date():
|
|
||||||
return
|
return
|
||||||
|
|
||||||
# Get manager
|
# Get office (must be valid)
|
||||||
manager = db.query(User).filter(User.id == manager_id, User.role == "manager").first()
|
office = db.query(Office).filter(Office.id == office_id).first()
|
||||||
if not manager or not manager.manager_parking_quota:
|
if not office or not office.parking_quota:
|
||||||
return
|
return
|
||||||
|
|
||||||
# Initialize pool if needed
|
# Initialize pool if needed
|
||||||
initialize_parking_pool(manager.id, manager.manager_parking_quota, date, db)
|
initialize_parking_pool(office.id, office.parking_quota, change_date, db)
|
||||||
|
|
||||||
if old_status == "present" and new_status in ["remote", "absent"]:
|
if old_status == PresenceStatus.PRESENT and new_status in [PresenceStatus.REMOTE, PresenceStatus.ABSENT]:
|
||||||
# User no longer coming - release their spot (will auto-reassign)
|
# User no longer coming - release their spot (will auto-reassign)
|
||||||
release_user_spot(manager.id, user_id, date, db)
|
release_user_spot(office.id, user_id, change_date, db)
|
||||||
|
|
||||||
elif new_status == "present":
|
elif new_status == PresenceStatus.PRESENT:
|
||||||
# User coming in - run fair assignment for this date
|
# Check booking window
|
||||||
assign_parking_fairly(manager.id, date, db)
|
should_assign = True
|
||||||
|
if office.booking_window_enabled:
|
||||||
|
# Allocation time is Day-1 at cutoff hour
|
||||||
|
cutoff_dt = datetime.combine(change_date - timedelta(days=1), datetime.min.time())
|
||||||
|
cutoff_dt = cutoff_dt.replace(
|
||||||
|
hour=office.booking_window_end_hour,
|
||||||
|
minute=office.booking_window_end_minute
|
||||||
|
)
|
||||||
|
|
||||||
|
# If now is before cutoff, do not assign yet (wait for batch job)
|
||||||
|
if datetime.utcnow() < cutoff_dt:
|
||||||
|
should_assign = False
|
||||||
|
config.logger.debug(f"Queuing parking request for user {user_id} on {change_date} (Window open until {cutoff_dt})")
|
||||||
|
|
||||||
|
if should_assign:
|
||||||
|
# User coming in - run fair assignment for this date
|
||||||
|
assign_parking_fairly(office.id, change_date, db)
|
||||||
|
|
||||||
|
|
||||||
|
def clear_assignments_for_office_date(office_id: str, pool_date: date, db: Session) -> int:
|
||||||
|
"""
|
||||||
|
Clear all parking assignments for an office on a specific date.
|
||||||
|
Returns number of cleared spots.
|
||||||
|
"""
|
||||||
|
assignments = db.query(DailyParkingAssignment).filter(
|
||||||
|
DailyParkingAssignment.office_id == office_id,
|
||||||
|
DailyParkingAssignment.date == pool_date,
|
||||||
|
DailyParkingAssignment.user_id != None
|
||||||
|
).all()
|
||||||
|
|
||||||
|
count = len(assignments)
|
||||||
|
for a in assignments:
|
||||||
|
a.user_id = None
|
||||||
|
|
||||||
|
db.commit()
|
||||||
|
return count
|
||||||
|
|
||||||
|
|
||||||
|
def run_batch_allocation(office_id: str, pool_date: date, db: Session) -> dict:
|
||||||
|
"""
|
||||||
|
Run the batch allocation for a specific date.
|
||||||
|
Force clears existing assignments to ensure a fair clean-slate allocation.
|
||||||
|
"""
|
||||||
|
# 1. Clear existing assignments
|
||||||
|
clear_assignments_for_office_date(office_id, pool_date, db)
|
||||||
|
|
||||||
|
# 2. Run fair allocation
|
||||||
|
return assign_parking_fairly(office_id, pool_date, db)
|
||||||
|
|||||||
@@ -161,17 +161,18 @@ def require_manager_or_admin(user=Depends(get_current_user)):
|
|||||||
def check_manager_access_to_user(current_user, target_user, db: Session) -> bool:
|
def check_manager_access_to_user(current_user, target_user, db: Session) -> bool:
|
||||||
"""
|
"""
|
||||||
Check if current_user (manager) has access to target_user.
|
Check if current_user (manager) has access to target_user.
|
||||||
Admins always have access. Managers can only access users they manage.
|
Admins always have access. Managers can only access users in their Office.
|
||||||
Returns True if access granted, raises HTTPException if not.
|
Returns True if access granted, raises HTTPException if not.
|
||||||
"""
|
"""
|
||||||
if current_user.role == "admin":
|
if current_user.role == "admin":
|
||||||
return True
|
return True
|
||||||
|
|
||||||
if current_user.role == "manager":
|
if current_user.role == "manager":
|
||||||
if target_user.manager_id != current_user.id:
|
# Access granted if they are in the same office
|
||||||
|
if not current_user.office_id or target_user.office_id != current_user.office_id:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_403_FORBIDDEN,
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
detail="User is not managed by you"
|
detail="User is not in your office"
|
||||||
)
|
)
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ Common helpers used across the application
|
|||||||
import uuid
|
import uuid
|
||||||
import re
|
import re
|
||||||
from typing import TYPE_CHECKING
|
from typing import TYPE_CHECKING
|
||||||
|
from database.models import UserRole
|
||||||
|
|
||||||
from app import config
|
from app import config
|
||||||
|
|
||||||
@@ -24,7 +25,7 @@ def is_ldap_user(user: "User") -> bool:
|
|||||||
|
|
||||||
def is_ldap_admin(user: "User") -> bool:
|
def is_ldap_admin(user: "User") -> bool:
|
||||||
"""Check if user is an LDAP-managed admin"""
|
"""Check if user is an LDAP-managed admin"""
|
||||||
return is_ldap_user(user) and user.role == "admin"
|
return is_ldap_user(user) and user.role == UserRole.ADMIN
|
||||||
|
|
||||||
|
|
||||||
def validate_password(password: str) -> list[str]:
|
def validate_password(password: str) -> list[str]:
|
||||||
|
|||||||
28
utils/promote_admins.py
Normal file
28
utils/promote_admins.py
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
import sys
|
||||||
|
import os
|
||||||
|
from dotenv import load_dotenv
|
||||||
|
|
||||||
|
# Add parent directory to path to allow importing from root
|
||||||
|
sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||||
|
|
||||||
|
# Load environment variables first
|
||||||
|
load_dotenv()
|
||||||
|
|
||||||
|
from database.connection import get_db_session
|
||||||
|
from database.models import User, UserRole
|
||||||
|
|
||||||
|
def promote_all_users():
|
||||||
|
print("Promoting all users to ADMIN...")
|
||||||
|
with get_db_session() as db:
|
||||||
|
users = db.query(User).all()
|
||||||
|
count = 0
|
||||||
|
for user in users:
|
||||||
|
if user.role != UserRole.ADMIN:
|
||||||
|
user.role = UserRole.ADMIN
|
||||||
|
count += 1
|
||||||
|
|
||||||
|
db.commit()
|
||||||
|
print(f"Promoted {count} users to ADMIN.")
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
promote_all_users()
|
||||||
Reference in New Issue
Block a user