diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..a2e058f --- /dev/null +++ b/.dockerignore @@ -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/ diff --git a/CLAUDE.md b/CLAUDE.md deleted file mode 100644 index 65d9a80..0000000 --- a/CLAUDE.md +++ /dev/null @@ -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) diff --git a/Caddyfile.snippet b/Caddyfile.snippet new file mode 100644 index 0000000..db04d5f --- /dev/null +++ b/Caddyfile.snippet @@ -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 +} diff --git a/DEPENDENCIES.md b/DEPENDENCIES.md new file mode 100644 index 0000000..d47ee18 --- /dev/null +++ b/DEPENDENCIES.md @@ -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). diff --git a/Dockerfile b/Dockerfile index 399d5da..7dfd4ad 100644 --- a/Dockerfile +++ b/Dockerfile @@ -2,6 +2,9 @@ FROM python:3.12-slim WORKDIR /app +# Prevent Python from buffering stdout and stderr +ENV PYTHONUNBUFFERED=1 + # Install dependencies COPY 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 # 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"] diff --git a/README.md b/README.md index a7492e5..eb8d8f9 100644 --- a/README.md +++ b/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 -- **Fair assignment algorithm**: Users with lowest parking/presence ratio get priority -- **Presence tracking**: Calendar-based presence marking (present/remote/absent) -- **Closing days**: Support for specific dates and weekly recurring closures -- **Guarantees & exclusions**: Per-user parking rules -- **Authelia/LLDAP integration**: SSO authentication with group-based roles +- **Modello Centrato sull'Ufficio**: I posti auto appartengono agli Uffici, non ai Manager o agli Utenti. +- **Algoritmo di Assegnazione Equa**: Gli utenti con il rapporto parcheggi/presenze più basso hanno la priorità. +- **Tracciamento Presenze**: Marcatura delle presenze basata su calendario (presente/remoto/assente). +- **Regole di Chiusura Flessibili**: Supporto per chiusure in date specifiche e chiusure settimanali ricorrenti per ufficio. +- **Garanzie ed Esclusioni**: Regole di parcheggio per utente gestite dagli amministratori dell'ufficio. +- **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/ -│ ├── routes/ # API endpoints -│ │ ├── auth.py # Authentication + holidays -│ │ ├── users.py # User management -│ │ ├── managers.py # Manager rules (closing days, guarantees) -│ │ ├── presence.py # Presence marking -│ │ └── parking.py # Parking assignments -│ └── config.py # Application configuration -├── database/ -│ ├── models.py # SQLAlchemy ORM models -│ └── connection.py # Database setup -├── services/ -│ ├── auth.py # JWT + password handling -│ ├── parking.py # Fair assignment algorithm -│ ├── holidays.py # Public holiday calculation -│ └── notifications.py # Email notifications (TODO: scheduler) -├── frontend/ -│ ├── pages/ # HTML pages -│ ├── js/ # JavaScript modules -│ └── css/ # Stylesheets -└── main.py # FastAPI application entry +app/ +├── routes/ # API endpoints +│ ├── auth.py # Autenticazione +│ ├── users.py # Gestione utenti +│ ├── offices.py # Gestione uffici (quote, regole) +│ ├── presence.py # Marcatura presenze +│ └── parking.py # Logica di assegnazione +└── config.py # Configurazione +database/ +├── models.py # Modelli SQLAlchemy ORM +└── connection.py # Setup Database +frontend/ # Frontend Vanilla JS pulito +├── pages/ # Viste HTML +├── js/ # Moduli logici +└── css/ # Stili ``` -## Quick Start (Development) +## Guida Rapida -```bash -# Create virtual environment -python3 -m venv .venv -source .venv/bin/activate +### Sviluppo Locale -# Install dependencies -pip install -r requirements.txt +1. **Setup Ambiente**: + ```bash + python3 -m venv .venv + source .venv/bin/activate + pip install -r requirements.txt + ``` -# Run development server -python main.py +2. **Avvio Server**: + ```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à: ``` - -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 +Rapporto = (Giorni Parcheggiati con successo) / (Giorni di Presenza in ufficio) ``` - -Or use Docker Compose: - -```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 | +- Gli utenti **Garantiti** vengono assegnati per primi. +- I posti **Rimanenti** sono distribuiti agli utenti presenti iniziando da chi ha il rapporto più **basso**. +- Gli utenti **Esclusi** non ricevono mai un posto. ## API Endpoints -### Authentication -- `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 +Di seguito la lista delle chiamate API disponibili suddivise per modulo. -### Users -- `GET /api/users` - List users (admin) -- `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 +### Auth (`/api/auth`) +Gestione autenticazione e sessione. -### Managers -- `GET /api/managers` - List managers -- `GET /api/managers/{id}` - Manager details -- `PUT /api/managers/{id}/settings` - Update parking quota (admin) -- `GET/POST/DELETE /api/managers/{id}/closing-days` - Specific closures -- `GET/POST/DELETE /api/managers/{id}/weekly-closing-days` - Recurring closures -- `GET/POST/DELETE /api/managers/{id}/guarantees` - Parking guarantees -- `GET/POST/DELETE /api/managers/{id}/exclusions` - Parking exclusions +- `POST /register`: Registrazione nuovo utente (Disabilitato se Authelia è attivo). +- `POST /login`: Login con email e password (ritorna token JWT/cookie). +- `POST /logout`: Logout e invalidazione sessione. +- `GET /me`: Ritorna informazioni sull'utente corrente. +- `GET /config`: Ritorna la configurazione pubblica di autenticazione. +- `GET /holidays/{year}`: Ritorna i giorni festivi per l'anno specificato. -### Presence -- `POST /api/presence/mark` - Mark presence -- `POST /api/presence/mark-bulk` - Bulk mark -- `GET /api/presence/my-presences` - Own presences -- `GET /api/presence/team` - Team calendar (manager/admin) +### Users (`/api/users`) +Gestione utenti e profili. -### Parking -- `GET /api/parking/assignments/{date}` - Day's assignments -- `GET /api/parking/my-assignments` - Own assignments -- `POST /api/parking/manual-assign` - Manual assignment -- `POST /api/parking/reassign-spot` - Reassign spot +- `GET /`: Lista di tutti gli utenti (Solo Admin). +- `POST /`: Crea un nuovo utente (Solo Admin). +- `GET /{user_id}`: Dettaglio di un utente specifico (Solo Admin). +- `PUT /{user_id}`: Aggiorna dati di un utente (Solo Admin). +- `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. -``` -ratio = parking_days / presence_days -``` +### Presence (`/api/presence`) +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 diff --git a/app/config.py b/app/config.py index 1d8ec2a..eefe2dd 100644 --- a/app/config.py +++ b/app/config.py @@ -7,6 +7,10 @@ import sys import logging from pathlib import Path +# Paths +BASE_DIR = Path(__file__).resolve().parent.parent +FRONTEND_DIR = BASE_DIR / "frontend" + # Configure logging LOG_LEVEL = os.getenv("LOG_LEVEL", "INFO").upper() logging.basicConfig( @@ -17,6 +21,19 @@ logger = logging.getLogger("org-parking") # Database 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}") # JWT Authentication @@ -35,7 +52,7 @@ HOST = os.getenv("HOST", "0.0.0.0") PORT = int(os.getenv("PORT", "8000")) # 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_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_WINDOW = int(os.getenv("RATE_LIMIT_WINDOW", "60")) # seconds -# Paths -BASE_DIR = Path(__file__).resolve().parent.parent -FRONTEND_DIR = BASE_DIR / "frontend" + diff --git a/app/routes/auth.py b/app/routes/auth.py index 4984673..22571a3 100644 --- a/app/routes/auth.py +++ b/app/routes/auth.py @@ -9,6 +9,7 @@ from slowapi import Limiter from slowapi.util import get_remote_address from database.connection import get_db +from database.models import UserRole from services.auth import ( create_user, authenticate_user, create_access_token, get_user_by_email @@ -25,7 +26,6 @@ class RegisterRequest(BaseModel): email: EmailStr password: str name: str - manager_id: str | None = None class LoginRequest(BaseModel): @@ -42,16 +42,16 @@ class UserResponse(BaseModel): id: str email: str name: str | None - manager_id: str | None - role: str - manager_parking_quota: int | None = None + office_id: str | None + office_name: str | None = None + role: UserRole week_start_day: int = 0 # Notification preferences - notify_weekly_parking: int = 1 - notify_daily_parking: int = 1 + notify_weekly_parking: bool = True + notify_daily_parking: bool = True notify_daily_parking_hour: int = 8 notify_daily_parking_minute: int = 0 - notify_parking_changes: int = 1 + notify_parking_changes: bool = True @router.post("/register", response_model=TokenResponse) @@ -76,8 +76,7 @@ def register(request: Request, data: RegisterRequest, db: Session = Depends(get_ db=db, email=data.email, password=data.password, - name=data.name, - manager_id=data.manager_id + name=data.name ) config.logger.info(f"New user registered: {data.email}") @@ -126,15 +125,15 @@ def get_me(user=Depends(get_current_user)): id=user.id, email=user.email, 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, - manager_parking_quota=user.manager_parking_quota, week_start_day=get_notification_default(user.week_start_day, 0), - notify_weekly_parking=get_notification_default(user.notify_weekly_parking, 1), - notify_daily_parking=get_notification_default(user.notify_daily_parking, 1), + notify_weekly_parking=get_notification_default(user.notify_weekly_parking, True), + 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_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) ) diff --git a/app/routes/managers.py b/app/routes/managers.py index c1eda53..acdb622 100644 --- a/app/routes/managers.py +++ b/app/routes/managers.py @@ -5,7 +5,7 @@ Manager settings, closing days, guarantees, and exclusions 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). """ -from datetime import datetime +from datetime import datetime, date from fastapi import APIRouter, Depends, HTTPException from pydantic import BaseModel from sqlalchemy.orm import Session @@ -26,7 +26,8 @@ router = APIRouter(prefix="/api/managers", tags=["managers"]) # Request/Response Models 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 @@ -36,14 +37,16 @@ class WeeklyClosingDayCreate(BaseModel): class GuaranteeCreate(BaseModel): user_id: str - start_date: str | None = None - end_date: str | None = None + start_date: date | None = None + end_date: date | None = None + notes: str | None = None class ExclusionCreate(BaseModel): user_id: str - start_date: str | None = None - end_date: str | None = None + start_date: date | None = None + end_date: date | None = None + notes: str | None = None 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") manager.manager_spot_prefix = data.spot_prefix - manager.updated_at = datetime.utcnow().isoformat() + manager.updated_at = datetime.utcnow() db.commit() return { @@ -155,7 +158,7 @@ def get_manager_closing_days(manager_id: str, db: Session = Depends(get_db), use days = db.query(ManagerClosingDay).filter( ManagerClosingDay.manager_id == manager_id ).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") @@ -172,10 +175,14 @@ def add_manager_closing_day(manager_id: str, data: ClosingDayCreate, db: Session 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 = ManagerClosingDay( id=generate_uuid(), manager_id=manager_id, date=data.date, + end_date=data.end_date, reason=data.reason ) 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_name": user_lookup.get(g.user_id), "start_date": g.start_date, - "end_date": g.end_date + "end_date": g.end_date, + "notes": g.notes } for g in guarantees ] @@ -292,13 +300,17 @@ def add_manager_guarantee(manager_id: str, data: GuaranteeCreate, db: Session = 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(), manager_id=manager_id, user_id=data.user_id, start_date=data.start_date, end_date=data.end_date, - created_at=datetime.utcnow().isoformat() + notes=data.notes, + created_at=datetime.utcnow() ) db.add(guarantee) 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_name": user_lookup.get(e.user_id), "start_date": e.start_date, - "end_date": e.end_date + "end_date": e.end_date, + "notes": e.notes } for e in exclusions ] @@ -362,13 +375,17 @@ def add_manager_exclusion(manager_id: str, data: ExclusionCreate, db: Session = 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(), manager_id=manager_id, user_id=data.user_id, start_date=data.start_date, end_date=data.end_date, - created_at=datetime.utcnow().isoformat() + notes=data.notes, + created_at=datetime.utcnow() ) db.add(exclusion) db.commit() diff --git a/app/routes/offices.py b/app/routes/offices.py new file mode 100644 index 0000000..e236bc6 --- /dev/null +++ b/app/routes/offices.py @@ -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"} diff --git a/app/routes/parking.py b/app/routes/parking.py index 0f32b23..8cd2c36 100644 --- a/app/routes/parking.py +++ b/app/routes/parking.py @@ -2,21 +2,33 @@ Parking Management Routes 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: - 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 """ from typing import List -from datetime import datetime +from datetime import datetime, date from fastapi import APIRouter, Depends, HTTPException from pydantic import BaseModel from sqlalchemy.orm import Session 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 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 app import config @@ -25,14 +37,14 @@ router = APIRouter(prefix="/api/parking", tags=["parking"]) # Request/Response Models class InitPoolRequest(BaseModel): - date: str # YYYY-MM-DD + date: date class ManualAssignRequest(BaseModel): - manager_id: str + office_id: str user_id: str spot_id: str - date: str + date: date class ReassignSpotRequest(BaseModel): @@ -42,50 +54,57 @@ class ReassignSpotRequest(BaseModel): class AssignmentResponse(BaseModel): id: str - date: str + date: date spot_id: str spot_display_name: str | None = None user_id: str | None - manager_id: str + office_id: str user_name: 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 -@router.post("/init-manager-pool") -def init_manager_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""" - try: - datetime.strptime(request.date, "%Y-%m-%d") - except ValueError: - raise HTTPException(status_code=400, detail="Invalid date format") +@router.post("/init-office-pool") +def init_office_pool(request: InitPoolRequest, db: Session = Depends(get_db), current_user=Depends(require_manager_or_admin)): + """Initialize parking pool for an office on a given date""" + pool_date = request.date - quota = current_user.manager_parking_quota or 0 - if quota == 0: - return {"success": True, "message": "No parking quota configured", "spots": 0} + if not current_user.office_id: + raise HTTPException(status_code=400, detail="User does not belong to an office") + + 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} -@router.get("/assignments/{date}", response_model=List[AssignmentResponse]) -def get_assignments(date: str, manager_id: str = None, db: Session = Depends(get_db), current_user=Depends(get_current_user)): - """Get parking assignments for a date, optionally filtered by manager""" - try: - datetime.strptime(date, "%Y-%m-%d") - except ValueError: - raise HTTPException(status_code=400, detail="Invalid date format") +@router.get("/assignments/{date_val}", response_model=List[AssignmentResponse]) +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 office""" + query_date = date_val - query = db.query(DailyParkingAssignment).filter(DailyParkingAssignment.date == date) - if manager_id: - query = query.filter(DailyParkingAssignment.manager_id == manager_id) + query = db.query(DailyParkingAssignment).filter(DailyParkingAssignment.date == query_date) + if office_id: + query = query.filter(DailyParkingAssignment.office_id == office_id) assignments = query.all() results = [] for assignment in assignments: - # Get display name using manager's spot prefix - spot_display_name = get_spot_display_name(assignment.spot_id, assignment.manager_id, db) + # Get display name using office's spot prefix + spot_display_name = get_spot_display_name(assignment.spot_id, assignment.office_id, db) result = AssignmentResponse( 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_display_name=spot_display_name, user_id=assignment.user_id, - manager_id=assignment.manager_id + office_id=assignment.office_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]) -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""" query = db.query(DailyParkingAssignment).filter( 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 = [] 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( 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_display_name=spot_display_name, user_id=assignment.user_id, - manager_id=assignment.manager_id, + office_id=assignment.office_id, user_name=current_user.name, 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 + + +@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") def manual_assign(data: ManualAssignRequest, db: Session = Depends(get_db), current_user=Depends(require_manager_or_admin)): """Manually assign a spot to a user""" + assign_date = data.date + # Verify user exists user = db.query(User).filter(User.id == data.user_id).first() if not user: raise HTTPException(status_code=404, detail="User not found") - # Verify manager exists and check permission - manager = db.query(User).filter(User.id == data.manager_id, User.role == "manager").first() - if not manager: - raise HTTPException(status_code=404, detail="Manager not found") + # Verify office exists + office = db.query(Office).filter(Office.id == data.office_id).first() + if not office: + raise HTTPException(status_code=404, detail="Office not found") - # Only admin or the manager themselves can assign spots - if current_user.role != "admin" and current_user.id != data.manager_id: - raise HTTPException(status_code=403, detail="Not authorized to assign spots for this manager") + # Only admin or the manager of that office can assign spots + is_manager = (current_user.role == UserRole.MANAGER and current_user.office_id == data.office_id) + 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 spot = db.query(DailyParkingAssignment).filter( - DailyParkingAssignment.manager_id == data.manager_id, - DailyParkingAssignment.date == data.date, + DailyParkingAssignment.office_id == data.office_id, + DailyParkingAssignment.date == assign_date, DailyParkingAssignment.spot_id == data.spot_id ).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) existing = db.query(DailyParkingAssignment).filter( - DailyParkingAssignment.date == data.date, + DailyParkingAssignment.date == assign_date, DailyParkingAssignment.user_id == data.user_id ).first() @@ -180,7 +227,7 @@ def manual_assign(data: ManualAssignRequest, db: Session = Depends(get_db), curr spot.user_id = data.user_id 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} @@ -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") # 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 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") # 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_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): 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 # 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 new_user = db.query(User).filter(User.id == data.new_user_id).first() if not new_user: @@ -275,7 +330,7 @@ def reassign_spot(data: ReassignSpotRequest, db: Session = Depends(get_db), curr db.refresh(assignment) # 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( id=assignment.id, @@ -283,7 +338,7 @@ def reassign_spot(data: ReassignSpotRequest, db: Session = Depends(get_db), curr spot_id=assignment.spot_id, spot_display_name=spot_display_name, user_id=assignment.user_id, - manager_id=assignment.manager_id + office_id=assignment.office_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") # 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_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): 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( - (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 ).all() diff --git a/app/routes/presence.py b/app/routes/presence.py index da5c7b6..1e073eb 100644 --- a/app/routes/presence.py +++ b/app/routes/presence.py @@ -3,13 +3,13 @@ Presence Management Routes User presence marking and admin management """ from typing import List -from datetime import datetime, timedelta -from fastapi import APIRouter, Depends, HTTPException +from datetime import datetime, timedelta, date +from fastapi import APIRouter, Depends, HTTPException, Query from pydantic import BaseModel from sqlalchemy.orm import Session 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.helpers import generate_uuid 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 class PresenceMarkRequest(BaseModel): - date: str # YYYY-MM-DD - status: str # present, remote, absent + date: date + status: PresenceStatus class AdminPresenceMarkRequest(BaseModel): user_id: str - date: str - status: str + date: date + 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): id: str user_id: str - date: str - status: str - created_at: str | None - updated_at: str | None + date: date + status: PresenceStatus + created_at: datetime | None + updated_at: datetime | None parking_spot_number: str | None = None class Config: @@ -59,51 +47,38 @@ class PresenceResponse(BaseModel): # 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): """Check if current_user has access to target_user""" - if current_user.role == "admin": + if current_user.role == UserRole.ADMIN: return True - if current_user.role == "manager": - # Manager can access users they manage - if target_user.manager_id == current_user.id: + if current_user.role == UserRole.MANAGER: + # Manager can access users in their Office + if target_user.office_id == current_user.office_id: 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") def _mark_presence_for_user( user_id: str, - date: str, - status: str, + presence_date: date, + status: PresenceStatus, db: Session, target_user: User ) -> UserPresence: """ Core presence marking logic - shared by user and admin routes. """ - validate_status(status) - parse_date(date) existing = db.query(UserPresence).filter( UserPresence.user_id == user_id, - UserPresence.date == date + UserPresence.date == presence_date ).first() - now = datetime.utcnow().isoformat() + now = datetime.utcnow() old_status = existing.status if existing else None if existing: @@ -116,7 +91,7 @@ def _mark_presence_for_user( presence = UserPresence( id=generate_uuid(), user_id=user_id, - date=date, + date=presence_date, status=status, created_at=now, updated_at=now @@ -125,114 +100,36 @@ def _mark_presence_for_user( db.commit() db.refresh(presence) - # Handle parking assignment - # 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": - # Manager is part of their own team for parking purposes - parking_manager_id = target_user.id - - if old_status != status and parking_manager_id: + # Handle parking assignment (if user is in an office) + if target_user.office_id and old_status != status: try: handle_presence_change( - user_id, date, - old_status or "absent", status, - parking_manager_id, db + user_id, presence_date, + old_status or PresenceStatus.ABSENT, status, + target_user.office_id, db ) 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 -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( user_id: str, - date: str, + presence_date: date, db: Session, target_user: User ) -> dict: """ Core presence deletion logic - shared by user and admin routes. """ - parse_date(date) presence = db.query(UserPresence).filter( UserPresence.user_id == user_id, - UserPresence.date == date + UserPresence.date == presence_date ).first() if not presence: @@ -242,20 +139,15 @@ def _delete_presence( db.delete(presence) db.commit() - # 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 parking_manager_id: + if target_user.office_id: try: handle_presence_change( - user_id, date, - old_status, "absent", - parking_manager_id, db + user_id, presence_date, + old_status, PresenceStatus.ABSENT, + target_user.office_id, db ) 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"} @@ -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) -@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]) -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""" query = db.query(UserPresence).filter(UserPresence.user_id == current_user.id) if start_date: - parse_date(start_date) query = query.filter(UserPresence.date >= start_date) if end_date: - parse_date(end_date) query = query.filter(UserPresence.date <= end_date) return query.order_by(UserPresence.date.desc()).all() -@router.delete("/{date}") -def delete_presence(date: str, db: Session = Depends(get_db), current_user=Depends(get_current_user)): +@router.delete("/{date_val}") +def delete_presence(date_val: date, db: Session = Depends(get_db), current_user=Depends(get_current_user)): """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 @@ -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) -@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)""" target_user = db.query(User).filter(User.id == 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 _delete_presence(user_id, date, db, target_user) + return _delete_presence(user_id, date_val, db, target_user) @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)): - """Get team presences with parking info, filtered by manager. - - Admins can see all teams - - Managers see their own team - - Employees can only see their own team (read-only view) +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 office presences with parking info. + - Admins can see all users (or filter by office_id) + - Managers see their own office's users + - 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 - # Note: Manager is part of their own team (for parking assignment purposes) - if current_user.role == "employee": - # Employees can only see their own team (users with same manager_id + the manager) - if not current_user.manager_id: - return [] # No manager assigned, no team to show - users = db.query(User).filter( - (User.manager_id == current_user.manager_id) | (User.id == current_user.manager_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() + if current_user.role == UserRole.ADMIN: + if office_id: + users = db.query(User).filter(User.office_id == office_id).all() + else: + users = db.query(User).all() + + elif current_user.office_id: + # Non-admin users see their office members + users = db.query(User).filter(User.office_id == current_user.office_id).all() + else: - # Manager sees their team + themselves - users = db.query(User).filter( - (User.manager_id == current_user.id) | (User.id == current_user.id) - ).all() + # No office assigned + return [] - # 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] + if not user_ids: + return [] + presences = db.query(UserPresence).filter( UserPresence.user_id.in_(user_ids), 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_info_lookup[p.user_id] = [] 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({ "id": p.id, "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 }) - # Build manager lookup for display - manager_ids = list(set(u.manager_id for u in users if u.manager_id)) - managers = db.query(User).filter(User.id.in_(manager_ids)).all() if manager_ids else [] - manager_lookup = {m.id: m.name for m in managers} + # Build office lookup for display (replacing old manager_lookup) + office_ids = list(set(u.office_id for u in users if u.office_id)) + offices = db.query(Office).filter(Office.id.in_(office_ids)).all() if office_ids else [] + office_lookup = {o.id: o.name for o in offices} # Build response result = [] @@ -410,8 +275,8 @@ def get_team_presences(start_date: str, end_date: str, manager_id: str = None, d result.append({ "id": user.id, "name": user.name, - "manager_id": user.manager_id, - "manager_name": manager_lookup.get(user.manager_id), + "office_id": user.office_id, + "office_name": office_lookup.get(user.office_id), "presences": [{"date": p.date, "status": p.status} for p in user_presences], "parking_dates": parking_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}") -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)""" target_user = db.query(User).filter(User.id == user_id).first() 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) if start_date: - parse_date(start_date) query = query.filter(UserPresence.date >= start_date) if end_date: - parse_date(end_date) query = query.filter(UserPresence.date <= end_date) presences = query.order_by(UserPresence.date.desc()).all() # Batch query parking assignments - date_strs = [p.date for p in presences] + dates = [p.date for p in presences] 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( DailyParkingAssignment.user_id == user_id, - DailyParkingAssignment.date.in_(date_strs) + DailyParkingAssignment.date.in_(dates) ).all() 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 result = [] diff --git a/app/routes/users.py b/app/routes/users.py index 5a18ac5..76e2040 100644 --- a/app/routes/users.py +++ b/app/routes/users.py @@ -8,7 +8,7 @@ from pydantic import BaseModel, EmailStr from sqlalchemy.orm import Session 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.helpers import ( generate_uuid, is_ldap_user, is_ldap_admin, @@ -25,16 +25,14 @@ class UserCreate(BaseModel): email: EmailStr password: str name: str | None = None - role: str = "employee" - manager_id: str | None = None + role: UserRole = UserRole.EMPLOYEE + office_id: str | None = None class UserUpdate(BaseModel): name: str | None = None - role: str | None = None - manager_id: str | None = None - manager_parking_quota: int | None = None - manager_spot_prefix: str | None = None + role: UserRole | None = None + office_id: str | None = None class ProfileUpdate(BaseModel): @@ -44,11 +42,11 @@ class ProfileUpdate(BaseModel): class SettingsUpdate(BaseModel): week_start_day: int | None = None # Notification preferences - notify_weekly_parking: int | None = None - notify_daily_parking: int | None = None + notify_weekly_parking: bool | None = None + notify_daily_parking: bool | None = None notify_daily_parking_hour: 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): @@ -60,61 +58,54 @@ class UserResponse(BaseModel): id: str email: str name: str | None - role: str - manager_id: str | None = None - manager_name: str | None = None - manager_parking_quota: int | None = None - manager_spot_prefix: str | None = None - managed_user_count: int | None = None + role: UserRole + office_id: str | None = None + office_name: str | None = None is_ldap_user: bool = False is_ldap_admin: bool = False created_at: str | None + parking_ratio: float | None = None class Config: 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. - - 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 - manager_name = None - if user.manager_id: - if manager_lookup is not None: - manager_name = manager_lookup.get(user.manager_id) + # Get office name - use lookup if available, otherwise query + office_name = None + if user.office_id: + if office_lookup is not None: + office_name = office_lookup.get(user.office_id) else: - manager = db.query(User).filter(User.id == user.manager_id).first() - if manager: - manager_name = manager.name + office = db.query(Office).filter(Office.id == user.office_id).first() + if office: + office_name = office.name - # Count managed users if this user is a manager - managed_user_count = None - if user.role == "manager": - if managed_counts is not None: - managed_user_count = managed_counts.get(user.id, 0) - else: - managed_user_count = db.query(User).filter(User.manager_id == user.id).count() + # Calculate parking ratio (score) + parking_ratio = None + if user.office_id: + try: + # Avoid circular import by importing inside function if needed, + # or ensure services.parking doesn't import this file. + from services.parking import get_user_parking_ratio + parking_ratio = get_user_parking_ratio(user.id, user.office_id, db) + except ImportError: + pass return { "id": user.id, "email": user.email, "name": user.name, "role": user.role, - "manager_id": user.manager_id, - "manager_name": manager_name, - "manager_parking_quota": user.manager_parking_quota, - "manager_spot_prefix": user.manager_spot_prefix, - "managed_user_count": managed_user_count, + "office_id": user.office_id, + "office_name": office_name, "is_ldap_user": is_ldap_user(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() # Build lookups to avoid N+1 queries - # Manager lookup: id -> name - manager_ids = list(set(u.manager_id for u in users if u.manager_id)) - managers = db.query(User).filter(User.id.in_(manager_ids)).all() if manager_ids else [] - manager_lookup = {m.id: m.name for m in managers} + # Office lookup: id -> name + office_ids = list(set(u.office_id for u in users if u.office_id)) + offices = db.query(Office).filter(Office.id.in_(office_ids)).all() if office_ids else [] + office_lookup = {o.id: o.name for o in offices} - # Managed user counts for managers - 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] + return [user_to_response(u, db, office_lookup) for u in users] @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(): raise HTTPException(status_code=400, detail="Email already registered") - if data.role not in ["admin", "manager", "employee"]: - raise HTTPException(status_code=400, detail="Invalid role") + # Role validation handled by Pydantic Enum # Validate password strength password_errors = validate_password(data.password) if password_errors: raise HTTPException(status_code=400, detail=format_password_errors(password_errors)) - if data.manager_id: - manager = db.query(User).filter(User.id == data.manager_id).first() - if not manager or manager.role != "manager": - raise HTTPException(status_code=400, detail="Invalid manager") + if data.office_id: + office = db.query(Office).filter(Office.id == data.office_id).first() + if not office: + raise HTTPException(status_code=400, detail="Invalid office") new_user = User( 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), name=data.name, role=data.role, - manager_id=data.manager_id, - created_at=datetime.utcnow().isoformat() + office_id=data.office_id, + created_at=datetime.utcnow() ) db.add(new_user) @@ -211,54 +190,20 @@ def update_user(user_id: str, data: UserUpdate, db: Session = Depends(get_db), u # Role update 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) - 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)") - # 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 - # Manager assignment (any user including admins can be assigned to a manager) - if data.manager_id is not None: - if data.manager_id: - manager = db.query(User).filter(User.id == data.manager_id).first() - if not manager or manager.role != "manager": - raise HTTPException(status_code=400, detail="Invalid manager") - if data.manager_id == user_id: - raise HTTPException(status_code=400, detail="User cannot be their own manager") - target.manager_id = data.manager_id if data.manager_id else None + # Office assignment + if "office_id" in data.__fields_set__: + if data.office_id: + office = db.query(Office).filter(Office.id == data.office_id).first() + if not office: + raise HTTPException(status_code=400, detail="Invalid office") + target.office_id = data.office_id if data.office_id else None - # Manager-specific fields - 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() + target.updated_at = datetime.utcnow() db.commit() db.refresh(target) 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: 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.commit() 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") def get_profile(db: Session = Depends(get_db), current_user=Depends(get_current_user)): """Get current user's profile""" - # Get manager name - manager_name = None - if current_user.manager_id: - manager = db.query(User).filter(User.id == current_user.manager_id).first() - if manager: - manager_name = manager.name + # Get office name + office_name = None + if current_user.office_id: + office = db.query(Office).filter(Office.id == current_user.office_id).first() + if office: + office_name = office.name return { "id": current_user.id, "email": current_user.email, "name": current_user.name, "role": current_user.role, - "manager_id": current_user.manager_id, - "manager_name": manager_name, + "office_id": current_user.office_id, + "office_name": office_name, "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): raise HTTPException(status_code=400, detail="Name is managed by LDAP") current_user.name = data.name - current_user.updated_at = datetime.utcnow().isoformat() + current_user.updated_at = datetime.utcnow() db.commit() return {"message": "Profile updated"} @@ -325,11 +264,11 @@ def get_settings(current_user=Depends(get_current_user)): """Get current user's settings""" return { "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_daily_parking": get_notification_default(current_user.notify_daily_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, True), "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_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)): """Update current user's settings""" if data.week_start_day is not None: - if data.week_start_day not in [0, 1]: - raise HTTPException(status_code=400, detail="Week start must be 0 (Sunday) or 1 (Monday)") + if data.week_start_day not in [0, 6]: + raise HTTPException(status_code=400, detail="Week start must be 0 (Monday) or 6 (Sunday)") current_user.week_start_day = data.week_start_day # 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: current_user.notify_parking_changes = data.notify_parking_changes - current_user.updated_at = datetime.utcnow().isoformat() + current_user.updated_at = datetime.utcnow() db.commit() return { "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)) current_user.password_hash = hash_password(data.new_password) - current_user.updated_at = datetime.utcnow().isoformat() + current_user.updated_at = datetime.utcnow() db.commit() config.logger.info(f"User {current_user.email} changed password") return {"message": "Password changed"} diff --git a/compose.yml b/compose.yml index 425989d..cd61b3e 100644 --- a/compose.yml +++ b/compose.yml @@ -3,8 +3,6 @@ services: build: . container_name: parking restart: unless-stopped - ports: - - "8000:8000" volumes: - ./data:/app/data env_file: @@ -21,6 +19,13 @@ services: start_period: 10s networks: - 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: org-network: diff --git a/create_test_db.py b/create_test_db.py deleted file mode 100644 index dacb070..0000000 --- a/create_test_db.py +++ /dev/null @@ -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") diff --git a/database/models.py b/database/models.py index de3f2df..9644375 100644 --- a/database/models.py +++ b/database/models.py @@ -2,12 +2,73 @@ SQLAlchemy ORM Models 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 datetime import datetime, date 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): """Application users""" __tablename__ = "users" @@ -16,34 +77,30 @@ class User(Base): email = Column(Text, unique=True, nullable=False) password_hash = Column(Text) name = Column(Text) - role = Column(Text, nullable=False, default="employee") # admin, manager, employee - manager_id = Column(Text, ForeignKey("users.id")) # Who manages this user (any user can have a manager) - - # 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. + role = Column(Enum(UserRole, values_callable=lambda obj: [e.value for e in obj]), nullable=False, default=UserRole.EMPLOYEE) + office_id = Column(Text, ForeignKey("offices.id")) # Which office this user belongs to # 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 - notify_weekly_parking = Column(Integer, default=1) # Weekly parking summary (Friday at 12) - notify_daily_parking = Column(Integer, default=1) # Daily parking reminder + notify_weekly_parking = Column(Boolean, default=True) # Weekly parking summary (Friday at 12) + 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_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) - updated_at = Column(Text) + created_at = Column(DateTime, default=datetime.utcnow) + updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) # 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") assignments = relationship("DailyParkingAssignment", back_populates="user", foreign_keys="DailyParkingAssignment.user_id") __table_args__ = ( 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) user_id = Column(Text, ForeignKey("users.id", ondelete="CASCADE"), nullable=False) - date = Column(Text, nullable=False) # YYYY-MM-DD - status = Column(Text, nullable=False) # present, remote, absent - created_at = Column(Text) - updated_at = Column(Text) + date = Column(Date, nullable=False) + status = Column(Enum(PresenceStatus, values_callable=lambda obj: [e.value for e in obj]), nullable=False) # present, remote, absent + created_at = Column(DateTime, default=datetime.utcnow) + updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) # Relationships user = relationship("User", back_populates="presences") @@ -68,97 +125,100 @@ class UserPresence(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" id = Column(Text, primary_key=True) - date = Column(Text, nullable=False) # YYYY-MM-DD - spot_id = Column(Text, nullable=False) # A1, A2, B1, B2, etc. (prefix from manager) + date = Column(Date, nullable=False) + spot_id = Column(Text, nullable=False) # A1, A2, B1, B2, etc. (prefix from office) 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 - created_at = Column(Text) + office_id = Column(Text, ForeignKey("offices.id", ondelete="CASCADE"), nullable=False) # Office that owns the spot + created_at = Column(DateTime, default=datetime.utcnow) # Relationships user = relationship("User", back_populates="assignments", foreign_keys=[user_id]) - manager = relationship("User", foreign_keys=[manager_id]) + office = relationship("Office") __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_date_spot', 'date', 'spot_id'), ) -class ManagerClosingDay(Base): - """Specific date closing days for a manager's parking pool (holidays, special closures)""" - __tablename__ = "manager_closing_days" +class OfficeClosingDay(Base): + """Specific date closing days for an office's parking pool (holidays, special closures)""" + __tablename__ = "office_closing_days" id = Column(Text, primary_key=True) - manager_id = Column(Text, ForeignKey("users.id", ondelete="CASCADE"), nullable=False) - date = Column(Text, nullable=False) # YYYY-MM-DD + office_id = Column(Text, ForeignKey("offices.id", ondelete="CASCADE"), nullable=False) + date = Column(Date, nullable=False) + end_date = Column(Date) reason = Column(Text) # Relationships - manager = relationship("User") + office = relationship("Office", back_populates="closing_days") __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): - """Weekly recurring closing days for a manager's parking pool (e.g., Saturday and Sunday)""" - __tablename__ = "manager_weekly_closing_days" +class OfficeWeeklyClosingDay(Base): + """Weekly recurring closing days for an office's parking pool (e.g., Saturday and Sunday)""" + __tablename__ = "office_weekly_closing_days" id = Column(Text, primary_key=True) - manager_id = Column(Text, ForeignKey("users.id", ondelete="CASCADE"), nullable=False) - weekday = Column(Integer, nullable=False) # 0=Sunday, 1=Monday, ..., 6=Saturday + office_id = Column(Text, ForeignKey("offices.id", ondelete="CASCADE"), nullable=False) + weekday = Column(Integer, nullable=False) # 0=Sunday, 1=Monday, ..., 6=Saturday (Matches WeekDay Enum logic) # Relationships - manager = relationship("User") + office = relationship("Office", back_populates="weekly_closing_days") __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): - """Users guaranteed a parking spot when present (set by manager)""" + """Users guaranteed a parking spot when present (set by office manager)""" __tablename__ = "parking_guarantees" 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) - start_date = Column(Text) # Optional: YYYY-MM-DD (null = no start limit) - end_date = Column(Text) # Optional: YYYY-MM-DD (null = no end limit) - created_at = Column(Text) + start_date = Column(Date) # Optional (null = no start limit) + end_date = Column(Date) # Optional (null = no end limit) + notes = Column(Text, nullable=True) + created_at = Column(DateTime, default=datetime.utcnow) # Relationships - manager = relationship("User", foreign_keys=[manager_id]) + office = relationship("Office") user = relationship("User", foreign_keys=[user_id]) __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): - """Users excluded from parking assignment (set by manager)""" + """Users excluded from parking assignment (set by office manager)""" __tablename__ = "parking_exclusions" 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) - start_date = Column(Text) # Optional: YYYY-MM-DD (null = no start limit) - end_date = Column(Text) # Optional: YYYY-MM-DD (null = no end limit) - created_at = Column(Text) + start_date = Column(Date) # Optional + end_date = Column(Date) # Optional + notes = Column(Text, nullable=True) + created_at = Column(DateTime, default=datetime.utcnow) # Relationships - manager = relationship("User", foreign_keys=[manager_id]) + office = relationship("Office") user = relationship("User", foreign_keys=[user_id]) __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) 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 - reference_date = Column(Text) # The date/week this notification refers to (YYYY-MM-DD or YYYY-Www) - sent_at = Column(Text, nullable=False) + 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) - keeping as Text for flexibility + sent_at = Column(DateTime, default=datetime.utcnow) __table_args__ = ( 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) 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) body = Column(Text, nullable=False) - created_at = Column(Text, nullable=False) - sent_at = Column(Text) # null = not sent yet + created_at = Column(DateTime, default=datetime.utcnow) + sent_at = Column(DateTime) # null = not sent yet __table_args__ = ( Index('idx_queue_pending', 'sent_at'), diff --git a/deploy/Caddyfile.snippet b/deploy/Caddyfile.snippet deleted file mode 100644 index 0f589ca..0000000 --- a/deploy/Caddyfile.snippet +++ /dev/null @@ -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 -} diff --git a/deploy/DEPLOY.md b/deploy/DEPLOY.md deleted file mode 100644 index 07e9a20..0000000 --- a/deploy/DEPLOY.md +++ /dev/null @@ -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 diff --git a/deploy/compose.production.yml b/deploy/compose.production.yml deleted file mode 100644 index 6c3a5fc..0000000 --- a/deploy/compose.production.yml +++ /dev/null @@ -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 diff --git a/frontend/assets/parking-map.png b/frontend/assets/parking-map.png new file mode 100644 index 0000000..b4669c3 Binary files /dev/null and b/frontend/assets/parking-map.png differ diff --git a/frontend/css/styles.css b/frontend/css/styles.css index f1c1ea1..3a36154 100644 --- a/frontend/css/styles.css +++ b/frontend/css/styles.css @@ -30,7 +30,9 @@ /* ============================================================================ Reset & Base ============================================================================ */ -*, *::before, *::after { +*, +*::before, +*::after { box-sizing: border-box; margin: 0; padding: 0; @@ -54,7 +56,9 @@ button { cursor: pointer; } -input, select, textarea { +input, +select, +textarea { font-family: inherit; font-size: inherit; } @@ -431,11 +435,12 @@ input, select, textarea { width: 100%; max-width: 480px; max-height: 90vh; - overflow: auto; + overflow-y: auto; + overflow-x: hidden; } .modal-small { - max-width: 360px; + max-width: 420px; } .modal-header { @@ -612,16 +617,23 @@ input, select, textarea { .calendar-day .parking-badge { position: absolute; - bottom: 0.25rem; - left: 50%; - transform: translateX(-50%); + bottom: 6px; + left: 4px; + right: 4px; background: #dbeafe; color: #1e40af; - font-size: 0.6rem; + font-size: 0.8rem; font-weight: 600; - padding: 0.1rem 0.3rem; - border-radius: 3px; + padding: 0.3rem 0; + border-radius: 6px; + border: 1px solid #93c5fd; 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 */ @@ -644,6 +656,28 @@ input, select, textarea { 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 { display: flex; @@ -975,11 +1009,11 @@ input, select, textarea { border-radius: 50%; } -.toggle-switch input:checked + .toggle-slider { +.toggle-switch input:checked+.toggle-slider { background-color: var(--primary); } -.toggle-switch input:checked + .toggle-slider:before { +.toggle-switch input:checked+.toggle-slider:before { transform: translateX(22px); } @@ -1747,3 +1781,24 @@ input, select, textarea { 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; + } +} \ No newline at end of file diff --git a/frontend/js/admin-offices.js b/frontend/js/admin-offices.js new file mode 100644 index 0000000..87746d1 --- /dev/null +++ b/frontend/js/admin-offices.js @@ -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 = 'Nessun ufficio trovato'; + return; + } + + tbody.innerHTML = offices.map(office => { + return ` + + ${office.name} + ${office.parking_quota} posti + ${office.spot_prefix || '-'} + ${office.user_count || 0} utenti + + + + + + `; + }).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; diff --git a/frontend/js/admin-users.js b/frontend/js/admin-users.js index b8dfd2a..b286a53 100644 --- a/frontend/js/admin-users.js +++ b/frontend/js/admin-users.js @@ -1,11 +1,12 @@ /** * Admin Users Page - * Manage users with LDAP-aware editing + * Manage users with LDAP-aware editing and Office assignment */ let currentUser = null; let users = []; -let managers = []; +let offices = []; +let currentSort = { column: 'name', direction: 'asc' }; document.addEventListener('DOMContentLoaded', async () => { currentUser = await api.requireAuth(); @@ -16,15 +17,15 @@ document.addEventListener('DOMContentLoaded', async () => { return; } - await loadManagers(); + await loadOffices(); await loadUsers(); setupEventListeners(); }); -async function loadManagers() { - const response = await api.get('/api/managers'); +async function loadOffices() { + const response = await api.get('/api/offices'); 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.email || '').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) { - tbody.innerHTML = 'No users found'; + tbody.innerHTML = 'Nessun utente trovato'; return; } tbody.innerHTML = filtered.map(user => { const ldapBadge = user.is_ldap_user ? 'LDAP' : ''; - const managerInfo = user.role === 'manager' - ? `${user.managed_user_count || 0} users` - : (user.manager_name || '-'); + const officeInfo = user.office_name || '-'; return ` ${user.name || '-'} ${ldapBadge} ${user.email} ${user.role} - ${managerInfo} + ${officeInfo} + ${user.parking_ratio !== null ? user.parking_ratio.toFixed(2) : '-'} - - + + `; @@ -93,20 +124,16 @@ async function editUser(userId) { document.getElementById('editName').value = user.name || ''; document.getElementById('editEmail').value = user.email; 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 - const managerSelect = document.getElementById('editManager'); - managerSelect.innerHTML = ''; - managers.forEach(m => { - if (m.id !== userId) { // Can't be own manager - const option = document.createElement('option'); - option.value = m.id; - option.textContent = m.name; - if (m.id === user.manager_id) option.selected = true; - managerSelect.appendChild(option); - } + // Populate office dropdown + const officeSelect = document.getElementById('editOffice'); + officeSelect.innerHTML = ''; + offices.forEach(o => { + const option = document.createElement('option'); + option.value = o.id; + option.textContent = o.name; + if (o.id === user.office_id) option.selected = true; + officeSelect.appendChild(option); }); // Handle LDAP restrictions @@ -126,13 +153,7 @@ async function editUser(userId) { roleSelect.disabled = isLdapAdmin; document.getElementById('roleHelp').style.display = isLdapAdmin ? 'block' : 'none'; - // Manager group - show for all users (admins can also be assigned to a manager) - 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('userModalTitle').textContent = 'Modifica Utente'; document.getElementById('userModal').style.display = 'flex'; } @@ -140,15 +161,15 @@ async function deleteUser(userId) { const user = users.find(u => u.id === userId); 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}`); if (response && response.ok) { - utils.showMessage('User deleted', 'success'); + utils.showMessage('Utente eliminato', 'success'); await loadUsers(); } else { 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); }); - // 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 document.getElementById('closeUserModal').addEventListener('click', () => { document.getElementById('userModal').style.display = 'none'; @@ -183,7 +197,7 @@ function setupEventListeners() { const data = { 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) @@ -192,23 +206,31 @@ function setupEventListeners() { 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); if (response && response.ok) { document.getElementById('userModal').style.display = 'none'; - utils.showMessage('User updated', 'success'); - await loadManagers(); // Reload in case role changed + utils.showMessage('Utente aggiornato', 'success'); await loadUsers(); } else { 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 diff --git a/frontend/js/api.js b/frontend/js/api.js index 896bf4e..8fa4dad 100644 --- a/frontend/js/api.js +++ b/frontend/js/api.js @@ -158,7 +158,7 @@ const api = { } 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(); - return { success: false, error: error.detail || 'Registration failed' }; + return { success: false, error: error.detail || 'Registrazione fallita' }; }, /** diff --git a/frontend/js/modal-logic.js b/frontend/js/modal-logic.js new file mode 100644 index 0000000..4d0cee8 --- /dev/null +++ b/frontend/js/modal-logic.js @@ -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 = `Parcheggio: 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 = ''; + + // 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 = ''; + select.innerHTML += ''; + + 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'; + } +}; diff --git a/frontend/js/nav.js b/frontend/js/nav.js index d11b891..c73c865 100644 --- a/frontend/js/nav.js +++ b/frontend/js/nav.js @@ -38,14 +38,20 @@ const ICONS = { + `, + settings: ` + + ` }; const NAV_ITEMS = [ - { href: '/presence', icon: 'calendar', label: 'My Presence' }, - { href: '/team-calendar', icon: 'users', label: 'Team Calendar' }, - { href: '/team-rules', icon: 'rules', label: 'Team Rules', roles: ['admin', 'manager'] }, - { href: '/admin/users', icon: 'user', label: 'Manage Users', roles: ['admin'] } + { href: '/presence', icon: 'calendar', label: 'La mia presenza' }, + { href: '/team-calendar', icon: 'users', label: 'Calendario del team' }, + { href: '/team-rules', icon: 'rules', label: 'Regole parcheggio', roles: ['admin', 'manager'] }, + { 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) { @@ -108,7 +114,7 @@ function setupMobileMenu() { const menuToggle = document.createElement('button'); menuToggle.className = 'menu-toggle'; menuToggle.innerHTML = MENU_ICON; - menuToggle.setAttribute('aria-label', 'Toggle menu'); + menuToggle.setAttribute('aria-label', 'Apri/Chiudi menu'); pageHeader.insertBefore(menuToggle, pageHeader.firstChild); // Add overlay diff --git a/frontend/js/parking-settings.js b/frontend/js/parking-settings.js new file mode 100644 index 0000000..c4b6081 --- /dev/null +++ b/frontend/js/parking-settings.js @@ -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'); + }); +} diff --git a/frontend/js/presence.js b/frontend/js/presence.js index 7d5322f..c1dc210 100644 --- a/frontend/js/presence.js +++ b/frontend/js/presence.js @@ -8,14 +8,31 @@ let currentDate = new Date(); let presenceData = {}; let parkingData = {}; let currentAssignmentId = null; +let weeklyClosingDays = []; +let specificClosingDays = []; +let statusDate = new Date(); +let statusViewMode = 'daily'; document.addEventListener('DOMContentLoaded', async () => { currentUser = await api.requireAuth(); 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(); setupEventListeners(); + + // Initialize Parking Status + initParkingStatus(); + setupStatusListeners(); }); 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() { const year = currentDate.getFullYear(); 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 document.getElementById('currentMonth').textContent = `${utils.getMonthName(month)} ${year}`; @@ -78,7 +117,7 @@ function renderCalendar() { grid.innerHTML = ''; // 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 = []; for (let i = 0; i < 7; i++) { dayNames.push(allDayNames[(weekStartDay + i) % 7]); @@ -120,7 +159,25 @@ function renderCalendar() { if (isHoliday) cell.classList.add('holiday'); 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}`); } @@ -134,140 +191,60 @@ function renderCalendar() { ${parkingBadge} `; - cell.addEventListener('click', () => openDayModal(dateStr, presence, parking)); + if (!isClosed) { + cell.addEventListener('click', () => openDayModal(dateStr, presence, parking)); + } grid.appendChild(cell); } } function openDayModal(dateStr, presence, parking) { - const modal = document.getElementById('dayModal'); - const title = document.getElementById('dayModalTitle'); - - title.textContent = utils.formatDateDisplay(dateStr); - - // 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'); - } + ModalLogic.openModal({ + dateStr, + presence, + parking }); - - // 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 = `Parking: 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) { - const modal = document.getElementById('dayModal'); - const date = modal.dataset.date; - +async function handleMarkPresence(status, date) { const response = await api.post('/api/presence/mark', { date, status }); if (response && response.ok) { await Promise.all([loadPresences(), loadParkingAssignments()]); renderCalendar(); - modal.style.display = 'none'; + ModalLogic.closeModal(); } else { const error = await response.json(); - alert(error.detail || 'Failed to mark presence'); + alert(error.detail || 'Impossibile segnare la presenza'); } } -async function clearPresence() { - const modal = document.getElementById('dayModal'); - const date = modal.dataset.date; - - if (!confirm('Clear presence for this date?')) return; - +async function handleClearPresence(date) { const response = await api.delete(`/api/presence/${date}`); if (response && response.ok) { await Promise.all([loadPresences(), loadParkingAssignments()]); renderCalendar(); - modal.style.display = 'none'; + ModalLogic.closeModal(); } } -async function releaseParking() { - const modal = document.getElementById('dayModal'); - const releaseBtn = document.getElementById('releaseParkingBtn'); - const assignmentId = releaseBtn.dataset.assignmentId; - - if (!assignmentId) return; - if (!confirm('Release your parking spot for this date?')) return; +async function handleReleaseParking(assignmentId) { + if (!confirm('Rilasciare il parcheggio per questa data?')) return; const response = await api.post(`/api/parking/release-my-spot/${assignmentId}`); - if (response && response.ok) { await loadParkingAssignments(); renderCalendar(); - modal.style.display = 'none'; + ModalLogic.closeModal(); } else { const error = await response.json(); - alert(error.detail || 'Failed to release parking spot'); + alert(error.detail || 'Impossibile rilasciare il parcheggio'); } } -async function openReassignModal() { - const assignmentId = currentAssignmentId; - 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 = ''; - - if (users.length === 0) { - select.innerHTML = ''; - } 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; - +async function handleReassignParking(assignmentId, newUserId) { + // Basic validation handled by select; confirm if (!assignmentId || !newUserId) { - alert('Please select a user'); + alert('Seleziona un utente'); return; } @@ -279,13 +256,15 @@ async function confirmReassign() { if (response && response.ok) { await loadParkingAssignments(); renderCalendar(); - document.getElementById('reassignModal').style.display = 'none'; + ModalLogic.closeModal(); } else { const error = await response.json(); - alert(error.detail || 'Failed to reassign parking spot'); + alert(error.detail || 'Impossibile riassegnare il parcheggio'); } } + + function setupEventListeners() { // Month navigation document.getElementById('prevMonth').addEventListener('click', async () => { @@ -300,69 +279,255 @@ function setupEventListeners() { renderCalendar(); }); - // Day modal - document.getElementById('closeDayModal').addEventListener('click', () => { - document.getElementById('dayModal').style.display = 'none'; + // Quick Entry Logic + const quickEntryModal = document.getElementById('quickEntryModal'); + 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 => { - btn.addEventListener('click', () => markPresence(btn.dataset.status)); - }); + if (quickEntryForm) { + quickEntryForm.addEventListener('submit', async (e) => { + e.preventDefault(); - document.getElementById('clearDayBtn').addEventListener('click', clearPresence); - document.getElementById('releaseParkingBtn').addEventListener('click', releaseParking); - document.getElementById('reassignParkingBtn').addEventListener('click', openReassignModal); + const startStr = document.getElementById('qeStartDate').value; + const endStr = document.getElementById('qeEndDate').value; + 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 - 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'); + const startDate = new Date(startStr); + const endDate = new Date(endStr); - // Bulk mark - document.getElementById('bulkMarkBtn').addEventListener('click', () => { - document.getElementById('bulkMarkModal').style.display = 'flex'; - }); + if (endDate < startDate) return utils.showMessage('La data di fine non può essere precedente alla data di inizio', 'error'); - document.getElementById('closeBulkModal').addEventListener('click', () => { - document.getElementById('bulkMarkModal').style.display = 'none'; - }); + quickEntryModal.style.display = 'none'; + utils.showMessage('Inserimento in corso...', 'warning'); - document.getElementById('cancelBulk').addEventListener('click', () => { - document.getElementById('bulkMarkModal').style.display = 'none'; - }); + const promises = []; + 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) => { - e.preventDefault(); + try { + 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; - const status = document.getElementById('bulkStatus').value; - const weekdaysOnly = document.getElementById('weekdaysOnly').checked; +// ---------------------------------------------------------------------------- +// Parking Status Logic +// ---------------------------------------------------------------------------- - const data = { start_date: startDate, end_date: endDate, status }; - if (weekdaysOnly) { - data.days = [1, 2, 3, 4, 5]; // Mon-Fri (JS weekday) - } +function initParkingStatus() { + updateStatusHeader(); + 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 = '
Caricamento...
'; + + try { + const response = await api.get(`/api/parking/assignments/${dateStr}?office_id=${officeId}`); if (response && response.ok) { - const results = await response.json(); - alert(`Marked ${results.length} dates`); - document.getElementById('bulkMarkModal').style.display = 'none'; - await Promise.all([loadPresences(), loadParkingAssignments()]); - renderCalendar(); + const assignments = await response.json(); + renderParkingStatus(assignments); } else { - const error = await response.json(); - alert(error.detail || 'Failed to bulk mark'); + if (grid) grid.innerHTML = '
Impossibile caricare i dati.
'; + } + } catch (e) { + console.error("Error loading parking status", e); + if (grid) grid.innerHTML = '
Errore di caricamento.
'; + } +} + +function renderParkingStatus(assignments) { + const grid = document.getElementById('spotsGrid'); + if (!grid) return; + + grid.innerHTML = ''; + + if (!assignments || assignments.length === 0) { + grid.innerHTML = '
Nessun posto configurato o disponibile.
'; + 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 = ` +
${spotName}
+
+ ${statusText} +
+ `; + + 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(); } }); } + + diff --git a/frontend/js/team-calendar.js b/frontend/js/team-calendar.js index c8eaa59..9c6684a 100644 --- a/frontend/js/team-calendar.js +++ b/frontend/js/team-calendar.js @@ -1,72 +1,118 @@ /** * Team Calendar Page * Shows presence and parking for all team members - * Filtered by manager (manager-centric model) + * Filtered by office (office-centric model) */ let currentUser = null; let currentStartDate = null; let viewMode = 'week'; // 'week' or 'month' -let managers = []; +let offices = []; let teamData = []; let parkingDataLookup = {}; let parkingAssignmentLookup = {}; let selectedUserId = null; let selectedDate = null; let currentAssignmentId = null; +let officeClosingRules = {}; // { officeId: { weekly: [], specific: [] } } document.addEventListener('DOMContentLoaded', async () => { currentUser = await api.requireAuth(); if (!currentUser) return; // 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); - await loadManagers(); + await loadOffices(); await loadTeamData(); + await loadTeamData(); + + // Initialize Modal Logic + ModalLogic.init({ + onMarkPresence: handleMarkPresence, + onClearPresence: handleClearPresence, + onReleaseParking: handleReleaseParking, + onReassignParking: handleReassignParking + }); + renderCalendar(); setupEventListeners(); }); -async function loadManagers() { - const response = await api.get('/api/managers'); - if (response && response.ok) { - managers = await response.json(); - const select = document.getElementById('managerFilter'); +function updateOfficeDisplay() { + const display = document.getElementById('currentOfficeNameDisplay'); + if (!display) return; - // Filter managers based on user role - let filteredManagers = managers; + const select = document.getElementById('officeFilter'); + + // 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') { - // Manager only sees themselves - filteredManagers = managers.filter(m => m.id === currentUser.id); - } else if (currentUser.role === 'employee') { - // Employee only sees their own manager - if (currentUser.manager_id) { - filteredManagers = managers.filter(m => m.id === currentUser.manager_id); + // Manager only sees their own office in the filter? + // Actually managers might want to filter if they (hypothetically) managed multiple, + // but currently User has 1 office. + if (currentUser.office_id) { + filteredOffices = offices.filter(o => o.id === currentUser.office_id); } else { - filteredManagers = []; + filteredOffices = []; } } - filteredManagers.forEach(manager => { + filteredOffices.forEach(office => { const option = document.createElement('option'); - option.value = manager.id; - const userCount = manager.managed_user_count || 0; - option.textContent = `${manager.name} (${userCount} users)`; + option.value = office.id; + option.textContent = `${office.name} (${office.user_count || 0} utenti)`; select.appendChild(option); }); - // Auto-select for managers and employees (they only see their team) - if (filteredManagers.length === 1) { - select.value = filteredManagers[0].id; - } - - // Hide manager filter for employees (they can only see their team) - if (currentUser.role === 'employee') { - select.style.display = 'none'; + // Auto-select for managers + if (currentUser.role === 'manager' && filteredOffices.length === 1) { + select.value = filteredOffices[0].id; } } + + // Initial update of office display + updateOfficeDisplay(); } function getDateRange() { @@ -85,15 +131,16 @@ function getDateRange() { } async function loadTeamData() { + await loadClosingData(); const { startDate, endDate } = getDateRange(); const startStr = utils.formatDate(startDate); const endStr = utils.formatDate(endDate); let url = `/api/presence/team?start_date=${startStr}&end_date=${endStr}`; - const managerFilter = document.getElementById('managerFilter').value; - if (managerFilter) { - url += `&manager_id=${managerFilter}`; + const officeFilter = document.getElementById('officeFilter').value; + if (officeFilter) { + url += `&office_id=${officeFilter}`; } 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() { const header = document.getElementById('calendarHeader'); const body = document.getElementById('calendarBody'); @@ -132,8 +241,8 @@ function renderCalendar() { const dayCount = Math.round((endDate - startDate) / (1000 * 60 * 60 * 24)) + 1; // Build header row - const dayNames = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']; - let headerHtml = 'NameManager'; + const dayNames = ['Dom', 'Lun', 'Mar', 'Mer', 'Gio', 'Ven', 'Sab']; + let headerHtml = 'NomeUfficio'; for (let i = 0; i < dayCount; i++) { const date = new Date(startDate); @@ -141,13 +250,19 @@ function renderCalendar() { const dayOfWeek = date.getDay(); const isWeekend = dayOfWeek === 0 || dayOfWeek === 6; const isHoliday = utils.isItalianHoliday(date); - const isToday = date.toDateString() === new Date().toDateString(); + const isToday = utils.formatDate(date) === utils.formatDate(new Date()); let classes = []; if (isWeekend) classes.push('weekend'); if (isHoliday) classes.push('holiday'); 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 += `
${dayNames[dayOfWeek].charAt(0)}
${date.getDate()}
@@ -157,7 +272,7 @@ function renderCalendar() { // Build body rows if (teamData.length === 0) { - body.innerHTML = `No team members found`; + body.innerHTML = `Nessun membro del team trovato`; return; } @@ -165,7 +280,7 @@ function renderCalendar() { teamData.forEach(member => { bodyHtml += ` ${member.name || 'Unknown'} - ${member.manager_name || '-'}`; + ${member.office_name || '-'}`; for (let i = 0; i < dayCount; i++) { const date = new Date(startDate); @@ -179,7 +294,7 @@ function renderCalendar() { const parkingKey = `${member.id}_${dateStr}`; const parkingSpot = parkingDataLookup[parkingKey]; 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']; if (isWeekend) cellClasses.push('weekend'); @@ -187,6 +302,47 @@ function renderCalendar() { if (isToday) cellClasses.push('today'); 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' let parkingBadge = ''; if (hasParking) { @@ -194,7 +350,7 @@ function renderCalendar() { parkingBadge = `${spotName}`; } - bodyHtml += `${parkingBadge}`; + bodyHtml += `${parkingBadge}`; } bodyHtml += ''; @@ -205,12 +361,14 @@ function renderCalendar() { if (currentUser.role === 'admin' || currentUser.role === 'manager') { body.querySelectorAll('.calendar-cell').forEach(cell => { cell.style.cursor = 'pointer'; - cell.addEventListener('click', () => { - const userId = cell.dataset.userId; - const date = cell.dataset.date; - const userName = cell.dataset.userName; - openDayModal(userId, date, userName); - }); + if (cell.dataset.closed !== 'true') { + cell.addEventListener('click', () => { + const userId = cell.dataset.userId; + const date = cell.dataset.date; + const userName = cell.dataset.userName; + openDayModal(userId, date, userName); + }); + } }); } } @@ -219,129 +377,107 @@ function openDayModal(userId, dateStr, userName) { selectedUserId = userId; selectedDate = dateStr; - const modal = document.getElementById('dayModal'); - document.getElementById('dayModalTitle').textContent = utils.formatDateDisplay(dateStr); - document.getElementById('dayModalUser').textContent = userName; - // Find current status and parking const member = teamData.find(m => m.id === userId); const presence = member?.presences.find(p => p.date === dateStr); const parkingKey = `${userId}_${dateStr}`; const parkingSpot = parkingDataLookup[parkingKey]; const assignmentId = parkingAssignmentLookup[parkingKey]; + currentAssignmentId = assignmentId; // Ensure this is set for modal logic - // Highlight current 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'); - } + const parkingObj = assignmentId ? { + id: assignmentId, + spot_display_name: parkingSpot, + spot_id: parkingSpot + } : null; + + 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 = `Parking: Spot ${parkingSpot}`; - currentAssignmentId = assignmentId; - } else { - parkingSection.style.display = 'none'; - currentAssignmentId = null; - } - - modal.style.display = 'flex'; } -async function markPresence(status) { - if (!selectedUserId || !selectedDate) return; +async function handleMarkPresence(status, date, userId) { + // 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', { - user_id: selectedUserId, - date: selectedDate, + user_id: targetUserId, + date: date, status: status }); if (response && response.ok) { - document.getElementById('dayModal').style.display = 'none'; + ModalLogic.closeModal(); await loadTeamData(); renderCalendar(); } else { const error = await response.json(); - alert(error.detail || 'Failed to mark presence'); + alert(error.detail || 'Impossibile segnare la presenza'); } } -async function clearPresence() { - if (!selectedUserId || !selectedDate) return; - if (!confirm('Clear presence for this date?')) return; +async function handleClearPresence(date, userId) { + const targetUserId = userId || selectedUserId; + 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) { - document.getElementById('dayModal').style.display = 'none'; + ModalLogic.closeModal(); await loadTeamData(); renderCalendar(); } } -async function openReassignModal() { - if (!currentAssignmentId) return; +async function handleReleaseParking(assignmentId) { + if (!confirm('Rilasciare il parcheggio per questa data?')) return; - // Load eligible users - const response = await api.get(`/api/parking/eligible-users/${currentAssignmentId}`); - if (!response || !response.ok) { - const error = await response.json(); - alert(error.detail || 'Failed to load eligible users'); - return; - } + // Note: Admin endpoint for releasing ANY spot vs "my spot" + // Since we are admin/manager here, we might need a general release endpoint or use reassign with null? + // The current 'release_my_spot' is only for self. + // 'reassign_spot' with null user_id is the way for admins. - const users = await response.json(); - const select = document.getElementById('reassignUser'); - select.innerHTML = ''; + const response = await api.post('/api/parking/reassign-spot', { + assignment_id: assignmentId, + new_user_id: null // Release + }); - if (users.length === 0) { - select.innerHTML = ''; + if (response && response.ok) { + ModalLogic.closeModal(); + await loadTeamData(); + renderCalendar(); } else { - users.forEach(user => { - const option = document.createElement('option'); - option.value = user.id; - option.textContent = user.name; - select.appendChild(option); - }); + const error = await response.json(); + alert(error.detail || 'Impossibile rilasciare il parcheggio'); } - - // 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) { - alert('Please select a user'); +async function handleReassignParking(assignmentId, newUserId) { + if (!assignmentId || !newUserId) { + alert('Seleziona un utente'); return; } const response = await api.post('/api/parking/reassign-spot', { - assignment_id: currentAssignmentId, + assignment_id: assignmentId, new_user_id: newUserId }); if (response && response.ok) { await loadTeamData(); renderCalendar(); - document.getElementById('reassignModal').style.display = 'none'; + ModalLogic.closeModal(); } else { 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); } else { // 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); } await loadTeamData(); renderCalendar(); }); - // Manager filter - document.getElementById('managerFilter').addEventListener('change', async () => { + + // Office filter + document.getElementById('officeFilter').addEventListener('change', async () => { + updateOfficeDisplay(); // Update label on change await loadTeamData(); 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'); - - // 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'); } diff --git a/frontend/js/team-rules.js b/frontend/js/team-rules.js index a812ec4..f7a8642 100644 --- a/frontend/js/team-rules.js +++ b/frontend/js/team-rules.js @@ -1,370 +1,383 @@ /** * Team Rules Page - * Manage closing days, parking guarantees, and exclusions - * - * Rules are set at manager level for their parking pool. + * Manage closing days, guarantees, and exclusions + * Office-centric model */ let currentUser = null; -let selectedManagerId = null; -let managerUsers = []; +let offices = []; +let currentOfficeId = null; +let officeUsers = []; +let currentWeeklyClosingDays = []; document.addEventListener('DOMContentLoaded', async () => { currentUser = await api.requireAuth(); if (!currentUser) return; - // Only managers and admins can access - if (currentUser.role === 'employee') { + // Only admins and managers can access this page + if (currentUser.role !== 'admin' && currentUser.role !== 'manager') { window.location.href = '/presence'; return; } - await loadManagers(); + await loadOffices(); setupEventListeners(); }); -async function loadManagers() { - const response = await api.get('/api/managers'); - if (response && response.ok) { - const managers = await response.json(); - const select = document.getElementById('managerSelect'); +async function loadOffices() { + const select = document.getElementById('officeSelect'); + const card = document.getElementById('officeSelectionCard'); - // Filter to managers this user can see - let filteredManagers = managers; + // Only Admins can see the office selector + 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') { - // Manager only sees themselves - filteredManagers = managers.filter(m => m.id === currentUser.id); + // Manager only sees their own office + if (currentUser.office_id) { + filteredOffices = offices.filter(o => o.id === currentUser.office_id); + } else { + filteredOffices = []; + } } - // Show managers in dropdown - let totalManagers = 0; - let firstManagerId = null; - - filteredManagers.forEach(manager => { + filteredOffices.forEach(office => { const option = document.createElement('option'); - option.value = manager.id; - // Show manager name with user count and parking quota - const userCount = manager.managed_user_count || 0; - const quota = manager.parking_quota || 0; - option.textContent = `${manager.name} (${userCount} users, ${quota} spots)`; + option.value = office.id; + option.textContent = office.name; select.appendChild(option); - totalManagers++; - if (!firstManagerId) firstManagerId = manager.id; }); - // Auto-select if only one manager - if (totalManagers === 1 && firstManagerId) { - select.value = firstManagerId; - await selectManager(firstManagerId); + // Auto-select for managers + if (currentUser.role === 'manager' && filteredOffices.length === 1) { + select.value = filteredOffices[0].id; + loadOfficeRules(filteredOffices[0].id); } } } -async function selectManager(managerId) { - selectedManagerId = managerId; - - if (!managerId) { +async function loadOfficeRules(officeId) { + if (!officeId) { document.getElementById('rulesContent').style.display = 'none'; - document.getElementById('noManagerMessage').style.display = 'block'; + document.getElementById('noOfficeMessage').style.display = 'block'; return; } + currentOfficeId = officeId; 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([ - loadWeeklyClosingDays(), - loadClosingDays(), - loadGuarantees(), - loadExclusions(), - loadManagerUsers() + loadWeeklyClosingDays(officeId), + loadClosingDays(officeId), + loadGuarantees(officeId), + loadExclusions(officeId) ]); } -async function loadWeeklyClosingDays() { - const response = await api.get(`/api/managers/${selectedManagerId}/weekly-closing-days`); +async function loadOfficeUsers(officeId) { + 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) { 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 => { const weekday = parseInt(cb.dataset.weekday); - cb.checked = weekdays.includes(weekday); + cb.checked = activeWeekdays.includes(weekday); }); } } -async function loadManagerUsers() { - const response = await api.get(`/api/managers/${selectedManagerId}/users`); - if (response && response.ok) { - managerUsers = await response.json(); - updateUserSelects(); +async function saveWeeklyClosingDays() { + const btn = document.getElementById('saveWeeklyClosingDaysBtn'); + if (!btn) return; + + 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 = ''; - 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'); if (response && response.ok) { const days = await response.json(); + if (days.length === 0) { - container.innerHTML = ''; + container.innerHTML = '

Nessun giorno di chiusura specifico.

'; return; } container.innerHTML = days.map(day => `
- ${utils.formatDateDisplay(day.date)} - ${day.reason ? `${day.reason}` : ''} + ${utils.formatDateDisplay(day.date)}${day.end_date ? ' - ' + utils.formatDateDisplay(day.end_date) : ''} + ${day.reason ? `${day.reason}` : ''}
`).join(''); } } -function formatDateRange(startDate, endDate) { - if (!startDate && !endDate) return ''; - if (startDate && !endDate) return `From ${utils.formatDateDisplay(startDate)}`; - if (!startDate && endDate) return `Until ${utils.formatDateDisplay(endDate)}`; - return `${utils.formatDateDisplay(startDate)} - ${utils.formatDateDisplay(endDate)}`; +async function addClosingDay(data) { + const response = await api.post(`/api/offices/${currentOfficeId}/closing-days`, data); + if (response && response.ok) { + await loadClosingDays(currentOfficeId); + 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() { - const response = await api.get(`/api/managers/${selectedManagerId}/guarantees`); +async function deleteClosingDay(id) { + 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'); if (response && response.ok) { const guarantees = await response.json(); + if (guarantees.length === 0) { - container.innerHTML = ''; + container.innerHTML = '

Nessuna garanzia di parcheggio attiva.

'; return; } - container.innerHTML = guarantees.map(g => { - const dateRange = formatDateRange(g.start_date, g.end_date); - return ` + container.innerHTML = guarantees.map(g => `
- ${g.user_name} - ${dateRange ? `${dateRange}` : ''} + ${g.user_name || 'Utente sconosciuto'} + + ${g.start_date ? 'Dal ' + utils.formatDateDisplay(g.start_date) : 'Da sempre'} + ${g.end_date ? ' al ' + utils.formatDateDisplay(g.end_date) : ''} + + ${g.notes ? `${g.notes}` : ''}
- `}).join(''); + `).join(''); } } -async function loadExclusions() { - const response = await api.get(`/api/managers/${selectedManagerId}/exclusions`); - const container = document.getElementById('exclusionsList'); - +async function addGuarantee(data) { + const response = await api.post(`/api/offices/${currentOfficeId}/guarantees`, data); if (response && response.ok) { - const exclusions = await response.json(); - if (exclusions.length === 0) { - container.innerHTML = ''; - return; - } - - container.innerHTML = exclusions.map(e => { - const dateRange = formatDateRange(e.start_date, e.end_date); - return ` -
-
- ${e.user_name} - ${dateRange ? `${dateRange}` : ''} -
- -
- `}).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(); + await loadGuarantees(currentOfficeId); + document.getElementById('guaranteeModal').style.display = 'none'; + document.getElementById('guaranteeForm').reset(); + } else { + const error = await response.json(); + alert(error.detail || 'Impossibile aggiungere la garanzia'); } } async function deleteGuarantee(id) { - if (!confirm('Remove this parking guarantee?')) return; - const response = await api.delete(`/api/managers/${selectedManagerId}/guarantees/${id}`); + if (!confirm('Eliminare questa garanzia?')) return; + const response = await api.delete(`/api/offices/${currentOfficeId}/guarantees/${id}`); 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 = '

Nessuna esclusione attiva.

'; + return; + } + + container.innerHTML = exclusions.map(e => ` +
+
+ ${e.user_name || 'Utente sconosciuto'} + + ${e.start_date ? 'Dal ' + utils.formatDateDisplay(e.start_date) : 'Da sempre'} + ${e.end_date ? ' al ' + utils.formatDateDisplay(e.end_date) : ''} + + ${e.notes ? `${e.notes}` : ''} +
+ +
+ `).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) { - if (!confirm('Remove this parking exclusion?')) return; - const response = await api.delete(`/api/managers/${selectedManagerId}/exclusions/${id}`); + if (!confirm('Eliminare questa esclusione?')) return; + const response = await api.delete(`/api/offices/${currentOfficeId}/exclusions/${id}`); if (response && response.ok) { - await loadExclusions(); + await loadExclusions(currentOfficeId); } } -function setupEventListeners() { - // Manager selection - document.getElementById('managerSelect').addEventListener('change', (e) => { - selectManager(e.target.value); - }); +function populateUserSelects() { + const selects = ['guaranteeUser', 'exclusionUser']; + selects.forEach(id => { + const select = document.getElementById(id); + const currentVal = select.value; + select.innerHTML = ''; - // Weekly closing day checkboxes - document.querySelectorAll('#weeklyClosingDays input[type="checkbox"]').forEach(cb => { - cb.addEventListener('change', async (e) => { - const weekday = parseInt(e.target.dataset.weekday); - - 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; - } - } - } - } + officeUsers.forEach(user => { + const option = document.createElement('option'); + option.value = user.id; + option.textContent = user.name; + select.appendChild(option); }); - }); - // Modal openers - document.getElementById('addClosingDayBtn').addEventListener('click', () => { - document.getElementById('closingDayForm').reset(); - document.getElementById('closingDayModal').style.display = 'flex'; + if (currentVal) select.value = currentVal; }); - - 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.deleteGuarantee = deleteGuarantee; window.deleteExclusion = deleteExclusion; diff --git a/frontend/js/utils.js b/frontend/js/utils.js index 0ace644..27cbfc2 100644 --- a/frontend/js/utils.js +++ b/frontend/js/utils.js @@ -97,7 +97,7 @@ function formatDate(date) { */ function formatDateDisplay(dateStr) { const date = new Date(dateStr + 'T12:00:00'); - return date.toLocaleDateString('en-US', { + return date.toLocaleDateString('it-IT', { weekday: 'short', month: 'short', day: 'numeric' @@ -109,8 +109,8 @@ function formatDateDisplay(dateStr) { */ function getMonthName(month) { const months = [ - 'January', 'February', 'March', 'April', 'May', 'June', - 'July', 'August', 'September', 'October', 'November', 'December' + 'Gennaio', 'Febbraio', 'Marzo', 'Aprile', 'Maggio', 'Giugno', + 'Luglio', 'Agosto', 'Settembre', 'Ottobre', 'Novembre', 'Dicembre' ]; return months[month]; } @@ -119,7 +119,7 @@ function getMonthName(month) { * Get day name */ function getDayName(dayIndex) { - const days = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']; + const days = ['Dom', 'Lun', 'Mar', 'Mer', 'Gio', 'Ven', 'Sab']; return days[dayIndex]; } @@ -133,7 +133,7 @@ function getDaysInMonth(year, month) { /** * Get start of week for a date */ -function getWeekStart(date, weekStartDay = 0) { +function getWeekStart(date, weekStartDay = 1) { const d = new Date(date); const day = d.getDay(); const diff = (day - weekStartDay + 7) % 7; @@ -146,7 +146,7 @@ function getWeekStart(date, weekStartDay = 0) { * Format date as short display (e.g., "Nov 26") */ function formatDateShort(date) { - return date.toLocaleDateString('en-US', { + return date.toLocaleDateString('it-IT', { month: 'short', day: 'numeric' }); @@ -163,12 +163,14 @@ function showMessage(message, type = 'success', duration = 3000) { toastContainer.id = 'toastContainer'; toastContainer.style.cssText = ` position: fixed; - top: 1rem; - right: 1rem; + bottom: 2rem; + left: 50%; + transform: translateX(-50%); z-index: 9999; display: flex; flex-direction: column; - gap: 0.5rem; + gap: 1rem; + align-items: center; `; document.body.appendChild(toastContainer); } @@ -176,17 +178,21 @@ function showMessage(message, type = 'success', duration = 3000) { const toast = document.createElement('div'); toast.className = `message ${type}`; toast.style.cssText = ` - padding: 0.75rem 1rem; - border-radius: 6px; - box-shadow: 0 2px 8px rgba(0,0,0,0.15); - animation: slideIn 0.2s ease; + padding: 1rem 1.5rem; + border-radius: 8px; + box-shadow: 0 4px 12px rgba(0,0,0,0.2); + animation: slideInBottom 0.3s ease; + font-size: 1.1rem; + font-weight: 500; + min-width: 300px; + text-align: center; `; toast.textContent = message; toastContainer.appendChild(toast); if (duration > 0) { setTimeout(() => { - toast.style.animation = 'slideOut 0.2s ease'; + toast.style.animation = 'fadeOut 0.3s ease'; setTimeout(() => toast.remove(), 200); }, duration); } @@ -196,14 +202,7 @@ function showMessage(message, type = 'success', duration = 3000) { * Close modal when clicking outside */ function setupModalClose(modalId) { - const modal = document.getElementById(modalId); - if (modal) { - modal.addEventListener('click', (e) => { - if (e.target.id === modalId) { - modal.style.display = 'none'; - } - }); - } + // Behavior disabled: clicking outside does not close modal } // Export utilities diff --git a/frontend/pages/admin-offices.html b/frontend/pages/admin-offices.html new file mode 100644 index 0000000..f3b9eb4 --- /dev/null +++ b/frontend/pages/admin-offices.html @@ -0,0 +1,113 @@ + + + + + + + Gestione Uffici - Parking Manager + + + + + + + +
+ + +
+
+
+

Lista Uffici

+ +
+
+ + + + + + + + + + + +
NomeQuota PostiPrefissoUtentiAzioni
+
+
+
+
+ + + + + + + + + + + \ No newline at end of file diff --git a/frontend/pages/admin-users.html b/frontend/pages/admin-users.html index 720f183..d47eade 100644 --- a/frontend/pages/admin-users.html +++ b/frontend/pages/admin-users.html @@ -1,5 +1,6 @@ + @@ -7,31 +8,33 @@ +