Primo commit

This commit is contained in:
2026-01-13 11:20:12 +01:00
parent ce9e2fdf2a
commit 17453f5d13
51 changed files with 3883 additions and 2508 deletions

20
.dockerignore Normal file
View File

@@ -0,0 +1,20 @@
__pycache__
*.pyc
*.pyo
*.pyd
.Python
env/
venv/
.venv/
pip-log.txt
pip-delete-this-directory.txt
.env
.env.example
.git/
.gitignore
data/
.vscode/
.idea/
coverage.xml
*.coverage
.pytest_cache/

253
CLAUDE.md
View File

@@ -1,253 +0,0 @@
# CLAUDE.md - Project Intelligence
## Project Overview
**Org-Parking** is a manager-centric parking spot management system for organizations. It features fair parking assignment based on presence/parking ratio, supporting both standalone JWT authentication and Authelia/LLDAP SSO integration.
### Technology Stack
- **Backend:** FastAPI + SQLAlchemy + SQLite
- **Frontend:** Vanilla JavaScript (no frameworks)
- **Auth:** JWT tokens + Authelia SSO support
- **Containerization:** Docker + Docker Compose
- **Rate Limiting:** slowapi
### Architecture
```
app/
├── config.py → Configuration with logging and validation
└── routes/ → API endpoints (auth, users, managers, presence, parking)
services/ → Business logic (parking algorithm, auth, notifications)
database/ → SQLAlchemy models and connection
frontend/ → Static HTML pages + JS modules
utils/
├── auth_middleware.py → JWT/Authelia authentication
└── helpers.py → Shared utility functions
```
## Build & Run Commands
```bash
# Development
SECRET_KEY=dev-secret-key python -m uvicorn main:app --reload --host 0.0.0.0 --port 8000
# Docker
docker compose up -d
# Dependencies
pip install -r requirements.txt
# Initialize test database
python create_test_db.py
```
## Code Style & Conventions
### Python
- FastAPI async patterns with `Depends()` for dependency injection
- Pydantic models for request/response validation
- SQLAlchemy ORM (no raw SQL)
- Use `generate_uuid()` from `utils.helpers` for UUIDs
- Use `config.logger` for logging (not print statements)
- Dates stored as TEXT in "YYYY-MM-DD" format
### JavaScript
- ES6 modules with centralized API client (`/js/api.js`)
- Token stored in localStorage, auto-included in requests
- Utility functions in `/js/utils.js`
- Role-based navigation in `/js/nav.js`
### Authentication
- Dual mode: JWT tokens (standalone) or Authelia headers (SSO)
- LDAP users have `password_hash = None`
- Use helper: `is_ldap_user(user)` from `utils.helpers`
### Utility Functions (`utils/helpers.py`)
```python
from utils.helpers import (
generate_uuid, # Use instead of str(uuid.uuid4())
is_ldap_user, # Check if user is LDAP-managed
is_ldap_admin, # Check if user is LDAP admin
validate_password, # Returns list of validation errors
format_password_errors, # Format errors into user message
get_notification_default # Get setting value with default
)
```
---
## Configuration (`app/config.py`)
Configuration is environment-based with required validation:
### Required
- `SECRET_KEY` - **MUST** be set (app exits if missing)
### Security
- `RATE_LIMIT_REQUESTS` - Requests per window (default: 5)
- `RATE_LIMIT_WINDOW` - Window in seconds (default: 60)
### Email (org-stack pattern)
- `SMTP_ENABLED` - Set to `true` to enable SMTP sending
- When disabled, emails are logged to `EMAIL_LOG_FILE`
- Follows org-stack pattern: direct send with file fallback
### Logging
- `LOG_LEVEL` - DEBUG, INFO, WARNING, ERROR (default: INFO)
- Use `config.logger` for all logging
---
## Notifications (`services/notifications.py`)
Simplified notification service following org-stack pattern:
```python
from services.notifications import (
send_email, # Direct send or file fallback
notify_parking_assigned, # When spot assigned
notify_parking_released, # When spot released
notify_parking_reassigned, # When spot reassigned
send_presence_reminder, # Weekly presence reminder
send_weekly_parking_summary, # Friday parking summary
send_daily_parking_reminder, # Daily parking reminder
run_scheduled_notifications # Called by cron/scheduler
)
```
### Email Behavior
1. If `SMTP_ENABLED=true`: Send via SMTP
2. If SMTP fails or disabled: Log to `EMAIL_LOG_FILE`
3. Never throws - always returns success/failure
---
## Recent Improvements
### Security Enhancements
- **Required SECRET_KEY**: App exits if not set
- **Rate limiting**: Login/register endpoints limited to 5 req/min
- **Password validation**: Requires uppercase, lowercase, number, 8+ chars
- **Proper logging**: All security events logged
### Performance Optimizations
- **Fixed N+1 queries** in:
- `list_users()` - Batch query for manager names and counts
- `list_managers()` - Batch query for managed user counts
- `get_manager_guarantees()` - Batch query for user names
- `get_manager_exclusions()` - Batch query for user names
### Code Consolidation
- **Utility functions** (`utils/helpers.py`):
- `generate_uuid()` - Replaces 50+ `str(uuid.uuid4())` calls
- `is_ldap_user()` - Replaces 4+ duplicated checks
- `validate_password()` - Consistent password validation
- **Simplified notifications** - Removed queue system, direct send
### Logging Improvements
- Centralized logging via `config.logger`
- Replaced `print()` with proper logging
- Security events logged (login, password change, etc.)
---
## API Quick Reference
### Authentication
- `POST /api/auth/register` - Create user (rate limited)
- `POST /api/auth/login` - Get JWT token (rate limited)
- `GET /api/auth/me` - Current user (JWT or Authelia)
### Presence
- `POST /api/presence/mark` - Mark single day
- `POST /api/presence/mark-bulk` - Mark multiple days
- `GET /api/presence/team` - Team calendar with parking
### Parking
- `POST /api/parking/manual-assign` - Manager assigns spot
- `POST /api/parking/reassign-spot` - Reassign existing spot
- `GET /api/parking/eligible-users/{id}` - Users for reassignment
### Manager Settings
- `GET/POST/DELETE /api/managers/closing-days`
- `GET/POST/DELETE /api/managers/weekly-closing-days`
- `GET/POST/DELETE /api/managers/guarantees`
- `GET/POST/DELETE /api/managers/exclusions`
---
## Development Notes
### Adding a New Route
1. Create file in `app/routes/`
2. Use `APIRouter(prefix="/api/...", tags=["..."])`
3. Register in `main.py`: `app.include_router(...)`
4. Add auth dependency: `current_user: User = Depends(get_current_user)`
5. Use `config.logger` for logging
6. Use `generate_uuid()` for new records
### Database Changes
No migration system (Alembic) configured. Schema changes require:
1. Update [database/models.py](database/models.py)
2. Delete SQLite file or write manual migration
3. Run `create_test_db.py` for fresh database
### Email Testing
With `SMTP_ENABLED=false`, check email log:
```bash
tail -f /tmp/parking-emails.log
```
### Running Scheduled Notifications
Add to cron or systemd timer:
```bash
# Every 5 minutes
*/5 * * * * cd /path/to/org-parking && python -c "
from database.connection import get_db
from services.notifications import run_scheduled_notifications
db = next(get_db())
run_scheduled_notifications(db)
"
```
---
## File Quick Links
| Purpose | File |
|---------|------|
| Main entry | [main.py](main.py) |
| Configuration | [app/config.py](app/config.py) |
| Database models | [database/models.py](database/models.py) |
| Parking algorithm | [services/parking.py](services/parking.py) |
| Notifications | [services/notifications.py](services/notifications.py) |
| Auth middleware | [utils/auth_middleware.py](utils/auth_middleware.py) |
| Utility helpers | [utils/helpers.py](utils/helpers.py) |
| Frontend API client | [frontend/js/api.js](frontend/js/api.js) |
| CSS styles | [frontend/css/styles.css](frontend/css/styles.css) |
| Docker config | [compose.yml](compose.yml) |
| Environment template | [.env.example](.env.example) |
---
## Deployment Notes
### Remote Server
- Host: `rocketscale.it`
- User: `rocky`
- SSH: `ssh rocky@rocketscale.it`
- Project path: `/home/rocky/org-parking`
- Related project: `/home/rocky/org-stack` (LLDAP, Authelia, etc.)
### Environment Variables
Copy `.env.example` to `.env` and configure:
```bash
# Generate secure key
openssl rand -hex 32
```
### Production Checklist
- [ ] Set strong `SECRET_KEY`
- [ ] Configure `ALLOWED_ORIGINS` (not `*`)
- [ ] Set `AUTHELIA_ENABLED=true` if using SSO
- [ ] Configure SMTP or check email log file
- [ ] Set up notification scheduler (cron/systemd)

13
Caddyfile.snippet Normal file
View File

@@ -0,0 +1,13 @@
parking.lvh.me {
# Integrazione Authelia per autenticazione
forward_auth authelia:9091 {
uri /api/verify?rd=https://parking.lvh.me/
copy_headers Remote-User Remote-Groups Remote-Name Remote-Email
}
# Proxy inverso verso il container parking sulla porta 8000
reverse_proxy parking:8000
# Usa certificati gestiti internamente per lvh.me (locale)
tls internal
}

27
DEPENDENCIES.md Normal file
View File

@@ -0,0 +1,27 @@
# Spiegazione delle Dipendenze del Progetto
Questo documento spiega il motivo per cui ogni libreria elencata nel file `requirements.txt` è necessaria per il funzionamento dell'applicazione.
## Librerie Principali
### 1. Framework e Server Web
* **`fastapi`**: È il cuore dell'applicazione. Si tratta di un framework web moderno e veloce per costruire API con Python. Gestisce le richieste HTTP, il routing (gli URL) e la validazione dei dati.
* **`uvicorn[standard]`**: È il server ASGI necessario per eseguire l'applicazione FastAPI. FastAPI da solo non può girare; ha bisogno di un server come Uvicorn per accettare le connessioni dai client (browser, altre app). La versione `[standard]` include dipendenze extra per migliorare le prestazioni (come `uvloop`) e gestire meglio i protocolli web.
### 2. Database e Dati
* **`sqlalchemy`**: È l'ORM (Object-Relational Mapper) utilizzato per interagire con il database. Permette di scrivere codice Python per creare, leggere, aggiornare ed eliminare dati nel database invece di scrivere SQL puro.
* **`pydantic[email]`**: Usato per la validazione dei dati. Assicura che i dati inviati all'API (come i dati di registrazione utente) siano nel formato corretto. L'extra `[email]` include la libreria `email-validator` per verificare che gli indirizzi email siano validi.
* **`pydantic-settings`**: Un'estensione di Pydantic specifica per la gestione delle configurazioni. Permette di leggere le impostazioni da variabili d'ambiente in modo sicuro e tipizzato.
### 3. Autenticazione e Sicurezza
* **`python-jose[cryptography]`**: Serve per gestire i JSON Web Tokens (JWT). È fondamentale per il sistema di login, permettendo di creare token sicuri per identificare gli utenti autenticati.
* **`bcrypt`**: Una libreria per l'hashing delle password. È essenziale per la sicurezza, poiché permette di salvare le password nel database in modo criptato e non in chiaro.
* **`python-multipart`**: Necessario per supportare l'upload di form data. FastAPI lo richiede specificamente per gestire l'autenticazione OAuth2 (il login tramite form username/password).
### 4. Utilità e Configurazioni
* **`python-dotenv`**: Permette all'applicazione di leggere le variabili d'configurazione da un file `.env`. È molto utile durante lo sviluppo per gestire segreti e impostazioni locali.
* **`slowapi`**: Una libreria per implementare il "Rate Limiting". Serve a proteggere l'API da un numero eccessivo di richieste in breve tempo (ad esempio, per prevenire attacchi di forza bruta o abusi).
### 5. Dipendenze Indirette o Specifiche
* **`email-validator`**: Richiesto da Pydantic per la validazione delle email.
* **`idna`**: Supporto per nomi di dominio internazionalizzati (spesso una dipendenza di `email-validator` o librerie di rete).

View File

@@ -2,6 +2,9 @@ FROM python:3.12-slim
WORKDIR /app WORKDIR /app
# Prevent Python from buffering stdout and stderr
ENV PYTHONUNBUFFERED=1
# Install dependencies # Install dependencies
COPY requirements.txt . COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt RUN pip install --no-cache-dir -r requirements.txt
@@ -20,4 +23,4 @@ HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
CMD python -c "import urllib.request; urllib.request.urlopen('http://localhost:8000/health')" || exit 1 CMD python -c "import urllib.request; urllib.request.urlopen('http://localhost:8000/health')" || exit 1
# Run with uvicorn # Run with uvicorn
CMD ["python", "-m", "uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"] CMD ["python", "-m", "uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000", "--workers", "1"]

332
README.md
View File

@@ -1,172 +1,214 @@
# Parking Manager # Org-Parking
A manager-centric parking spot management application with fair assignment algorithm. Un'applicazione leggera gestionale per i parcheggi aziendali, progettata per le organizzazioni. Offre un algoritmo di assegnazione equa, tracciamento delle presenze ed è ottimizzata per basse risorse.
## Features ## Funzionalità
- **Manager-centric model**: Managers own parking spots, not offices - **Modello Centrato sull'Ufficio**: I posti auto appartengono agli Uffici, non ai Manager o agli Utenti.
- **Fair assignment algorithm**: Users with lowest parking/presence ratio get priority - **Algoritmo di Assegnazione Equa**: Gli utenti con il rapporto parcheggi/presenze più basso hanno la priorità.
- **Presence tracking**: Calendar-based presence marking (present/remote/absent) - **Tracciamento Presenze**: Marcatura delle presenze basata su calendario (presente/remoto/assente).
- **Closing days**: Support for specific dates and weekly recurring closures - **Regole di Chiusura Flessibili**: Supporto per chiusure in date specifiche e chiusure settimanali ricorrenti per ufficio.
- **Guarantees & exclusions**: Per-user parking rules - **Garanzie ed Esclusioni**: Regole di parcheggio per utente gestite dagli amministratori dell'ufficio.
- **Authelia/LLDAP integration**: SSO authentication with group-based roles - **Accesso Basato sui Ruoli**:
- **Admin**: Controllo completo del sistema, creazione uffici, gestione utenti.
- **Manager**: Gestisce le impostazioni del proprio ufficio e il team.
- **Impiegato**: Segna presenza, visualizza calendario, controlla stato parcheggio.
- **Basso Consumo di Memoria**: Ottimizzato per piccoli server (<500MB RAM).
- **Autenticazione**: Auth JWT integrata o integrazione SSO con Authelia.
## Architecture ## Architettura
``` ```
├── app/ app/
├── routes/ # API endpoints ├── routes/ # API endpoints
│ ├── auth.py # Authentication + holidays │ ├── auth.py # Autenticazione
│ ├── users.py # User management │ ├── users.py # Gestione utenti
│ ├── managers.py # Manager rules (closing days, guarantees) │ ├── offices.py # Gestione uffici (quote, regole)
│ ├── presence.py # Presence marking │ ├── presence.py # Marcatura presenze
│ └── parking.py # Parking assignments │ └── parking.py # Logica di assegnazione
└── config.py # Application configuration └── config.py # Configurazione
├── database/ database/
├── models.py # SQLAlchemy ORM models ├── models.py # Modelli SQLAlchemy ORM
└── connection.py # Database setup └── connection.py # Setup Database
├── services/ frontend/ # Frontend Vanilla JS pulito
│ ├── auth.py # JWT + password handling ├── pages/ # Viste HTML
│ ├── parking.py # Fair assignment algorithm ├── js/ # Moduli logici
│ ├── holidays.py # Public holiday calculation └── css/ # Stili
│ └── notifications.py # Email notifications (TODO: scheduler)
├── frontend/
│ ├── pages/ # HTML pages
│ ├── js/ # JavaScript modules
│ └── css/ # Stylesheets
└── main.py # FastAPI application entry
``` ```
## Quick Start (Development) ## Guida Rapida
```bash ### Sviluppo Locale
# Create virtual environment
python3 -m venv .venv
source .venv/bin/activate
# Install dependencies 1. **Setup Ambiente**:
pip install -r requirements.txt ```bash
python3 -m venv .venv
source .venv/bin/activate
pip install -r requirements.txt
```
# Run development server 2. **Avvio Server**:
python main.py ```bash
python main.py
```
Accedi a `http://localhost:8000`
### Deployment Docker (Consigliato)
Questa applicazione è ottimizzata per ambienti con risorse limitate (es. Raspberry Pi, piccoli VPS).
1. **Build**:
```bash
docker compose build
```
2. **Run**:
```bash
docker compose up -d
```
**Nota sull'Uso della Memoria**:
Il `Dockerfile` è configurato per eseguire `uvicorn` con `--workers 1` per minimizzare l'uso della memoria (~50MB in idle). Se in esecuzione su un server più grande, puoi rimuovere questo flag nel `Dockerfile`.
## Configurazione
Copia `.env.example` in `.env` e configura:
| Variabile | Descrizione | Default |
|-----------|-------------|---------|
| `SECRET_KEY` | **Richiesto**. Chiave cripto per JWT. | `""` (Esce se mancante) |
| `DATABASE_URL` | Vedi docs SQLAlchemy. | `sqlite:///data/parking.db` |
| `AUTHELIA_ENABLED`| Abilita supporto header SSO. | `false` |
| `SMTP_ENABLED` | Abilita notifiche email. | `false` |
| `LOG_LEVEL` | Verbosità log. | `INFO` |
## Algoritmo di Equità
I posti auto vengono assegnati giornalmente basandosi su un rapporto di equità:
``` ```
Rapporto = (Giorni Parcheggiati con successo) / (Giorni di Presenza in ufficio)
Access at http://localhost:8000
## Docker Deployment
```bash
# Build image
docker build -t parking-manager .
# Run with environment variables
docker run -d \
-p 8000:8000 \
-v ./data:/app/data \
-e SECRET_KEY=your-secret-key \
-e AUTHELIA_ENABLED=true \
parking-manager
``` ```
- Gli utenti **Garantiti** vengono assegnati per primi.
Or use Docker Compose: - I posti **Rimanenti** sono distribuiti agli utenti presenti iniziando da chi ha il rapporto più **basso**.
- Gli utenti **Esclusi** non ricevono mai un posto.
```bash
docker compose up -d
```
## Environment Variables
| Variable | Description | Default |
|----------|-------------|---------|
| `SECRET_KEY` | JWT signing key | Random (dev only) |
| `HOST` | Bind address | `0.0.0.0` |
| `PORT` | Server port | `8000` |
| `DATABASE_URL` | SQLite path | `sqlite:///data/parking.db` |
| `AUTHELIA_ENABLED` | Enable Authelia SSO | `false` |
| `ALLOWED_ORIGINS` | CORS origins | `*` |
### SMTP (Notifications - Optional)
| Variable | Description |
|----------|-------------|
| `SMTP_HOST` | SMTP server hostname |
| `SMTP_PORT` | SMTP port (default: 587) |
| `SMTP_USER` | SMTP username |
| `SMTP_PASSWORD` | SMTP password |
| `SMTP_FROM` | From email address |
## Authentication
### Standalone Mode
Built-in JWT authentication with bcrypt password hashing. Users register/login via `/login` and `/register`.
### Authelia Mode
When `AUTHELIA_ENABLED=true`, the app trusts Authelia headers:
- `Remote-User`: User email/username
- `Remote-Name`: Display name
- `Remote-Groups`: Comma-separated group list
Group mapping (follows lldap naming convention):
- `parking_admins` → admin role
- `managers` → manager role
- Others → employee role
## User Roles
| Role | Permissions |
|------|-------------|
| **admin** | Full access, manage users and managers |
| **manager** | Manage their team, set parking rules |
| **employee** | Mark own presence, view calendar |
## API Endpoints ## API Endpoints
### Authentication Di seguito la lista delle chiamate API disponibili suddivise per modulo.
- `POST /api/auth/login` - Login
- `POST /api/auth/register` - Register (standalone mode)
- `POST /api/auth/logout` - Logout
- `GET /api/auth/me` - Current user info
- `GET /api/auth/holidays/{year}` - Public holidays
### Users ### Auth (`/api/auth`)
- `GET /api/users` - List users (admin) Gestione autenticazione e sessione.
- `POST /api/users` - Create user (admin)
- `PUT /api/users/{id}` - Update user (admin)
- `DELETE /api/users/{id}` - Delete user (admin)
- `GET /api/users/me/profile` - Own profile
- `PUT /api/users/me/settings` - Own settings
### Managers - `POST /register`: Registrazione nuovo utente (Disabilitato se Authelia è attivo).
- `GET /api/managers` - List managers - `POST /login`: Login con email e password (ritorna token JWT/cookie).
- `GET /api/managers/{id}` - Manager details - `POST /logout`: Logout e invalidazione sessione.
- `PUT /api/managers/{id}/settings` - Update parking quota (admin) - `GET /me`: Ritorna informazioni sull'utente corrente.
- `GET/POST/DELETE /api/managers/{id}/closing-days` - Specific closures - `GET /config`: Ritorna la configurazione pubblica di autenticazione.
- `GET/POST/DELETE /api/managers/{id}/weekly-closing-days` - Recurring closures - `GET /holidays/{year}`: Ritorna i giorni festivi per l'anno specificato.
- `GET/POST/DELETE /api/managers/{id}/guarantees` - Parking guarantees
- `GET/POST/DELETE /api/managers/{id}/exclusions` - Parking exclusions
### Presence ### Users (`/api/users`)
- `POST /api/presence/mark` - Mark presence Gestione utenti e profili.
- `POST /api/presence/mark-bulk` - Bulk mark
- `GET /api/presence/my-presences` - Own presences
- `GET /api/presence/team` - Team calendar (manager/admin)
### Parking - `GET /`: Lista di tutti gli utenti (Solo Admin).
- `GET /api/parking/assignments/{date}` - Day's assignments - `POST /`: Crea un nuovo utente (Solo Admin).
- `GET /api/parking/my-assignments` - Own assignments - `GET /{user_id}`: Dettaglio di un utente specifico (Solo Admin).
- `POST /api/parking/manual-assign` - Manual assignment - `PUT /{user_id}`: Aggiorna dati di un utente (Solo Admin).
- `POST /api/parking/reassign-spot` - Reassign spot - `DELETE /{user_id}`: Elimina un utente (Solo Admin).
- `GET /me/profile`: Ottieni il proprio profilo.
- `PUT /me/profile`: Aggiorna il proprio profilo.
- `GET /me/settings`: Ottieni le proprie impostazioni.
- `PUT /me/settings`: Aggiorna le proprie impostazioni.
- `POST /me/change-password`: Modifica la propria password.
## Fairness Algorithm ### Offices (`/api/offices`)
Gestione uffici, regole di chiusura e quote.
Parking spots are assigned based on a fairness ratio: - `GET /`: Lista di tutti gli uffici.
- `POST /`: Crea un nuovo ufficio (Solo Admin).
- `GET /{office_id}`: Dettagli di un ufficio.
- `PUT /{office_id}`: Aggiorna configurazione ufficio (Solo Admin).
- `DELETE /{office_id}`: Elimina un ufficio (Solo Admin).
- `GET /{office_id}/users`: Lista utenti assegnati all'ufficio.
- `GET /{office_id}/closing-days`: Lista giorni di chiusura specifici.
- `POST /{office_id}/closing-days`: Aggiungi giorno di chiusura.
- `DELETE /{office_id}/closing-days/{id}`: Rimuovi giorno di chiusura.
- `GET /{office_id}/weekly-closing-days`: Lista giorni di chiusura settimanali (es. Sabato/Domenica).
- `POST /{office_id}/weekly-closing-days`: Aggiungi giorno di chiusura settimanale.
- `DELETE /{office_id}/weekly-closing-days/{id}`: Rimuovi giorno di chiusura settimanale.
- `GET /{office_id}/guarantees`: Lista utenti con posto garantito.
- `POST /{office_id}/guarantees`: Aggiungi garanzia posto.
- `DELETE /{office_id}/guarantees/{id}`: Rimuovi garanzia.
- `GET /{office_id}/exclusions`: Lista utenti esclusi dal parcheggio.
- `POST /{office_id}/exclusions`: Aggiungi esclusione.
- `DELETE /{office_id}/exclusions/{id}`: Rimuovi esclusione.
``` ### Presence (`/api/presence`)
ratio = parking_days / presence_days Gestione presenze giornaliere.
```
Users with the lowest ratio get priority. Guaranteed users are always assigned first. - `POST /mark`: Segna la propria presenza per una data (Presente/Remoto/Assente).
- `GET /my-presences`: Lista delle proprie presenze.
- `DELETE /{date}`: Rimuovi la propria presenza per una data.
- `POST /admin/mark`: Segna presenza per un altro utente (Manager/Admin).
- `DELETE /admin/{user_id}/{date}`: Rimuovi presenza di un altro utente (Manager/Admin).
- `GET /team`: Visualizza presenze e stato parcheggio del team.
- `GET /admin/{user_id}`: Storico presenze di un utente.
## License ### Parking (`/api/parking`)
Gestione assegnazioni posti auto.
- `POST /init-office-pool`: Inizializza i posti disponibili per un giorno.
- `GET /assignments/{date}`: Lista assegnazioni per una data.
- `GET /my-assignments`: Le mie assegnazioni parcheggio.
- `POST /run-allocation`: Esegui manualmente l'algoritmo di assegnazione per una data.
- `POST /clear-assignments`: Cancella tutte le assegnazioni per una data.
- `POST /manual-assign`: Assegna manualmente un posto a un utente.
- `POST /reassign-spot`: Riassegna o libera un posto già assegnato.
- `POST /release-my-spot/{id}`: Rilascia il proprio posto assegnato.
- `GET /eligible-users/{id}`: Lista utenti idonei a ricevere un posto riassegnato.
## Utilizzo con AUTHELIA
Org-Parking supporta l'integrazione nativa con **Authelia** (o altri provider SSO compatibili con Forward Auth). Questo permette il Single Sign-On (SSO) e la gestione centralizzata degli utenti.
### Configurazione
1. **Abilita Authelia**:
Nel file `.env`, imposta `AUTHELIA_ENABLED=true`.
2. **Configura gli Header del Proxy**:
Assicurati che il tuo reverse proxy (es. Traefik, Nginx) passi i seguenti header all'applicazione dopo l'autenticazione:
* `Remote-User`: Username dell'utente (spesso uguale all'email).
* `Remote-Email`: Email dell'utente.
* `Remote-Name`: Nome completo dell'utente (Opzionale).
* `Remote-Groups`: Gruppi di appartenenza (separati da virgola).
3. **Gestione Admin**:
L'applicazione assegna automaticamente il ruolo di **Admin** agli utenti che appartengono al gruppo specificato nella variabile `AUTHELIA_ADMIN_GROUP` (default: `parking_admins`).
* Se un utente viene rimosso da questo gruppo su Authelia, perderà i privilegi di admin al login successivo.
* Gli altri ruoli (Manager, Employee) e l'assegnazione agli uffici vengono gestiti manualmente all'interno di Org-Parking da un amministratore.
### Comportamento
* **Creazione Utenti**: Gli utenti vengono creati automaticamente nel database di Org-Parking al loro primo accesso riuscito tramite Authelia.
* **Login/Logout**: Le pagine di login e registrazione interne vengono disabilitate o reindirizzano al portale SSO.
* **Password**: Org-Parking non gestisce le password in questa modalità; l'autenticazione è interamente delegata al provider esterno.
* **Sicurezza**: L'applicazione si fida degli header `Remote-*` solo se `AUTHELIA_ENABLED` è attivo. Assicurarsi che il container non sia esposto direttamente a internet senza passare per il proxy di autenticazione.
## Note di Deployment
- **Database**: SQLite è usato di default per semplicità e basso overhead. I dati persistono in `./data/parking.db`.
- **Sicurezza**:
- Rate limiting è attivo sugli endpoint sensibili (Login/Register).
- Le password sono hashate con Bcrypt.
- L'autenticazione via cookie è sicura di default.
### Risoluzione Problemi Comuni
**Errore: "Il reindirizzamento è stato determinato per essere non sicuro"**
Questo errore appare se si tenta di accedere all'applicazione tramite HTTP (es. `http://lvh.me:8000`) mentre Authelia è su HTTPS. Authelia blocca i reindirizzamenti verso protocolli non sicuri.
**Soluzione**: Accedere all'applicazione tramite il Reverse Proxy (Caddy) usando HTTPS, ad esempio `https://parking.lvh.me`. Assicurarsi che Caddy sia configurato per gestire il dominio e l'autenticazione.
## Licenza
MIT MIT

View File

@@ -7,6 +7,10 @@ import sys
import logging import logging
from pathlib import Path from pathlib import Path
# Paths
BASE_DIR = Path(__file__).resolve().parent.parent
FRONTEND_DIR = BASE_DIR / "frontend"
# Configure logging # Configure logging
LOG_LEVEL = os.getenv("LOG_LEVEL", "INFO").upper() LOG_LEVEL = os.getenv("LOG_LEVEL", "INFO").upper()
logging.basicConfig( logging.basicConfig(
@@ -17,6 +21,19 @@ logger = logging.getLogger("org-parking")
# Database # Database
DATABASE_PATH = os.getenv("DATABASE_PATH", "parking.db") DATABASE_PATH = os.getenv("DATABASE_PATH", "parking.db")
# Fix for local execution: if path is absolute (docker) but dir doesn't exist, fallback to local data/
if os.path.isabs(DATABASE_PATH) and not os.path.exists(os.path.dirname(DATABASE_PATH)):
# Check if we are aiming for /app/data but running locally
if str(DATABASE_PATH).startswith("/app/") or not os.access(os.path.dirname(DATABASE_PATH), os.W_OK):
logger.warning(f"Configured DATABASE_PATH '{DATABASE_PATH}' folder not found/writable. Switching to local 'data' directory.")
local_data_dir = BASE_DIR / "data"
local_data_dir.mkdir(exist_ok=True)
DATABASE_PATH = str(local_data_dir / os.path.basename(DATABASE_PATH))
logger.info(f"Using local database path: {DATABASE_PATH}")
DATABASE_URL = os.getenv("DATABASE_URL", f"sqlite:///{DATABASE_PATH}") DATABASE_URL = os.getenv("DATABASE_URL", f"sqlite:///{DATABASE_PATH}")
# JWT Authentication # JWT Authentication
@@ -35,7 +52,7 @@ HOST = os.getenv("HOST", "0.0.0.0")
PORT = int(os.getenv("PORT", "8000")) PORT = int(os.getenv("PORT", "8000"))
# CORS # CORS
ALLOWED_ORIGINS = os.getenv("ALLOWED_ORIGINS", "http://localhost:8000,http://127.0.0.1:8000").split(",") ALLOWED_ORIGINS = os.getenv("ALLOWED_ORIGINS", "http://localhost:8000,http://127.0.0.1:8000,http://lvh.me").split(",")
# Authelia Integration # Authelia Integration
AUTHELIA_ENABLED = os.getenv("AUTHELIA_ENABLED", "false").lower() == "true" AUTHELIA_ENABLED = os.getenv("AUTHELIA_ENABLED", "false").lower() == "true"
@@ -67,6 +84,4 @@ EMAIL_LOG_FILE = os.getenv("EMAIL_LOG_FILE", "/tmp/parking-emails.log")
RATE_LIMIT_REQUESTS = int(os.getenv("RATE_LIMIT_REQUESTS", "5")) # requests per window RATE_LIMIT_REQUESTS = int(os.getenv("RATE_LIMIT_REQUESTS", "5")) # requests per window
RATE_LIMIT_WINDOW = int(os.getenv("RATE_LIMIT_WINDOW", "60")) # seconds RATE_LIMIT_WINDOW = int(os.getenv("RATE_LIMIT_WINDOW", "60")) # seconds
# Paths
BASE_DIR = Path(__file__).resolve().parent.parent
FRONTEND_DIR = BASE_DIR / "frontend"

View File

@@ -9,6 +9,7 @@ from slowapi import Limiter
from slowapi.util import get_remote_address from slowapi.util import get_remote_address
from database.connection import get_db from database.connection import get_db
from database.models import UserRole
from services.auth import ( from services.auth import (
create_user, authenticate_user, create_access_token, create_user, authenticate_user, create_access_token,
get_user_by_email get_user_by_email
@@ -25,7 +26,6 @@ class RegisterRequest(BaseModel):
email: EmailStr email: EmailStr
password: str password: str
name: str name: str
manager_id: str | None = None
class LoginRequest(BaseModel): class LoginRequest(BaseModel):
@@ -42,16 +42,16 @@ class UserResponse(BaseModel):
id: str id: str
email: str email: str
name: str | None name: str | None
manager_id: str | None office_id: str | None
role: str office_name: str | None = None
manager_parking_quota: int | None = None role: UserRole
week_start_day: int = 0 week_start_day: int = 0
# Notification preferences # Notification preferences
notify_weekly_parking: int = 1 notify_weekly_parking: bool = True
notify_daily_parking: int = 1 notify_daily_parking: bool = True
notify_daily_parking_hour: int = 8 notify_daily_parking_hour: int = 8
notify_daily_parking_minute: int = 0 notify_daily_parking_minute: int = 0
notify_parking_changes: int = 1 notify_parking_changes: bool = True
@router.post("/register", response_model=TokenResponse) @router.post("/register", response_model=TokenResponse)
@@ -76,8 +76,7 @@ def register(request: Request, data: RegisterRequest, db: Session = Depends(get_
db=db, db=db,
email=data.email, email=data.email,
password=data.password, password=data.password,
name=data.name, name=data.name
manager_id=data.manager_id
) )
config.logger.info(f"New user registered: {data.email}") config.logger.info(f"New user registered: {data.email}")
@@ -126,15 +125,15 @@ def get_me(user=Depends(get_current_user)):
id=user.id, id=user.id,
email=user.email, email=user.email,
name=user.name, name=user.name,
manager_id=user.manager_id, office_id=user.office_id,
office_name=user.office.name if user.office else None,
role=user.role, role=user.role,
manager_parking_quota=user.manager_parking_quota,
week_start_day=get_notification_default(user.week_start_day, 0), week_start_day=get_notification_default(user.week_start_day, 0),
notify_weekly_parking=get_notification_default(user.notify_weekly_parking, 1), notify_weekly_parking=get_notification_default(user.notify_weekly_parking, True),
notify_daily_parking=get_notification_default(user.notify_daily_parking, 1), notify_daily_parking=get_notification_default(user.notify_daily_parking, True),
notify_daily_parking_hour=get_notification_default(user.notify_daily_parking_hour, 8), notify_daily_parking_hour=get_notification_default(user.notify_daily_parking_hour, 8),
notify_daily_parking_minute=get_notification_default(user.notify_daily_parking_minute, 0), notify_daily_parking_minute=get_notification_default(user.notify_daily_parking_minute, 0),
notify_parking_changes=get_notification_default(user.notify_parking_changes, 1) notify_parking_changes=get_notification_default(user.notify_parking_changes, True)
) )

View File

@@ -5,7 +5,7 @@ Manager settings, closing days, guarantees, and exclusions
Key concept: Managers own parking spots and set rules for their managed users. Key concept: Managers own parking spots and set rules for their managed users.
Rules are set at manager level (users have manager_id pointing to their manager). Rules are set at manager level (users have manager_id pointing to their manager).
""" """
from datetime import datetime from datetime import datetime, date
from fastapi import APIRouter, Depends, HTTPException from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel from pydantic import BaseModel
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
@@ -26,7 +26,8 @@ router = APIRouter(prefix="/api/managers", tags=["managers"])
# Request/Response Models # Request/Response Models
class ClosingDayCreate(BaseModel): class ClosingDayCreate(BaseModel):
date: str # YYYY-MM-DD date: date # Start date
end_date: date | None = None # Optional end date (inclusive)
reason: str | None = None reason: str | None = None
@@ -36,14 +37,16 @@ class WeeklyClosingDayCreate(BaseModel):
class GuaranteeCreate(BaseModel): class GuaranteeCreate(BaseModel):
user_id: str user_id: str
start_date: str | None = None start_date: date | None = None
end_date: str | None = None end_date: date | None = None
notes: str | None = None
class ExclusionCreate(BaseModel): class ExclusionCreate(BaseModel):
user_id: str user_id: str
start_date: str | None = None start_date: date | None = None
end_date: str | None = None end_date: date | None = None
notes: str | None = None
class ManagerSettingsUpdate(BaseModel): class ManagerSettingsUpdate(BaseModel):
@@ -124,7 +127,7 @@ def update_manager_settings(manager_id: str, data: ManagerSettingsUpdate, db: Se
raise HTTPException(status_code=400, detail=f"Spot prefix '{data.spot_prefix}' is already used") raise HTTPException(status_code=400, detail=f"Spot prefix '{data.spot_prefix}' is already used")
manager.manager_spot_prefix = data.spot_prefix manager.manager_spot_prefix = data.spot_prefix
manager.updated_at = datetime.utcnow().isoformat() manager.updated_at = datetime.utcnow()
db.commit() db.commit()
return { return {
@@ -155,7 +158,7 @@ def get_manager_closing_days(manager_id: str, db: Session = Depends(get_db), use
days = db.query(ManagerClosingDay).filter( days = db.query(ManagerClosingDay).filter(
ManagerClosingDay.manager_id == manager_id ManagerClosingDay.manager_id == manager_id
).order_by(ManagerClosingDay.date).all() ).order_by(ManagerClosingDay.date).all()
return [{"id": d.id, "date": d.date, "reason": d.reason} for d in days] return [{"id": d.id, "date": d.date, "end_date": d.end_date, "reason": d.reason} for d in days]
@router.post("/{manager_id}/closing-days") @router.post("/{manager_id}/closing-days")
@@ -172,10 +175,14 @@ def add_manager_closing_day(manager_id: str, data: ClosingDayCreate, db: Session
if existing: if existing:
raise HTTPException(status_code=400, detail="Closing day already exists for this date") raise HTTPException(status_code=400, detail="Closing day already exists for this date")
if data.end_date and data.end_date < data.date:
raise HTTPException(status_code=400, detail="End date must be after start date")
closing_day = ManagerClosingDay( closing_day = ManagerClosingDay(
id=generate_uuid(), id=generate_uuid(),
manager_id=manager_id, manager_id=manager_id,
date=data.date, date=data.date,
end_date=data.end_date,
reason=data.reason reason=data.reason
) )
db.add(closing_day) db.add(closing_day)
@@ -270,7 +277,8 @@ def get_manager_guarantees(manager_id: str, db: Session = Depends(get_db), user=
"user_id": g.user_id, "user_id": g.user_id,
"user_name": user_lookup.get(g.user_id), "user_name": user_lookup.get(g.user_id),
"start_date": g.start_date, "start_date": g.start_date,
"end_date": g.end_date "end_date": g.end_date,
"notes": g.notes
} }
for g in guarantees for g in guarantees
] ]
@@ -292,13 +300,17 @@ def add_manager_guarantee(manager_id: str, data: GuaranteeCreate, db: Session =
if existing: if existing:
raise HTTPException(status_code=400, detail="User already has a parking guarantee") raise HTTPException(status_code=400, detail="User already has a parking guarantee")
if data.start_date and data.end_date and data.end_date < data.start_date:
raise HTTPException(status_code=400, detail="End date must be after start date")
guarantee = ParkingGuarantee( guarantee = ParkingGuarantee(
id=generate_uuid(), id=generate_uuid(),
manager_id=manager_id, manager_id=manager_id,
user_id=data.user_id, user_id=data.user_id,
start_date=data.start_date, start_date=data.start_date,
end_date=data.end_date, end_date=data.end_date,
created_at=datetime.utcnow().isoformat() notes=data.notes,
created_at=datetime.utcnow()
) )
db.add(guarantee) db.add(guarantee)
db.commit() db.commit()
@@ -340,7 +352,8 @@ def get_manager_exclusions(manager_id: str, db: Session = Depends(get_db), user=
"user_id": e.user_id, "user_id": e.user_id,
"user_name": user_lookup.get(e.user_id), "user_name": user_lookup.get(e.user_id),
"start_date": e.start_date, "start_date": e.start_date,
"end_date": e.end_date "end_date": e.end_date,
"notes": e.notes
} }
for e in exclusions for e in exclusions
] ]
@@ -362,13 +375,17 @@ def add_manager_exclusion(manager_id: str, data: ExclusionCreate, db: Session =
if existing: if existing:
raise HTTPException(status_code=400, detail="User already has a parking exclusion") raise HTTPException(status_code=400, detail="User already has a parking exclusion")
if data.start_date and data.end_date and data.end_date < data.start_date:
raise HTTPException(status_code=400, detail="End date must be after start date")
exclusion = ParkingExclusion( exclusion = ParkingExclusion(
id=generate_uuid(), id=generate_uuid(),
manager_id=manager_id, manager_id=manager_id,
user_id=data.user_id, user_id=data.user_id,
start_date=data.start_date, start_date=data.start_date,
end_date=data.end_date, end_date=data.end_date,
created_at=datetime.utcnow().isoformat() notes=data.notes,
created_at=datetime.utcnow()
) )
db.add(exclusion) db.add(exclusion)
db.commit() db.commit()

500
app/routes/offices.py Normal file
View File

@@ -0,0 +1,500 @@
"""
Office Management Routes
Office settings, closing days, guarantees, and exclusions
"""
from datetime import datetime, date
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel
from sqlalchemy.orm import Session
from sqlalchemy import func
from database.connection import get_db
from database.models import (
User, Office,
OfficeClosingDay, OfficeWeeklyClosingDay,
ParkingGuarantee, ParkingExclusion,
UserRole
)
from utils.auth_middleware import require_admin, require_manager_or_admin, get_current_user
from utils.helpers import generate_uuid
from app import config
router = APIRouter(prefix="/api/offices", tags=["offices"])
# Request/Response Models
class ValidOfficeCreate(BaseModel):
name: str
parking_quota: int = 0
class ClosingDayCreate(BaseModel):
date: date # Start date
end_date: date | None = None # Optional end date (inclusive)
reason: str | None = None
class WeeklyClosingDayCreate(BaseModel):
weekday: int # 0=Sunday, 1=Monday, ..., 6=Saturday
class GuaranteeCreate(BaseModel):
user_id: str
start_date: date | None = None
end_date: date | None = None
notes: str | None = None
class ExclusionCreate(BaseModel):
user_id: str
start_date: date | None = None
end_date: date | None = None
notes: str | None = None
class OfficeSettingsUpdate(BaseModel):
parking_quota: int | None = None
name: str | None = None
booking_window_enabled: bool | None = None
booking_window_end_hour: int | None = None
booking_window_end_minute: int | None = None
# Helper check
def check_office_access(user: User, office_id: str):
if user.role == UserRole.ADMIN:
return True
if user.role == UserRole.MANAGER and user.office_id == office_id:
return True
raise HTTPException(status_code=403, detail="Access denied to this office")
# Office listing and details
@router.get("")
def list_offices(db: Session = Depends(get_db), user=Depends(require_manager_or_admin)):
"""Get all offices with their user count and parking quota"""
offices = db.query(Office).all()
# Batch query user counts
counts = db.query(User.office_id, func.count(User.id)).filter(
User.office_id.isnot(None)
).group_by(User.office_id).all()
user_counts = {office_id: count for office_id, count in counts}
return [
{
"id": office.id,
"name": office.name,
"parking_quota": office.parking_quota,
"spot_prefix": office.spot_prefix,
"user_count": user_counts.get(office.id, 0)
}
for office in offices
]
def get_next_available_prefix(db: Session) -> str:
"""Find the next available office prefix (A, B, C... AA, AB...)"""
existing = db.query(Office.spot_prefix).filter(Office.spot_prefix.isnot(None)).all()
used_prefixes = {row[0] for row in existing}
# Try single letters A-Z
for i in range(26):
char = chr(65 + i)
if char not in used_prefixes:
return char
# Try double letters AA-ZZ if needed
for i in range(26):
for j in range(26):
char = chr(65 + i) + chr(65 + j)
if char not in used_prefixes:
return char
raise HTTPException(status_code=400, detail="No more office prefixes available")
@router.post("")
def create_office(data: ValidOfficeCreate, db: Session = Depends(get_db), user=Depends(require_admin)):
"""Create a new office (admin only)"""
office = Office(
id=generate_uuid(),
name=data.name,
parking_quota=data.parking_quota,
spot_prefix=get_next_available_prefix(db),
created_at=datetime.utcnow()
)
db.add(office)
db.commit()
return office
@router.get("/{office_id}")
def get_office_details(office_id: str, db: Session = Depends(get_db), user=Depends(require_manager_or_admin)):
"""Get office details"""
office = db.query(Office).filter(Office.id == office_id).first()
if not office:
raise HTTPException(status_code=404, detail="Office not found")
check_office_access(user, office_id)
user_count = db.query(User).filter(User.office_id == office_id).count()
return {
"id": office.id,
"name": office.name,
"parking_quota": office.parking_quota,
"spot_prefix": office.spot_prefix,
"user_count": user_count,
"booking_window_enabled": office.booking_window_enabled,
"booking_window_end_hour": office.booking_window_end_hour,
"booking_window_end_minute": office.booking_window_end_minute
}
@router.put("/{office_id}")
def update_office_settings(office_id: str, data: OfficeSettingsUpdate, db: Session = Depends(get_db), user=Depends(require_admin)):
"""Update office settings (admin only) - Manager can view but usually Admin sets quota"""
# Verify access - currently assume admin manages quota. If manager should too, update logic.
# User request description: "Admin manage all offices with CRUD... rimodulare posti auto".
# So Managers might not edit quota? Or maybe they can?
# Keeping it simple: require_admin for structural changes.
office = db.query(Office).filter(Office.id == office_id).first()
if not office:
raise HTTPException(status_code=404, detail="Office not found")
if data.name:
office.name = data.name
if data.parking_quota is not None:
if data.parking_quota < 0:
raise HTTPException(status_code=400, detail="Parking quota must be non-negative")
office.parking_quota = data.parking_quota
if data.booking_window_enabled is not None:
office.booking_window_enabled = data.booking_window_enabled
if data.booking_window_end_hour is not None:
if not (0 <= data.booking_window_end_hour <= 23):
raise HTTPException(status_code=400, detail="Hour must be 0-23")
office.booking_window_end_hour = data.booking_window_end_hour
if data.booking_window_end_minute is not None:
if not (0 <= data.booking_window_end_minute <= 59):
raise HTTPException(status_code=400, detail="Minute must be 0-59")
office.booking_window_end_minute = data.booking_window_end_minute
office.updated_at = datetime.utcnow()
db.commit()
return {
"id": office.id,
"name": office.name,
"parking_quota": office.parking_quota,
"spot_prefix": office.spot_prefix,
"booking_window_enabled": office.booking_window_enabled,
"booking_window_end_hour": office.booking_window_end_hour,
"booking_window_end_minute": office.booking_window_end_minute
}
@router.delete("/{office_id}")
def delete_office(office_id: str, db: Session = Depends(get_db), user=Depends(require_admin)):
"""Delete an office (admin only)"""
office = db.query(Office).filter(Office.id == office_id).first()
if not office:
raise HTTPException(status_code=404, detail="Office not found")
db.delete(office)
db.commit()
return {"message": "Office deleted"}
@router.get("/{office_id}/users")
def get_office_users(office_id: str, db: Session = Depends(get_db), user=Depends(require_manager_or_admin)):
"""Get all users in an office"""
check_office_access(user, office_id)
office = db.query(Office).filter(Office.id == office_id).first()
if not office:
raise HTTPException(status_code=404, detail="Office not found")
users = db.query(User).filter(User.office_id == office_id).all()
return [{"id": u.id, "name": u.name, "email": u.email, "role": u.role} for u in users]
# Closing days
@router.get("/{office_id}/closing-days")
def get_office_closing_days(office_id: str, db: Session = Depends(get_db), user=Depends(get_current_user)):
"""Get closing days for an office"""
# Any user in the office can read closing days? Or just manager?
# check_office_access(user, office_id) # Let's allow read for all authenticated (frontend might need it)
days = db.query(OfficeClosingDay).filter(
OfficeClosingDay.office_id == office_id
).order_by(OfficeClosingDay.date).all()
return [{"id": d.id, "date": d.date, "end_date": d.end_date, "reason": d.reason} for d in days]
@router.post("/{office_id}/closing-days")
def add_office_closing_day(office_id: str, data: ClosingDayCreate, db: Session = Depends(get_db), user=Depends(require_manager_or_admin)):
"""Add a closing day for an office"""
check_office_access(user, office_id)
office = db.query(Office).filter(Office.id == office_id).first()
if not office:
raise HTTPException(status_code=404, detail="Office not found")
existing = db.query(OfficeClosingDay).filter(
OfficeClosingDay.office_id == office_id,
OfficeClosingDay.date == data.date
).first()
if existing:
raise HTTPException(status_code=400, detail="Closing day already exists for this date")
if data.end_date and data.end_date < data.date:
raise HTTPException(status_code=400, detail="End date must be after start date")
closing_day = OfficeClosingDay(
id=generate_uuid(),
office_id=office_id,
date=data.date,
end_date=data.end_date,
reason=data.reason
)
db.add(closing_day)
db.commit()
return {"id": closing_day.id, "message": "Closing day added"}
@router.delete("/{office_id}/closing-days/{closing_day_id}")
def remove_office_closing_day(office_id: str, closing_day_id: str, db: Session = Depends(get_db), user=Depends(require_manager_or_admin)):
"""Remove a closing day for an office"""
check_office_access(user, office_id)
closing_day = db.query(OfficeClosingDay).filter(
OfficeClosingDay.id == closing_day_id,
OfficeClosingDay.office_id == office_id
).first()
if not closing_day:
raise HTTPException(status_code=404, detail="Closing day not found")
db.delete(closing_day)
db.commit()
return {"message": "Closing day removed"}
# Weekly closing days
@router.get("/{office_id}/weekly-closing-days")
def get_office_weekly_closing_days(office_id: str, db: Session = Depends(get_db), user=Depends(get_current_user)):
"""Get weekly closing days for an office"""
days = db.query(OfficeWeeklyClosingDay).filter(
OfficeWeeklyClosingDay.office_id == office_id
).all()
return [{"id": d.id, "weekday": d.weekday} for d in days]
@router.post("/{office_id}/weekly-closing-days")
def add_office_weekly_closing_day(office_id: str, data: WeeklyClosingDayCreate, db: Session = Depends(get_db), user=Depends(require_manager_or_admin)):
"""Add a weekly closing day for an office"""
check_office_access(user, office_id)
office = db.query(Office).filter(Office.id == office_id).first()
if not office:
raise HTTPException(status_code=404, detail="Office not found")
if data.weekday < 0 or data.weekday > 6:
raise HTTPException(status_code=400, detail="Weekday must be 0-6 (0=Sunday, 6=Saturday)")
existing = db.query(OfficeWeeklyClosingDay).filter(
OfficeWeeklyClosingDay.office_id == office_id,
OfficeWeeklyClosingDay.weekday == data.weekday
).first()
if existing:
raise HTTPException(status_code=400, detail="Weekly closing day already exists for this weekday")
weekly_closing = OfficeWeeklyClosingDay(
id=generate_uuid(),
office_id=office_id,
weekday=data.weekday
)
db.add(weekly_closing)
db.commit()
return {"id": weekly_closing.id, "message": "Weekly closing day added"}
@router.delete("/{office_id}/weekly-closing-days/{weekly_id}")
def remove_office_weekly_closing_day(office_id: str, weekly_id: str, db: Session = Depends(get_db), user=Depends(require_manager_or_admin)):
"""Remove a weekly closing day for an office"""
check_office_access(user, office_id)
weekly_closing = db.query(OfficeWeeklyClosingDay).filter(
OfficeWeeklyClosingDay.id == weekly_id,
OfficeWeeklyClosingDay.office_id == office_id
).first()
if not weekly_closing:
raise HTTPException(status_code=404, detail="Weekly closing day not found")
db.delete(weekly_closing)
db.commit()
return {"message": "Weekly closing day removed"}
# Guarantees
@router.get("/{office_id}/guarantees")
def get_office_guarantees(office_id: str, db: Session = Depends(get_db), user=Depends(require_manager_or_admin)):
"""Get parking guarantees for an office"""
check_office_access(user, office_id)
guarantees = db.query(ParkingGuarantee).filter(ParkingGuarantee.office_id == office_id).all()
# Batch query to get all user names at once
user_ids = [g.user_id for g in guarantees]
if user_ids:
users = db.query(User).filter(User.id.in_(user_ids)).all()
user_lookup = {u.id: u.name for u in users}
else:
user_lookup = {}
return [
{
"id": g.id,
"user_id": g.user_id,
"user_name": user_lookup.get(g.user_id),
"start_date": g.start_date,
"end_date": g.end_date,
"notes": g.notes
}
for g in guarantees
]
@router.post("/{office_id}/guarantees")
def add_office_guarantee(office_id: str, data: GuaranteeCreate, db: Session = Depends(get_db), user=Depends(require_manager_or_admin)):
"""Add parking guarantee for an office"""
check_office_access(user, office_id)
office = db.query(Office).filter(Office.id == office_id).first()
if not office:
raise HTTPException(status_code=404, detail="Office not found")
if not db.query(User).filter(User.id == data.user_id).first():
raise HTTPException(status_code=404, detail="User not found")
existing = db.query(ParkingGuarantee).filter(
ParkingGuarantee.office_id == office_id,
ParkingGuarantee.user_id == data.user_id
).first()
if existing:
raise HTTPException(status_code=400, detail="User already has a parking guarantee")
if data.start_date and data.end_date and data.end_date < data.start_date:
raise HTTPException(status_code=400, detail="End date must be after start date")
guarantee = ParkingGuarantee(
id=generate_uuid(),
office_id=office_id,
user_id=data.user_id,
start_date=data.start_date,
end_date=data.end_date,
notes=data.notes,
created_at=datetime.utcnow()
)
db.add(guarantee)
db.commit()
return {"id": guarantee.id, "message": "Guarantee added"}
@router.delete("/{office_id}/guarantees/{guarantee_id}")
def remove_office_guarantee(office_id: str, guarantee_id: str, db: Session = Depends(get_db), user=Depends(require_manager_or_admin)):
"""Remove parking guarantee for an office"""
check_office_access(user, office_id)
guarantee = db.query(ParkingGuarantee).filter(
ParkingGuarantee.id == guarantee_id,
ParkingGuarantee.office_id == office_id
).first()
if not guarantee:
raise HTTPException(status_code=404, detail="Guarantee not found")
db.delete(guarantee)
db.commit()
return {"message": "Guarantee removed"}
# Exclusions
@router.get("/{office_id}/exclusions")
def get_office_exclusions(office_id: str, db: Session = Depends(get_db), user=Depends(require_manager_or_admin)):
"""Get parking exclusions for an office"""
check_office_access(user, office_id)
exclusions = db.query(ParkingExclusion).filter(ParkingExclusion.office_id == office_id).all()
# Batch query to get all user names at once
user_ids = [e.user_id for e in exclusions]
if user_ids:
users = db.query(User).filter(User.id.in_(user_ids)).all()
user_lookup = {u.id: u.name for u in users}
else:
user_lookup = {}
return [
{
"id": e.id,
"user_id": e.user_id,
"user_name": user_lookup.get(e.user_id),
"start_date": e.start_date,
"end_date": e.end_date,
"notes": e.notes
}
for e in exclusions
]
@router.post("/{office_id}/exclusions")
def add_office_exclusion(office_id: str, data: ExclusionCreate, db: Session = Depends(get_db), user=Depends(require_manager_or_admin)):
"""Add parking exclusion for an office"""
check_office_access(user, office_id)
office = db.query(Office).filter(Office.id == office_id).first()
if not office:
raise HTTPException(status_code=404, detail="Office not found")
if not db.query(User).filter(User.id == data.user_id).first():
raise HTTPException(status_code=404, detail="User not found")
existing = db.query(ParkingExclusion).filter(
ParkingExclusion.office_id == office_id,
ParkingExclusion.user_id == data.user_id
).first()
if existing:
raise HTTPException(status_code=400, detail="User already has a parking exclusion")
if data.start_date and data.end_date and data.end_date < data.start_date:
raise HTTPException(status_code=400, detail="End date must be after start date")
exclusion = ParkingExclusion(
id=generate_uuid(),
office_id=office_id,
user_id=data.user_id,
start_date=data.start_date,
end_date=data.end_date,
notes=data.notes,
created_at=datetime.utcnow()
)
db.add(exclusion)
db.commit()
return {"id": exclusion.id, "message": "Exclusion added"}
@router.delete("/{office_id}/exclusions/{exclusion_id}")
def remove_office_exclusion(office_id: str, exclusion_id: str, db: Session = Depends(get_db), user=Depends(require_manager_or_admin)):
"""Remove parking exclusion for an office"""
check_office_access(user, office_id)
exclusion = db.query(ParkingExclusion).filter(
ParkingExclusion.id == exclusion_id,
ParkingExclusion.office_id == office_id
).first()
if not exclusion:
raise HTTPException(status_code=404, detail="Exclusion not found")
db.delete(exclusion)
db.commit()
return {"message": "Exclusion removed"}

View File

@@ -2,21 +2,33 @@
Parking Management Routes Parking Management Routes
Parking assignments, spot management, and pool initialization Parking assignments, spot management, and pool initialization
Manager-centric model:
- Managers own parking spots (defined by manager_parking_quota)
- Spots are named with manager's letter prefix (A1, A2, B1, B2...)
- Assignments reference manager_id directly
"""
"""
Parking Management Routes
Parking assignments, spot management, and pool initialization
Manager-centric model: Manager-centric model:
- Managers own parking spots (defined by manager_parking_quota) - Managers own parking spots (defined by manager_parking_quota)
- Spots are named with manager's letter prefix (A1, A2, B1, B2...) - Spots are named with manager's letter prefix (A1, A2, B1, B2...)
- Assignments reference manager_id directly - Assignments reference manager_id directly
""" """
from typing import List from typing import List
from datetime import datetime from datetime import datetime, date
from fastapi import APIRouter, Depends, HTTPException from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel from pydantic import BaseModel
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from database.connection import get_db from database.connection import get_db
from database.models import DailyParkingAssignment, User from database.models import DailyParkingAssignment, User, UserRole, Office
from utils.auth_middleware import get_current_user, require_manager_or_admin from utils.auth_middleware import get_current_user, require_manager_or_admin
from services.parking import initialize_parking_pool, get_spot_display_name from services.parking import (
initialize_parking_pool, get_spot_display_name, release_user_spot,
run_batch_allocation, clear_assignments_for_office_date
)
from services.notifications import notify_parking_assigned, notify_parking_released, notify_parking_reassigned from services.notifications import notify_parking_assigned, notify_parking_released, notify_parking_reassigned
from app import config from app import config
@@ -25,14 +37,14 @@ router = APIRouter(prefix="/api/parking", tags=["parking"])
# Request/Response Models # Request/Response Models
class InitPoolRequest(BaseModel): class InitPoolRequest(BaseModel):
date: str # YYYY-MM-DD date: date
class ManualAssignRequest(BaseModel): class ManualAssignRequest(BaseModel):
manager_id: str office_id: str
user_id: str user_id: str
spot_id: str spot_id: str
date: str date: date
class ReassignSpotRequest(BaseModel): class ReassignSpotRequest(BaseModel):
@@ -42,50 +54,57 @@ class ReassignSpotRequest(BaseModel):
class AssignmentResponse(BaseModel): class AssignmentResponse(BaseModel):
id: str id: str
date: str date: date
spot_id: str spot_id: str
spot_display_name: str | None = None spot_display_name: str | None = None
user_id: str | None user_id: str | None
manager_id: str office_id: str
user_name: str | None = None user_name: str | None = None
user_email: str | None = None user_email: str | None = None
class RunAllocationRequest(BaseModel):
date: date
office_id: str
class ClearAssignmentsRequest(BaseModel):
date: date
office_id: str
# Routes # Routes
@router.post("/init-manager-pool") @router.post("/init-office-pool")
def init_manager_pool(request: InitPoolRequest, db: Session = Depends(get_db), current_user=Depends(require_manager_or_admin)): def init_office_pool(request: InitPoolRequest, db: Session = Depends(get_db), current_user=Depends(require_manager_or_admin)):
"""Initialize parking pool for a manager on a given date""" """Initialize parking pool for an office on a given date"""
try: pool_date = request.date
datetime.strptime(request.date, "%Y-%m-%d")
except ValueError:
raise HTTPException(status_code=400, detail="Invalid date format")
quota = current_user.manager_parking_quota or 0 if not current_user.office_id:
if quota == 0: raise HTTPException(status_code=400, detail="User does not belong to an office")
return {"success": True, "message": "No parking quota configured", "spots": 0}
office = db.query(Office).filter(Office.id == current_user.office_id).first()
if not office or not office.parking_quota:
return {"success": True, "message": "No parking quota configured", "spots": 0}
spots = initialize_parking_pool(current_user.id, quota, request.date, db) spots = initialize_parking_pool(office.id, office.parking_quota, pool_date, db)
return {"success": True, "spots": spots} return {"success": True, "spots": spots}
@router.get("/assignments/{date}", response_model=List[AssignmentResponse]) @router.get("/assignments/{date_val}", response_model=List[AssignmentResponse])
def get_assignments(date: str, manager_id: str = None, db: Session = Depends(get_db), current_user=Depends(get_current_user)): def get_assignments(date_val: date, office_id: str = None, db: Session = Depends(get_db), current_user=Depends(get_current_user)):
"""Get parking assignments for a date, optionally filtered by manager""" """Get parking assignments for a date, optionally filtered by office"""
try: query_date = date_val
datetime.strptime(date, "%Y-%m-%d")
except ValueError:
raise HTTPException(status_code=400, detail="Invalid date format")
query = db.query(DailyParkingAssignment).filter(DailyParkingAssignment.date == date) query = db.query(DailyParkingAssignment).filter(DailyParkingAssignment.date == query_date)
if manager_id: if office_id:
query = query.filter(DailyParkingAssignment.manager_id == manager_id) query = query.filter(DailyParkingAssignment.office_id == office_id)
assignments = query.all() assignments = query.all()
results = [] results = []
for assignment in assignments: for assignment in assignments:
# Get display name using manager's spot prefix # Get display name using office's spot prefix
spot_display_name = get_spot_display_name(assignment.spot_id, assignment.manager_id, db) spot_display_name = get_spot_display_name(assignment.spot_id, assignment.office_id, db)
result = AssignmentResponse( result = AssignmentResponse(
id=assignment.id, id=assignment.id,
@@ -93,7 +112,7 @@ def get_assignments(date: str, manager_id: str = None, db: Session = Depends(get
spot_id=assignment.spot_id, spot_id=assignment.spot_id,
spot_display_name=spot_display_name, spot_display_name=spot_display_name,
user_id=assignment.user_id, user_id=assignment.user_id,
manager_id=assignment.manager_id office_id=assignment.office_id
) )
if assignment.user_id: if assignment.user_id:
@@ -108,7 +127,7 @@ def get_assignments(date: str, manager_id: str = None, db: Session = Depends(get
@router.get("/my-assignments", response_model=List[AssignmentResponse]) @router.get("/my-assignments", response_model=List[AssignmentResponse])
def get_my_assignments(start_date: str = None, end_date: str = None, db: Session = Depends(get_db), current_user=Depends(get_current_user)): def get_my_assignments(start_date: date = None, end_date: date = None, db: Session = Depends(get_db), current_user=Depends(get_current_user)):
"""Get current user's parking assignments""" """Get current user's parking assignments"""
query = db.query(DailyParkingAssignment).filter( query = db.query(DailyParkingAssignment).filter(
DailyParkingAssignment.user_id == current_user.id DailyParkingAssignment.user_id == current_user.id
@@ -123,7 +142,7 @@ def get_my_assignments(start_date: str = None, end_date: str = None, db: Session
results = [] results = []
for assignment in assignments: for assignment in assignments:
spot_display_name = get_spot_display_name(assignment.spot_id, assignment.manager_id, db) spot_display_name = get_spot_display_name(assignment.spot_id, assignment.office_id, db)
results.append(AssignmentResponse( results.append(AssignmentResponse(
id=assignment.id, id=assignment.id,
@@ -131,7 +150,7 @@ def get_my_assignments(start_date: str = None, end_date: str = None, db: Session
spot_id=assignment.spot_id, spot_id=assignment.spot_id,
spot_display_name=spot_display_name, spot_display_name=spot_display_name,
user_id=assignment.user_id, user_id=assignment.user_id,
manager_id=assignment.manager_id, office_id=assignment.office_id,
user_name=current_user.name, user_name=current_user.name,
user_email=current_user.email user_email=current_user.email
)) ))
@@ -139,27 +158,55 @@ def get_my_assignments(start_date: str = None, end_date: str = None, db: Session
return results return results
return results
@router.post("/run-allocation")
def run_fair_allocation(data: RunAllocationRequest, db: Session = Depends(get_db), current_user=Depends(require_manager_or_admin)):
"""Manually trigger fair allocation for a date (Test Tool)"""
# Verify office access
if current_user.role == UserRole.MANAGER and current_user.office_id != data.office_id:
raise HTTPException(status_code=403, detail="Not authorized for this office")
result = run_batch_allocation(data.office_id, data.date, db)
return {"message": "Allocation completed", "result": result}
@router.post("/clear-assignments")
def clear_assignments(data: ClearAssignmentsRequest, db: Session = Depends(get_db), current_user=Depends(require_manager_or_admin)):
"""Clear all assignments for a date (Test Tool)"""
# Verify office access
if current_user.role == UserRole.MANAGER and current_user.office_id != data.office_id:
raise HTTPException(status_code=403, detail="Not authorized for this office")
count = clear_assignments_for_office_date(data.office_id, data.date, db)
return {"message": "Assignments cleared", "count": count}
@router.post("/manual-assign") @router.post("/manual-assign")
def manual_assign(data: ManualAssignRequest, db: Session = Depends(get_db), current_user=Depends(require_manager_or_admin)): def manual_assign(data: ManualAssignRequest, db: Session = Depends(get_db), current_user=Depends(require_manager_or_admin)):
"""Manually assign a spot to a user""" """Manually assign a spot to a user"""
assign_date = data.date
# Verify user exists # Verify user exists
user = db.query(User).filter(User.id == data.user_id).first() user = db.query(User).filter(User.id == data.user_id).first()
if not user: if not user:
raise HTTPException(status_code=404, detail="User not found") raise HTTPException(status_code=404, detail="User not found")
# Verify manager exists and check permission # Verify office exists
manager = db.query(User).filter(User.id == data.manager_id, User.role == "manager").first() office = db.query(Office).filter(Office.id == data.office_id).first()
if not manager: if not office:
raise HTTPException(status_code=404, detail="Manager not found") raise HTTPException(status_code=404, detail="Office not found")
# Only admin or the manager themselves can assign spots # Only admin or the manager of that office can assign spots
if current_user.role != "admin" and current_user.id != data.manager_id: is_manager = (current_user.role == UserRole.MANAGER and current_user.office_id == data.office_id)
raise HTTPException(status_code=403, detail="Not authorized to assign spots for this manager") if current_user.role != UserRole.ADMIN and not is_manager:
raise HTTPException(status_code=403, detail="Not authorized to assign spots for this office")
# Check if spot exists and is free # Check if spot exists and is free
spot = db.query(DailyParkingAssignment).filter( spot = db.query(DailyParkingAssignment).filter(
DailyParkingAssignment.manager_id == data.manager_id, DailyParkingAssignment.office_id == data.office_id,
DailyParkingAssignment.date == data.date, DailyParkingAssignment.date == assign_date,
DailyParkingAssignment.spot_id == data.spot_id DailyParkingAssignment.spot_id == data.spot_id
).first() ).first()
@@ -170,7 +217,7 @@ def manual_assign(data: ManualAssignRequest, db: Session = Depends(get_db), curr
# Check if user already has a spot for this date (from any manager) # Check if user already has a spot for this date (from any manager)
existing = db.query(DailyParkingAssignment).filter( existing = db.query(DailyParkingAssignment).filter(
DailyParkingAssignment.date == data.date, DailyParkingAssignment.date == assign_date,
DailyParkingAssignment.user_id == data.user_id DailyParkingAssignment.user_id == data.user_id
).first() ).first()
@@ -180,7 +227,7 @@ def manual_assign(data: ManualAssignRequest, db: Session = Depends(get_db), curr
spot.user_id = data.user_id spot.user_id = data.user_id
db.commit() db.commit()
spot_display_name = get_spot_display_name(data.spot_id, data.manager_id, db) spot_display_name = get_spot_display_name(data.spot_id, data.office_id, db)
return {"message": "Spot assigned", "spot_id": data.spot_id, "spot_display_name": spot_display_name} return {"message": "Spot assigned", "spot_id": data.spot_id, "spot_display_name": spot_display_name}
@@ -198,7 +245,7 @@ def release_my_spot(assignment_id: str, db: Session = Depends(get_db), current_u
raise HTTPException(status_code=403, detail="You can only release your own parking spot") raise HTTPException(status_code=403, detail="You can only release your own parking spot")
# Get spot display name for notification # Get spot display name for notification
spot_display_name = get_spot_display_name(assignment.spot_id, assignment.manager_id, db) spot_display_name = get_spot_display_name(assignment.spot_id, assignment.office_id, db)
assignment.user_id = None assignment.user_id = None
db.commit() db.commit()
@@ -223,9 +270,9 @@ def reassign_spot(data: ReassignSpotRequest, db: Session = Depends(get_db), curr
raise HTTPException(status_code=404, detail="Assignment not found") raise HTTPException(status_code=404, detail="Assignment not found")
# Check permission: admin, manager who owns the spot, or current spot holder # Check permission: admin, manager who owns the spot, or current spot holder
is_admin = current_user.role == 'admin' is_admin = current_user.role == UserRole.ADMIN
is_spot_owner = assignment.user_id == current_user.id is_spot_owner = assignment.user_id == current_user.id
is_manager = current_user.id == assignment.manager_id is_manager = (current_user.role == UserRole.MANAGER and current_user.office_id == assignment.office_id)
if not (is_admin or is_manager or is_spot_owner): if not (is_admin or is_manager or is_spot_owner):
raise HTTPException(status_code=403, detail="Not authorized to reassign this spot") raise HTTPException(status_code=403, detail="Not authorized to reassign this spot")
@@ -235,9 +282,17 @@ def reassign_spot(data: ReassignSpotRequest, db: Session = Depends(get_db), curr
old_user = db.query(User).filter(User.id == old_user_id).first() if old_user_id else None old_user = db.query(User).filter(User.id == old_user_id).first() if old_user_id else None
# Get spot display name for notifications # Get spot display name for notifications
spot_display_name = get_spot_display_name(assignment.spot_id, assignment.manager_id, db) spot_display_name = get_spot_display_name(assignment.spot_id, assignment.office_id, db)
if data.new_user_id: if data.new_user_id == "auto":
# "Auto assign" means releasing the spot so the system picks the next person
# release_user_spot returns True if it released it (and potentially reassigned it)
success = release_user_spot(assignment.office_id, assignment.user_id, assignment.date, db)
if not success:
raise HTTPException(status_code=400, detail="Could not auto-reassign spot")
return {"message": "Spot released for auto-assignment"}
elif data.new_user_id:
# Check new user exists # Check new user exists
new_user = db.query(User).filter(User.id == data.new_user_id).first() new_user = db.query(User).filter(User.id == data.new_user_id).first()
if not new_user: if not new_user:
@@ -275,7 +330,7 @@ def reassign_spot(data: ReassignSpotRequest, db: Session = Depends(get_db), curr
db.refresh(assignment) db.refresh(assignment)
# Build response # Build response
spot_display_name = get_spot_display_name(assignment.spot_id, assignment.manager_id, db) spot_display_name = get_spot_display_name(assignment.spot_id, assignment.office_id, db)
result = AssignmentResponse( result = AssignmentResponse(
id=assignment.id, id=assignment.id,
@@ -283,7 +338,7 @@ def reassign_spot(data: ReassignSpotRequest, db: Session = Depends(get_db), curr
spot_id=assignment.spot_id, spot_id=assignment.spot_id,
spot_display_name=spot_display_name, spot_display_name=spot_display_name,
user_id=assignment.user_id, user_id=assignment.user_id,
manager_id=assignment.manager_id office_id=assignment.office_id
) )
if assignment.user_id: if assignment.user_id:
@@ -308,16 +363,16 @@ def get_eligible_users(assignment_id: str, db: Session = Depends(get_db), curren
raise HTTPException(status_code=404, detail="Assignment not found") raise HTTPException(status_code=404, detail="Assignment not found")
# Check permission: admin, manager who owns the spot, or current spot holder # Check permission: admin, manager who owns the spot, or current spot holder
is_admin = current_user.role == 'admin' is_admin = current_user.role == UserRole.ADMIN
is_spot_owner = assignment.user_id == current_user.id is_spot_owner = assignment.user_id == current_user.id
is_manager = current_user.id == assignment.manager_id is_manager = (current_user.role == UserRole.MANAGER and current_user.office_id == assignment.office_id)
if not (is_admin or is_manager or is_spot_owner): if not (is_admin or is_manager or is_spot_owner):
raise HTTPException(status_code=403, detail="Not authorized") raise HTTPException(status_code=403, detail="Not authorized")
# Get users in this manager's team (including the manager themselves) # Get users in this office (including the manager themselves)
users = db.query(User).filter( users = db.query(User).filter(
(User.manager_id == assignment.manager_id) | (User.id == assignment.manager_id), User.office_id == assignment.office_id,
User.id != assignment.user_id # Exclude current holder User.id != assignment.user_id # Exclude current holder
).all() ).all()

View File

@@ -3,13 +3,13 @@ Presence Management Routes
User presence marking and admin management User presence marking and admin management
""" """
from typing import List from typing import List
from datetime import datetime, timedelta from datetime import datetime, timedelta, date
from fastapi import APIRouter, Depends, HTTPException from fastapi import APIRouter, Depends, HTTPException, Query
from pydantic import BaseModel from pydantic import BaseModel
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from database.connection import get_db from database.connection import get_db
from database.models import UserPresence, User, DailyParkingAssignment from database.models import UserPresence, User, DailyParkingAssignment, UserRole, PresenceStatus, Office
from utils.auth_middleware import get_current_user, require_manager_or_admin from utils.auth_middleware import get_current_user, require_manager_or_admin
from utils.helpers import generate_uuid from utils.helpers import generate_uuid
from services.parking import handle_presence_change, get_spot_display_name from services.parking import handle_presence_change, get_spot_display_name
@@ -20,38 +20,26 @@ router = APIRouter(prefix="/api/presence", tags=["presence"])
# Request/Response Models # Request/Response Models
class PresenceMarkRequest(BaseModel): class PresenceMarkRequest(BaseModel):
date: str # YYYY-MM-DD date: date
status: str # present, remote, absent status: PresenceStatus
class AdminPresenceMarkRequest(BaseModel): class AdminPresenceMarkRequest(BaseModel):
user_id: str user_id: str
date: str date: date
status: str status: PresenceStatus
class BulkPresenceRequest(BaseModel):
start_date: str
end_date: str
status: str
days: List[int] | None = None # Optional: [0,1,2,3,4] for Mon-Fri
class AdminBulkPresenceRequest(BaseModel):
user_id: str
start_date: str
end_date: str
status: str
days: List[int] | None = None
class PresenceResponse(BaseModel): class PresenceResponse(BaseModel):
id: str id: str
user_id: str user_id: str
date: str date: date
status: str status: PresenceStatus
created_at: str | None created_at: datetime | None
updated_at: str | None updated_at: datetime | None
parking_spot_number: str | None = None parking_spot_number: str | None = None
class Config: class Config:
@@ -59,51 +47,38 @@ class PresenceResponse(BaseModel):
# Helper functions # Helper functions
def validate_status(status: str):
if status not in ["present", "remote", "absent"]:
raise HTTPException(status_code=400, detail="Status must be: present, remote, or absent")
def parse_date(date_str: str) -> datetime:
try:
return datetime.strptime(date_str, "%Y-%m-%d")
except ValueError:
raise HTTPException(status_code=400, detail="Invalid date format. Use YYYY-MM-DD")
def check_manager_access(current_user: User, target_user: User, db: Session): def check_manager_access(current_user: User, target_user: User, db: Session):
"""Check if current_user has access to target_user""" """Check if current_user has access to target_user"""
if current_user.role == "admin": if current_user.role == UserRole.ADMIN:
return True return True
if current_user.role == "manager": if current_user.role == UserRole.MANAGER:
# Manager can access users they manage # Manager can access users in their Office
if target_user.manager_id == current_user.id: if target_user.office_id == current_user.office_id:
return True return True
raise HTTPException(status_code=403, detail="User is not managed by you") raise HTTPException(status_code=403, detail="User is not in your office")
raise HTTPException(status_code=403, detail="Access denied") raise HTTPException(status_code=403, detail="Access denied")
def _mark_presence_for_user( def _mark_presence_for_user(
user_id: str, user_id: str,
date: str, presence_date: date,
status: str, status: PresenceStatus,
db: Session, db: Session,
target_user: User target_user: User
) -> UserPresence: ) -> UserPresence:
""" """
Core presence marking logic - shared by user and admin routes. Core presence marking logic - shared by user and admin routes.
""" """
validate_status(status)
parse_date(date)
existing = db.query(UserPresence).filter( existing = db.query(UserPresence).filter(
UserPresence.user_id == user_id, UserPresence.user_id == user_id,
UserPresence.date == date UserPresence.date == presence_date
).first() ).first()
now = datetime.utcnow().isoformat() now = datetime.utcnow()
old_status = existing.status if existing else None old_status = existing.status if existing else None
if existing: if existing:
@@ -116,7 +91,7 @@ def _mark_presence_for_user(
presence = UserPresence( presence = UserPresence(
id=generate_uuid(), id=generate_uuid(),
user_id=user_id, user_id=user_id,
date=date, date=presence_date,
status=status, status=status,
created_at=now, created_at=now,
updated_at=now updated_at=now
@@ -125,114 +100,36 @@ def _mark_presence_for_user(
db.commit() db.commit()
db.refresh(presence) db.refresh(presence)
# Handle parking assignment # Handle parking assignment (if user is in an office)
# Use manager_id if user has one, or user's own id if they are a manager if target_user.office_id and old_status != status:
parking_manager_id = target_user.manager_id
if not parking_manager_id and target_user.role == "manager":
# Manager is part of their own team for parking purposes
parking_manager_id = target_user.id
if old_status != status and parking_manager_id:
try: try:
handle_presence_change( handle_presence_change(
user_id, date, user_id, presence_date,
old_status or "absent", status, old_status or PresenceStatus.ABSENT, status,
parking_manager_id, db target_user.office_id, db
) )
except Exception as e: except Exception as e:
config.logger.warning(f"Parking handler failed for user {user_id} on {date}: {e}") config.logger.warning(f"Parking handler failed for user {user_id} on {presence_date}: {e}")
return presence return presence
def _bulk_mark_presence(
user_id: str,
start_date: str,
end_date: str,
status: str,
days: List[int] | None,
db: Session,
target_user: User
) -> List[UserPresence]:
"""
Core bulk presence marking logic - shared by user and admin routes.
"""
validate_status(status)
start = parse_date(start_date)
end = parse_date(end_date)
if end < start:
raise HTTPException(status_code=400, detail="End date must be after start date")
if (end - start).days > 90:
raise HTTPException(status_code=400, detail="Range cannot exceed 90 days")
results = []
current_date = start
now = datetime.utcnow().isoformat()
while current_date <= end:
if days is None or current_date.weekday() in days:
date_str = current_date.strftime("%Y-%m-%d")
existing = db.query(UserPresence).filter(
UserPresence.user_id == user_id,
UserPresence.date == date_str
).first()
old_status = existing.status if existing else None
if existing:
existing.status = status
existing.updated_at = now
results.append(existing)
else:
presence = UserPresence(
id=generate_uuid(),
user_id=user_id,
date=date_str,
status=status,
created_at=now,
updated_at=now
)
db.add(presence)
results.append(presence)
# Handle parking for each date
# Use manager_id if user has one, or user's own id if they are a manager
parking_manager_id = target_user.manager_id
if not parking_manager_id and target_user.role == "manager":
parking_manager_id = target_user.id
if old_status != status and parking_manager_id:
try:
handle_presence_change(
user_id, date_str,
old_status or "absent", status,
parking_manager_id, db
)
except Exception as e:
config.logger.warning(f"Parking handler failed for user {user_id} on {date_str}: {e}")
current_date += timedelta(days=1)
db.commit()
return results
def _delete_presence( def _delete_presence(
user_id: str, user_id: str,
date: str, presence_date: date,
db: Session, db: Session,
target_user: User target_user: User
) -> dict: ) -> dict:
""" """
Core presence deletion logic - shared by user and admin routes. Core presence deletion logic - shared by user and admin routes.
""" """
parse_date(date)
presence = db.query(UserPresence).filter( presence = db.query(UserPresence).filter(
UserPresence.user_id == user_id, UserPresence.user_id == user_id,
UserPresence.date == date UserPresence.date == presence_date
).first() ).first()
if not presence: if not presence:
@@ -242,20 +139,15 @@ def _delete_presence(
db.delete(presence) db.delete(presence)
db.commit() db.commit()
# Use manager_id if user has one, or user's own id if they are a manager if target_user.office_id:
parking_manager_id = target_user.manager_id
if not parking_manager_id and target_user.role == "manager":
parking_manager_id = target_user.id
if parking_manager_id:
try: try:
handle_presence_change( handle_presence_change(
user_id, date, user_id, presence_date,
old_status, "absent", old_status, PresenceStatus.ABSENT,
parking_manager_id, db target_user.office_id, db
) )
except Exception as e: except Exception as e:
config.logger.warning(f"Parking handler failed for user {user_id} on {date}: {e}") config.logger.warning(f"Parking handler failed for user {user_id} on {presence_date}: {e}")
return {"message": "Presence deleted"} return {"message": "Presence deleted"}
@@ -267,34 +159,26 @@ def mark_presence(data: PresenceMarkRequest, db: Session = Depends(get_db), curr
return _mark_presence_for_user(current_user.id, data.date, data.status, db, current_user) return _mark_presence_for_user(current_user.id, data.date, data.status, db, current_user)
@router.post("/mark-bulk", response_model=List[PresenceResponse])
def mark_bulk_presence(data: BulkPresenceRequest, db: Session = Depends(get_db), current_user=Depends(get_current_user)):
"""Mark presence for a date range"""
return _bulk_mark_presence(
current_user.id, data.start_date, data.end_date,
data.status, data.days, db, current_user
)
@router.get("/my-presences", response_model=List[PresenceResponse]) @router.get("/my-presences", response_model=List[PresenceResponse])
def get_my_presences(start_date: str = None, end_date: str = None, db: Session = Depends(get_db), current_user=Depends(get_current_user)): def get_my_presences(start_date: date = None, end_date: date = None, db: Session = Depends(get_db), current_user=Depends(get_current_user)):
"""Get current user's presences""" """Get current user's presences"""
query = db.query(UserPresence).filter(UserPresence.user_id == current_user.id) query = db.query(UserPresence).filter(UserPresence.user_id == current_user.id)
if start_date: if start_date:
parse_date(start_date)
query = query.filter(UserPresence.date >= start_date) query = query.filter(UserPresence.date >= start_date)
if end_date: if end_date:
parse_date(end_date)
query = query.filter(UserPresence.date <= end_date) query = query.filter(UserPresence.date <= end_date)
return query.order_by(UserPresence.date.desc()).all() return query.order_by(UserPresence.date.desc()).all()
@router.delete("/{date}") @router.delete("/{date_val}")
def delete_presence(date: str, db: Session = Depends(get_db), current_user=Depends(get_current_user)): def delete_presence(date_val: date, db: Session = Depends(get_db), current_user=Depends(get_current_user)):
"""Delete presence for a date""" """Delete presence for a date"""
return _delete_presence(current_user.id, date, db, current_user) return _delete_presence(current_user.id, date_val, db, current_user)
# Admin/Manager Routes # Admin/Manager Routes
@@ -309,66 +193,47 @@ def admin_mark_presence(data: AdminPresenceMarkRequest, db: Session = Depends(ge
return _mark_presence_for_user(data.user_id, data.date, data.status, db, target_user) return _mark_presence_for_user(data.user_id, data.date, data.status, db, target_user)
@router.post("/admin/mark-bulk", response_model=List[PresenceResponse])
def admin_mark_bulk_presence(data: AdminBulkPresenceRequest, db: Session = Depends(get_db), current_user=Depends(require_manager_or_admin)):
"""Bulk mark presence for any user (manager/admin)"""
target_user = db.query(User).filter(User.id == data.user_id).first()
if not target_user:
raise HTTPException(status_code=404, detail="User not found")
check_manager_access(current_user, target_user, db)
return _bulk_mark_presence(
data.user_id, data.start_date, data.end_date,
data.status, data.days, db, target_user
)
@router.delete("/admin/{user_id}/{date}")
def admin_delete_presence(user_id: str, date: str, db: Session = Depends(get_db), current_user=Depends(require_manager_or_admin)): @router.delete("/admin/{user_id}/{date_val}")
def admin_delete_presence(user_id: str, date_val: date, db: Session = Depends(get_db), current_user=Depends(require_manager_or_admin)):
"""Delete presence for any user (manager/admin)""" """Delete presence for any user (manager/admin)"""
target_user = db.query(User).filter(User.id == user_id).first() target_user = db.query(User).filter(User.id == user_id).first()
if not target_user: if not target_user:
raise HTTPException(status_code=404, detail="User not found") raise HTTPException(status_code=404, detail="User not found")
check_manager_access(current_user, target_user, db) check_manager_access(current_user, target_user, db)
return _delete_presence(user_id, date, db, target_user) return _delete_presence(user_id, date_val, db, target_user)
@router.get("/team") @router.get("/team")
def get_team_presences(start_date: str, end_date: str, manager_id: str = None, db: Session = Depends(get_db), current_user=Depends(get_current_user)): def get_team_presences(start_date: date, end_date: date, office_id: str = None, db: Session = Depends(get_db), current_user=Depends(get_current_user)):
"""Get team presences with parking info, filtered by manager. """Get office presences with parking info.
- Admins can see all teams - Admins can see all users (or filter by office_id)
- Managers see their own team - Managers see their own office's users
- Employees can only see their own team (read-only view) - Employees can see their own office's users (read-only view)
""" """
parse_date(start_date)
parse_date(end_date)
# Get users based on permissions and manager filter if current_user.role == UserRole.ADMIN:
# Note: Manager is part of their own team (for parking assignment purposes) if office_id:
if current_user.role == "employee": users = db.query(User).filter(User.office_id == office_id).all()
# Employees can only see their own team (users with same manager_id + the manager) else:
if not current_user.manager_id: users = db.query(User).all()
return [] # No manager assigned, no team to show
users = db.query(User).filter( elif current_user.office_id:
(User.manager_id == current_user.manager_id) | (User.id == current_user.manager_id) # Non-admin users see their office members
).all() users = db.query(User).filter(User.office_id == current_user.office_id).all()
elif manager_id:
# Filter by specific manager (for admins/managers) - include the manager themselves
users = db.query(User).filter(
(User.manager_id == manager_id) | (User.id == manager_id)
).all()
elif current_user.role == "admin":
# Admin sees all users
users = db.query(User).all()
else: else:
# Manager sees their team + themselves # No office assigned
users = db.query(User).filter( return []
(User.manager_id == current_user.id) | (User.id == current_user.id)
).all()
# Batch query presences and parking for all users # Batch query presences and parking for all selected users
user_ids = [u.id for u in users] user_ids = [u.id for u in users]
if not user_ids:
return []
presences = db.query(UserPresence).filter( presences = db.query(UserPresence).filter(
UserPresence.user_id.in_(user_ids), UserPresence.user_id.in_(user_ids),
UserPresence.date >= start_date, UserPresence.date >= start_date,
@@ -389,7 +254,7 @@ def get_team_presences(start_date: str, end_date: str, manager_id: str = None, d
parking_lookup[p.user_id] = [] parking_lookup[p.user_id] = []
parking_info_lookup[p.user_id] = [] parking_info_lookup[p.user_id] = []
parking_lookup[p.user_id].append(p.date) parking_lookup[p.user_id].append(p.date)
spot_display_name = get_spot_display_name(p.spot_id, p.manager_id, db) spot_display_name = get_spot_display_name(p.spot_id, p.office_id, db)
parking_info_lookup[p.user_id].append({ parking_info_lookup[p.user_id].append({
"id": p.id, "id": p.id,
"date": p.date, "date": p.date,
@@ -397,10 +262,10 @@ def get_team_presences(start_date: str, end_date: str, manager_id: str = None, d
"spot_display_name": spot_display_name "spot_display_name": spot_display_name
}) })
# Build manager lookup for display # Build office lookup for display (replacing old manager_lookup)
manager_ids = list(set(u.manager_id for u in users if u.manager_id)) office_ids = list(set(u.office_id for u in users if u.office_id))
managers = db.query(User).filter(User.id.in_(manager_ids)).all() if manager_ids else [] offices = db.query(Office).filter(Office.id.in_(office_ids)).all() if office_ids else []
manager_lookup = {m.id: m.name for m in managers} office_lookup = {o.id: o.name for o in offices}
# Build response # Build response
result = [] result = []
@@ -410,8 +275,8 @@ def get_team_presences(start_date: str, end_date: str, manager_id: str = None, d
result.append({ result.append({
"id": user.id, "id": user.id,
"name": user.name, "name": user.name,
"manager_id": user.manager_id, "office_id": user.office_id,
"manager_name": manager_lookup.get(user.manager_id), "office_name": office_lookup.get(user.office_id),
"presences": [{"date": p.date, "status": p.status} for p in user_presences], "presences": [{"date": p.date, "status": p.status} for p in user_presences],
"parking_dates": parking_lookup.get(user.id, []), "parking_dates": parking_lookup.get(user.id, []),
"parking_info": parking_info_lookup.get(user.id, []) "parking_info": parking_info_lookup.get(user.id, [])
@@ -421,7 +286,7 @@ def get_team_presences(start_date: str, end_date: str, manager_id: str = None, d
@router.get("/admin/{user_id}") @router.get("/admin/{user_id}")
def get_user_presences(user_id: str, start_date: str = None, end_date: str = None, db: Session = Depends(get_db), current_user=Depends(require_manager_or_admin)): def get_user_presences(user_id: str, start_date: date = None, end_date: date = None, db: Session = Depends(get_db), current_user=Depends(require_manager_or_admin)):
"""Get any user's presences with parking info (manager/admin)""" """Get any user's presences with parking info (manager/admin)"""
target_user = db.query(User).filter(User.id == user_id).first() target_user = db.query(User).filter(User.id == user_id).first()
if not target_user: if not target_user:
@@ -432,24 +297,23 @@ def get_user_presences(user_id: str, start_date: str = None, end_date: str = Non
query = db.query(UserPresence).filter(UserPresence.user_id == user_id) query = db.query(UserPresence).filter(UserPresence.user_id == user_id)
if start_date: if start_date:
parse_date(start_date)
query = query.filter(UserPresence.date >= start_date) query = query.filter(UserPresence.date >= start_date)
if end_date: if end_date:
parse_date(end_date)
query = query.filter(UserPresence.date <= end_date) query = query.filter(UserPresence.date <= end_date)
presences = query.order_by(UserPresence.date.desc()).all() presences = query.order_by(UserPresence.date.desc()).all()
# Batch query parking assignments # Batch query parking assignments
date_strs = [p.date for p in presences] dates = [p.date for p in presences]
parking_map = {} parking_map = {}
if date_strs: if dates:
# Note: Assignments link to user. We can find spot display name by looking up assignment -> office
assignments = db.query(DailyParkingAssignment).filter( assignments = db.query(DailyParkingAssignment).filter(
DailyParkingAssignment.user_id == user_id, DailyParkingAssignment.user_id == user_id,
DailyParkingAssignment.date.in_(date_strs) DailyParkingAssignment.date.in_(dates)
).all() ).all()
for a in assignments: for a in assignments:
parking_map[a.date] = get_spot_display_name(a.spot_id, a.manager_id, db) parking_map[a.date] = get_spot_display_name(a.spot_id, a.office_id, db)
# Build response # Build response
result = [] result = []

View File

@@ -8,7 +8,7 @@ from pydantic import BaseModel, EmailStr
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from database.connection import get_db from database.connection import get_db
from database.models import User from database.models import User, UserRole, Office
from utils.auth_middleware import get_current_user, require_admin from utils.auth_middleware import get_current_user, require_admin
from utils.helpers import ( from utils.helpers import (
generate_uuid, is_ldap_user, is_ldap_admin, generate_uuid, is_ldap_user, is_ldap_admin,
@@ -25,16 +25,14 @@ class UserCreate(BaseModel):
email: EmailStr email: EmailStr
password: str password: str
name: str | None = None name: str | None = None
role: str = "employee" role: UserRole = UserRole.EMPLOYEE
manager_id: str | None = None office_id: str | None = None
class UserUpdate(BaseModel): class UserUpdate(BaseModel):
name: str | None = None name: str | None = None
role: str | None = None role: UserRole | None = None
manager_id: str | None = None office_id: str | None = None
manager_parking_quota: int | None = None
manager_spot_prefix: str | None = None
class ProfileUpdate(BaseModel): class ProfileUpdate(BaseModel):
@@ -44,11 +42,11 @@ class ProfileUpdate(BaseModel):
class SettingsUpdate(BaseModel): class SettingsUpdate(BaseModel):
week_start_day: int | None = None week_start_day: int | None = None
# Notification preferences # Notification preferences
notify_weekly_parking: int | None = None notify_weekly_parking: bool | None = None
notify_daily_parking: int | None = None notify_daily_parking: bool | None = None
notify_daily_parking_hour: int | None = None notify_daily_parking_hour: int | None = None
notify_daily_parking_minute: int | None = None notify_daily_parking_minute: int | None = None
notify_parking_changes: int | None = None notify_parking_changes: bool | None = None
class ChangePasswordRequest(BaseModel): class ChangePasswordRequest(BaseModel):
@@ -60,61 +58,54 @@ class UserResponse(BaseModel):
id: str id: str
email: str email: str
name: str | None name: str | None
role: str role: UserRole
manager_id: str | None = None office_id: str | None = None
manager_name: str | None = None office_name: str | None = None
manager_parking_quota: int | None = None
manager_spot_prefix: str | None = None
managed_user_count: int | None = None
is_ldap_user: bool = False is_ldap_user: bool = False
is_ldap_admin: bool = False is_ldap_admin: bool = False
created_at: str | None created_at: str | None
parking_ratio: float | None = None
class Config: class Config:
from_attributes = True from_attributes = True
def user_to_response(user: User, db: Session, manager_lookup: dict = None, managed_counts: dict = None) -> dict: def user_to_response(user: User, db: Session, office_lookup: dict = None) -> dict:
""" """
Convert user to response dict with computed fields. Convert user to response dict with computed fields.
Args:
user: The user to convert
db: Database session
manager_lookup: Optional pre-fetched dict of manager_id -> name (for batch operations)
managed_counts: Optional pre-fetched dict of user_id -> managed_user_count (for batch operations)
""" """
# Get manager name - use lookup if available, otherwise query # Get office name - use lookup if available, otherwise query
manager_name = None office_name = None
if user.manager_id: if user.office_id:
if manager_lookup is not None: if office_lookup is not None:
manager_name = manager_lookup.get(user.manager_id) office_name = office_lookup.get(user.office_id)
else: else:
manager = db.query(User).filter(User.id == user.manager_id).first() office = db.query(Office).filter(Office.id == user.office_id).first()
if manager: if office:
manager_name = manager.name office_name = office.name
# Count managed users if this user is a manager # Calculate parking ratio (score)
managed_user_count = None parking_ratio = None
if user.role == "manager": if user.office_id:
if managed_counts is not None: try:
managed_user_count = managed_counts.get(user.id, 0) # Avoid circular import by importing inside function if needed,
else: # or ensure services.parking doesn't import this file.
managed_user_count = db.query(User).filter(User.manager_id == user.id).count() from services.parking import get_user_parking_ratio
parking_ratio = get_user_parking_ratio(user.id, user.office_id, db)
except ImportError:
pass
return { return {
"id": user.id, "id": user.id,
"email": user.email, "email": user.email,
"name": user.name, "name": user.name,
"role": user.role, "role": user.role,
"manager_id": user.manager_id, "office_id": user.office_id,
"manager_name": manager_name, "office_name": office_name,
"manager_parking_quota": user.manager_parking_quota,
"manager_spot_prefix": user.manager_spot_prefix,
"managed_user_count": managed_user_count,
"is_ldap_user": is_ldap_user(user), "is_ldap_user": is_ldap_user(user),
"is_ldap_admin": is_ldap_admin(user), "is_ldap_admin": is_ldap_admin(user),
"created_at": user.created_at "created_at": user.created_at.isoformat() if user.created_at else None,
"parking_ratio": parking_ratio
} }
@@ -125,23 +116,12 @@ def list_users(db: Session = Depends(get_db), user=Depends(require_admin)):
users = db.query(User).all() users = db.query(User).all()
# Build lookups to avoid N+1 queries # Build lookups to avoid N+1 queries
# Manager lookup: id -> name # Office lookup: id -> name
manager_ids = list(set(u.manager_id for u in users if u.manager_id)) office_ids = list(set(u.office_id for u in users if u.office_id))
managers = db.query(User).filter(User.id.in_(manager_ids)).all() if manager_ids else [] offices = db.query(Office).filter(Office.id.in_(office_ids)).all() if office_ids else []
manager_lookup = {m.id: m.name for m in managers} office_lookup = {o.id: o.name for o in offices}
# Managed user counts for managers return [user_to_response(u, db, office_lookup) for u in users]
from sqlalchemy import func
manager_user_ids = [u.id for u in users if u.role == "manager"]
if manager_user_ids:
counts = db.query(User.manager_id, func.count(User.id)).filter(
User.manager_id.in_(manager_user_ids)
).group_by(User.manager_id).all()
managed_counts = {manager_id: count for manager_id, count in counts}
else:
managed_counts = {}
return [user_to_response(u, db, manager_lookup, managed_counts) for u in users]
@router.get("/{user_id}") @router.get("/{user_id}")
@@ -162,18 +142,17 @@ def create_user(data: UserCreate, db: Session = Depends(get_db), user=Depends(re
if db.query(User).filter(User.email == data.email).first(): if db.query(User).filter(User.email == data.email).first():
raise HTTPException(status_code=400, detail="Email already registered") raise HTTPException(status_code=400, detail="Email already registered")
if data.role not in ["admin", "manager", "employee"]: # Role validation handled by Pydantic Enum
raise HTTPException(status_code=400, detail="Invalid role")
# Validate password strength # Validate password strength
password_errors = validate_password(data.password) password_errors = validate_password(data.password)
if password_errors: if password_errors:
raise HTTPException(status_code=400, detail=format_password_errors(password_errors)) raise HTTPException(status_code=400, detail=format_password_errors(password_errors))
if data.manager_id: if data.office_id:
manager = db.query(User).filter(User.id == data.manager_id).first() office = db.query(Office).filter(Office.id == data.office_id).first()
if not manager or manager.role != "manager": if not office:
raise HTTPException(status_code=400, detail="Invalid manager") raise HTTPException(status_code=400, detail="Invalid office")
new_user = User( new_user = User(
id=generate_uuid(), id=generate_uuid(),
@@ -181,8 +160,8 @@ def create_user(data: UserCreate, db: Session = Depends(get_db), user=Depends(re
password_hash=hash_password(data.password), password_hash=hash_password(data.password),
name=data.name, name=data.name,
role=data.role, role=data.role,
manager_id=data.manager_id, office_id=data.office_id,
created_at=datetime.utcnow().isoformat() created_at=datetime.utcnow()
) )
db.add(new_user) db.add(new_user)
@@ -211,54 +190,20 @@ def update_user(user_id: str, data: UserUpdate, db: Session = Depends(get_db), u
# Role update # Role update
if data.role is not None: if data.role is not None:
if data.role not in ["admin", "manager", "employee"]:
raise HTTPException(status_code=400, detail="Invalid role")
# Can't change admin role for LDAP admins (they get admin from parking_admins group) # Can't change admin role for LDAP admins (they get admin from parking_admins group)
if target_is_ldap_admin and data.role != "admin": if target_is_ldap_admin and data.role != UserRole.ADMIN:
raise HTTPException(status_code=400, detail="Admin role is managed by LDAP group (parking_admins)") raise HTTPException(status_code=400, detail="Admin role is managed by LDAP group (parking_admins)")
# If changing from manager to another role, check for managed users
if target.role == "manager" and data.role != "manager":
managed_count = db.query(User).filter(User.manager_id == user_id).count()
if managed_count > 0:
raise HTTPException(status_code=400, detail=f"Cannot change role: {managed_count} users are assigned to this manager")
# Clear manager-specific fields
target.manager_parking_quota = 0
target.manager_spot_prefix = None
target.role = data.role target.role = data.role
# Manager assignment (any user including admins can be assigned to a manager) # Office assignment
if data.manager_id is not None: if "office_id" in data.__fields_set__:
if data.manager_id: if data.office_id:
manager = db.query(User).filter(User.id == data.manager_id).first() office = db.query(Office).filter(Office.id == data.office_id).first()
if not manager or manager.role != "manager": if not office:
raise HTTPException(status_code=400, detail="Invalid manager") raise HTTPException(status_code=400, detail="Invalid office")
if data.manager_id == user_id: target.office_id = data.office_id if data.office_id else None
raise HTTPException(status_code=400, detail="User cannot be their own manager")
target.manager_id = data.manager_id if data.manager_id else None
# Manager-specific fields target.updated_at = datetime.utcnow()
if data.manager_parking_quota is not None:
if target.role != "manager":
raise HTTPException(status_code=400, detail="Parking quota only for managers")
target.manager_parking_quota = data.manager_parking_quota
if data.manager_spot_prefix is not None:
if target.role != "manager":
raise HTTPException(status_code=400, detail="Spot prefix only for managers")
prefix = data.manager_spot_prefix.upper() if data.manager_spot_prefix else None
if prefix and not prefix.isalpha():
raise HTTPException(status_code=400, detail="Spot prefix must be a letter")
# Check for duplicate prefix
if prefix:
existing = db.query(User).filter(
User.manager_spot_prefix == prefix,
User.id != user_id
).first()
if existing:
raise HTTPException(status_code=400, detail=f"Spot prefix '{prefix}' is already used by another manager")
target.manager_spot_prefix = prefix
target.updated_at = datetime.utcnow().isoformat()
db.commit() db.commit()
db.refresh(target) db.refresh(target)
return user_to_response(target, db) return user_to_response(target, db)
@@ -274,12 +219,6 @@ def delete_user(user_id: str, db: Session = Depends(get_db), current_user=Depend
if not target: if not target:
raise HTTPException(status_code=404, detail="User not found") raise HTTPException(status_code=404, detail="User not found")
# Check if user is a manager with managed users
if target.role == "manager":
managed_count = db.query(User).filter(User.manager_id == user_id).count()
if managed_count > 0:
raise HTTPException(status_code=400, detail=f"Cannot delete: {managed_count} users are assigned to this manager")
db.delete(target) db.delete(target)
db.commit() db.commit()
return {"message": "User deleted"} return {"message": "User deleted"}
@@ -289,20 +228,20 @@ def delete_user(user_id: str, db: Session = Depends(get_db), current_user=Depend
@router.get("/me/profile") @router.get("/me/profile")
def get_profile(db: Session = Depends(get_db), current_user=Depends(get_current_user)): def get_profile(db: Session = Depends(get_db), current_user=Depends(get_current_user)):
"""Get current user's profile""" """Get current user's profile"""
# Get manager name # Get office name
manager_name = None office_name = None
if current_user.manager_id: if current_user.office_id:
manager = db.query(User).filter(User.id == current_user.manager_id).first() office = db.query(Office).filter(Office.id == current_user.office_id).first()
if manager: if office:
manager_name = manager.name office_name = office.name
return { return {
"id": current_user.id, "id": current_user.id,
"email": current_user.email, "email": current_user.email,
"name": current_user.name, "name": current_user.name,
"role": current_user.role, "role": current_user.role,
"manager_id": current_user.manager_id, "office_id": current_user.office_id,
"manager_name": manager_name, "office_name": office_name,
"is_ldap_user": is_ldap_user(current_user) "is_ldap_user": is_ldap_user(current_user)
} }
@@ -314,7 +253,7 @@ def update_profile(data: ProfileUpdate, db: Session = Depends(get_db), current_u
if is_ldap_user(current_user): if is_ldap_user(current_user):
raise HTTPException(status_code=400, detail="Name is managed by LDAP") raise HTTPException(status_code=400, detail="Name is managed by LDAP")
current_user.name = data.name current_user.name = data.name
current_user.updated_at = datetime.utcnow().isoformat() current_user.updated_at = datetime.utcnow()
db.commit() db.commit()
return {"message": "Profile updated"} return {"message": "Profile updated"}
@@ -325,11 +264,11 @@ def get_settings(current_user=Depends(get_current_user)):
"""Get current user's settings""" """Get current user's settings"""
return { return {
"week_start_day": get_notification_default(current_user.week_start_day, 0), "week_start_day": get_notification_default(current_user.week_start_day, 0),
"notify_weekly_parking": get_notification_default(current_user.notify_weekly_parking, 1), "notify_weekly_parking": get_notification_default(current_user.notify_weekly_parking, True),
"notify_daily_parking": get_notification_default(current_user.notify_daily_parking, 1), "notify_daily_parking": get_notification_default(current_user.notify_daily_parking, True),
"notify_daily_parking_hour": get_notification_default(current_user.notify_daily_parking_hour, 8), "notify_daily_parking_hour": get_notification_default(current_user.notify_daily_parking_hour, 8),
"notify_daily_parking_minute": get_notification_default(current_user.notify_daily_parking_minute, 0), "notify_daily_parking_minute": get_notification_default(current_user.notify_daily_parking_minute, 0),
"notify_parking_changes": get_notification_default(current_user.notify_parking_changes, 1) "notify_parking_changes": get_notification_default(current_user.notify_parking_changes, True)
} }
@@ -337,8 +276,8 @@ def get_settings(current_user=Depends(get_current_user)):
def update_settings(data: SettingsUpdate, db: Session = Depends(get_db), current_user=Depends(get_current_user)): def update_settings(data: SettingsUpdate, db: Session = Depends(get_db), current_user=Depends(get_current_user)):
"""Update current user's settings""" """Update current user's settings"""
if data.week_start_day is not None: if data.week_start_day is not None:
if data.week_start_day not in [0, 1]: if data.week_start_day not in [0, 6]:
raise HTTPException(status_code=400, detail="Week start must be 0 (Sunday) or 1 (Monday)") raise HTTPException(status_code=400, detail="Week start must be 0 (Monday) or 6 (Sunday)")
current_user.week_start_day = data.week_start_day current_user.week_start_day = data.week_start_day
# Notification preferences # Notification preferences
@@ -361,7 +300,7 @@ def update_settings(data: SettingsUpdate, db: Session = Depends(get_db), current
if data.notify_parking_changes is not None: if data.notify_parking_changes is not None:
current_user.notify_parking_changes = data.notify_parking_changes current_user.notify_parking_changes = data.notify_parking_changes
current_user.updated_at = datetime.utcnow().isoformat() current_user.updated_at = datetime.utcnow()
db.commit() db.commit()
return { return {
"message": "Settings updated", "message": "Settings updated",
@@ -389,7 +328,7 @@ def change_password(data: ChangePasswordRequest, db: Session = Depends(get_db),
raise HTTPException(status_code=400, detail=format_password_errors(password_errors)) raise HTTPException(status_code=400, detail=format_password_errors(password_errors))
current_user.password_hash = hash_password(data.new_password) current_user.password_hash = hash_password(data.new_password)
current_user.updated_at = datetime.utcnow().isoformat() current_user.updated_at = datetime.utcnow()
db.commit() db.commit()
config.logger.info(f"User {current_user.email} changed password") config.logger.info(f"User {current_user.email} changed password")
return {"message": "Password changed"} return {"message": "Password changed"}

View File

@@ -3,8 +3,6 @@ services:
build: . build: .
container_name: parking container_name: parking
restart: unless-stopped restart: unless-stopped
ports:
- "8000:8000"
volumes: volumes:
- ./data:/app/data - ./data:/app/data
env_file: env_file:
@@ -21,6 +19,13 @@ services:
start_period: 10s start_period: 10s
networks: networks:
- org-network - org-network
labels:
- "caddy=parking.lvh.me"
- "caddy.reverse_proxy={{upstreams 8000}}"
- "caddy.forward_auth=authelia:9091"
- "caddy.forward_auth.uri=/api/verify?rd=https://parking.lvh.me/"
- "caddy.forward_auth.copy_headers=Remote-User Remote-Groups Remote-Name Remote-Email"
networks: networks:
org-network: org-network:

View File

@@ -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")

View File

@@ -2,12 +2,73 @@
SQLAlchemy ORM Models SQLAlchemy ORM Models
Clean, focused data models for parking management Clean, focused data models for parking management
""" """
from sqlalchemy import Column, String, Integer, Text, ForeignKey, Index import enum
from sqlalchemy import Column, String, Integer, Text, ForeignKey, Index, Enum, Date, DateTime, Boolean
from sqlalchemy.orm import relationship, declarative_base from sqlalchemy.orm import relationship, declarative_base
from datetime import datetime, date
Base = declarative_base() Base = declarative_base()
class UserRole(str, enum.Enum):
ADMIN = "admin"
MANAGER = "manager"
EMPLOYEE = "employee"
class PresenceStatus(str, enum.Enum):
PRESENT = "present"
REMOTE = "remote"
ABSENT = "absent"
class NotificationType(str, enum.Enum):
PRESENCE_REMINDER = "presence_reminder"
WEEKLY_PARKING = "weekly_parking"
DAILY_PARKING = "daily_parking"
PARKING_CHANGE = "parking_change"
class WeekDay(enum.IntEnum):
# Matches Python's calendar (0=Monday)? No!
# The current DB convention in ManagerWeeklyClosingDay seems to be 0=Sunday based on comment:
# "0=Sunday, 1=Monday, ..., 6=Saturday"
# To keep consistency with existing logic comments, we'll stick to that,
# OR we can switch to standard Python (0=Monday).
# Plan said: "IntEnum matching DB convention (0=Sunday, 1=Monday, ...)"
MONDAY = 0
TUESDAY = 1
WEDNESDAY = 2
THURSDAY = 3
FRIDAY = 4
SATURDAY = 5
SUNDAY = 6
class Office(Base):
"""Organization units that have parking spots"""
__tablename__ = "offices"
id = Column(Text, primary_key=True)
name = Column(Text, nullable=False)
parking_quota = Column(Integer, default=0)
spot_prefix = Column(Text) # Letter prefix: A, B, C
# Booking Window Settings (Batch Assignment)
booking_window_enabled = Column(Boolean, default=False)
booking_window_end_hour = Column(Integer, default=18) # 0-23
booking_window_end_minute = Column(Integer, default=0) # 0-59
created_at = Column(DateTime, default=datetime.utcnow)
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
# Relationships
users = relationship("User", back_populates="office")
closing_days = relationship("OfficeClosingDay", back_populates="office", cascade="all, delete-orphan")
weekly_closing_days = relationship("OfficeWeeklyClosingDay", back_populates="office", cascade="all, delete-orphan")
class User(Base): class User(Base):
"""Application users""" """Application users"""
__tablename__ = "users" __tablename__ = "users"
@@ -16,34 +77,30 @@ class User(Base):
email = Column(Text, unique=True, nullable=False) email = Column(Text, unique=True, nullable=False)
password_hash = Column(Text) password_hash = Column(Text)
name = Column(Text) name = Column(Text)
role = Column(Text, nullable=False, default="employee") # admin, manager, employee role = Column(Enum(UserRole, values_callable=lambda obj: [e.value for e in obj]), nullable=False, default=UserRole.EMPLOYEE)
manager_id = Column(Text, ForeignKey("users.id")) # Who manages this user (any user can have a manager) office_id = Column(Text, ForeignKey("offices.id")) # Which office this user belongs to
# Manager-specific fields (only relevant for role='manager')
manager_parking_quota = Column(Integer, default=0) # Number of spots this manager controls
manager_spot_prefix = Column(Text) # Letter prefix for spots: A, B, C, etc.
# User preferences # User preferences
week_start_day = Column(Integer, default=0) # 0=Sunday, 1=Monday, ..., 6=Saturday week_start_day = Column(Integer, default=0) # 0=Sunday, 1=Monday, ... (Matches WeekDay logic)
# Notification preferences # Notification preferences
notify_weekly_parking = Column(Integer, default=1) # Weekly parking summary (Friday at 12) notify_weekly_parking = Column(Boolean, default=True) # Weekly parking summary (Friday at 12)
notify_daily_parking = Column(Integer, default=1) # Daily parking reminder notify_daily_parking = Column(Boolean, default=True) # Daily parking reminder
notify_daily_parking_hour = Column(Integer, default=8) # Hour for daily reminder (0-23) notify_daily_parking_hour = Column(Integer, default=8) # Hour for daily reminder (0-23)
notify_daily_parking_minute = Column(Integer, default=0) # Minute for daily reminder (0-59) notify_daily_parking_minute = Column(Integer, default=0) # Minute for daily reminder (0-59)
notify_parking_changes = Column(Integer, default=1) # Immediate notification on assignment changes notify_parking_changes = Column(Boolean, default=True) # Immediate notification on assignment changes
created_at = Column(Text) created_at = Column(DateTime, default=datetime.utcnow)
updated_at = Column(Text) updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
# Relationships # Relationships
manager = relationship("User", remote_side=[id], backref="managed_users") office = relationship("Office", back_populates="users")
presences = relationship("UserPresence", back_populates="user", cascade="all, delete-orphan") presences = relationship("UserPresence", back_populates="user", cascade="all, delete-orphan")
assignments = relationship("DailyParkingAssignment", back_populates="user", foreign_keys="DailyParkingAssignment.user_id") assignments = relationship("DailyParkingAssignment", back_populates="user", foreign_keys="DailyParkingAssignment.user_id")
__table_args__ = ( __table_args__ = (
Index('idx_user_email', 'email'), Index('idx_user_email', 'email'),
Index('idx_user_manager', 'manager_id'), Index('idx_user_office', 'office_id'),
) )
@@ -53,10 +110,10 @@ class UserPresence(Base):
id = Column(Text, primary_key=True) id = Column(Text, primary_key=True)
user_id = Column(Text, ForeignKey("users.id", ondelete="CASCADE"), nullable=False) user_id = Column(Text, ForeignKey("users.id", ondelete="CASCADE"), nullable=False)
date = Column(Text, nullable=False) # YYYY-MM-DD date = Column(Date, nullable=False)
status = Column(Text, nullable=False) # present, remote, absent status = Column(Enum(PresenceStatus, values_callable=lambda obj: [e.value for e in obj]), nullable=False) # present, remote, absent
created_at = Column(Text) created_at = Column(DateTime, default=datetime.utcnow)
updated_at = Column(Text) updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
# Relationships # Relationships
user = relationship("User", back_populates="presences") user = relationship("User", back_populates="presences")
@@ -68,97 +125,100 @@ class UserPresence(Base):
class DailyParkingAssignment(Base): class DailyParkingAssignment(Base):
"""Parking spot assignments per day - spots belong to managers""" """Parking spot assignments per day - spots belong to offices"""
__tablename__ = "daily_parking_assignments" __tablename__ = "daily_parking_assignments"
id = Column(Text, primary_key=True) id = Column(Text, primary_key=True)
date = Column(Text, nullable=False) # YYYY-MM-DD date = Column(Date, nullable=False)
spot_id = Column(Text, nullable=False) # A1, A2, B1, B2, etc. (prefix from manager) spot_id = Column(Text, nullable=False) # A1, A2, B1, B2, etc. (prefix from office)
user_id = Column(Text, ForeignKey("users.id", ondelete="SET NULL")) user_id = Column(Text, ForeignKey("users.id", ondelete="SET NULL"))
manager_id = Column(Text, ForeignKey("users.id", ondelete="CASCADE"), nullable=False) # Manager who owns the spot office_id = Column(Text, ForeignKey("offices.id", ondelete="CASCADE"), nullable=False) # Office that owns the spot
created_at = Column(Text) created_at = Column(DateTime, default=datetime.utcnow)
# Relationships # Relationships
user = relationship("User", back_populates="assignments", foreign_keys=[user_id]) user = relationship("User", back_populates="assignments", foreign_keys=[user_id])
manager = relationship("User", foreign_keys=[manager_id]) office = relationship("Office")
__table_args__ = ( __table_args__ = (
Index('idx_assignment_manager_date', 'manager_id', 'date'), Index('idx_assignment_office_date', 'office_id', 'date'),
Index('idx_assignment_user', 'user_id'), Index('idx_assignment_user', 'user_id'),
Index('idx_assignment_date_spot', 'date', 'spot_id'), Index('idx_assignment_date_spot', 'date', 'spot_id'),
) )
class ManagerClosingDay(Base): class OfficeClosingDay(Base):
"""Specific date closing days for a manager's parking pool (holidays, special closures)""" """Specific date closing days for an office's parking pool (holidays, special closures)"""
__tablename__ = "manager_closing_days" __tablename__ = "office_closing_days"
id = Column(Text, primary_key=True) id = Column(Text, primary_key=True)
manager_id = Column(Text, ForeignKey("users.id", ondelete="CASCADE"), nullable=False) office_id = Column(Text, ForeignKey("offices.id", ondelete="CASCADE"), nullable=False)
date = Column(Text, nullable=False) # YYYY-MM-DD date = Column(Date, nullable=False)
end_date = Column(Date)
reason = Column(Text) reason = Column(Text)
# Relationships # Relationships
manager = relationship("User") office = relationship("Office", back_populates="closing_days")
__table_args__ = ( __table_args__ = (
Index('idx_closing_manager_date', 'manager_id', 'date', unique=True), Index('idx_closing_office_date', 'office_id', 'date', unique=True),
) )
class ManagerWeeklyClosingDay(Base): class OfficeWeeklyClosingDay(Base):
"""Weekly recurring closing days for a manager's parking pool (e.g., Saturday and Sunday)""" """Weekly recurring closing days for an office's parking pool (e.g., Saturday and Sunday)"""
__tablename__ = "manager_weekly_closing_days" __tablename__ = "office_weekly_closing_days"
id = Column(Text, primary_key=True) id = Column(Text, primary_key=True)
manager_id = Column(Text, ForeignKey("users.id", ondelete="CASCADE"), nullable=False) office_id = Column(Text, ForeignKey("offices.id", ondelete="CASCADE"), nullable=False)
weekday = Column(Integer, nullable=False) # 0=Sunday, 1=Monday, ..., 6=Saturday weekday = Column(Integer, nullable=False) # 0=Sunday, 1=Monday, ..., 6=Saturday (Matches WeekDay Enum logic)
# Relationships # Relationships
manager = relationship("User") office = relationship("Office", back_populates="weekly_closing_days")
__table_args__ = ( __table_args__ = (
Index('idx_weekly_closing_manager_day', 'manager_id', 'weekday', unique=True), Index('idx_weekly_closing_office_day', 'office_id', 'weekday', unique=True),
) )
class ParkingGuarantee(Base): class ParkingGuarantee(Base):
"""Users guaranteed a parking spot when present (set by manager)""" """Users guaranteed a parking spot when present (set by office manager)"""
__tablename__ = "parking_guarantees" __tablename__ = "parking_guarantees"
id = Column(Text, primary_key=True) id = Column(Text, primary_key=True)
manager_id = Column(Text, ForeignKey("users.id", ondelete="CASCADE"), nullable=False) office_id = Column(Text, ForeignKey("offices.id", ondelete="CASCADE"), nullable=False)
user_id = Column(Text, ForeignKey("users.id", ondelete="CASCADE"), nullable=False) user_id = Column(Text, ForeignKey("users.id", ondelete="CASCADE"), nullable=False)
start_date = Column(Text) # Optional: YYYY-MM-DD (null = no start limit) start_date = Column(Date) # Optional (null = no start limit)
end_date = Column(Text) # Optional: YYYY-MM-DD (null = no end limit) end_date = Column(Date) # Optional (null = no end limit)
created_at = Column(Text) notes = Column(Text, nullable=True)
created_at = Column(DateTime, default=datetime.utcnow)
# Relationships # Relationships
manager = relationship("User", foreign_keys=[manager_id]) office = relationship("Office")
user = relationship("User", foreign_keys=[user_id]) user = relationship("User", foreign_keys=[user_id])
__table_args__ = ( __table_args__ = (
Index('idx_guarantee_manager_user', 'manager_id', 'user_id', unique=True), Index('idx_guarantee_office_user', 'office_id', 'user_id', unique=True),
) )
class ParkingExclusion(Base): class ParkingExclusion(Base):
"""Users excluded from parking assignment (set by manager)""" """Users excluded from parking assignment (set by office manager)"""
__tablename__ = "parking_exclusions" __tablename__ = "parking_exclusions"
id = Column(Text, primary_key=True) id = Column(Text, primary_key=True)
manager_id = Column(Text, ForeignKey("users.id", ondelete="CASCADE"), nullable=False) office_id = Column(Text, ForeignKey("offices.id", ondelete="CASCADE"), nullable=False)
user_id = Column(Text, ForeignKey("users.id", ondelete="CASCADE"), nullable=False) user_id = Column(Text, ForeignKey("users.id", ondelete="CASCADE"), nullable=False)
start_date = Column(Text) # Optional: YYYY-MM-DD (null = no start limit) start_date = Column(Date) # Optional
end_date = Column(Text) # Optional: YYYY-MM-DD (null = no end limit) end_date = Column(Date) # Optional
created_at = Column(Text) notes = Column(Text, nullable=True)
created_at = Column(DateTime, default=datetime.utcnow)
# Relationships # Relationships
manager = relationship("User", foreign_keys=[manager_id]) office = relationship("Office")
user = relationship("User", foreign_keys=[user_id]) user = relationship("User", foreign_keys=[user_id])
__table_args__ = ( __table_args__ = (
Index('idx_exclusion_manager_user', 'manager_id', 'user_id', unique=True), Index('idx_exclusion_office_user', 'office_id', 'user_id', unique=True),
) )
@@ -168,9 +228,9 @@ class NotificationLog(Base):
id = Column(Text, primary_key=True) id = Column(Text, primary_key=True)
user_id = Column(Text, ForeignKey("users.id", ondelete="CASCADE"), nullable=False) user_id = Column(Text, ForeignKey("users.id", ondelete="CASCADE"), nullable=False)
notification_type = Column(Text, nullable=False) # presence_reminder, weekly_parking, daily_parking, parking_change notification_type = Column(Enum(NotificationType, values_callable=lambda obj: [e.value for e in obj]), nullable=False)
reference_date = Column(Text) # The date/week this notification refers to (YYYY-MM-DD or YYYY-Www) reference_date = Column(Text) # The date/week this notification refers to (YYYY-MM-DD or YYYY-Www) - keeping as Text for flexibility
sent_at = Column(Text, nullable=False) sent_at = Column(DateTime, default=datetime.utcnow)
__table_args__ = ( __table_args__ = (
Index('idx_notification_user_type_date', 'user_id', 'notification_type', 'reference_date'), Index('idx_notification_user_type_date', 'user_id', 'notification_type', 'reference_date'),
@@ -183,11 +243,11 @@ class NotificationQueue(Base):
id = Column(Text, primary_key=True) id = Column(Text, primary_key=True)
user_id = Column(Text, ForeignKey("users.id", ondelete="CASCADE"), nullable=False) user_id = Column(Text, ForeignKey("users.id", ondelete="CASCADE"), nullable=False)
notification_type = Column(Text, nullable=False) # parking_change notification_type = Column(Enum(NotificationType, values_callable=lambda obj: [e.value for e in obj]), nullable=False) # parking_change
subject = Column(Text, nullable=False) subject = Column(Text, nullable=False)
body = Column(Text, nullable=False) body = Column(Text, nullable=False)
created_at = Column(Text, nullable=False) created_at = Column(DateTime, default=datetime.utcnow)
sent_at = Column(Text) # null = not sent yet sent_at = Column(DateTime) # null = not sent yet
__table_args__ = ( __table_args__ = (
Index('idx_queue_pending', 'sent_at'), Index('idx_queue_pending', 'sent_at'),

View File

@@ -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
}

View File

@@ -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

View File

@@ -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

Binary file not shown.

After

Width:  |  Height:  |  Size: 786 KiB

View File

@@ -30,7 +30,9 @@
/* ============================================================================ /* ============================================================================
Reset & Base Reset & Base
============================================================================ */ ============================================================================ */
*, *::before, *::after { *,
*::before,
*::after {
box-sizing: border-box; box-sizing: border-box;
margin: 0; margin: 0;
padding: 0; padding: 0;
@@ -54,7 +56,9 @@ button {
cursor: pointer; cursor: pointer;
} }
input, select, textarea { input,
select,
textarea {
font-family: inherit; font-family: inherit;
font-size: inherit; font-size: inherit;
} }
@@ -431,11 +435,12 @@ input, select, textarea {
width: 100%; width: 100%;
max-width: 480px; max-width: 480px;
max-height: 90vh; max-height: 90vh;
overflow: auto; overflow-y: auto;
overflow-x: hidden;
} }
.modal-small { .modal-small {
max-width: 360px; max-width: 420px;
} }
.modal-header { .modal-header {
@@ -612,16 +617,23 @@ input, select, textarea {
.calendar-day .parking-badge { .calendar-day .parking-badge {
position: absolute; position: absolute;
bottom: 0.25rem; bottom: 6px;
left: 50%; left: 4px;
transform: translateX(-50%); right: 4px;
background: #dbeafe; background: #dbeafe;
color: #1e40af; color: #1e40af;
font-size: 0.6rem; font-size: 0.8rem;
font-weight: 600; font-weight: 600;
padding: 0.1rem 0.3rem; padding: 0.3rem 0;
border-radius: 3px; border-radius: 6px;
border: 1px solid #93c5fd;
line-height: 1; line-height: 1;
text-align: center;
transform: none;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05);
} }
/* Status colors */ /* Status colors */
@@ -644,6 +656,28 @@ input, select, textarea {
background: white; background: white;
} }
/* Closed Day */
.calendar-day.closed {
background: #e5e7eb;
color: #9ca3af;
cursor: not-allowed;
border-color: #d1d5db;
}
.calendar-day.closed:hover {
border-color: #d1d5db;
}
.calendar-day.closed .day-number {
opacity: 0.5;
}
.team-calendar td.closed {
background: #e5e7eb;
color: #9ca3af;
cursor: not-allowed;
}
/* Legend */ /* Legend */
.legend { .legend {
display: flex; display: flex;
@@ -975,11 +1009,11 @@ input, select, textarea {
border-radius: 50%; border-radius: 50%;
} }
.toggle-switch input:checked + .toggle-slider { .toggle-switch input:checked+.toggle-slider {
background-color: var(--primary); background-color: var(--primary);
} }
.toggle-switch input:checked + .toggle-slider:before { .toggle-switch input:checked+.toggle-slider:before {
transform: translateX(22px); transform: translateX(22px);
} }
@@ -1747,3 +1781,24 @@ input, select, textarea {
font-size: 0.75rem; font-size: 0.75rem;
} }
} }
/* Toast Animations */
@keyframes slideInBottom {
from {
transform: translate(-50%, 100%);
opacity: 0;
}
to {
transform: translate(-50%, 0);
opacity: 1;
}
}
@keyframes fadeOut {
from {
opacity: 1;
}
to {
opacity: 0;
}
}

View File

@@ -0,0 +1,167 @@
/**
* Admin Offices Page
* Manage offices, quotas, and prefixes
*/
let currentUser = null;
let offices = [];
document.addEventListener('DOMContentLoaded', async () => {
currentUser = await api.requireAuth();
if (!currentUser) return;
if (currentUser.role !== 'admin') {
window.location.href = '/presence';
return;
}
await loadOffices();
setupEventListeners();
});
async function loadOffices() {
const response = await api.get('/api/offices');
if (response && response.ok) {
offices = await response.json();
renderOffices();
}
}
function renderOffices() {
const tbody = document.getElementById('officesBody');
if (offices.length === 0) {
tbody.innerHTML = '<tr><td colspan="5" class="text-center">Nessun ufficio trovato</td></tr>';
return;
}
tbody.innerHTML = offices.map(office => {
return `
<tr>
<td>${office.name}</td>
<td><span class="badge badge-info">${office.parking_quota} posti</span></td>
<td><strong>${office.spot_prefix || '-'}</strong></td>
<td>${office.user_count || 0} utenti</td>
<td>
<button class="btn btn-sm btn-secondary" onclick="editOffice('${office.id}')">Modifica</button>
<button class="btn btn-sm btn-danger" onclick="deleteOffice('${office.id}')" ${office.user_count > 0 ? 'title="Impossibile eliminare uffici con utenti" disabled' : ''}>Elimina</button>
</td>
</tr>
`;
}).join('');
}
function openModal(title) {
document.getElementById('officeModalTitle').textContent = title;
document.getElementById('officeModal').style.display = 'flex';
}
function closeModal() {
document.getElementById('officeModal').style.display = 'none';
document.getElementById('officeForm').reset();
document.getElementById('officeId').value = '';
}
async function editOffice(officeId) {
const office = offices.find(o => o.id === officeId);
if (!office) return;
document.getElementById('officeId').value = office.id;
document.getElementById('officeName').value = office.name;
document.getElementById('officeQuota').value = office.parking_quota;
openModal('Modifica Ufficio');
}
async function deleteOffice(officeId) {
const office = offices.find(o => o.id === officeId);
if (!office) return;
if (!confirm(`Eliminare l'ufficio "${office.name}"?`)) return;
const response = await api.delete(`/api/offices/${officeId}`);
if (response && response.ok) {
utils.showMessage('Ufficio eliminato', 'success');
await loadOffices();
} else {
const error = await response.json();
utils.showMessage(error.detail || 'Impossibile eliminare l\'ufficio', 'error');
}
}
function setupEventListeners() {
// Add button
document.getElementById('addOfficeBtn').addEventListener('click', () => {
openModal('Nuovo Ufficio');
});
// Modal close
document.getElementById('closeOfficeModal').addEventListener('click', closeModal);
document.getElementById('cancelOffice').addEventListener('click', closeModal);
utils.setupModalClose('officeModal');
// Debug tracking for save button
const saveBtn = document.getElementById('saveOfficeBtn');
if (saveBtn) {
saveBtn.addEventListener('click', () => console.log('Save button clicked'));
}
// Form submit
const form = document.getElementById('officeForm');
form.addEventListener('submit', handleOfficeSubmit);
}
async function handleOfficeSubmit(e) {
e.preventDefault();
console.log('Form submitting...');
const saveBtn = document.getElementById('saveOfficeBtn');
const originalText = saveBtn.innerHTML;
saveBtn.disabled = true;
saveBtn.innerHTML = 'Salvataggio...';
const officeId = document.getElementById('officeId').value;
const data = {
name: document.getElementById('officeName').value,
parking_quota: parseInt(document.getElementById('officeQuota').value) || 0
};
console.log('Payload:', data);
try {
let response;
if (officeId) {
response = await api.put(`/api/offices/${officeId}`, data);
} else {
response = await api.post('/api/offices', data);
}
console.log('Response status:', response ? response.status : 'null');
if (response && response.ok) {
closeModal();
utils.showMessage(officeId ? 'Ufficio aggiornato' : 'Ufficio creato', 'success');
await loadOffices();
} else {
let errorMessage = 'Errore operazione';
try {
const error = await response.json();
errorMessage = error.detail || errorMessage;
} catch (e) {
console.error('Error parsing JSON error:', e);
errorMessage = 'Errore server imprevisto (' + (response ? response.status : 'network') + ')';
}
utils.showMessage(errorMessage, 'error');
}
} catch (error) {
console.error('Form submit exception:', error);
utils.showMessage('Errore di connessione: ' + error.message, 'error');
} finally {
saveBtn.disabled = false;
saveBtn.innerHTML = originalText;
}
}
// Global functions
window.editOffice = editOffice;
window.deleteOffice = deleteOffice;

View File

@@ -1,11 +1,12 @@
/** /**
* Admin Users Page * Admin Users Page
* Manage users with LDAP-aware editing * Manage users with LDAP-aware editing and Office assignment
*/ */
let currentUser = null; let currentUser = null;
let users = []; let users = [];
let managers = []; let offices = [];
let currentSort = { column: 'name', direction: 'asc' };
document.addEventListener('DOMContentLoaded', async () => { document.addEventListener('DOMContentLoaded', async () => {
currentUser = await api.requireAuth(); currentUser = await api.requireAuth();
@@ -16,15 +17,15 @@ document.addEventListener('DOMContentLoaded', async () => {
return; return;
} }
await loadManagers(); await loadOffices();
await loadUsers(); await loadUsers();
setupEventListeners(); setupEventListeners();
}); });
async function loadManagers() { async function loadOffices() {
const response = await api.get('/api/managers'); const response = await api.get('/api/offices');
if (response && response.ok) { if (response && response.ok) {
managers = await response.json(); offices = await response.json();
} }
} }
@@ -46,30 +47,60 @@ function renderUsers(filter = '') {
(u.name || '').toLowerCase().includes(filterLower) || (u.name || '').toLowerCase().includes(filterLower) ||
(u.email || '').toLowerCase().includes(filterLower) || (u.email || '').toLowerCase().includes(filterLower) ||
(u.role || '').toLowerCase().includes(filterLower) || (u.role || '').toLowerCase().includes(filterLower) ||
(u.manager_name || '').toLowerCase().includes(filterLower) (u.office_name || '').toLowerCase().includes(filterLower)
); );
}
// Sort
filtered.sort((a, b) => {
let valA = a[currentSort.column];
let valB = b[currentSort.column];
// Handle nulls for ratio
if (currentSort.column === 'parking_ratio') {
valA = valA !== null ? valA : 999; // Null ratio (new users) -> low priority? No, new users have ratio 0.
// Actually get_user_parking_ratio returns 0.0 for new users.
// If office_id is missing, it's None. Treat as high val to push to bottom?
valA = (valA === undefined || valA === null) ? 999 : valA;
valB = (valB === undefined || valB === null) ? 999 : valB;
} else {
valA = (valA || '').toString().toLowerCase();
valB = (valB || '').toString().toLowerCase();
}
if (valA < valB) return currentSort.direction === 'asc' ? -1 : 1;
if (valA > valB) return currentSort.direction === 'asc' ? 1 : -1;
return 0;
});
// Update icons
document.querySelectorAll('th.sortable .sort-icon').forEach(icon => icon.textContent = '');
const activeTh = document.querySelector(`th[data-sort="${currentSort.column}"]`);
if (activeTh) {
const icon = activeTh.querySelector('.sort-icon');
if (icon) icon.textContent = currentSort.direction === 'asc' ? ' ▲' : ' ▼';
} }
if (filtered.length === 0) { if (filtered.length === 0) {
tbody.innerHTML = '<tr><td colspan="5" class="text-center">No users found</td></tr>'; tbody.innerHTML = '<tr><td colspan="5" class="text-center">Nessun utente trovato</td></tr>';
return; return;
} }
tbody.innerHTML = filtered.map(user => { tbody.innerHTML = filtered.map(user => {
const ldapBadge = user.is_ldap_user ? '<span class="badge badge-info">LDAP</span>' : ''; const ldapBadge = user.is_ldap_user ? '<span class="badge badge-info">LDAP</span>' : '';
const managerInfo = user.role === 'manager' const officeInfo = user.office_name || '-';
? `<span class="badge badge-success">${user.managed_user_count || 0} users</span>`
: (user.manager_name || '-');
return ` return `
<tr> <tr>
<td>${user.name || '-'} ${ldapBadge}</td> <td>${user.name || '-'} ${ldapBadge}</td>
<td>${user.email}</td> <td>${user.email}</td>
<td><span class="badge badge-${getRoleBadgeClass(user.role)}">${user.role}</span></td> <td><span class="badge badge-${getRoleBadgeClass(user.role)}">${user.role}</span></td>
<td>${managerInfo}</td> <td>${officeInfo}</td>
<td>${user.parking_ratio !== null ? user.parking_ratio.toFixed(2) : '-'}</td>
<td> <td>
<button class="btn btn-sm btn-secondary" onclick="editUser('${user.id}')">Edit</button> <button class="btn btn-sm btn-secondary" onclick="editUser('${user.id}')">Modifica</button>
<button class="btn btn-sm btn-danger" onclick="deleteUser('${user.id}')" ${user.id === currentUser.id ? 'disabled' : ''}>Delete</button> <button class="btn btn-sm btn-danger" onclick="deleteUser('${user.id}')" ${user.id === currentUser.id ? 'disabled' : ''}>Elimina</button>
</td> </td>
</tr> </tr>
`; `;
@@ -93,20 +124,16 @@ async function editUser(userId) {
document.getElementById('editName').value = user.name || ''; document.getElementById('editName').value = user.name || '';
document.getElementById('editEmail').value = user.email; document.getElementById('editEmail').value = user.email;
document.getElementById('editRole').value = user.role; document.getElementById('editRole').value = user.role;
document.getElementById('editQuota').value = user.manager_parking_quota || 0;
document.getElementById('editPrefix').value = user.manager_spot_prefix || '';
// Populate manager dropdown // Populate office dropdown
const managerSelect = document.getElementById('editManager'); const officeSelect = document.getElementById('editOffice');
managerSelect.innerHTML = '<option value="">No manager</option>'; officeSelect.innerHTML = '<option value="">Nessun ufficio</option>';
managers.forEach(m => { offices.forEach(o => {
if (m.id !== userId) { // Can't be own manager const option = document.createElement('option');
const option = document.createElement('option'); option.value = o.id;
option.value = m.id; option.textContent = o.name;
option.textContent = m.name; if (o.id === user.office_id) option.selected = true;
if (m.id === user.manager_id) option.selected = true; officeSelect.appendChild(option);
managerSelect.appendChild(option);
}
}); });
// Handle LDAP restrictions // Handle LDAP restrictions
@@ -126,13 +153,7 @@ async function editUser(userId) {
roleSelect.disabled = isLdapAdmin; roleSelect.disabled = isLdapAdmin;
document.getElementById('roleHelp').style.display = isLdapAdmin ? 'block' : 'none'; document.getElementById('roleHelp').style.display = isLdapAdmin ? 'block' : 'none';
// Manager group - show for all users (admins can also be assigned to a manager) document.getElementById('userModalTitle').textContent = 'Modifica Utente';
document.getElementById('managerGroup').style.display = 'block';
// Manager fields - show only for managers
document.getElementById('managerFields').style.display = user.role === 'manager' ? 'block' : 'none';
document.getElementById('userModalTitle').textContent = 'Edit User';
document.getElementById('userModal').style.display = 'flex'; document.getElementById('userModal').style.display = 'flex';
} }
@@ -140,15 +161,15 @@ async function deleteUser(userId) {
const user = users.find(u => u.id === userId); const user = users.find(u => u.id === userId);
if (!user) return; if (!user) return;
if (!confirm(`Delete user "${user.name || user.email}"?`)) return; if (!confirm(`Eliminare l'utente "${user.name || user.email}"?`)) return;
const response = await api.delete(`/api/users/${userId}`); const response = await api.delete(`/api/users/${userId}`);
if (response && response.ok) { if (response && response.ok) {
utils.showMessage('User deleted', 'success'); utils.showMessage('Utente eliminato', 'success');
await loadUsers(); await loadUsers();
} else { } else {
const error = await response.json(); const error = await response.json();
utils.showMessage(error.detail || 'Failed to delete user', 'error'); utils.showMessage(error.detail || 'Impossibile eliminare l\'utente', 'error');
} }
} }
@@ -158,13 +179,6 @@ function setupEventListeners() {
renderUsers(e.target.value); renderUsers(e.target.value);
}); });
// Role change - toggle manager fields (manager group always visible since any user can have a manager)
document.getElementById('editRole').addEventListener('change', (e) => {
const role = e.target.value;
document.getElementById('managerFields').style.display = role === 'manager' ? 'block' : 'none';
// Manager group stays visible - any user (including admins) can have a manager assigned
});
// Modal close // Modal close
document.getElementById('closeUserModal').addEventListener('click', () => { document.getElementById('closeUserModal').addEventListener('click', () => {
document.getElementById('userModal').style.display = 'none'; document.getElementById('userModal').style.display = 'none';
@@ -183,7 +197,7 @@ function setupEventListeners() {
const data = { const data = {
role: role, role: role,
manager_id: document.getElementById('editManager').value || null office_id: document.getElementById('editOffice').value || null
}; };
// Only include name if not disabled (LDAP users can't change name) // Only include name if not disabled (LDAP users can't change name)
@@ -192,23 +206,31 @@ function setupEventListeners() {
data.name = nameInput.value; data.name = nameInput.value;
} }
// Manager-specific fields
if (role === 'manager') {
data.manager_parking_quota = parseInt(document.getElementById('editQuota').value) || 0;
data.manager_spot_prefix = document.getElementById('editPrefix').value || null;
}
const response = await api.put(`/api/users/${userId}`, data); const response = await api.put(`/api/users/${userId}`, data);
if (response && response.ok) { if (response && response.ok) {
document.getElementById('userModal').style.display = 'none'; document.getElementById('userModal').style.display = 'none';
utils.showMessage('User updated', 'success'); utils.showMessage('Utente aggiornato', 'success');
await loadManagers(); // Reload in case role changed
await loadUsers(); await loadUsers();
} else { } else {
const error = await response.json(); const error = await response.json();
utils.showMessage(error.detail || 'Failed to update user', 'error'); utils.showMessage(error.detail || 'Impossibile aggiornare l\'utente', 'error');
} }
}); });
// Sort headers
document.querySelectorAll('th.sortable').forEach(th => {
th.addEventListener('click', () => {
const column = th.dataset.sort;
if (currentSort.column === column) {
currentSort.direction = currentSort.direction === 'asc' ? 'desc' : 'asc';
} else {
currentSort.column = column;
currentSort.direction = 'asc';
}
renderUsers(document.getElementById('searchInput').value);
});
});
} }
// Make functions available globally for onclick handlers // Make functions available globally for onclick handlers

View File

@@ -158,7 +158,7 @@ const api = {
} }
const error = await response.json(); const error = await response.json();
return { success: false, error: error.detail || 'Login failed' }; return { success: false, error: error.detail || 'Login fallito' };
}, },
/** /**
@@ -178,7 +178,7 @@ const api = {
} }
const error = await response.json(); const error = await response.json();
return { success: false, error: error.detail || 'Registration failed' }; return { success: false, error: error.detail || 'Registrazione fallita' };
}, },
/** /**

188
frontend/js/modal-logic.js Normal file
View File

@@ -0,0 +1,188 @@
/**
* Shared logic for the Day/Presence Modal
* Handles UI interactions, assignments, and integrated reassign form.
*/
const ModalLogic = {
// Configuration holding callbacks
config: {
onMarkPresence: async (status, date, userId) => { },
onClearPresence: async (date, userId) => { },
onReleaseParking: async (assignmentId) => { },
onReassignParking: async (assignmentId, newUserId) => { },
onReload: async () => { } // Callback to reload calendar data
},
// State
currentAssignmentId: null,
currentDate: null,
currentUserId: null,
init(config) {
this.config = { ...this.config, ...config };
this.setupEventListeners();
},
setupEventListeners() {
// Close buttons
document.getElementById('closeDayModal')?.addEventListener('click', () => {
document.getElementById('dayModal').style.display = 'none';
});
// Status buttons
document.querySelectorAll('#dayModal .status-btn').forEach(btn => {
btn.addEventListener('click', () => {
if (this.currentDate) {
this.config.onMarkPresence(btn.dataset.status, this.currentDate, this.currentUserId);
}
});
});
// Actions
document.getElementById('clearDayBtn')?.addEventListener('click', () => {
if (this.currentDate) {
this.config.onClearPresence(this.currentDate, this.currentUserId);
}
});
document.getElementById('releaseParkingBtn')?.addEventListener('click', () => {
if (this.currentAssignmentId) {
this.config.onReleaseParking(this.currentAssignmentId);
}
});
document.getElementById('reassignParkingBtn')?.addEventListener('click', () => {
this.showReassignForm();
});
// Reassign Form
document.getElementById('cancelReassign')?.addEventListener('click', () => {
this.hideReassignForm();
});
document.getElementById('confirmReassign')?.addEventListener('click', () => {
const select = document.getElementById('reassignUser');
this.config.onReassignParking(this.currentAssignmentId, select.value);
});
// Close on click outside
window.addEventListener('click', (e) => {
const modal = document.getElementById('dayModal');
if (e.target === modal) {
modal.style.display = 'none';
}
});
},
openModal(data) {
const { dateStr, userName, presence, parking, userId, isReadOnly } = data;
this.currentDate = dateStr;
this.currentUserId = userId; // Optional, for team view
this.currentAssignmentId = parking ? parking.id : null;
const modal = document.getElementById('dayModal');
const title = document.getElementById('dayModalTitle');
const userLabel = document.getElementById('dayModalUser');
title.textContent = utils.formatDateDisplay(dateStr);
// Show/Hide User Name (for Team Calendar)
if (userName && userLabel) {
userLabel.textContent = userName;
userLabel.style.display = 'block';
} else if (userLabel) {
userLabel.style.display = 'none';
}
// Highlight status
document.querySelectorAll('#dayModal .status-btn').forEach(btn => {
const status = btn.dataset.status;
if (presence && presence.status === status) {
btn.classList.add('active');
} else {
btn.classList.remove('active');
}
});
// Clear button visibility
const clearBtn = document.getElementById('clearDayBtn');
if (presence) {
clearBtn.style.display = 'block';
} else {
clearBtn.style.display = 'none';
}
// Parking Section & Reset Form
this.hideReassignForm(); // Reset view to actions
const parkingSection = document.getElementById('parkingSection');
const parkingInfo = document.getElementById('parkingInfo');
if (parking) {
parkingSection.style.display = 'block';
const spotName = parking.spot_display_name || parking.spot_id;
parkingInfo.innerHTML = `<strong>Parcheggio:</strong> Posto ${spotName}`;
} else {
parkingSection.style.display = 'none';
}
modal.style.display = 'flex';
},
async showReassignForm() {
if (!this.currentAssignmentId) return;
const actionsDiv = document.getElementById('parkingActions');
const formDiv = document.getElementById('reassignForm');
actionsDiv.style.display = 'none';
formDiv.style.display = 'flex';
const select = document.getElementById('reassignUser');
select.innerHTML = '<option value="">Caricamento utenti...</option>';
// Load eligible users (Global API function assumed available)
try {
const response = await api.get(`/api/parking/eligible-users/${this.currentAssignmentId}`);
if (!response || !response.ok) {
const error = await response.json();
alert(error.detail || 'Impossibile caricare gli utenti idonei');
this.hideReassignForm();
return;
}
const users = await response.json();
select.innerHTML = '<option value="">Seleziona utente...</option>';
select.innerHTML += '<option value="auto">Assegna automaticamente</option>';
if (users.length === 0) {
const option = document.createElement('option');
option.disabled = true;
option.textContent = "Nessun altro utente disponibile";
select.appendChild(option);
} else {
users.forEach(user => {
const option = document.createElement('option');
option.value = user.id;
const officeInfo = user.office_name ? ` (${user.office_name})` : '';
option.textContent = user.name + officeInfo;
select.appendChild(option);
});
}
} catch (e) {
console.error(e);
alert('Errore di rete');
this.hideReassignForm();
}
},
hideReassignForm() {
document.getElementById('reassignForm').style.display = 'none';
document.getElementById('parkingActions').style.display = 'flex';
},
closeModal() {
document.getElementById('dayModal').style.display = 'none';
}
};

View File

@@ -38,14 +38,20 @@ const ICONS = {
<rect x="13.5" y="11" width="1.5" height="1.5" fill="currentColor"></rect> <rect x="13.5" y="11" width="1.5" height="1.5" fill="currentColor"></rect>
<rect x="9" y="16" width="1.5" height="1.5" fill="currentColor"></rect> <rect x="9" y="16" width="1.5" height="1.5" fill="currentColor"></rect>
<rect x="13.5" y="16" width="1.5" height="1.5" fill="currentColor"></rect> <rect x="13.5" y="16" width="1.5" height="1.5" fill="currentColor"></rect>
</svg>`,
settings: `<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="12" cy="12" r="3"></circle>
<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"></path>
</svg>` </svg>`
}; };
const NAV_ITEMS = [ const NAV_ITEMS = [
{ href: '/presence', icon: 'calendar', label: 'My Presence' }, { href: '/presence', icon: 'calendar', label: 'La mia presenza' },
{ href: '/team-calendar', icon: 'users', label: 'Team Calendar' }, { href: '/team-calendar', icon: 'users', label: 'Calendario del team' },
{ href: '/team-rules', icon: 'rules', label: 'Team Rules', roles: ['admin', 'manager'] }, { href: '/team-rules', icon: 'rules', label: 'Regole parcheggio', roles: ['admin', 'manager'] },
{ href: '/admin/users', icon: 'user', label: 'Manage Users', roles: ['admin'] } { href: '/admin/users', icon: 'user', label: 'Gestione Utenti', roles: ['admin'] },
{ href: '/admin/offices', icon: 'building', label: 'Gestione Uffici', roles: ['admin'] },
{ href: '/parking-settings', icon: 'settings', label: 'Impostazioni Ufficio', roles: ['admin', 'manager'] }
]; ];
function getIcon(name) { function getIcon(name) {
@@ -108,7 +114,7 @@ function setupMobileMenu() {
const menuToggle = document.createElement('button'); const menuToggle = document.createElement('button');
menuToggle.className = 'menu-toggle'; menuToggle.className = 'menu-toggle';
menuToggle.innerHTML = MENU_ICON; menuToggle.innerHTML = MENU_ICON;
menuToggle.setAttribute('aria-label', 'Toggle menu'); menuToggle.setAttribute('aria-label', 'Apri/Chiudi menu');
pageHeader.insertBefore(menuToggle, pageHeader.firstChild); pageHeader.insertBefore(menuToggle, pageHeader.firstChild);
// Add overlay // Add overlay

View File

@@ -0,0 +1,226 @@
let currentUser = null;
let currentOffice = null;
document.addEventListener('DOMContentLoaded', async () => {
if (!api.requireAuth()) return;
currentUser = await api.getCurrentUser();
if (!currentUser) return;
// Only Manager or Admin
if (!['admin', 'manager'].includes(currentUser.role)) {
window.location.href = '/';
return;
}
// Initialize UI
populateHourSelect();
// Set default date to tomorrow
const tomorrow = new Date();
tomorrow.setDate(tomorrow.getDate() + 1);
document.getElementById('testDateStart').valueAsDate = tomorrow;
await loadOffices();
setupEventListeners();
});
async function loadOffices() {
const select = document.getElementById('officeSelect');
const card = document.getElementById('officeSelectionCard');
const content = document.getElementById('settingsContent');
// Only Admins see the selector
if (currentUser.role === 'admin') {
card.style.display = 'block';
content.style.display = 'none'; // Hide until selected
try {
const response = await api.get('/api/offices');
if (response && response.ok) {
const offices = await response.json();
offices.forEach(office => {
const option = document.createElement('option');
option.value = office.id;
option.textContent = office.name;
select.appendChild(option);
});
}
} catch (e) {
console.error(e);
utils.showMessage('Errore caricamento uffici', 'error');
}
} else {
// Manager uses their own office
card.style.display = 'none';
content.style.display = 'block';
if (currentUser.office_id) {
await loadOfficeSettings(currentUser.office_id);
} else {
utils.showMessage('Nessun ufficio assegnato al manager', 'error');
}
}
}
function populateHourSelect() {
const select = document.getElementById('bookingWindowHour');
select.innerHTML = '';
for (let h = 0; h < 24; h++) {
const option = document.createElement('option');
option.value = h;
option.textContent = h.toString().padStart(2, '0');
select.appendChild(option);
}
}
async function loadOfficeSettings(id) {
const officeId = id;
if (!officeId) {
utils.showMessage('Nessun ufficio selezionato', 'error');
return;
}
try {
const response = await api.get(`/api/offices/${officeId}`);
if (!response.ok) throw new Error('Failed to load office');
const office = await response.json();
currentOffice = office;
// Populate form
document.getElementById('bookingWindowEnabled').checked = office.booking_window_enabled || false;
document.getElementById('bookingWindowHour').value = office.booking_window_end_hour ?? 18; // Default 18
document.getElementById('bookingWindowMinute').value = office.booking_window_end_minute ?? 0;
updateVisibility();
} catch (e) {
console.error(e);
utils.showMessage('Errore nel caricamento impostazioni', 'error');
}
}
function updateVisibility() {
const enabled = document.getElementById('bookingWindowEnabled').checked;
document.getElementById('cutoffTimeGroup').style.display = enabled ? 'block' : 'none';
}
function setupEventListeners() {
// Office Select
document.getElementById('officeSelect').addEventListener('change', (e) => {
const id = e.target.value;
if (id) {
document.getElementById('settingsContent').style.display = 'block';
loadOfficeSettings(id);
} else {
document.getElementById('settingsContent').style.display = 'none';
}
});
// Toggle visibility
document.getElementById('bookingWindowEnabled').addEventListener('change', updateVisibility);
// Save Settings
document.getElementById('scheduleForm').addEventListener('submit', async (e) => {
e.preventDefault();
if (!currentOffice) return;
const data = {
booking_window_enabled: document.getElementById('bookingWindowEnabled').checked,
booking_window_end_hour: parseInt(document.getElementById('bookingWindowHour').value),
booking_window_end_minute: parseInt(document.getElementById('bookingWindowMinute').value)
};
try {
const res = await api.put(`/api/offices/${currentOffice.id}`, data);
if (res) {
utils.showMessage('Impostazioni salvate con successo', 'success');
currentOffice = res;
}
} catch (e) {
utils.showMessage('Errore nel salvataggio', 'error');
}
});
// Test Tools
document.getElementById('runAllocationBtn').addEventListener('click', async () => {
if (!confirm('Sei sicuro di voler avviare l\'assegnazione ORA? Questo potrebbe sovrascrivere le assegnazioni esistenti per la data selezionata.')) return;
const dateStart = document.getElementById('testDateStart').value;
const dateEnd = document.getElementById('testDateEnd').value;
if (!dateStart) return utils.showMessage('Seleziona una data di inizio', 'error');
let start = new Date(dateStart);
let end = dateEnd ? new Date(dateEnd) : new Date(dateStart);
if (end < start) {
return utils.showMessage('La data di fine deve essere successiva alla data di inizio', 'error');
}
let current = new Date(start);
let successCount = 0;
let errorCount = 0;
utils.showMessage('Avvio assegnazione...', 'success');
while (current <= end) {
const dateStr = current.toISOString().split('T')[0];
try {
await api.post('/api/parking/run-allocation', {
date: dateStr,
office_id: currentOffice.id
});
successCount++;
} catch (e) {
console.error(`Error for ${dateStr}`, e);
errorCount++;
}
current.setDate(current.getDate() + 1);
}
if (errorCount === 0) {
utils.showMessage(`Assegnazione completata per ${successCount} giorni.`, 'success');
} else {
utils.showMessage(`Completato con errori: ${successCount} successi, ${errorCount} errori.`, 'warning');
}
});
document.getElementById('clearAssignmentsBtn').addEventListener('click', async () => {
if (!confirm('ATTENZIONE: Stai per eliminare TUTTE le assegnazioni per il periodo selezionato. Procedere?')) return;
const dateStart = document.getElementById('testDateStart').value;
const dateEnd = document.getElementById('testDateEnd').value;
if (!dateStart) return utils.showMessage('Seleziona una data di inizio', 'error');
let start = new Date(dateStart);
let end = dateEnd ? new Date(dateEnd) : new Date(dateStart);
if (end < start) {
return utils.showMessage('La data di fine deve essere successiva alla data di inizio', 'error');
}
let current = new Date(start);
let totalRemoved = 0;
utils.showMessage('Rimozione in corso...', 'warning');
while (current <= end) {
const dateStr = current.toISOString().split('T')[0];
try {
const res = await api.post('/api/parking/clear-assignments', {
date: dateStr,
office_id: currentOffice.id
});
totalRemoved += (res.count || 0);
} catch (e) {
console.error(`Error clearing ${dateStr}`, e);
}
current.setDate(current.getDate() + 1);
}
utils.showMessage(`Operazione eseguita. ${totalRemoved} assegnazioni rimosse in totale.`, 'warning');
});
}

View File

@@ -8,14 +8,31 @@ let currentDate = new Date();
let presenceData = {}; let presenceData = {};
let parkingData = {}; let parkingData = {};
let currentAssignmentId = null; let currentAssignmentId = null;
let weeklyClosingDays = [];
let specificClosingDays = [];
let statusDate = new Date();
let statusViewMode = 'daily';
document.addEventListener('DOMContentLoaded', async () => { document.addEventListener('DOMContentLoaded', async () => {
currentUser = await api.requireAuth(); currentUser = await api.requireAuth();
if (!currentUser) return; if (!currentUser) return;
await Promise.all([loadPresences(), loadParkingAssignments()]); await Promise.all([loadPresences(), loadParkingAssignments(), loadClosingDays()]);
// Initialize Modal Logic
ModalLogic.init({
onMarkPresence: handleMarkPresence,
onClearPresence: handleClearPresence,
onReleaseParking: handleReleaseParking,
onReassignParking: handleReassignParking
});
renderCalendar(); renderCalendar();
setupEventListeners(); setupEventListeners();
// Initialize Parking Status
initParkingStatus();
setupStatusListeners();
}); });
async function loadPresences() { async function loadPresences() {
@@ -56,10 +73,32 @@ async function loadParkingAssignments() {
} }
} }
async function loadClosingDays() {
if (!currentUser.office_id) return;
try {
const [weeklyRes, specificRes] = await Promise.all([
api.get(`/api/offices/${currentUser.office_id}/weekly-closing-days`),
api.get(`/api/offices/${currentUser.office_id}/closing-days`)
]);
if (weeklyRes && weeklyRes.ok) {
const days = await weeklyRes.json();
weeklyClosingDays = days.map(d => d.weekday);
}
if (specificRes && specificRes.ok) {
specificClosingDays = await specificRes.json();
}
} catch (e) {
console.error('Error loading closing days:', e);
}
}
function renderCalendar() { function renderCalendar() {
const year = currentDate.getFullYear(); const year = currentDate.getFullYear();
const month = currentDate.getMonth(); const month = currentDate.getMonth();
const weekStartDay = currentUser.week_start_day || 0; // 0=Sunday, 1=Monday const weekStartDay = currentUser.week_start_day || 1; // 0=Sunday, 1=Monday (default to Monday)
// Update month header // Update month header
document.getElementById('currentMonth').textContent = `${utils.getMonthName(month)} ${year}`; document.getElementById('currentMonth').textContent = `${utils.getMonthName(month)} ${year}`;
@@ -78,7 +117,7 @@ function renderCalendar() {
grid.innerHTML = ''; grid.innerHTML = '';
// Day headers - reorder based on week start day // Day headers - reorder based on week start day
const allDayNames = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']; const allDayNames = ['Dom', 'Lun', 'Mar', 'Mer', 'Gio', 'Ven', 'Sab'];
const dayNames = []; const dayNames = [];
for (let i = 0; i < 7; i++) { for (let i = 0; i < 7; i++) {
dayNames.push(allDayNames[(weekStartDay + i) % 7]); dayNames.push(allDayNames[(weekStartDay + i) % 7]);
@@ -120,7 +159,25 @@ function renderCalendar() {
if (isHoliday) cell.classList.add('holiday'); if (isHoliday) cell.classList.add('holiday');
if (isToday) cell.classList.add('today'); if (isToday) cell.classList.add('today');
if (presence) { // Check closing days
// Note: JS getDay(): 0=Sunday, 1=Monday...
// DB WeekDay: 0=Sunday, etc. (They match)
const isWeeklyClosed = weeklyClosingDays.includes(dayOfWeek);
const isSpecificClosed = specificClosingDays.some(d => {
const start = new Date(d.date);
const end = d.end_date ? new Date(d.end_date) : start;
// Reset times for strict date comparison
start.setHours(0, 0, 0, 0);
end.setHours(0, 0, 0, 0);
return date >= start && date <= end;
});
const isClosed = isWeeklyClosed || isSpecificClosed;
if (isClosed) {
cell.classList.add('closed');
cell.title = "Ufficio Chiuso";
} else if (presence) {
cell.classList.add(`status-${presence.status}`); cell.classList.add(`status-${presence.status}`);
} }
@@ -134,140 +191,60 @@ function renderCalendar() {
${parkingBadge} ${parkingBadge}
`; `;
cell.addEventListener('click', () => openDayModal(dateStr, presence, parking)); if (!isClosed) {
cell.addEventListener('click', () => openDayModal(dateStr, presence, parking));
}
grid.appendChild(cell); grid.appendChild(cell);
} }
} }
function openDayModal(dateStr, presence, parking) { function openDayModal(dateStr, presence, parking) {
const modal = document.getElementById('dayModal'); ModalLogic.openModal({
const title = document.getElementById('dayModalTitle'); dateStr,
presence,
title.textContent = utils.formatDateDisplay(dateStr); parking
// Highlight current status
document.querySelectorAll('.status-btn').forEach(btn => {
const status = btn.dataset.status;
if (presence && presence.status === status) {
btn.classList.add('active');
} else {
btn.classList.remove('active');
}
}); });
// Update parking section
const parkingSection = document.getElementById('parkingSection');
const parkingInfo = document.getElementById('parkingInfo');
const releaseBtn = document.getElementById('releaseParkingBtn');
if (parking) {
parkingSection.style.display = 'block';
const spotName = parking.spot_display_name || parking.spot_id;
parkingInfo.innerHTML = `<strong>Parking:</strong> Spot ${spotName}`;
releaseBtn.dataset.assignmentId = parking.id;
document.getElementById('reassignParkingBtn').dataset.assignmentId = parking.id;
currentAssignmentId = parking.id;
} else {
parkingSection.style.display = 'none';
}
modal.dataset.date = dateStr;
modal.style.display = 'flex';
} }
async function markPresence(status) { async function handleMarkPresence(status, date) {
const modal = document.getElementById('dayModal');
const date = modal.dataset.date;
const response = await api.post('/api/presence/mark', { date, status }); const response = await api.post('/api/presence/mark', { date, status });
if (response && response.ok) { if (response && response.ok) {
await Promise.all([loadPresences(), loadParkingAssignments()]); await Promise.all([loadPresences(), loadParkingAssignments()]);
renderCalendar(); renderCalendar();
modal.style.display = 'none'; ModalLogic.closeModal();
} else { } else {
const error = await response.json(); const error = await response.json();
alert(error.detail || 'Failed to mark presence'); alert(error.detail || 'Impossibile segnare la presenza');
} }
} }
async function clearPresence() { async function handleClearPresence(date) {
const modal = document.getElementById('dayModal');
const date = modal.dataset.date;
if (!confirm('Clear presence for this date?')) return;
const response = await api.delete(`/api/presence/${date}`); const response = await api.delete(`/api/presence/${date}`);
if (response && response.ok) { if (response && response.ok) {
await Promise.all([loadPresences(), loadParkingAssignments()]); await Promise.all([loadPresences(), loadParkingAssignments()]);
renderCalendar(); renderCalendar();
modal.style.display = 'none'; ModalLogic.closeModal();
} }
} }
async function releaseParking() { async function handleReleaseParking(assignmentId) {
const modal = document.getElementById('dayModal'); if (!confirm('Rilasciare il parcheggio per questa data?')) return;
const releaseBtn = document.getElementById('releaseParkingBtn');
const assignmentId = releaseBtn.dataset.assignmentId;
if (!assignmentId) return;
if (!confirm('Release your parking spot for this date?')) return;
const response = await api.post(`/api/parking/release-my-spot/${assignmentId}`); const response = await api.post(`/api/parking/release-my-spot/${assignmentId}`);
if (response && response.ok) { if (response && response.ok) {
await loadParkingAssignments(); await loadParkingAssignments();
renderCalendar(); renderCalendar();
modal.style.display = 'none'; ModalLogic.closeModal();
} else { } else {
const error = await response.json(); const error = await response.json();
alert(error.detail || 'Failed to release parking spot'); alert(error.detail || 'Impossibile rilasciare il parcheggio');
} }
} }
async function openReassignModal() { async function handleReassignParking(assignmentId, newUserId) {
const assignmentId = currentAssignmentId; // Basic validation handled by select; confirm
if (!assignmentId) return;
// Load eligible users
const response = await api.get(`/api/parking/eligible-users/${assignmentId}`);
if (!response || !response.ok) {
const error = await response.json();
alert(error.detail || 'Failed to load eligible users');
return;
}
const users = await response.json();
const select = document.getElementById('reassignUser');
select.innerHTML = '<option value="">Select user...</option>';
if (users.length === 0) {
select.innerHTML = '<option value="">No eligible users available</option>';
} else {
users.forEach(user => {
const option = document.createElement('option');
option.value = user.id;
option.textContent = user.name;
select.appendChild(option);
});
}
// Get spot info from parking data
const parking = Object.values(parkingData).find(p => p.id === assignmentId);
if (parking) {
const spotName = parking.spot_display_name || parking.spot_id;
document.getElementById('reassignSpotInfo').textContent = `Spot ${spotName}`;
}
document.getElementById('dayModal').style.display = 'none';
document.getElementById('reassignModal').style.display = 'flex';
}
async function confirmReassign() {
const assignmentId = currentAssignmentId;
const newUserId = document.getElementById('reassignUser').value;
if (!assignmentId || !newUserId) { if (!assignmentId || !newUserId) {
alert('Please select a user'); alert('Seleziona un utente');
return; return;
} }
@@ -279,13 +256,15 @@ async function confirmReassign() {
if (response && response.ok) { if (response && response.ok) {
await loadParkingAssignments(); await loadParkingAssignments();
renderCalendar(); renderCalendar();
document.getElementById('reassignModal').style.display = 'none'; ModalLogic.closeModal();
} else { } else {
const error = await response.json(); const error = await response.json();
alert(error.detail || 'Failed to reassign parking spot'); alert(error.detail || 'Impossibile riassegnare il parcheggio');
} }
} }
function setupEventListeners() { function setupEventListeners() {
// Month navigation // Month navigation
document.getElementById('prevMonth').addEventListener('click', async () => { document.getElementById('prevMonth').addEventListener('click', async () => {
@@ -300,69 +279,255 @@ function setupEventListeners() {
renderCalendar(); renderCalendar();
}); });
// Day modal // Quick Entry Logic
document.getElementById('closeDayModal').addEventListener('click', () => { const quickEntryModal = document.getElementById('quickEntryModal');
document.getElementById('dayModal').style.display = 'none'; const quickEntryBtn = document.getElementById('quickEntryBtn');
const closeQuickEntryBtn = document.getElementById('closeQuickEntryModal');
const cancelQuickEntryBtn = document.getElementById('cancelQuickEntry');
const quickEntryForm = document.getElementById('quickEntryForm');
if (quickEntryBtn) {
quickEntryBtn.addEventListener('click', () => {
// Default dates: tomorrow
const tomorrow = new Date();
tomorrow.setDate(tomorrow.getDate() + 1);
document.getElementById('qeStartDate').valueAsDate = tomorrow;
document.getElementById('qeEndDate').valueAsDate = tomorrow;
document.getElementById('qeStatus').value = '';
// Clear selections
document.querySelectorAll('.qe-status-btn').forEach(btn => btn.classList.remove('active'));
quickEntryModal.style.display = 'flex';
});
}
if (closeQuickEntryBtn) closeQuickEntryBtn.addEventListener('click', () => quickEntryModal.style.display = 'none');
if (cancelQuickEntryBtn) cancelQuickEntryBtn.addEventListener('click', () => quickEntryModal.style.display = 'none');
// Status selection in QE
document.querySelectorAll('.qe-status-btn').forEach(btn => {
btn.addEventListener('click', () => {
document.querySelectorAll('.qe-status-btn').forEach(b => b.classList.remove('active'));
btn.classList.add('active');
document.getElementById('qeStatus').value = btn.dataset.status;
});
}); });
document.querySelectorAll('.status-btn').forEach(btn => { if (quickEntryForm) {
btn.addEventListener('click', () => markPresence(btn.dataset.status)); quickEntryForm.addEventListener('submit', async (e) => {
}); e.preventDefault();
document.getElementById('clearDayBtn').addEventListener('click', clearPresence); const startStr = document.getElementById('qeStartDate').value;
document.getElementById('releaseParkingBtn').addEventListener('click', releaseParking); const endStr = document.getElementById('qeEndDate').value;
document.getElementById('reassignParkingBtn').addEventListener('click', openReassignModal); const status = document.getElementById('qeStatus').value;
utils.setupModalClose('dayModal'); if (!status) return utils.showMessage('Seleziona uno stato', 'error');
if (!startStr || !endStr) return utils.showMessage('Seleziona le date', 'error');
// Reassign modal const startDate = new Date(startStr);
document.getElementById('closeReassignModal').addEventListener('click', () => { const endDate = new Date(endStr);
document.getElementById('reassignModal').style.display = 'none';
});
document.getElementById('cancelReassign').addEventListener('click', () => {
document.getElementById('reassignModal').style.display = 'none';
});
document.getElementById('confirmReassign').addEventListener('click', confirmReassign);
utils.setupModalClose('reassignModal');
// Bulk mark if (endDate < startDate) return utils.showMessage('La data di fine non può essere precedente alla data di inizio', 'error');
document.getElementById('bulkMarkBtn').addEventListener('click', () => {
document.getElementById('bulkMarkModal').style.display = 'flex';
});
document.getElementById('closeBulkModal').addEventListener('click', () => { quickEntryModal.style.display = 'none';
document.getElementById('bulkMarkModal').style.display = 'none'; utils.showMessage('Inserimento in corso...', 'warning');
});
document.getElementById('cancelBulk').addEventListener('click', () => { const promises = [];
document.getElementById('bulkMarkModal').style.display = 'none'; let current = new Date(startDate);
});
utils.setupModalClose('bulkMarkModal'); while (current <= endDate) {
const dStr = current.toISOString().split('T')[0];
if (status === 'clear') {
promises.push(api.delete(`/api/presence/${dStr}`));
} else {
promises.push(api.post('/api/presence/mark', { date: dStr, status: status }));
}
current.setDate(current.getDate() + 1);
}
document.getElementById('bulkMarkForm').addEventListener('submit', async (e) => { try {
e.preventDefault(); await Promise.all(promises);
utils.showMessage('Inserimento completato!', 'success');
await Promise.all([loadPresences(), loadParkingAssignments()]);
renderCalendar();
} catch (err) {
console.error(err);
utils.showMessage('Errore durante l\'inserimento. Alcuni giorni potrebbero non essere stati aggiornati.', 'error');
}
});
}
}
const startDate = document.getElementById('startDate').value; // ----------------------------------------------------------------------------
const endDate = document.getElementById('endDate').value; // Parking Status Logic
const status = document.getElementById('bulkStatus').value; // ----------------------------------------------------------------------------
const weekdaysOnly = document.getElementById('weekdaysOnly').checked;
const data = { start_date: startDate, end_date: endDate, status }; function initParkingStatus() {
if (weekdaysOnly) { updateStatusHeader();
data.days = [1, 2, 3, 4, 5]; // Mon-Fri (JS weekday) loadDailyStatus();
}
const response = await api.post('/api/presence/mark-bulk', data); // Update office name if available
if (currentUser && currentUser.office_name) {
const nameDisplay = document.getElementById('statusOfficeName');
if (nameDisplay) nameDisplay.textContent = currentUser.office_name;
const headerDisplay = document.getElementById('currentOfficeDisplay');
if (headerDisplay) headerDisplay.textContent = currentUser.office_name;
} else {
const nameDisplay = document.getElementById('statusOfficeName');
if (nameDisplay) nameDisplay.textContent = 'Tuo Ufficio';
const headerDisplay = document.getElementById('currentOfficeDisplay');
if (headerDisplay) headerDisplay.textContent = 'Tuo Ufficio';
}
}
function updateStatusHeader() {
const options = { weekday: 'long', year: 'numeric', month: 'long', day: 'numeric' };
const dateStr = statusDate.toLocaleDateString('it-IT', options);
const capitalizedDate = dateStr.charAt(0).toUpperCase() + dateStr.slice(1);
const statusDateDisplay = document.getElementById('statusDateDisplay');
if (statusDateDisplay) statusDateDisplay.textContent = capitalizedDate;
const pickerDateDisplay = document.getElementById('pickerDateDisplay');
if (pickerDateDisplay) pickerDateDisplay.textContent = utils.formatDate(statusDate);
const summaryDateDisplay = document.getElementById('summaryDateDisplay');
if (summaryDateDisplay) summaryDateDisplay.textContent = dateStr;
const picker = document.getElementById('statusDatePicker');
if (picker) {
const yyyy = statusDate.getFullYear();
const mm = String(statusDate.getMonth() + 1).padStart(2, '0');
const dd = String(statusDate.getDate()).padStart(2, '0');
picker.value = `${yyyy}-${mm}-${dd}`;
}
}
async function loadDailyStatus() {
if (!currentUser || !currentUser.office_id) return;
const dateStr = utils.formatDate(statusDate);
const officeId = currentUser.office_id;
const grid = document.getElementById('spotsGrid');
// Keep grid height to avoid jump if possible, or just loading styling
if (grid) grid.innerHTML = '<div style="width:100%; text-align:center; padding:2rem; color:var(--text-secondary);">Caricamento...</div>';
try {
const response = await api.get(`/api/parking/assignments/${dateStr}?office_id=${officeId}`);
if (response && response.ok) { if (response && response.ok) {
const results = await response.json(); const assignments = await response.json();
alert(`Marked ${results.length} dates`); renderParkingStatus(assignments);
document.getElementById('bulkMarkModal').style.display = 'none';
await Promise.all([loadPresences(), loadParkingAssignments()]);
renderCalendar();
} else { } else {
const error = await response.json(); if (grid) grid.innerHTML = '<div style="width:100%; text-align:center; padding:1rem;">Impossibile caricare i dati.</div>';
alert(error.detail || 'Failed to bulk mark'); }
} catch (e) {
console.error("Error loading parking status", e);
if (grid) grid.innerHTML = '<div style="width:100%; text-align:center; padding:1rem;">Errore di caricamento.</div>';
}
}
function renderParkingStatus(assignments) {
const grid = document.getElementById('spotsGrid');
if (!grid) return;
grid.innerHTML = '';
if (!assignments || assignments.length === 0) {
grid.innerHTML = '<div style="width:100%; text-align:center; padding:1rem;">Nessun posto configurato o disponibile.</div>';
const badge = document.getElementById('spotsCountBadge');
if (badge) badge.textContent = `Liberi: 0/0`;
return;
}
// Sort
assignments.sort((a, b) => {
const nameA = a.spot_display_name || a.spot_id;
const nameB = b.spot_display_name || b.spot_id;
return nameA.localeCompare(nameB, undefined, { numeric: true, sensitivity: 'base' });
});
let total = assignments.length;
let free = 0;
assignments.forEach(a => {
const isFree = !a.user_id;
if (isFree) free++;
const spotName = a.spot_display_name || a.spot_id;
const statusText = isFree ? 'Libero' : (a.user_name || 'Occupato');
// Colors: Free = Green (default), Occupied = Yellow (requested)
// Yellow palette: Border #eab308, bg #fefce8, text #a16207, icon #eab308
const borderColor = isFree ? '#22c55e' : '#eab308';
const bgColor = isFree ? '#f0fdf4' : '#fefce8';
const textColor = isFree ? '#15803d' : '#a16207';
const iconColor = isFree ? '#22c55e' : '#eab308';
const el = document.createElement('div');
el.className = 'spot-card';
el.style.cssText = `
border: 1px solid ${borderColor};
background: ${bgColor};
border-radius: 8px;
padding: 1rem;
width: 140px;
min-width: 120px;
text-align: center;
display: flex;
flex-direction: column;
align-items: center;
gap: 0.5rem;
transition: all 0.2s;
`;
// New Car Icon (Front Facing Sedan style or similar simple shape)
// Using a cleaner SVG path
el.innerHTML = `
<div style="font-weight: 600; font-size: 1.1rem; color: #1f2937;">${spotName}</div>
<div style="color: ${textColor}; font-size: 0.85rem; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; max-width: 100%;" title="${statusText}">
${statusText}
</div>
`;
grid.appendChild(el);
});
const badge = document.getElementById('spotsCountBadge');
if (badge) badge.textContent = `Liberi: ${free}/${total}`;
}
function setupStatusListeners() {
const prevDay = document.getElementById('statusPrevDay');
if (prevDay) prevDay.addEventListener('click', () => {
statusDate.setDate(statusDate.getDate() - 1);
updateStatusHeader();
loadDailyStatus();
});
const nextDay = document.getElementById('statusNextDay');
if (nextDay) nextDay.addEventListener('click', () => {
statusDate.setDate(statusDate.getDate() + 1);
updateStatusHeader();
loadDailyStatus();
});
const datePicker = document.getElementById('statusDatePicker');
if (datePicker) datePicker.addEventListener('change', (e) => {
if (e.target.value) {
statusDate = new Date(e.target.value);
updateStatusHeader();
loadDailyStatus();
} }
}); });
} }

View File

@@ -1,72 +1,118 @@
/** /**
* Team Calendar Page * Team Calendar Page
* Shows presence and parking for all team members * Shows presence and parking for all team members
* Filtered by manager (manager-centric model) * Filtered by office (office-centric model)
*/ */
let currentUser = null; let currentUser = null;
let currentStartDate = null; let currentStartDate = null;
let viewMode = 'week'; // 'week' or 'month' let viewMode = 'week'; // 'week' or 'month'
let managers = []; let offices = [];
let teamData = []; let teamData = [];
let parkingDataLookup = {}; let parkingDataLookup = {};
let parkingAssignmentLookup = {}; let parkingAssignmentLookup = {};
let selectedUserId = null; let selectedUserId = null;
let selectedDate = null; let selectedDate = null;
let currentAssignmentId = null; let currentAssignmentId = null;
let officeClosingRules = {}; // { officeId: { weekly: [], specific: [] } }
document.addEventListener('DOMContentLoaded', async () => { document.addEventListener('DOMContentLoaded', async () => {
currentUser = await api.requireAuth(); currentUser = await api.requireAuth();
if (!currentUser) return; if (!currentUser) return;
// Initialize start date based on week start preference // Initialize start date based on week start preference
const weekStartDay = currentUser.week_start_day || 0; const weekStartDay = currentUser.week_start_day || 1;
currentStartDate = utils.getWeekStart(new Date(), weekStartDay); currentStartDate = utils.getWeekStart(new Date(), weekStartDay);
await loadManagers(); await loadOffices();
await loadTeamData(); await loadTeamData();
await loadTeamData();
// Initialize Modal Logic
ModalLogic.init({
onMarkPresence: handleMarkPresence,
onClearPresence: handleClearPresence,
onReleaseParking: handleReleaseParking,
onReassignParking: handleReassignParking
});
renderCalendar(); renderCalendar();
setupEventListeners(); setupEventListeners();
}); });
async function loadManagers() { function updateOfficeDisplay() {
const response = await api.get('/api/managers'); const display = document.getElementById('currentOfficeNameDisplay');
if (response && response.ok) { if (!display) return;
managers = await response.json();
const select = document.getElementById('managerFilter');
// Filter managers based on user role const select = document.getElementById('officeFilter');
let filteredManagers = managers;
// If user is employee, show their office name directly
if (currentUser.role === 'employee') {
display.textContent = currentUser.office_name || "Mio Ufficio";
return;
}
// For admin/manager, show selected
if (select && select.value) {
// Find name in options
const option = select.options[select.selectedIndex];
if (option) {
// Remove the count (xx utenti) part if desired, or keep it.
// User requested "nome del'ufficio", let's keep it simple.
// Option text is "Name (Count users)"
// let text = option.textContent.split('(')[0].trim();
display.textContent = option.textContent;
} else {
display.textContent = "Tutti gli Uffici";
}
} else {
display.textContent = "Tutti gli Uffici";
}
}
async function loadOffices() {
const select = document.getElementById('officeFilter');
// Only Admins and Managers can list offices
// Employees will just see their own office logic handled in loadTeamData
// Only Admins can see the office selector
if (currentUser.role !== 'admin') {
select.style.display = 'none';
// Employees stop here, Managers continue to allow auto-selection logic below
if (currentUser.role === 'employee') return;
}
const response = await api.get('/api/offices');
if (response && response.ok) {
offices = await response.json();
let filteredOffices = offices;
if (currentUser.role === 'manager') { if (currentUser.role === 'manager') {
// Manager only sees themselves // Manager only sees their own office in the filter?
filteredManagers = managers.filter(m => m.id === currentUser.id); // Actually managers might want to filter if they (hypothetically) managed multiple,
} else if (currentUser.role === 'employee') { // but currently User has 1 office.
// Employee only sees their own manager if (currentUser.office_id) {
if (currentUser.manager_id) { filteredOffices = offices.filter(o => o.id === currentUser.office_id);
filteredManagers = managers.filter(m => m.id === currentUser.manager_id);
} else { } else {
filteredManagers = []; filteredOffices = [];
} }
} }
filteredManagers.forEach(manager => { filteredOffices.forEach(office => {
const option = document.createElement('option'); const option = document.createElement('option');
option.value = manager.id; option.value = office.id;
const userCount = manager.managed_user_count || 0; option.textContent = `${office.name} (${office.user_count || 0} utenti)`;
option.textContent = `${manager.name} (${userCount} users)`;
select.appendChild(option); select.appendChild(option);
}); });
// Auto-select for managers and employees (they only see their team) // Auto-select for managers
if (filteredManagers.length === 1) { if (currentUser.role === 'manager' && filteredOffices.length === 1) {
select.value = filteredManagers[0].id; select.value = filteredOffices[0].id;
}
// Hide manager filter for employees (they can only see their team)
if (currentUser.role === 'employee') {
select.style.display = 'none';
} }
} }
// Initial update of office display
updateOfficeDisplay();
} }
function getDateRange() { function getDateRange() {
@@ -85,15 +131,16 @@ function getDateRange() {
} }
async function loadTeamData() { async function loadTeamData() {
await loadClosingData();
const { startDate, endDate } = getDateRange(); const { startDate, endDate } = getDateRange();
const startStr = utils.formatDate(startDate); const startStr = utils.formatDate(startDate);
const endStr = utils.formatDate(endDate); const endStr = utils.formatDate(endDate);
let url = `/api/presence/team?start_date=${startStr}&end_date=${endStr}`; let url = `/api/presence/team?start_date=${startStr}&end_date=${endStr}`;
const managerFilter = document.getElementById('managerFilter').value; const officeFilter = document.getElementById('officeFilter').value;
if (managerFilter) { if (officeFilter) {
url += `&manager_id=${managerFilter}`; url += `&office_id=${officeFilter}`;
} }
const response = await api.get(url); const response = await api.get(url);
@@ -114,6 +161,68 @@ async function loadTeamData() {
} }
} }
async function loadClosingData() {
officeClosingRules = {};
let officeIdsToLoad = [];
const selectedOfficeId = document.getElementById('officeFilter').value;
if (selectedOfficeId) {
officeIdsToLoad = [selectedOfficeId];
} else if (currentUser.role === 'employee' || (currentUser.role === 'manager' && currentUser.office_id)) {
officeIdsToLoad = [currentUser.office_id];
} else if (offices.length > 0) {
// Admin viewing all or Manager with access to list
officeIdsToLoad = offices.map(o => o.id);
}
if (officeIdsToLoad.length === 0) return;
// Fetch in parallel
const promises = officeIdsToLoad.map(async (oid) => {
try {
const [weeklyRes, specificRes] = await Promise.all([
api.get(`/api/offices/${oid}/weekly-closing-days`),
api.get(`/api/offices/${oid}/closing-days`)
]);
officeClosingRules[oid] = { weekly: [], specific: [] };
if (weeklyRes && weeklyRes.ok) {
const days = await weeklyRes.json();
officeClosingRules[oid].weekly = days.map(d => d.weekday);
}
if (specificRes && specificRes.ok) {
officeClosingRules[oid].specific = await specificRes.json();
// OPTIMIZATION: Pre-calculate all specific closed dates into a Set
const closedSet = new Set();
officeClosingRules[oid].specific.forEach(range => {
let start = new Date(range.date);
let end = range.end_date ? new Date(range.end_date) : new Date(range.date);
// Normalize to noon to avoid timezone issues when stepping
start.setHours(12, 0, 0, 0);
end.setHours(12, 0, 0, 0);
let current = new Date(start);
while (current <= end) {
closedSet.add(utils.formatDate(current));
current.setDate(current.getDate() + 1);
}
});
officeClosingRules[oid].closedDatesSet = closedSet;
}
} catch (e) {
console.error(`Error loading closing days for office ${oid}:`, e);
}
});
await Promise.all(promises);
}
function renderCalendar() { function renderCalendar() {
const header = document.getElementById('calendarHeader'); const header = document.getElementById('calendarHeader');
const body = document.getElementById('calendarBody'); const body = document.getElementById('calendarBody');
@@ -132,8 +241,8 @@ function renderCalendar() {
const dayCount = Math.round((endDate - startDate) / (1000 * 60 * 60 * 24)) + 1; const dayCount = Math.round((endDate - startDate) / (1000 * 60 * 60 * 24)) + 1;
// Build header row // Build header row
const dayNames = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']; const dayNames = ['Dom', 'Lun', 'Mar', 'Mer', 'Gio', 'Ven', 'Sab'];
let headerHtml = '<th>Name</th><th>Manager</th>'; let headerHtml = '<th>Nome</th><th>Ufficio</th>';
for (let i = 0; i < dayCount; i++) { for (let i = 0; i < dayCount; i++) {
const date = new Date(startDate); const date = new Date(startDate);
@@ -141,13 +250,19 @@ function renderCalendar() {
const dayOfWeek = date.getDay(); const dayOfWeek = date.getDay();
const isWeekend = dayOfWeek === 0 || dayOfWeek === 6; const isWeekend = dayOfWeek === 0 || dayOfWeek === 6;
const isHoliday = utils.isItalianHoliday(date); const isHoliday = utils.isItalianHoliday(date);
const isToday = date.toDateString() === new Date().toDateString(); const isToday = utils.formatDate(date) === utils.formatDate(new Date());
let classes = []; let classes = [];
if (isWeekend) classes.push('weekend'); if (isWeekend) classes.push('weekend');
if (isHoliday) classes.push('holiday'); if (isHoliday) classes.push('holiday');
if (isToday) classes.push('today'); if (isToday) classes.push('today');
if (isToday) classes.push('today');
// Header doesn't show closed status in multi-office view
// unless we want to check if ALL are closed?
// For now, simpler to leave header clean.
headerHtml += `<th class="${classes.join(' ')}"> headerHtml += `<th class="${classes.join(' ')}">
<div>${dayNames[dayOfWeek].charAt(0)}</div> <div>${dayNames[dayOfWeek].charAt(0)}</div>
<div class="day-number">${date.getDate()}</div> <div class="day-number">${date.getDate()}</div>
@@ -157,7 +272,7 @@ function renderCalendar() {
// Build body rows // Build body rows
if (teamData.length === 0) { if (teamData.length === 0) {
body.innerHTML = `<tr><td colspan="${dayCount + 2}" class="text-center">No team members found</td></tr>`; body.innerHTML = `<tr><td colspan="${dayCount + 2}" class="text-center">Nessun membro del team trovato</td></tr>`;
return; return;
} }
@@ -165,7 +280,7 @@ function renderCalendar() {
teamData.forEach(member => { teamData.forEach(member => {
bodyHtml += `<tr> bodyHtml += `<tr>
<td class="member-name">${member.name || 'Unknown'}</td> <td class="member-name">${member.name || 'Unknown'}</td>
<td class="member-manager">${member.manager_name || '-'}</td>`; <td class="member-manager">${member.office_name || '-'}</td>`;
for (let i = 0; i < dayCount; i++) { for (let i = 0; i < dayCount; i++) {
const date = new Date(startDate); const date = new Date(startDate);
@@ -179,7 +294,7 @@ function renderCalendar() {
const parkingKey = `${member.id}_${dateStr}`; const parkingKey = `${member.id}_${dateStr}`;
const parkingSpot = parkingDataLookup[parkingKey]; const parkingSpot = parkingDataLookup[parkingKey];
const hasParking = member.parking_dates && member.parking_dates.includes(dateStr); const hasParking = member.parking_dates && member.parking_dates.includes(dateStr);
const isToday = date.toDateString() === new Date().toDateString(); const isToday = dateStr === utils.formatDate(new Date());
let cellClasses = ['calendar-cell']; let cellClasses = ['calendar-cell'];
if (isWeekend) cellClasses.push('weekend'); if (isWeekend) cellClasses.push('weekend');
@@ -187,6 +302,47 @@ function renderCalendar() {
if (isToday) cellClasses.push('today'); if (isToday) cellClasses.push('today');
if (presence) cellClasses.push(`status-${presence.status}`); if (presence) cellClasses.push(`status-${presence.status}`);
if (isToday) cellClasses.push('today');
if (presence) cellClasses.push(`status-${presence.status}`);
// Optimized closing day check
// Pre-calculate loop-invariant sets outside if not already done, but here we do it per-cell because of date dependency?
// BETTER: We should pre-calculate a "closedMap" for the viewed range for each office?
// OR: Just optimize the inner check.
// Optimization: Create a lookup string for the current date once
// (Already have dateStr)
const memberRules = officeClosingRules[member.office_id];
let isClosed = false;
if (memberRules) {
// Check weekly
if (memberRules.weekly.includes(dayOfWeek)) {
isClosed = true;
} else if (memberRules.specific && memberRules.specific.length > 0) {
// Check specific
// Optimization: Use the string date lookup if we had a Set, but we have ranges.
// We can optimize by converting ranges to Sets ONCE when loading data,
// OR just stick to this check if N is small.
// Given the "optimization" task, let's just make sure we don't do new Date() inside.
// The `specific` array contains objects with `date` and `end_date` strings.
// We can compare strings directly if format is YYYY-MM-DD and we are careful.
// Optimization: check if dateStr is in a Set of closed dates for this office?
// Let's implement the Set lookup logic in `loadClosingData` or `renderCalendar` start.
// For now, let's assume `memberRules.closedDatesSet` exists.
if (memberRules.closedDatesSet && memberRules.closedDatesSet.has(dateStr)) {
isClosed = true;
}
}
}
if (isClosed) {
cellClasses.push('closed');
}
// Show parking badge instead of just 'P' // Show parking badge instead of just 'P'
let parkingBadge = ''; let parkingBadge = '';
if (hasParking) { if (hasParking) {
@@ -194,7 +350,7 @@ function renderCalendar() {
parkingBadge = `<span class="parking-badge-sm">${spotName}</span>`; parkingBadge = `<span class="parking-badge-sm">${spotName}</span>`;
} }
bodyHtml += `<td class="${cellClasses.join(' ')}" data-user-id="${member.id}" data-date="${dateStr}" data-user-name="${member.name || ''}">${parkingBadge}</td>`; bodyHtml += `<td class="${cellClasses.join(' ')}" data-user-id="${member.id}" data-date="${dateStr}" data-user-name="${member.name || ''}" ${isClosed ? 'data-closed="true"' : ''}>${parkingBadge}</td>`;
} }
bodyHtml += '</tr>'; bodyHtml += '</tr>';
@@ -205,12 +361,14 @@ function renderCalendar() {
if (currentUser.role === 'admin' || currentUser.role === 'manager') { if (currentUser.role === 'admin' || currentUser.role === 'manager') {
body.querySelectorAll('.calendar-cell').forEach(cell => { body.querySelectorAll('.calendar-cell').forEach(cell => {
cell.style.cursor = 'pointer'; cell.style.cursor = 'pointer';
cell.addEventListener('click', () => { if (cell.dataset.closed !== 'true') {
const userId = cell.dataset.userId; cell.addEventListener('click', () => {
const date = cell.dataset.date; const userId = cell.dataset.userId;
const userName = cell.dataset.userName; const date = cell.dataset.date;
openDayModal(userId, date, userName); const userName = cell.dataset.userName;
}); openDayModal(userId, date, userName);
});
}
}); });
} }
} }
@@ -219,129 +377,107 @@ function openDayModal(userId, dateStr, userName) {
selectedUserId = userId; selectedUserId = userId;
selectedDate = dateStr; selectedDate = dateStr;
const modal = document.getElementById('dayModal');
document.getElementById('dayModalTitle').textContent = utils.formatDateDisplay(dateStr);
document.getElementById('dayModalUser').textContent = userName;
// Find current status and parking // Find current status and parking
const member = teamData.find(m => m.id === userId); const member = teamData.find(m => m.id === userId);
const presence = member?.presences.find(p => p.date === dateStr); const presence = member?.presences.find(p => p.date === dateStr);
const parkingKey = `${userId}_${dateStr}`; const parkingKey = `${userId}_${dateStr}`;
const parkingSpot = parkingDataLookup[parkingKey]; const parkingSpot = parkingDataLookup[parkingKey];
const assignmentId = parkingAssignmentLookup[parkingKey]; const assignmentId = parkingAssignmentLookup[parkingKey];
currentAssignmentId = assignmentId; // Ensure this is set for modal logic
// Highlight current status const parkingObj = assignmentId ? {
document.querySelectorAll('#dayModal .status-btn').forEach(btn => { id: assignmentId,
const status = btn.dataset.status; spot_display_name: parkingSpot,
if (presence && presence.status === status) { spot_id: parkingSpot
btn.classList.add('active'); } : null;
} else {
btn.classList.remove('active'); ModalLogic.openModal({
} dateStr,
userName,
presence,
parking: parkingObj,
userId
}); });
// Update parking section
const parkingSection = document.getElementById('parkingSection');
const parkingInfo = document.getElementById('parkingInfo');
if (parkingSpot) {
parkingSection.style.display = 'block';
parkingInfo.innerHTML = `<strong>Parking:</strong> Spot ${parkingSpot}`;
currentAssignmentId = assignmentId;
} else {
parkingSection.style.display = 'none';
currentAssignmentId = null;
}
modal.style.display = 'flex';
} }
async function markPresence(status) { async function handleMarkPresence(status, date, userId) {
if (!selectedUserId || !selectedDate) return; // userId passed from ModalLogic if provided, or use selectedUserId
const targetUserId = userId || selectedUserId;
if (!targetUserId) return;
const response = await api.post('/api/presence/admin/mark', { const response = await api.post('/api/presence/admin/mark', {
user_id: selectedUserId, user_id: targetUserId,
date: selectedDate, date: date,
status: status status: status
}); });
if (response && response.ok) { if (response && response.ok) {
document.getElementById('dayModal').style.display = 'none'; ModalLogic.closeModal();
await loadTeamData(); await loadTeamData();
renderCalendar(); renderCalendar();
} else { } else {
const error = await response.json(); const error = await response.json();
alert(error.detail || 'Failed to mark presence'); alert(error.detail || 'Impossibile segnare la presenza');
} }
} }
async function clearPresence() { async function handleClearPresence(date, userId) {
if (!selectedUserId || !selectedDate) return; const targetUserId = userId || selectedUserId;
if (!confirm('Clear presence for this date?')) return; if (!targetUserId) return;
const response = await api.delete(`/api/presence/admin/${selectedUserId}/${selectedDate}`); // confirm is not needed here if ModalLogic doesn't mandate it, but keeping logic
// ModalLogic buttons usually trigger this directly.
const response = await api.delete(`/api/presence/admin/${targetUserId}/${date}`);
if (response && response.ok) { if (response && response.ok) {
document.getElementById('dayModal').style.display = 'none'; ModalLogic.closeModal();
await loadTeamData(); await loadTeamData();
renderCalendar(); renderCalendar();
} }
} }
async function openReassignModal() { async function handleReleaseParking(assignmentId) {
if (!currentAssignmentId) return; if (!confirm('Rilasciare il parcheggio per questa data?')) return;
// Load eligible users // Note: Admin endpoint for releasing ANY spot vs "my spot"
const response = await api.get(`/api/parking/eligible-users/${currentAssignmentId}`); // Since we are admin/manager here, we might need a general release endpoint or use reassign with null?
if (!response || !response.ok) { // The current 'release_my_spot' is only for self.
const error = await response.json(); // 'reassign_spot' with null user_id is the way for admins.
alert(error.detail || 'Failed to load eligible users');
return;
}
const users = await response.json(); const response = await api.post('/api/parking/reassign-spot', {
const select = document.getElementById('reassignUser'); assignment_id: assignmentId,
select.innerHTML = '<option value="">Select user...</option>'; new_user_id: null // Release
});
if (users.length === 0) { if (response && response.ok) {
select.innerHTML = '<option value="">No eligible users available</option>'; ModalLogic.closeModal();
await loadTeamData();
renderCalendar();
} else { } else {
users.forEach(user => { const error = await response.json();
const option = document.createElement('option'); alert(error.detail || 'Impossibile rilasciare il parcheggio');
option.value = user.id;
option.textContent = user.name;
select.appendChild(option);
});
} }
// Get spot info
const parkingKey = `${selectedUserId}_${selectedDate}`;
const spotName = parkingDataLookup[parkingKey] || 'Unknown';
document.getElementById('reassignSpotInfo').textContent = `Spot ${spotName}`;
document.getElementById('dayModal').style.display = 'none';
document.getElementById('reassignModal').style.display = 'flex';
} }
async function confirmReassign() {
const newUserId = document.getElementById('reassignUser').value;
if (!currentAssignmentId || !newUserId) { async function handleReassignParking(assignmentId, newUserId) {
alert('Please select a user'); if (!assignmentId || !newUserId) {
alert('Seleziona un utente');
return; return;
} }
const response = await api.post('/api/parking/reassign-spot', { const response = await api.post('/api/parking/reassign-spot', {
assignment_id: currentAssignmentId, assignment_id: assignmentId,
new_user_id: newUserId new_user_id: newUserId
}); });
if (response && response.ok) { if (response && response.ok) {
await loadTeamData(); await loadTeamData();
renderCalendar(); renderCalendar();
document.getElementById('reassignModal').style.display = 'none'; ModalLogic.closeModal();
} else { } else {
const error = await response.json(); const error = await response.json();
alert(error.detail || 'Failed to reassign parking spot'); alert(error.detail || 'Impossibile riassegnare il parcheggio');
} }
} }
@@ -375,40 +511,20 @@ function setupEventListeners() {
currentStartDate = new Date(currentStartDate.getFullYear(), currentStartDate.getMonth(), 1); currentStartDate = new Date(currentStartDate.getFullYear(), currentStartDate.getMonth(), 1);
} else { } else {
// Set to current week start // Set to current week start
const weekStartDay = currentUser.week_start_day || 0; const weekStartDay = currentUser.week_start_day || 1;
currentStartDate = utils.getWeekStart(new Date(), weekStartDay); currentStartDate = utils.getWeekStart(new Date(), weekStartDay);
} }
await loadTeamData(); await loadTeamData();
renderCalendar(); renderCalendar();
}); });
// Manager filter
document.getElementById('managerFilter').addEventListener('change', async () => { // Office filter
document.getElementById('officeFilter').addEventListener('change', async () => {
updateOfficeDisplay(); // Update label on change
await loadTeamData(); await loadTeamData();
renderCalendar(); renderCalendar();
}); });
// Day modal
document.getElementById('closeDayModal').addEventListener('click', () => {
document.getElementById('dayModal').style.display = 'none';
});
document.querySelectorAll('#dayModal .status-btn').forEach(btn => {
btn.addEventListener('click', () => markPresence(btn.dataset.status));
});
document.getElementById('clearDayBtn').addEventListener('click', clearPresence);
document.getElementById('reassignParkingBtn').addEventListener('click', openReassignModal);
utils.setupModalClose('dayModal'); utils.setupModalClose('dayModal');
// Reassign modal
document.getElementById('closeReassignModal').addEventListener('click', () => {
document.getElementById('reassignModal').style.display = 'none';
});
document.getElementById('cancelReassign').addEventListener('click', () => {
document.getElementById('reassignModal').style.display = 'none';
});
document.getElementById('confirmReassign').addEventListener('click', confirmReassign);
utils.setupModalClose('reassignModal');
} }

View File

@@ -1,370 +1,383 @@
/** /**
* Team Rules Page * Team Rules Page
* Manage closing days, parking guarantees, and exclusions * Manage closing days, guarantees, and exclusions
* * Office-centric model
* Rules are set at manager level for their parking pool.
*/ */
let currentUser = null; let currentUser = null;
let selectedManagerId = null; let offices = [];
let managerUsers = []; let currentOfficeId = null;
let officeUsers = [];
let currentWeeklyClosingDays = [];
document.addEventListener('DOMContentLoaded', async () => { document.addEventListener('DOMContentLoaded', async () => {
currentUser = await api.requireAuth(); currentUser = await api.requireAuth();
if (!currentUser) return; if (!currentUser) return;
// Only managers and admins can access // Only admins and managers can access this page
if (currentUser.role === 'employee') { if (currentUser.role !== 'admin' && currentUser.role !== 'manager') {
window.location.href = '/presence'; window.location.href = '/presence';
return; return;
} }
await loadManagers(); await loadOffices();
setupEventListeners(); setupEventListeners();
}); });
async function loadManagers() { async function loadOffices() {
const response = await api.get('/api/managers'); const select = document.getElementById('officeSelect');
if (response && response.ok) { const card = document.getElementById('officeSelectionCard');
const managers = await response.json();
const select = document.getElementById('managerSelect');
// Filter to managers this user can see // Only Admins can see the office selector
let filteredManagers = managers; if (currentUser.role !== 'admin') {
if (card) card.style.display = 'none';
}
const response = await api.get('/api/offices');
if (response && response.ok) {
offices = await response.json();
let filteredOffices = offices;
if (currentUser.role === 'manager') { if (currentUser.role === 'manager') {
// Manager only sees themselves // Manager only sees their own office
filteredManagers = managers.filter(m => m.id === currentUser.id); if (currentUser.office_id) {
filteredOffices = offices.filter(o => o.id === currentUser.office_id);
} else {
filteredOffices = [];
}
} }
// Show managers in dropdown filteredOffices.forEach(office => {
let totalManagers = 0;
let firstManagerId = null;
filteredManagers.forEach(manager => {
const option = document.createElement('option'); const option = document.createElement('option');
option.value = manager.id; option.value = office.id;
// Show manager name with user count and parking quota option.textContent = office.name;
const userCount = manager.managed_user_count || 0;
const quota = manager.parking_quota || 0;
option.textContent = `${manager.name} (${userCount} users, ${quota} spots)`;
select.appendChild(option); select.appendChild(option);
totalManagers++;
if (!firstManagerId) firstManagerId = manager.id;
}); });
// Auto-select if only one manager // Auto-select for managers
if (totalManagers === 1 && firstManagerId) { if (currentUser.role === 'manager' && filteredOffices.length === 1) {
select.value = firstManagerId; select.value = filteredOffices[0].id;
await selectManager(firstManagerId); loadOfficeRules(filteredOffices[0].id);
} }
} }
} }
async function selectManager(managerId) { async function loadOfficeRules(officeId) {
selectedManagerId = managerId; if (!officeId) {
if (!managerId) {
document.getElementById('rulesContent').style.display = 'none'; document.getElementById('rulesContent').style.display = 'none';
document.getElementById('noManagerMessage').style.display = 'block'; document.getElementById('noOfficeMessage').style.display = 'block';
return; return;
} }
currentOfficeId = officeId;
document.getElementById('rulesContent').style.display = 'block'; document.getElementById('rulesContent').style.display = 'block';
document.getElementById('noManagerMessage').style.display = 'none'; document.getElementById('noOfficeMessage').style.display = 'none';
// Load users for this office (for dropdowns)
await loadOfficeUsers(officeId);
await Promise.all([ await Promise.all([
loadWeeklyClosingDays(), loadWeeklyClosingDays(officeId),
loadClosingDays(), loadClosingDays(officeId),
loadGuarantees(), loadGuarantees(officeId),
loadExclusions(), loadExclusions(officeId)
loadManagerUsers()
]); ]);
} }
async function loadWeeklyClosingDays() { async function loadOfficeUsers(officeId) {
const response = await api.get(`/api/managers/${selectedManagerId}/weekly-closing-days`); const response = await api.get(`/api/offices/${officeId}/users`);
if (response && response.ok) {
officeUsers = await response.json();
}
}
// Weekly Closing Days
async function loadWeeklyClosingDays(officeId) {
const response = await api.get(`/api/offices/${officeId}/weekly-closing-days`);
if (response && response.ok) { if (response && response.ok) {
const days = await response.json(); const days = await response.json();
const weekdays = days.map(d => d.weekday); currentWeeklyClosingDays = days;
const activeWeekdays = days.map(d => d.weekday);
// Update checkboxes
document.querySelectorAll('#weeklyClosingDays input[type="checkbox"]').forEach(cb => { document.querySelectorAll('#weeklyClosingDays input[type="checkbox"]').forEach(cb => {
const weekday = parseInt(cb.dataset.weekday); const weekday = parseInt(cb.dataset.weekday);
cb.checked = weekdays.includes(weekday); cb.checked = activeWeekdays.includes(weekday);
}); });
} }
} }
async function loadManagerUsers() { async function saveWeeklyClosingDays() {
const response = await api.get(`/api/managers/${selectedManagerId}/users`); const btn = document.getElementById('saveWeeklyClosingDaysBtn');
if (response && response.ok) { if (!btn) return;
managerUsers = await response.json();
updateUserSelects(); const originalText = btn.textContent;
btn.disabled = true;
btn.textContent = 'Salvataggio...';
try {
const promises = [];
const checkboxes = document.querySelectorAll('#weeklyClosingDays input[type="checkbox"]');
for (const cb of checkboxes) {
const weekday = parseInt(cb.dataset.weekday);
const isChecked = cb.checked;
const existingEntry = currentWeeklyClosingDays.find(d => d.weekday === weekday);
if (isChecked && !existingEntry) {
// Add
promises.push(api.post(`/api/offices/${currentOfficeId}/weekly-closing-days`, { weekday }));
} else if (!isChecked && existingEntry) {
// Remove
promises.push(api.delete(`/api/offices/${currentOfficeId}/weekly-closing-days/${existingEntry.id}`));
}
}
await Promise.all(promises);
utils.showMessage('Giorni di chiusura aggiornati', 'success');
await loadWeeklyClosingDays(currentOfficeId);
} catch (error) {
console.error(error);
utils.showMessage('Errore durante il salvataggio', 'error');
} finally {
btn.disabled = false;
btn.textContent = originalText;
} }
} }
function updateUserSelects() {
['guaranteeUser', 'exclusionUser'].forEach(selectId => {
const select = document.getElementById(selectId);
select.innerHTML = '<option value="">Select user...</option>';
managerUsers.forEach(user => {
const option = document.createElement('option');
option.value = user.id;
option.textContent = user.name;
select.appendChild(option);
});
});
}
async function loadClosingDays() {
const response = await api.get(`/api/managers/${selectedManagerId}/closing-days`); // Closing Days
async function loadClosingDays(officeId) {
const response = await api.get(`/api/offices/${officeId}/closing-days`);
const container = document.getElementById('closingDaysList'); const container = document.getElementById('closingDaysList');
if (response && response.ok) { if (response && response.ok) {
const days = await response.json(); const days = await response.json();
if (days.length === 0) { if (days.length === 0) {
container.innerHTML = ''; container.innerHTML = '<p class="text-muted">Nessun giorno di chiusura specifico.</p>';
return; return;
} }
container.innerHTML = days.map(day => ` container.innerHTML = days.map(day => `
<div class="rule-item"> <div class="rule-item">
<div class="rule-info"> <div class="rule-info">
<span class="rule-date">${utils.formatDateDisplay(day.date)}</span> <strong>${utils.formatDateDisplay(day.date)}${day.end_date ? ' - ' + utils.formatDateDisplay(day.end_date) : ''}</strong>
${day.reason ? `<span class="rule-reason">${day.reason}</span>` : ''} ${day.reason ? `<span class="rule-note">${day.reason}</span>` : ''}
</div> </div>
<button class="btn-icon btn-danger" onclick="deleteClosingDay('${day.id}')"> <button class="btn-icon btn-danger" onclick="deleteClosingDay('${day.id}')">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> &times;
<polyline points="3 6 5 6 21 6"></polyline>
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"></path>
</svg>
</button> </button>
</div> </div>
`).join(''); `).join('');
} }
} }
function formatDateRange(startDate, endDate) { async function addClosingDay(data) {
if (!startDate && !endDate) return ''; const response = await api.post(`/api/offices/${currentOfficeId}/closing-days`, data);
if (startDate && !endDate) return `From ${utils.formatDateDisplay(startDate)}`; if (response && response.ok) {
if (!startDate && endDate) return `Until ${utils.formatDateDisplay(endDate)}`; await loadClosingDays(currentOfficeId);
return `${utils.formatDateDisplay(startDate)} - ${utils.formatDateDisplay(endDate)}`; document.getElementById('closingDayModal').style.display = 'none';
document.getElementById('closingDayForm').reset();
} else {
const error = await response.json();
alert(error.detail || 'Impossibile aggiungere il giorno di chiusura');
}
} }
async function loadGuarantees() { async function deleteClosingDay(id) {
const response = await api.get(`/api/managers/${selectedManagerId}/guarantees`); if (!confirm('Eliminare questo giorno di chiusura?')) return;
const response = await api.delete(`/api/offices/${currentOfficeId}/closing-days/${id}`);
if (response && response.ok) {
await loadClosingDays(currentOfficeId);
}
}
// Guarantees
async function loadGuarantees(officeId) {
const response = await api.get(`/api/offices/${officeId}/guarantees`);
const container = document.getElementById('guaranteesList'); const container = document.getElementById('guaranteesList');
if (response && response.ok) { if (response && response.ok) {
const guarantees = await response.json(); const guarantees = await response.json();
if (guarantees.length === 0) { if (guarantees.length === 0) {
container.innerHTML = ''; container.innerHTML = '<p class="text-muted">Nessuna garanzia di parcheggio attiva.</p>';
return; return;
} }
container.innerHTML = guarantees.map(g => { container.innerHTML = guarantees.map(g => `
const dateRange = formatDateRange(g.start_date, g.end_date);
return `
<div class="rule-item"> <div class="rule-item">
<div class="rule-info"> <div class="rule-info">
<span class="rule-name">${g.user_name}</span> <strong>${g.user_name || 'Utente sconosciuto'}</strong>
${dateRange ? `<span class="rule-date-range">${dateRange}</span>` : ''} <span class="rule-dates">
${g.start_date ? 'Dal ' + utils.formatDateDisplay(g.start_date) : 'Da sempre'}
${g.end_date ? ' al ' + utils.formatDateDisplay(g.end_date) : ''}
</span>
${g.notes ? `<span class="rule-note">${g.notes}</span>` : ''}
</div> </div>
<button class="btn-icon btn-danger" onclick="deleteGuarantee('${g.id}')"> <button class="btn-icon btn-danger" onclick="deleteGuarantee('${g.id}')">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> &times;
<polyline points="3 6 5 6 21 6"></polyline>
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"></path>
</svg>
</button> </button>
</div> </div>
`}).join(''); `).join('');
} }
} }
async function loadExclusions() { async function addGuarantee(data) {
const response = await api.get(`/api/managers/${selectedManagerId}/exclusions`); const response = await api.post(`/api/offices/${currentOfficeId}/guarantees`, data);
const container = document.getElementById('exclusionsList');
if (response && response.ok) { if (response && response.ok) {
const exclusions = await response.json(); await loadGuarantees(currentOfficeId);
if (exclusions.length === 0) { document.getElementById('guaranteeModal').style.display = 'none';
container.innerHTML = ''; document.getElementById('guaranteeForm').reset();
return; } else {
} const error = await response.json();
alert(error.detail || 'Impossibile aggiungere la garanzia');
container.innerHTML = exclusions.map(e => {
const dateRange = formatDateRange(e.start_date, e.end_date);
return `
<div class="rule-item">
<div class="rule-info">
<span class="rule-name">${e.user_name}</span>
${dateRange ? `<span class="rule-date-range">${dateRange}</span>` : ''}
</div>
<button class="btn-icon btn-danger" onclick="deleteExclusion('${e.id}')">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polyline points="3 6 5 6 21 6"></polyline>
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"></path>
</svg>
</button>
</div>
`}).join('');
}
}
// Delete functions
async function deleteClosingDay(id) {
if (!confirm('Delete this closing day?')) return;
const response = await api.delete(`/api/managers/${selectedManagerId}/closing-days/${id}`);
if (response && response.ok) {
await loadClosingDays();
} }
} }
async function deleteGuarantee(id) { async function deleteGuarantee(id) {
if (!confirm('Remove this parking guarantee?')) return; if (!confirm('Eliminare questa garanzia?')) return;
const response = await api.delete(`/api/managers/${selectedManagerId}/guarantees/${id}`); const response = await api.delete(`/api/offices/${currentOfficeId}/guarantees/${id}`);
if (response && response.ok) { if (response && response.ok) {
await loadGuarantees(); await loadGuarantees(currentOfficeId);
}
}
// Exclusions
async function loadExclusions(officeId) {
const response = await api.get(`/api/offices/${officeId}/exclusions`);
const container = document.getElementById('exclusionsList');
if (response && response.ok) {
const exclusions = await response.json();
if (exclusions.length === 0) {
container.innerHTML = '<p class="text-muted">Nessuna esclusione attiva.</p>';
return;
}
container.innerHTML = exclusions.map(e => `
<div class="rule-item">
<div class="rule-info">
<strong>${e.user_name || 'Utente sconosciuto'}</strong>
<span class="rule-dates">
${e.start_date ? 'Dal ' + utils.formatDateDisplay(e.start_date) : 'Da sempre'}
${e.end_date ? ' al ' + utils.formatDateDisplay(e.end_date) : ''}
</span>
${e.notes ? `<span class="rule-note">${e.notes}</span>` : ''}
</div>
<button class="btn-icon btn-danger" onclick="deleteExclusion('${e.id}')">
&times;
</button>
</div>
`).join('');
}
}
async function addExclusion(data) {
const response = await api.post(`/api/offices/${currentOfficeId}/exclusions`, data);
if (response && response.ok) {
await loadExclusions(currentOfficeId);
document.getElementById('exclusionModal').style.display = 'none';
document.getElementById('exclusionForm').reset();
} else {
const error = await response.json();
alert(error.detail || 'Impossibile aggiungere l\'esclusione');
} }
} }
async function deleteExclusion(id) { async function deleteExclusion(id) {
if (!confirm('Remove this parking exclusion?')) return; if (!confirm('Eliminare questa esclusione?')) return;
const response = await api.delete(`/api/managers/${selectedManagerId}/exclusions/${id}`); const response = await api.delete(`/api/offices/${currentOfficeId}/exclusions/${id}`);
if (response && response.ok) { if (response && response.ok) {
await loadExclusions(); await loadExclusions(currentOfficeId);
} }
} }
function setupEventListeners() { function populateUserSelects() {
// Manager selection const selects = ['guaranteeUser', 'exclusionUser'];
document.getElementById('managerSelect').addEventListener('change', (e) => { selects.forEach(id => {
selectManager(e.target.value); const select = document.getElementById(id);
}); const currentVal = select.value;
select.innerHTML = '<option value="">Seleziona utente...</option>';
// Weekly closing day checkboxes officeUsers.forEach(user => {
document.querySelectorAll('#weeklyClosingDays input[type="checkbox"]').forEach(cb => { const option = document.createElement('option');
cb.addEventListener('change', async (e) => { option.value = user.id;
const weekday = parseInt(e.target.dataset.weekday); option.textContent = user.name;
select.appendChild(option);
if (e.target.checked) {
// Add weekly closing day
const response = await api.post(`/api/managers/${selectedManagerId}/weekly-closing-days`, { weekday });
if (!response || !response.ok) {
e.target.checked = false;
const error = await response.json();
alert(error.detail || 'Failed to add weekly closing day');
}
} else {
// Remove weekly closing day - need to find the ID first
const getResponse = await api.get(`/api/managers/${selectedManagerId}/weekly-closing-days`);
if (getResponse && getResponse.ok) {
const days = await getResponse.json();
const day = days.find(d => d.weekday === weekday);
if (day) {
const deleteResponse = await api.delete(`/api/managers/${selectedManagerId}/weekly-closing-days/${day.id}`);
if (!deleteResponse || !deleteResponse.ok) {
e.target.checked = true;
}
}
}
}
}); });
});
// Modal openers if (currentVal) select.value = currentVal;
document.getElementById('addClosingDayBtn').addEventListener('click', () => {
document.getElementById('closingDayForm').reset();
document.getElementById('closingDayModal').style.display = 'flex';
}); });
document.getElementById('addGuaranteeBtn').addEventListener('click', () => {
document.getElementById('guaranteeForm').reset();
document.getElementById('guaranteeModal').style.display = 'flex';
});
document.getElementById('addExclusionBtn').addEventListener('click', () => {
document.getElementById('exclusionForm').reset();
document.getElementById('exclusionModal').style.display = 'flex';
});
// Modal closers
['closeClosingDayModal', 'cancelClosingDay'].forEach(id => {
document.getElementById(id).addEventListener('click', () => {
document.getElementById('closingDayModal').style.display = 'none';
});
});
['closeGuaranteeModal', 'cancelGuarantee'].forEach(id => {
document.getElementById(id).addEventListener('click', () => {
document.getElementById('guaranteeModal').style.display = 'none';
});
});
['closeExclusionModal', 'cancelExclusion'].forEach(id => {
document.getElementById(id).addEventListener('click', () => {
document.getElementById('exclusionModal').style.display = 'none';
});
});
// Form submissions
document.getElementById('closingDayForm').addEventListener('submit', async (e) => {
e.preventDefault();
const data = {
date: document.getElementById('closingDate').value,
reason: document.getElementById('closingReason').value || null
};
const response = await api.post(`/api/managers/${selectedManagerId}/closing-days`, data);
if (response && response.ok) {
document.getElementById('closingDayModal').style.display = 'none';
await loadClosingDays();
} else {
const error = await response.json();
alert(error.detail || 'Failed to add closing day');
}
});
document.getElementById('guaranteeForm').addEventListener('submit', async (e) => {
e.preventDefault();
const data = {
user_id: document.getElementById('guaranteeUser').value,
start_date: document.getElementById('guaranteeStartDate').value || null,
end_date: document.getElementById('guaranteeEndDate').value || null
};
const response = await api.post(`/api/managers/${selectedManagerId}/guarantees`, data);
if (response && response.ok) {
document.getElementById('guaranteeModal').style.display = 'none';
await loadGuarantees();
} else {
const error = await response.json();
alert(error.detail || 'Failed to add guarantee');
}
});
document.getElementById('exclusionForm').addEventListener('submit', async (e) => {
e.preventDefault();
const data = {
user_id: document.getElementById('exclusionUser').value,
start_date: document.getElementById('exclusionStartDate').value || null,
end_date: document.getElementById('exclusionEndDate').value || null
};
const response = await api.post(`/api/managers/${selectedManagerId}/exclusions`, data);
if (response && response.ok) {
document.getElementById('exclusionModal').style.display = 'none';
await loadExclusions();
} else {
const error = await response.json();
alert(error.detail || 'Failed to add exclusion');
}
});
// Modal background clicks
utils.setupModalClose('closingDayModal');
utils.setupModalClose('guaranteeModal');
utils.setupModalClose('exclusionModal');
} }
// Make delete functions globally accessible function setupEventListeners() {
// Office select
document.getElementById('officeSelect').addEventListener('change', (e) => {
loadOfficeRules(e.target.value);
});
// Save Weekly closing days
const saveBtn = document.getElementById('saveWeeklyClosingDaysBtn');
if (saveBtn) {
saveBtn.addEventListener('click', saveWeeklyClosingDays);
}
// Modals
const modals = [
{ id: 'closingDayModal', btn: 'addClosingDayBtn', close: 'closeClosingDayModal', cancel: 'cancelClosingDay' },
{ id: 'guaranteeModal', btn: 'addGuaranteeBtn', close: 'closeGuaranteeModal', cancel: 'cancelGuarantee' },
{ id: 'exclusionModal', btn: 'addExclusionBtn', close: 'closeExclusionModal', cancel: 'cancelExclusion' }
];
modals.forEach(m => {
document.getElementById(m.btn).addEventListener('click', () => {
if (m.id !== 'closingDayModal') populateUserSelects();
document.getElementById(m.id).style.display = 'flex';
});
document.getElementById(m.close).addEventListener('click', () => {
document.getElementById(m.id).style.display = 'none';
});
document.getElementById(m.cancel).addEventListener('click', () => {
document.getElementById(m.id).style.display = 'none';
});
utils.setupModalClose(m.id);
});
// Forms
document.getElementById('closingDayForm').addEventListener('submit', (e) => {
e.preventDefault();
addClosingDay({
date: document.getElementById('closingDate').value,
end_date: document.getElementById('closingEndDate').value || null,
reason: document.getElementById('closingReason').value || null
});
});
document.getElementById('guaranteeForm').addEventListener('submit', (e) => {
e.preventDefault();
addGuarantee({
user_id: document.getElementById('guaranteeUser').value,
start_date: document.getElementById('guaranteeStartDate').value || null,
end_date: document.getElementById('guaranteeEndDate').value || null,
notes: document.getElementById('guaranteeNotes').value || null
});
});
document.getElementById('exclusionForm').addEventListener('submit', (e) => {
e.preventDefault();
addExclusion({
user_id: document.getElementById('exclusionUser').value,
start_date: document.getElementById('exclusionStartDate').value || null,
end_date: document.getElementById('exclusionEndDate').value || null,
notes: document.getElementById('exclusionNotes').value || null
});
});
}
// Global functions
window.deleteClosingDay = deleteClosingDay; window.deleteClosingDay = deleteClosingDay;
window.deleteGuarantee = deleteGuarantee; window.deleteGuarantee = deleteGuarantee;
window.deleteExclusion = deleteExclusion; window.deleteExclusion = deleteExclusion;

View File

@@ -97,7 +97,7 @@ function formatDate(date) {
*/ */
function formatDateDisplay(dateStr) { function formatDateDisplay(dateStr) {
const date = new Date(dateStr + 'T12:00:00'); const date = new Date(dateStr + 'T12:00:00');
return date.toLocaleDateString('en-US', { return date.toLocaleDateString('it-IT', {
weekday: 'short', weekday: 'short',
month: 'short', month: 'short',
day: 'numeric' day: 'numeric'
@@ -109,8 +109,8 @@ function formatDateDisplay(dateStr) {
*/ */
function getMonthName(month) { function getMonthName(month) {
const months = [ const months = [
'January', 'February', 'March', 'April', 'May', 'June', 'Gennaio', 'Febbraio', 'Marzo', 'Aprile', 'Maggio', 'Giugno',
'July', 'August', 'September', 'October', 'November', 'December' 'Luglio', 'Agosto', 'Settembre', 'Ottobre', 'Novembre', 'Dicembre'
]; ];
return months[month]; return months[month];
} }
@@ -119,7 +119,7 @@ function getMonthName(month) {
* Get day name * Get day name
*/ */
function getDayName(dayIndex) { function getDayName(dayIndex) {
const days = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']; const days = ['Dom', 'Lun', 'Mar', 'Mer', 'Gio', 'Ven', 'Sab'];
return days[dayIndex]; return days[dayIndex];
} }
@@ -133,7 +133,7 @@ function getDaysInMonth(year, month) {
/** /**
* Get start of week for a date * Get start of week for a date
*/ */
function getWeekStart(date, weekStartDay = 0) { function getWeekStart(date, weekStartDay = 1) {
const d = new Date(date); const d = new Date(date);
const day = d.getDay(); const day = d.getDay();
const diff = (day - weekStartDay + 7) % 7; const diff = (day - weekStartDay + 7) % 7;
@@ -146,7 +146,7 @@ function getWeekStart(date, weekStartDay = 0) {
* Format date as short display (e.g., "Nov 26") * Format date as short display (e.g., "Nov 26")
*/ */
function formatDateShort(date) { function formatDateShort(date) {
return date.toLocaleDateString('en-US', { return date.toLocaleDateString('it-IT', {
month: 'short', month: 'short',
day: 'numeric' day: 'numeric'
}); });
@@ -163,12 +163,14 @@ function showMessage(message, type = 'success', duration = 3000) {
toastContainer.id = 'toastContainer'; toastContainer.id = 'toastContainer';
toastContainer.style.cssText = ` toastContainer.style.cssText = `
position: fixed; position: fixed;
top: 1rem; bottom: 2rem;
right: 1rem; left: 50%;
transform: translateX(-50%);
z-index: 9999; z-index: 9999;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 0.5rem; gap: 1rem;
align-items: center;
`; `;
document.body.appendChild(toastContainer); document.body.appendChild(toastContainer);
} }
@@ -176,17 +178,21 @@ function showMessage(message, type = 'success', duration = 3000) {
const toast = document.createElement('div'); const toast = document.createElement('div');
toast.className = `message ${type}`; toast.className = `message ${type}`;
toast.style.cssText = ` toast.style.cssText = `
padding: 0.75rem 1rem; padding: 1rem 1.5rem;
border-radius: 6px; border-radius: 8px;
box-shadow: 0 2px 8px rgba(0,0,0,0.15); box-shadow: 0 4px 12px rgba(0,0,0,0.2);
animation: slideIn 0.2s ease; animation: slideInBottom 0.3s ease;
font-size: 1.1rem;
font-weight: 500;
min-width: 300px;
text-align: center;
`; `;
toast.textContent = message; toast.textContent = message;
toastContainer.appendChild(toast); toastContainer.appendChild(toast);
if (duration > 0) { if (duration > 0) {
setTimeout(() => { setTimeout(() => {
toast.style.animation = 'slideOut 0.2s ease'; toast.style.animation = 'fadeOut 0.3s ease';
setTimeout(() => toast.remove(), 200); setTimeout(() => toast.remove(), 200);
}, duration); }, duration);
} }
@@ -196,14 +202,7 @@ function showMessage(message, type = 'success', duration = 3000) {
* Close modal when clicking outside * Close modal when clicking outside
*/ */
function setupModalClose(modalId) { function setupModalClose(modalId) {
const modal = document.getElementById(modalId); // Behavior disabled: clicking outside does not close modal
if (modal) {
modal.addEventListener('click', (e) => {
if (e.target.id === modalId) {
modal.style.display = 'none';
}
});
}
} }
// Export utilities // Export utilities

View File

@@ -0,0 +1,113 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Gestione Uffici - Parking Manager</title>
<link rel="icon" type="image/svg+xml" href="/favicon.svg">
<link rel="stylesheet" href="/css/styles.css">
</head>
<body>
<aside class="sidebar">
<div class="sidebar-header">
<h1>Gestione Parcheggi</h1>
</div>
<nav class="sidebar-nav"></nav>
<div class="sidebar-footer">
<div class="user-menu">
<button class="user-button" id="userMenuButton">
<div class="user-avatar">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor"
stroke-width="2">
<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"></path>
<circle cx="12" cy="7" r="4"></circle>
</svg>
</div>
<div class="user-info">
<div class="user-name" id="userName">Caricamento...</div>
<div class="user-role" id="userRole">-</div>
</div>
</button>
<div class="user-dropdown" id="userDropdown" style="display: none;">
<a href="/profile" class="dropdown-item">Profilo</a>
<a href="/settings" class="dropdown-item">Impostazioni</a>
<hr class="dropdown-divider">
<button class="dropdown-item" id="logoutButton">Esci</button>
</div>
</div>
</div>
</aside>
<main class="main-content">
<header class="page-header">
<h2>Gestione Uffici</h2>
<div class="header-actions">
</div>
</header>
<div class="content-wrapper">
<div class="card">
<div style="padding-bottom: 1rem; display: flex; justify-content: space-between; align-items: center;">
<h3 style="margin: 0;">Lista Uffici</h3>
<button class="btn btn-dark" id="addOfficeBtn">Nuovo Ufficio</button>
</div>
<div class="data-table-container">
<table class="data-table" id="officesTable">
<thead>
<tr>
<th>Nome</th>
<th>Quota Posti</th>
<th>Prefisso</th>
<th>Utenti</th>
<th>Azioni</th>
</tr>
</thead>
<tbody id="officesBody"></tbody>
</table>
</div>
</div>
</div>
</main>
<!-- Office Modal -->
<div class="modal" id="officeModal" style="display: none;">
<div class="modal-content">
<div class="modal-header">
<h3 id="officeModalTitle">Nuovo Ufficio</h3>
<button class="modal-close" id="closeOfficeModal">&times;</button>
</div>
<div class="modal-body">
<form id="officeForm">
<input type="hidden" id="officeId">
<div class="form-group">
<label for="officeName">Nome Ufficio</label>
<input type="text" id="officeName" required>
</div>
<div class="form-group">
<label for="officeQuota">Quota Parcheggio</label>
<input type="number" id="officeQuota" min="0" value="0" required>
<small class="text-muted">Numero totale di posti auto assegnati a questo ufficio</small>
</div>
<div class="form-actions">
<button type="button" class="btn btn-secondary" id="cancelOffice">Annulla</button>
<button type="submit" class="btn btn-dark" id="saveOfficeBtn">Salva</button>
</div>
</form>
</div>
</div>
</div>
<script src="/js/api.js"></script>
<script src="/js/utils.js"></script>
<script src="/js/nav.js"></script>
<script src="/js/admin-offices.js"></script>
</body>
</html>

View File

@@ -1,5 +1,6 @@
<!DOCTYPE html> <!DOCTYPE html>
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
@@ -7,31 +8,33 @@
<link rel="icon" type="image/svg+xml" href="/favicon.svg"> <link rel="icon" type="image/svg+xml" href="/favicon.svg">
<link rel="stylesheet" href="/css/styles.css"> <link rel="stylesheet" href="/css/styles.css">
</head> </head>
<body> <body>
<aside class="sidebar"> <aside class="sidebar">
<div class="sidebar-header"> <div class="sidebar-header">
<h1>Parking Manager</h1> <h1>Gestione Parcheggi</h1>
</div> </div>
<nav class="sidebar-nav"></nav> <nav class="sidebar-nav"></nav>
<div class="sidebar-footer"> <div class="sidebar-footer">
<div class="user-menu"> <div class="user-menu">
<button class="user-button" id="userMenuButton"> <button class="user-button" id="userMenuButton">
<div class="user-avatar"> <div class="user-avatar">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor"
stroke-width="2">
<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"></path> <path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"></path>
<circle cx="12" cy="7" r="4"></circle> <circle cx="12" cy="7" r="4"></circle>
</svg> </svg>
</div> </div>
<div class="user-info"> <div class="user-info">
<div class="user-name" id="userName">Loading...</div> <div class="user-name" id="userName">Caricamento...</div>
<div class="user-role" id="userRole">-</div> <div class="user-role" id="userRole">-</div>
</div> </div>
</button> </button>
<div class="user-dropdown" id="userDropdown" style="display: none;"> <div class="user-dropdown" id="userDropdown" style="display: none;">
<a href="/profile" class="dropdown-item">Profile</a> <a href="/profile" class="dropdown-item">Profilo</a>
<a href="/settings" class="dropdown-item">Settings</a> <a href="/settings" class="dropdown-item">Impostazioni</a>
<hr class="dropdown-divider"> <hr class="dropdown-divider">
<button class="dropdown-item" id="logoutButton">Logout</button> <button class="dropdown-item" id="logoutButton">Esci</button>
</div> </div>
</div> </div>
</div> </div>
@@ -39,23 +42,33 @@
<main class="main-content"> <main class="main-content">
<header class="page-header"> <header class="page-header">
<h2>Manage Users</h2> <h2>Gestione Utenti</h2>
<div class="header-actions"> <div class="header-actions">
<input type="text" id="searchInput" class="form-input" placeholder="Search users...">
</div> </div>
</header> </header>
<div class="content-wrapper"> <div class="content-wrapper">
<div class="card"> <div class="card">
<div style="padding-bottom: 1rem; display: flex; justify-content: space-between; align-items: center;">
<h3 style="margin: 0;">Lista Utenti</h3>
<input type="text" id="searchInput" class="form-input" placeholder="Cerca utenti..."
style="max-width: 300px;">
</div>
<div class="data-table-container"> <div class="data-table-container">
<table class="data-table" id="usersTable"> <table class="data-table" id="usersTable">
<thead> <thead>
<tr> <tr>
<th>Name</th> <th class="sortable" data-sort="name" style="cursor: pointer;">Nome <span
<th>Email</th> class="sort-icon"></span></th>
<th>Role</th> <th class="sortable" data-sort="email" style="cursor: pointer;">Email <span
<th>Manager</th> class="sort-icon"></span></th>
<th>Actions</th> <th class="sortable" data-sort="role" style="cursor: pointer;">Ruolo <span
class="sort-icon"></span></th>
<th class="sortable" data-sort="office_name" style="cursor: pointer;">Ufficio <span
class="sort-icon"></span></th>
<th class="sortable" data-sort="parking_ratio" style="cursor: pointer;">Punteggio <span
class="sort-icon"></span></th>
<th>Azioni</th>
</tr> </tr>
</thead> </thead>
<tbody id="usersBody"></tbody> <tbody id="usersBody"></tbody>
@@ -69,7 +82,7 @@
<div class="modal" id="userModal" style="display: none;"> <div class="modal" id="userModal" style="display: none;">
<div class="modal-content"> <div class="modal-content">
<div class="modal-header"> <div class="modal-header">
<h3 id="userModalTitle">Edit User</h3> <h3 id="userModalTitle">Modifica Utente</h3>
<button class="modal-close" id="closeUserModal">&times;</button> <button class="modal-close" id="closeUserModal">&times;</button>
</div> </div>
<div class="modal-body"> <div class="modal-body">
@@ -78,54 +91,42 @@
<!-- LDAP notice --> <!-- LDAP notice -->
<div id="ldapNotice" class="form-notice" style="display: none;"> <div id="ldapNotice" class="form-notice" style="display: none;">
<small>This user is managed by LDAP. Some fields cannot be edited.</small> <small>Questo utente è gestito da LDAP. Alcuni campi non possono essere modificati.</small>
</div> </div>
<div class="form-group"> <div class="form-group">
<label for="editName">Name</label> <label for="editName">Nome</label>
<input type="text" id="editName" required> <input type="text" id="editName" required>
<small id="nameHelp" class="text-muted" style="display: none;">Managed by LDAP</small> <small id="nameHelp" class="text-muted" style="display: none;">Gestito da LDAP</small>
</div> </div>
<div class="form-group"> <div class="form-group">
<label for="editEmail">Email</label> <label for="editEmail">Email</label>
<input type="email" id="editEmail" disabled> <input type="email" id="editEmail" disabled>
</div> </div>
<div class="form-group"> <div class="form-group">
<label for="editRole">Role</label> <label for="editRole">Ruolo</label>
<select id="editRole" required> <select id="editRole" required>
<option value="employee">Employee</option> <option value="employee">Dipendente</option>
<option value="manager">Manager</option> <option value="manager">Manager</option>
<option value="admin">Admin</option> <option value="admin">Admin</option>
</select> </select>
<small id="roleHelp" class="text-muted" style="display: none;">Admin role is managed by LDAP group</small> <small id="roleHelp" class="text-muted" style="display: none;">Il ruolo admin è gestito dal
gruppo LDAP</small>
</div> </div>
<div class="form-group" id="managerGroup"> <div class="form-group" id="officeGroup">
<label for="editManager">Manager</label> <label for="editOffice">Ufficio</label>
<select id="editManager"> <select id="editOffice">
<option value="">No manager</option> <option value="">Nessun ufficio</option>
</select> </select>
<small class="text-muted">Who manages this user</small> <small class="text-muted">Ufficio di appartenenza</small>
</div> </div>
<!-- Manager-specific fields --> <!-- Manager-specific fields -->
<div id="managerFields" style="display: none;">
<hr>
<h4>Manager Settings</h4>
<div class="form-group">
<label for="editQuota">Parking Quota</label>
<input type="number" id="editQuota" min="0" value="0">
<small class="text-muted">Number of parking spots this manager controls</small>
</div>
<div class="form-group">
<label for="editPrefix">Spot Prefix</label>
<input type="text" id="editPrefix" maxlength="2" placeholder="A, B, C...">
<small class="text-muted">Letter prefix for parking spots (e.g., A for spots A1, A2...)</small>
</div>
</div>
<div class="form-actions"> <div class="form-actions">
<button type="button" class="btn btn-secondary" id="cancelUser">Cancel</button> <button type="button" class="btn btn-secondary" id="cancelUser">Annulla</button>
<button type="submit" class="btn btn-dark">Save</button> <button type="submit" class="btn btn-dark">Salva</button>
</div> </div>
</form> </form>
</div> </div>
@@ -137,4 +138,5 @@
<script src="/js/nav.js"></script> <script src="/js/nav.js"></script>
<script src="/js/admin-users.js"></script> <script src="/js/admin-users.js"></script>
</body> </body>
</html>
</html>

View File

@@ -11,13 +11,13 @@
<div class="auth-container"> <div class="auth-container">
<div class="auth-card"> <div class="auth-card">
<div class="auth-header"> <div class="auth-header">
<h1>Parking Manager</h1> <h1>Gestione Parcheggi</h1>
<p>Manage team presence and parking assignments</p> <p>Gestisci la presenza del team e le assegnazioni dei parcheggi</p>
</div> </div>
<div id="authButtons" style="display: flex; flex-direction: column; gap: 1rem;"> <div id="authButtons" style="display: flex; flex-direction: column; gap: 1rem;">
<!-- Buttons will be populated by JavaScript based on auth mode --> <!-- Buttons will be populated by JavaScript based on auth mode -->
<div class="loading">Loading...</div> <div class="loading">Caricamento...</div>
</div> </div>
</div> </div>
</div> </div>
@@ -56,30 +56,30 @@
if (config.login_url) { if (config.login_url) {
// Redirect to Authelia login with return URL // Redirect to Authelia login with return URL
const returnUrl = encodeURIComponent(window.location.origin + '/presence'); const returnUrl = encodeURIComponent(window.location.origin + '/presence');
buttons += `<a href="${config.login_url}?rd=${returnUrl}" class="btn btn-dark btn-full">Sign In</a>`; buttons += `<a href="${config.login_url}?rd=${returnUrl}" class="btn btn-dark btn-full">Accedi</a>`;
} else { } else {
// No login URL configured - just try to access the app (Authelia will intercept) // No login URL configured - just try to access the app (Authelia will intercept)
buttons += `<a href="/presence" class="btn btn-dark btn-full">Sign In</a>`; buttons += `<a href="/presence" class="btn btn-dark btn-full">Accedi</a>`;
} }
if (config.registration_url) { if (config.registration_url) {
buttons += `<a href="${config.registration_url}" class="btn btn-secondary btn-full" target="_blank">Create Account</a>`; buttons += `<a href="${config.registration_url}" class="btn btn-secondary btn-full" target="_blank">Crea Account</a>`;
buttons += `<p class="auth-footer" style="margin-top: 0.5rem; text-align: center; font-size: 0.875rem; color: #666;">Registration requires admin approval</p>`; buttons += `<p class="auth-footer" style="margin-top: 0.5rem; text-align: center; font-size: 0.875rem; color: #666;">La registrazione richiede l'approvazione dell'amministratore</p>`;
} }
buttonsDiv.innerHTML = buttons; buttonsDiv.innerHTML = buttons;
} else { } else {
// Standalone mode: Local login and registration // Standalone mode: Local login and registration
buttonsDiv.innerHTML = ` buttonsDiv.innerHTML = `
<a href="/login" class="btn btn-dark btn-full">Sign In</a> <a href="/login" class="btn btn-dark btn-full">Accedi</a>
<a href="/register" class="btn btn-secondary btn-full">Create Account</a> <a href="/register" class="btn btn-secondary btn-full">Crea Account</a>
`; `;
} }
} catch (e) { } catch (e) {
// Fallback to standalone mode // Fallback to standalone mode
buttonsDiv.innerHTML = ` buttonsDiv.innerHTML = `
<a href="/login" class="btn btn-dark btn-full">Sign In</a> <a href="/login" class="btn btn-dark btn-full">Accedi</a>
<a href="/register" class="btn btn-secondary btn-full">Create Account</a> <a href="/register" class="btn btn-secondary btn-full">Crea Account</a>
`; `;
} }
} }

View File

@@ -1,5 +1,6 @@
<!DOCTYPE html> <!DOCTYPE html>
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
@@ -7,12 +8,13 @@
<link rel="icon" type="image/svg+xml" href="/favicon.svg"> <link rel="icon" type="image/svg+xml" href="/favicon.svg">
<link rel="stylesheet" href="/css/styles.css"> <link rel="stylesheet" href="/css/styles.css">
</head> </head>
<body> <body>
<div class="auth-container"> <div class="auth-container">
<div class="auth-card"> <div class="auth-card">
<div class="auth-header"> <div class="auth-header">
<h1>Welcome Back</h1> <h1>Bentornato</h1>
<p>Sign in to your account</p> <p>Accedi al tuo account</p>
</div> </div>
<div id="errorMessage"></div> <div id="errorMessage"></div>
@@ -26,11 +28,11 @@
<label for="password">Password</label> <label for="password">Password</label>
<input type="password" id="password" required autocomplete="current-password"> <input type="password" id="password" required autocomplete="current-password">
</div> </div>
<button type="submit" class="btn btn-dark btn-full">Sign In</button> <button type="submit" class="btn btn-dark btn-full">Accedi</button>
</form> </form>
<div class="auth-footer"> <div class="auth-footer">
Don't have an account? <a href="/register">Sign up</a> Non hai un account? <a href="/register">Registrati</a>
</div> </div>
</div> </div>
</div> </div>
@@ -80,4 +82,5 @@
}); });
</script> </script>
</body> </body>
</html>
</html>

View File

@@ -0,0 +1,155 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Office Settings - Parking Manager</title>
<link rel="icon" type="image/svg+xml" href="/favicon.svg">
<link rel="stylesheet" href="/css/styles.css">
</head>
<body>
<aside class="sidebar">
<div class="sidebar-header">
<h1>Gestione Parcheggi</h1>
</div>
<nav class="sidebar-nav"></nav>
<div class="sidebar-footer">
<div class="user-menu">
<button class="user-button" id="userMenuButton">
<div class="user-avatar">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor"
stroke-width="2">
<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"></path>
<circle cx="12" cy="7" r="4"></circle>
</svg>
</div>
<div class="user-info">
<div class="user-name" id="userName">Caricamento...</div>
<div class="user-role" id="userRole">-</div>
</div>
</button>
<div class="user-dropdown" id="userDropdown" style="display: none;">
<a href="/profile" class="dropdown-item">Profilo</a>
<a href="/settings" class="dropdown-item">Impostazioni</a>
<hr class="dropdown-divider">
<button class="dropdown-item" id="logoutButton">Esci</button>
</div>
</div>
</div>
</aside>
<main class="main-content">
<header class="page-header">
<h2>Impostazioni Ufficio</h2>
</header>
<div class="content-wrapper">
<!-- Office Selection Card (Admin Only) -->
<div class="card" id="officeSelectionCard" style="margin-bottom: 1.5rem; display: none;">
<div style="display: flex; align-items: center; gap: 1rem;">
<label for="officeSelect" style="font-weight: 500; min-width: max-content;">Seleziona
Ufficio:</label>
<select id="officeSelect" class="form-select" style="flex: 1; max-width: 300px;">
<option value="">Seleziona Ufficio</option>
</select>
</div>
</div>
<div id="settingsContent" style="display: none;">
<!-- Card 1: Batch Scheduling Settings -->
<div class="card">
<div class="card-header">
<h3>Schedulazione Automatica</h3>
</div>
<div class="card-body">
<form id="scheduleForm">
<div class="form-group">
<label class="toggle-label">
<span>Abilita Assegnazione Batch</span>
<label class="toggle-switch">
<input type="checkbox" id="bookingWindowEnabled">
<span class="toggle-slider"></span>
</label>
</label>
<small class="text-muted">Se abilitato, l'assegnazione dei posti avverrà solo dopo
l'orario
di cut-off del giorno precedente.</small>
</div>
<div class="form-group" id="cutoffTimeGroup">
<label>Orario di Cut-off (Giorno Precedente)</label>
<div style="display: flex; gap: 0.5rem; align-items: center;">
<select id="bookingWindowHour" style="width: 80px;">
<!-- Populated by JS -->
</select>
<span>:</span>
<select id="bookingWindowMinute" style="width: 80px;">
<option value="0">00</option>
<option value="15">15</option>
<option value="30">30</option>
<option value="45">45</option>
</select>
</div>
<small class="text-muted">Le presenze inserite prima di questo orario saranno messe in
attesa.</small>
</div>
<div class="form-actions">
<button type="submit" class="btn btn-dark">Salva Impostazioni</button>
</div>
</form>
</div>
</div>
<!-- Card 2: Testing Tools -->
<div class="card">
<div class="card-header"
style="display: flex; justify-content: space-between; align-items: center;">
<h3>Strumenti di Test</h3>
<span class="badge badge-warning">Testing Only</span>
</div>
<div class="card-body">
<p class="text-muted" style="margin-bottom: 1rem;">Usa questi strumenti per verificare il
funzionamento dell'assegnazione automatica.</p>
<div class="form-group">
<label>Range di Date di Test</label>
<div style="display: flex; gap: 1rem;">
<div>
<small>Da:</small>
<input type="date" id="testDateStart" class="form-control" style="width: 160px;">
</div>
<div>
<small>A (incluso):</small>
<input type="date" id="testDateEnd" class="form-control" style="width: 160px;">
</div>
</div>
<small class="text-muted">Lascia "A" vuoto per eseguire su un singolo giorno.</small>
</div>
<div style="display: flex; gap: 1rem; margin-top: 1rem;">
<button id="runAllocationBtn" class="btn btn-primary">
Esegui Assegnazione Ora
</button>
<button id="clearAssignmentsBtn" class="btn btn-danger">
Elimina Tutte le Assegnazioni
</button>
</div>
</div>
</div>
</div> <!-- End settingsContent -->
</div>
</main>
<script src="/js/api.js"></script>
<script src="/js/utils.js"></script>
<script src="/js/nav.js"></script>
<script src="/js/parking-settings.js"></script>
</body>
</html>

View File

@@ -1,5 +1,6 @@
<!DOCTYPE html> <!DOCTYPE html>
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
@@ -7,31 +8,33 @@
<link rel="icon" type="image/svg+xml" href="/favicon.svg"> <link rel="icon" type="image/svg+xml" href="/favicon.svg">
<link rel="stylesheet" href="/css/styles.css"> <link rel="stylesheet" href="/css/styles.css">
</head> </head>
<body> <body>
<aside class="sidebar"> <aside class="sidebar">
<div class="sidebar-header"> <div class="sidebar-header">
<h1>Parking Manager</h1> <h1>Gestione Parcheggi</h1>
</div> </div>
<nav class="sidebar-nav"></nav> <nav class="sidebar-nav"></nav>
<div class="sidebar-footer"> <div class="sidebar-footer">
<div class="user-menu"> <div class="user-menu">
<button class="user-button" id="userMenuButton"> <button class="user-button" id="userMenuButton">
<div class="user-avatar"> <div class="user-avatar">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor"
stroke-width="2">
<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"></path> <path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"></path>
<circle cx="12" cy="7" r="4"></circle> <circle cx="12" cy="7" r="4"></circle>
</svg> </svg>
</div> </div>
<div class="user-info"> <div class="user-info">
<div class="user-name" id="userName">Loading...</div> <div class="user-name" id="userName">Caricamento...</div>
<div class="user-role" id="userRole">-</div> <div class="user-role" id="userRole">-</div>
</div> </div>
</button> </button>
<div class="user-dropdown" id="userDropdown" style="display: none;"> <div class="user-dropdown" id="userDropdown" style="display: none;">
<a href="/profile" class="dropdown-item">Profile</a> <a href="/profile" class="dropdown-item">Profilo</a>
<a href="/settings" class="dropdown-item">Settings</a> <a href="/settings" class="dropdown-item">Impostazioni</a>
<hr class="dropdown-divider"> <hr class="dropdown-divider">
<button class="dropdown-item" id="logoutButton">Logout</button> <button class="dropdown-item" id="logoutButton">Esci</button>
</div> </div>
</div> </div>
</div> </div>
@@ -39,26 +42,36 @@
<main class="main-content"> <main class="main-content">
<header class="page-header"> <header class="page-header">
<h2>My Presence</h2> <h2>Dashboard</h2>
<div class="header-actions"> <div class="header-actions">
<button class="btn btn-secondary" id="bulkMarkBtn">Bulk Mark</button>
</div> </div>
</header> </header>
<div class="content-wrapper"> <div class="content-wrapper">
<div class="card presence-card"> <div class="card presence-card">
<div style="margin-bottom: 1.5rem;">
<h3>Calendario</h3>
</div>
<div class="calendar-header"> <div class="calendar-header">
<button class="btn-icon" id="prevMonth"> <button class="btn-icon" id="prevMonth">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor"
stroke-width="2">
<polyline points="15 18 9 12 15 6"></polyline> <polyline points="15 18 9 12 15 6"></polyline>
</svg> </svg>
</button> </button>
<h3 id="currentMonth">Loading...</h3> <h3 id="currentMonth">Caricamento...</h3>
<button class="btn-icon" id="nextMonth"> <button class="btn-icon" id="nextMonth">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor"
stroke-width="2">
<polyline points="9 18 15 12 9 6"></polyline> <polyline points="9 18 15 12 9 6"></polyline>
</svg> </svg>
</button> </button>
<div style="display: flex; gap: 0.5rem; align-items: center;">
<button class="btn btn-dark btn-sm" id="quickEntryBtn" style="font-size: 0.85rem;">
Inserimento Veloce
</button>
</div>
</div> </div>
<div class="calendar-grid" id="calendarGrid"></div> <div class="calendar-grid" id="calendarGrid"></div>
@@ -66,121 +79,211 @@
<div class="legend"> <div class="legend">
<div class="legend-item"> <div class="legend-item">
<div class="legend-color status-present"></div> <div class="legend-color status-present"></div>
<span>Present (Office)</span> <span>In sede</span>
</div> </div>
<div class="legend-item"> <div class="legend-item">
<div class="legend-color status-remote"></div> <div class="legend-color status-remote"></div>
<span>Remote</span> <span>Remoto</span>
</div> </div>
<div class="legend-item"> <div class="legend-item">
<div class="legend-color status-absent"></div> <div class="legend-color status-absent"></div>
<span>Absent</span> <span>Assente</span>
</div> </div>
</div> </div>
</div> </div>
<!-- Parking Status Section -->
<div class="card" id="parkingStatusCard" style="margin-top: 2rem;">
<div style="margin-bottom: 1.5rem;">
<h3>Stato Parcheggio</h3>
</div>
<!-- Daily View Controls -->
<div id="dailyViewControls">
<!-- Date Navigation (Centered) -->
<div style="display: flex; justify-content: center; margin-bottom: 2rem;">
<div
style="display: flex; align-items: center; gap: 0.5rem; background: white; padding: 0.5rem; border-radius: 8px; border: 1px solid var(--border);">
<button class="btn-icon" id="statusPrevDay"
style="border: none; width: 32px; height: 32px;">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor"
stroke-width="2">
<polyline points="15 18 9 12 15 6"></polyline>
</svg>
</button>
<div style="position: relative; text-align: center; min-width: 200px;">
<div id="statusDateDisplay"
style="font-weight: 600; font-size: 1rem; text-transform: capitalize;"></div>
<input type="date" id="statusDatePicker"
style="position: absolute; inset: 0; opacity: 0; cursor: pointer;">
</div>
<button class="btn-icon" id="statusNextDay"
style="border: none; width: 32px; height: 32px;">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor"
stroke-width="2">
<polyline points="9 18 15 12 9 6"></polyline>
</svg>
</button>
</div>
</div>
<!-- Office Header (No Logo) -->
<!-- Office Header (No Logo) -->
<div
style="display: flex; align-items: center; justify-content: flex-start; gap: 1rem; margin-bottom: 1.5rem;">
<div style="font-weight: 600; font-size: 1.1rem; color: var(--text);">
Ufficio: <span id="currentOfficeDisplay" style="color: var(--primary);">...</span>
</div>
<span class="badge"
style="background: #eff6ff; color: #1d4ed8; border: 1px solid #dbeafe; padding: 0.35rem 0.75rem; font-size: 0.85rem;">
Liberi: <span id="spotsCountBadge">0/0</span>
</span>
</div>
<!-- Spots Grid -->
<div id="spotsGrid" style="display: flex; gap: 1rem; flex-wrap: wrap; margin-bottom: 0;">
<!-- Spots injected here -->
<div style="width: 100%; text-align: center; color: var(--text-secondary); padding: 2rem;">
Caricamento posti...
</div>
</div>
</div>
</div>
<div class="card parking-map-card" style="margin-top: 2rem;">
<h3>Mappa Parcheggio</h3>
<img src="/assets/parking-map.png" alt="Mappa Parcheggio"
style="width: 100%; height: auto; border-radius: 4px; border: 1px solid var(--border);">
</div>
</div> </div>
</main> </main>
<!-- Day Modal --> <!-- Quick Entry Modal -->
<div class="modal" id="dayModal" style="display: none;"> <div class="modal" id="quickEntryModal" style="display: none;">
<div class="modal-content modal-small"> <div class="modal-content modal-small">
<div class="modal-header"> <div class="modal-header">
<h3 id="dayModalTitle">Mark Presence</h3> <h3>Inserimento Veloce</h3>
<button class="modal-close" id="closeDayModal">&times;</button> <button class="modal-close" id="closeQuickEntryModal">&times;</button>
</div> </div>
<div class="modal-body"> <div class="modal-body">
<div class="status-buttons"> <form id="quickEntryForm">
<button class="status-btn" data-status="present"> <div class="form-group">
<div class="status-icon status-present"></div> <label>Range di Date</label>
<span>Present</span> <div style="display: flex; gap: 1rem;">
</button> <div style="flex: 1;">
<button class="status-btn" data-status="remote"> <small>Da:</small>
<div class="status-icon status-remote"></div> <input type="date" id="qeStartDate" class="form-control" required>
<span>Remote</span> </div>
</button> <div style="flex: 1;">
<button class="status-btn" data-status="absent"> <small>A (incluso):</small>
<div class="status-icon status-absent"></div> <input type="date" id="qeEndDate" class="form-control" required>
<span>Absent</span> </div>
</button> </div>
</div>
<div id="parkingSection" style="display: none; margin-top: 1rem; padding-top: 1rem; border-top: 1px solid var(--border);">
<div id="parkingInfo" style="margin-bottom: 0.75rem; font-size: 0.9rem;"></div>
<div style="display: flex; gap: 0.5rem;">
<button class="btn btn-secondary" style="flex: 1;" id="reassignParkingBtn">Reassign</button>
<button class="btn btn-secondary" style="flex: 1;" id="releaseParkingBtn">Release</button>
</div> </div>
</div>
<button class="btn btn-secondary btn-full" id="clearDayBtn" style="margin-top: 1rem;">Clear Presence</button>
</div>
</div>
</div>
<!-- Reassign Parking Modal --> <div class="form-group">
<div class="modal" id="reassignModal" style="display: none;"> <label>Stato da applicare</label>
<div class="modal-content modal-small"> <div class="status-buttons">
<div class="modal-header"> <button type="button" class="status-btn qe-status-btn" data-status="present">
<h3>Reassign Parking Spot</h3> <div class="status-icon status-present"></div>
<button class="modal-close" id="closeReassignModal">&times;</button> <span>In sede</span>
</div> </button>
<div class="modal-body"> <button type="button" class="status-btn qe-status-btn" data-status="remote">
<p id="reassignSpotInfo" style="margin-bottom: 1rem; font-size: 0.9rem;"></p> <div class="status-icon status-remote"></div>
<div class="form-group"> <span>Remoto</span>
<label for="reassignUser">Assign to</label> </button>
<select id="reassignUser" required> <button type="button" class="status-btn qe-status-btn" data-status="absent">
<option value="">Select user...</option> <div class="status-icon status-absent"></div>
</select> <span>Assente</span>
</div> </button>
<div class="form-actions"> <button type="button" class="status-btn qe-status-btn" data-status="clear">
<button type="button" class="btn btn-secondary" id="cancelReassign">Cancel</button> <div class="status-icon"
<button type="button" class="btn btn-dark" id="confirmReassign">Reassign</button> style="border: 2px solid #ef4444; background: #fee2e2; display: flex; align-items: center; justify-content: center;">
</div> <span style="color: #ef4444; font-weight: bold; font-size: 1.2rem;">&times;</span>
</div> </div>
</div> <span>Rimuovi</span>
</div> </button>
</div>
<input type="hidden" id="qeStatus" required>
</div>
<!-- Bulk Mark Modal -->
<div class="modal" id="bulkMarkModal" style="display: none;">
<div class="modal-content">
<div class="modal-header">
<h3>Bulk Mark Presence</h3>
<button class="modal-close" id="closeBulkModal">&times;</button>
</div>
<div class="modal-body">
<form id="bulkMarkForm">
<div class="form-group">
<label for="startDate">Start Date</label>
<input type="date" id="startDate" required>
</div>
<div class="form-group">
<label for="endDate">End Date</label>
<input type="date" id="endDate" required>
</div>
<div class="form-group">
<label for="bulkStatus">Status</label>
<select id="bulkStatus" required>
<option value="present">Present (Office)</option>
<option value="remote">Remote</option>
<option value="absent">Absent</option>
</select>
</div>
<div class="form-group checkbox-group">
<label class="checkbox-label">
<input type="checkbox" id="weekdaysOnly">
<span>Weekdays only (Mon-Fri)</span>
</label>
</div>
<div class="form-actions"> <div class="form-actions">
<button type="button" class="btn btn-secondary" id="cancelBulk">Cancel</button> <button type="button" class="btn btn-secondary" id="cancelQuickEntry">Annulla</button>
<button type="submit" class="btn btn-dark">Mark Dates</button> <button type="submit" class="btn btn-dark">Applica</button>
</div> </div>
</form> </form>
</div> </div>
</div> </div>
</div> </div>
<!-- Day Modal -->
<div class="modal" id="dayModal" style="display: none;">
<div class="modal-content modal-small">
<div class="modal-header">
<h3 id="dayModalTitle">Segna presenza</h3>
<button class="modal-close" id="closeDayModal">&times;</button>
</div>
<div class="modal-body">
<p id="dayModalUser" style="display:none; margin-bottom: 1rem; font-weight: 500;"></p>
<div class="status-buttons">
<button class="status-btn" data-status="present">
<div class="status-icon status-present"></div>
<span>In sede</span>
</button>
<button class="status-btn" data-status="remote">
<div class="status-icon status-remote"></div>
<span>Remoto</span>
</button>
<button class="status-btn" data-status="absent">
<div class="status-icon status-absent"></div>
<span>Assente</span>
</button>
</div>
<button class="btn btn-secondary btn-full" id="clearDayBtn" style="margin-top: 1rem;">Cancella
presenza</button>
<div id="parkingSection"
style="display: none; margin-top: 1rem; padding-top: 1rem; border-top: 1px solid var(--border);">
<div id="parkingInfo" style="margin-bottom: 0.75rem; font-size: 0.9rem;"></div>
<div id="parkingActions" style="display: flex; gap: 0.5rem;">
<button class="btn btn-secondary" style="flex: 1;" id="reassignParkingBtn">Cedi posto</button>
<button class="btn btn-secondary" style="flex: 1;" id="releaseParkingBtn">Lascia libero</button>
</div>
<div id="reassignForm"
style="display: none; flex-direction: column; gap: 0.5rem; margin-top: 0.5rem;">
<div class="form-group" style="margin-bottom: 0.5rem;">
<label for="reassignUser" style="font-size: 0.9rem;">Assegna a</label>
<select id="reassignUser" class="form-control" style="width: 100%;">
<option value="">Seleziona utente...</option>
</select>
</div>
<div style="display: flex; gap: 0.5rem;">
<button type="button" class="btn btn-secondary" style="flex: 1;"
id="cancelReassign">Annulla</button>
<button type="button" class="btn btn-dark" style="flex: 1;"
id="confirmReassign">Riassegna</button>
</div>
</div>
</div>
</div>
</div>
</div>
<script src="/js/api.js"></script> <script src="/js/api.js"></script>
<script src="/js/utils.js"></script> <script src="/js/utils.js"></script>
<script src="/js/nav.js"></script> <script src="/js/nav.js"></script>
<script src="/js/modal-logic.js"></script>
<script src="/js/presence.js"></script> <script src="/js/presence.js"></script>
</body> </body>
</html>
</html>

View File

@@ -1,5 +1,6 @@
<!DOCTYPE html> <!DOCTYPE html>
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
@@ -7,31 +8,33 @@
<link rel="icon" type="image/svg+xml" href="/favicon.svg"> <link rel="icon" type="image/svg+xml" href="/favicon.svg">
<link rel="stylesheet" href="/css/styles.css"> <link rel="stylesheet" href="/css/styles.css">
</head> </head>
<body> <body>
<aside class="sidebar"> <aside class="sidebar">
<div class="sidebar-header"> <div class="sidebar-header">
<h1>Parking Manager</h1> <h1>Gestione Parcheggi</h1>
</div> </div>
<nav class="sidebar-nav"></nav> <nav class="sidebar-nav"></nav>
<div class="sidebar-footer"> <div class="sidebar-footer">
<div class="user-menu"> <div class="user-menu">
<button class="user-button" id="userMenuButton"> <button class="user-button" id="userMenuButton">
<div class="user-avatar"> <div class="user-avatar">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor"
stroke-width="2">
<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"></path> <path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"></path>
<circle cx="12" cy="7" r="4"></circle> <circle cx="12" cy="7" r="4"></circle>
</svg> </svg>
</div> </div>
<div class="user-info"> <div class="user-info">
<div class="user-name" id="userName">Loading...</div> <div class="user-name" id="userName">Caricamento...</div>
<div class="user-role" id="userRole">-</div> <div class="user-role" id="userRole">-</div>
</div> </div>
</button> </button>
<div class="user-dropdown" id="userDropdown" style="display: none;"> <div class="user-dropdown" id="userDropdown" style="display: none;">
<a href="/profile" class="dropdown-item">Profile</a> <a href="/profile" class="dropdown-item">Profilo</a>
<a href="/settings" class="dropdown-item">Settings</a> <a href="/settings" class="dropdown-item">Impostazioni</a>
<hr class="dropdown-divider"> <hr class="dropdown-divider">
<button class="dropdown-item" id="logoutButton">Logout</button> <button class="dropdown-item" id="logoutButton">Esci</button>
</div> </div>
</div> </div>
</div> </div>
@@ -39,43 +42,44 @@
<main class="main-content"> <main class="main-content">
<header class="page-header"> <header class="page-header">
<h2>Profile</h2> <h2>Profilo</h2>
</header> </header>
<div class="content-wrapper"> <div class="content-wrapper">
<div class="card"> <div class="card">
<div class="card-header"> <div class="card-header">
<h3>Personal Information</h3> <h3>Informazioni Personali</h3>
</div> </div>
<div class="card-body"> <div class="card-body">
<!-- LDAP Notice --> <!-- LDAP Notice -->
<div id="ldapNotice" class="form-notice" style="display: none; margin-bottom: 1rem;"> <div id="ldapNotice" class="form-notice" style="display: none; margin-bottom: 1rem;">
<small>Your account is managed by LDAP. Some information cannot be changed here.</small> <small>Il tuo account è gestito da LDAP. Alcune informazioni non possono essere modificate
qui.</small>
</div> </div>
<form id="profileForm"> <form id="profileForm">
<div class="form-group"> <div class="form-group">
<label for="name">Full Name</label> <label for="name">Nome Completo</label>
<input type="text" id="name" required> <input type="text" id="name" required>
<small id="nameHelp" class="text-muted" style="display: none;">Managed by LDAP</small> <small id="nameHelp" class="text-muted" style="display: none;">Gestito da LDAP</small>
</div> </div>
<div class="form-group"> <div class="form-group">
<label for="email">Email</label> <label for="email">Email</label>
<input type="email" id="email" disabled> <input type="email" id="email" disabled>
<small class="text-muted">Email cannot be changed</small> <small class="text-muted">L'email non può essere modificata</small>
</div> </div>
<div class="form-group"> <div class="form-group">
<label for="role">Role</label> <label for="role">Ruolo</label>
<input type="text" id="role" disabled> <input type="text" id="role" disabled>
<small class="text-muted">Role is assigned by your administrator</small> <small class="text-muted">Il ruolo è assegnato dal tuo amministratore</small>
</div> </div>
<div class="form-group"> <div class="form-group">
<label for="manager">Manager</label> <label for="manager">Manager</label>
<input type="text" id="manager" disabled> <input type="text" id="manager" disabled>
<small class="text-muted">Your manager is assigned by the administrator</small> <small class="text-muted">Il tuo manager è assegnato dall'amministratore</small>
</div> </div>
<div class="form-actions" id="profileActions"> <div class="form-actions" id="profileActions">
<button type="submit" class="btn btn-dark">Save Changes</button> <button type="submit" class="btn btn-dark">Salva Modifiche</button>
</div> </div>
</form> </form>
</div> </div>
@@ -84,25 +88,25 @@
<!-- Password section - hidden for LDAP users --> <!-- Password section - hidden for LDAP users -->
<div class="card" id="passwordCard"> <div class="card" id="passwordCard">
<div class="card-header"> <div class="card-header">
<h3>Change Password</h3> <h3>Cambia Password</h3>
</div> </div>
<div class="card-body"> <div class="card-body">
<form id="passwordForm"> <form id="passwordForm">
<div class="form-group"> <div class="form-group">
<label for="currentPassword">Current Password</label> <label for="currentPassword">Password Attuale</label>
<input type="password" id="currentPassword" required> <input type="password" id="currentPassword" required>
</div> </div>
<div class="form-group"> <div class="form-group">
<label for="newPassword">New Password</label> <label for="newPassword">Nuova Password</label>
<input type="password" id="newPassword" required minlength="8"> <input type="password" id="newPassword" required minlength="8">
<small>Minimum 8 characters</small> <small>Minimo 8 caratteri</small>
</div> </div>
<div class="form-group"> <div class="form-group">
<label for="confirmPassword">Confirm New Password</label> <label for="confirmPassword">Conferma Nuova Password</label>
<input type="password" id="confirmPassword" required> <input type="password" id="confirmPassword" required>
</div> </div>
<div class="form-actions"> <div class="form-actions">
<button type="submit" class="btn btn-dark">Change Password</button> <button type="submit" class="btn btn-dark">Cambia Password</button>
</div> </div>
</form> </form>
</div> </div>
@@ -135,7 +139,7 @@
document.getElementById('name').value = profile.name || ''; document.getElementById('name').value = profile.name || '';
document.getElementById('email').value = profile.email; document.getElementById('email').value = profile.email;
document.getElementById('role').value = profile.role; document.getElementById('role').value = profile.role;
document.getElementById('manager').value = profile.manager_name || 'None'; document.getElementById('manager').value = profile.manager_name || 'Nessuno';
// LDAP mode adjustments // LDAP mode adjustments
if (isLdapUser) { if (isLdapUser) {
@@ -154,7 +158,7 @@
e.preventDefault(); e.preventDefault();
if (isLdapUser) { if (isLdapUser) {
utils.showMessage('Profile is managed by LDAP', 'error'); utils.showMessage('Il profilo è gestito da LDAP', 'error');
return; return;
} }
@@ -164,13 +168,13 @@
const response = await api.put('/api/users/me/profile', data); const response = await api.put('/api/users/me/profile', data);
if (response && response.ok) { if (response && response.ok) {
utils.showMessage('Profile updated successfully', 'success'); utils.showMessage('Profilo aggiornato con successo', 'success');
// Update nav display // Update nav display
const nameEl = document.getElementById('userName'); const nameEl = document.getElementById('userName');
if (nameEl) nameEl.textContent = data.name; if (nameEl) nameEl.textContent = data.name;
} else { } else {
const error = await response.json(); const error = await response.json();
utils.showMessage(error.detail || 'Failed to update profile', 'error'); utils.showMessage(error.detail || 'Impossibile aggiornare il profilo', 'error');
} }
}); });
@@ -182,7 +186,7 @@
const confirmPassword = document.getElementById('confirmPassword').value; const confirmPassword = document.getElementById('confirmPassword').value;
if (newPassword !== confirmPassword) { if (newPassword !== confirmPassword) {
utils.showMessage('Passwords do not match', 'error'); utils.showMessage('Le password non corrispondono', 'error');
return; return;
} }
@@ -193,14 +197,15 @@
const response = await api.post('/api/users/me/change-password', data); const response = await api.post('/api/users/me/change-password', data);
if (response && response.ok) { if (response && response.ok) {
utils.showMessage('Password changed successfully', 'success'); utils.showMessage('Password cambiata con successo', 'success');
document.getElementById('passwordForm').reset(); document.getElementById('passwordForm').reset();
} else { } else {
const error = await response.json(); const error = await response.json();
utils.showMessage(error.detail || 'Failed to change password', 'error'); utils.showMessage(error.detail || 'Impossibile cambiare la password', 'error');
} }
}); });
} }
</script> </script>
</body> </body>
</html>
</html>

View File

@@ -1,5 +1,6 @@
<!DOCTYPE html> <!DOCTYPE html>
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
@@ -7,19 +8,20 @@
<link rel="icon" type="image/svg+xml" href="/favicon.svg"> <link rel="icon" type="image/svg+xml" href="/favicon.svg">
<link rel="stylesheet" href="/css/styles.css"> <link rel="stylesheet" href="/css/styles.css">
</head> </head>
<body> <body>
<div class="auth-container"> <div class="auth-container">
<div class="auth-card"> <div class="auth-card">
<div class="auth-header"> <div class="auth-header">
<h1>Create Account</h1> <h1>Crea Account</h1>
<p>Sign up for a new account</p> <p>Registrati per un nuovo account</p>
</div> </div>
<div id="errorMessage"></div> <div id="errorMessage"></div>
<form id="registerForm"> <form id="registerForm">
<div class="form-group"> <div class="form-group">
<label for="name">Full Name</label> <label for="name">Nome Completo</label>
<input type="text" id="name" required autocomplete="name"> <input type="text" id="name" required autocomplete="name">
</div> </div>
<div class="form-group"> <div class="form-group">
@@ -29,13 +31,13 @@
<div class="form-group"> <div class="form-group">
<label for="password">Password</label> <label for="password">Password</label>
<input type="password" id="password" required autocomplete="new-password" minlength="8"> <input type="password" id="password" required autocomplete="new-password" minlength="8">
<small>Minimum 8 characters</small> <small>Minimo 8 caratteri</small>
</div> </div>
<button type="submit" class="btn btn-dark btn-full">Create Account</button> <button type="submit" class="btn btn-dark btn-full">Crea Account</button>
</form> </form>
<div class="auth-footer"> <div class="auth-footer">
Already have an account? <a href="/login">Sign in</a> Hai già un account? <a href="/login">Accedi</a>
</div> </div>
</div> </div>
</div> </div>
@@ -85,4 +87,5 @@
}); });
</script> </script>
</body> </body>
</html>
</html>

View File

@@ -1,5 +1,6 @@
<!DOCTYPE html> <!DOCTYPE html>
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
@@ -7,31 +8,33 @@
<link rel="icon" type="image/svg+xml" href="/favicon.svg"> <link rel="icon" type="image/svg+xml" href="/favicon.svg">
<link rel="stylesheet" href="/css/styles.css"> <link rel="stylesheet" href="/css/styles.css">
</head> </head>
<body> <body>
<aside class="sidebar"> <aside class="sidebar">
<div class="sidebar-header"> <div class="sidebar-header">
<h1>Parking Manager</h1> <h1>Gestione Parcheggi</h1>
</div> </div>
<nav class="sidebar-nav"></nav> <nav class="sidebar-nav"></nav>
<div class="sidebar-footer"> <div class="sidebar-footer">
<div class="user-menu"> <div class="user-menu">
<button class="user-button" id="userMenuButton"> <button class="user-button" id="userMenuButton">
<div class="user-avatar"> <div class="user-avatar">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor"
stroke-width="2">
<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"></path> <path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"></path>
<circle cx="12" cy="7" r="4"></circle> <circle cx="12" cy="7" r="4"></circle>
</svg> </svg>
</div> </div>
<div class="user-info"> <div class="user-info">
<div class="user-name" id="userName">Loading...</div> <div class="user-name" id="userName">Caricamento...</div>
<div class="user-role" id="userRole">-</div> <div class="user-role" id="userRole">-</div>
</div> </div>
</button> </button>
<div class="user-dropdown" id="userDropdown" style="display: none;"> <div class="user-dropdown" id="userDropdown" style="display: none;">
<a href="/profile" class="dropdown-item">Profile</a> <a href="/profile" class="dropdown-item">Profilo</a>
<a href="/settings" class="dropdown-item">Settings</a> <a href="/settings" class="dropdown-item">Impostazioni</a>
<hr class="dropdown-divider"> <hr class="dropdown-divider">
<button class="dropdown-item" id="logoutButton">Logout</button> <button class="dropdown-item" id="logoutButton">Esci</button>
</div> </div>
</div> </div>
</div> </div>
@@ -39,58 +42,41 @@
<main class="main-content"> <main class="main-content">
<header class="page-header"> <header class="page-header">
<h2>Settings</h2> <h2>Impostazioni</h2>
</header> </header>
<div class="content-wrapper"> <div class="content-wrapper">
<div class="card">
<div class="card-header">
<h3>Preferences</h3>
</div>
<div class="card-body">
<form id="settingsForm">
<div class="form-group">
<label for="weekStartDay">Week Starts On</label>
<select id="weekStartDay">
<option value="0">Sunday</option>
<option value="1">Monday</option>
</select>
</div>
<div class="form-actions">
<button type="submit" class="btn btn-dark">Save Settings</button>
</div>
</form>
</div>
</div>
<div class="card"> <div class="card">
<div class="card-header"> <div class="card-header">
<h3>Parking Notifications</h3> <h3>Notifiche Parcheggio</h3>
</div> </div>
<div class="card-body"> <div class="card-body">
<form id="notificationForm"> <form id="notificationForm">
<div class="form-group"> <div class="form-group">
<label class="toggle-label"> <label class="toggle-label">
<span>Weekly Summary</span> <span>Riepilogo Settimanale</span>
<label class="toggle-switch"> <label class="toggle-switch">
<input type="checkbox" id="notifyWeeklyParking"> <input type="checkbox" id="notifyWeeklyParking">
<span class="toggle-slider"></span> <span class="toggle-slider"></span>
</label> </label>
</label> </label>
<small class="text-muted">Receive weekly parking assignments summary every Friday at 12:00</small> <small class="text-muted">Ricevi il riepilogo settimanale delle assegnazioni parcheggio ogni
Venerdì alle 12:00</small>
</div> </div>
<div class="form-group"> <div class="form-group">
<label class="toggle-label"> <label class="toggle-label">
<span>Daily Reminder</span> <span>Promemoria Giornaliero</span>
<label class="toggle-switch"> <label class="toggle-switch">
<input type="checkbox" id="notifyDailyParking"> <input type="checkbox" id="notifyDailyParking">
<span class="toggle-slider"></span> <span class="toggle-slider"></span>
</label> </label>
</label> </label>
<small class="text-muted">Receive daily parking reminder on working days</small> <small class="text-muted">Ricevi promemoria giornaliero nei giorni lavorativi</small>
</div> </div>
<div class="form-group" id="dailyTimeGroup" style="margin-left: 1rem;"> <div class="form-group" id="dailyTimeGroup" style="margin-left: 1rem;">
<label>Reminder Time</label> <label>Orario Promemoria</label>
<div style="display: flex; gap: 0.5rem; align-items: center;"> <div style="display: flex; gap: 0.5rem; align-items: center;">
<select id="notifyDailyHour" style="width: 80px;"> <select id="notifyDailyHour" style="width: 80px;">
<!-- Hours populated by JS --> <!-- Hours populated by JS -->
@@ -106,16 +92,17 @@
</div> </div>
<div class="form-group"> <div class="form-group">
<label class="toggle-label"> <label class="toggle-label">
<span>Assignment Changes</span> <span>Cambiamenti Assegnazione</span>
<label class="toggle-switch"> <label class="toggle-switch">
<input type="checkbox" id="notifyParkingChanges"> <input type="checkbox" id="notifyParkingChanges">
<span class="toggle-slider"></span> <span class="toggle-slider"></span>
</label> </label>
</label> </label>
<small class="text-muted">Receive immediate notifications when your parking assignment changes</small> <small class="text-muted">Ricevi notifiche immediate quando la tua assegnazione del
parcheggio cambia</small>
</div> </div>
<div class="form-actions"> <div class="form-actions">
<button type="submit" class="btn btn-dark">Save Notifications</button> <button type="submit" class="btn btn-dark">Salva Notifiche</button>
</div> </div>
</form> </form>
</div> </div>
@@ -151,7 +138,7 @@
} }
function populateForm() { function populateForm() {
document.getElementById('weekStartDay').value = currentUser.week_start_day || 0; // Notification settings
// Notification settings // Notification settings
document.getElementById('notifyWeeklyParking').checked = currentUser.notify_weekly_parking !== 0; document.getElementById('notifyWeeklyParking').checked = currentUser.notify_weekly_parking !== 0;
@@ -170,22 +157,7 @@
function setupEventListeners() { function setupEventListeners() {
// Settings form // Settings form
document.getElementById('settingsForm').addEventListener('submit', async (e) => {
e.preventDefault();
const data = {
week_start_day: parseInt(document.getElementById('weekStartDay').value)
};
const response = await api.put('/api/users/me/settings', data);
if (response && response.ok) {
utils.showMessage('Settings saved successfully', 'success');
currentUser = await api.getCurrentUser();
} else {
const error = await response.json();
utils.showMessage(error.detail || 'Failed to save settings', 'error');
}
});
// Notification form // Notification form
document.getElementById('notificationForm').addEventListener('submit', async (e) => { document.getElementById('notificationForm').addEventListener('submit', async (e) => {
@@ -201,11 +173,11 @@
const response = await api.put('/api/users/me/settings', data); const response = await api.put('/api/users/me/settings', data);
if (response && response.ok) { if (response && response.ok) {
utils.showMessage('Notification settings saved', 'success'); utils.showMessage('Impostazioni notifiche salvate', 'success');
currentUser = await api.getCurrentUser(); currentUser = await api.getCurrentUser();
} else { } else {
const error = await response.json(); const error = await response.json();
utils.showMessage(error.detail || 'Failed to save notifications', 'error'); utils.showMessage(error.detail || 'Impossibile salvare le notifiche', 'error');
} }
}); });
@@ -214,4 +186,5 @@
} }
</script> </script>
</body> </body>
</html>
</html>

View File

@@ -1,5 +1,6 @@
<!DOCTYPE html> <!DOCTYPE html>
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
@@ -7,31 +8,33 @@
<link rel="icon" type="image/svg+xml" href="/favicon.svg"> <link rel="icon" type="image/svg+xml" href="/favicon.svg">
<link rel="stylesheet" href="/css/styles.css"> <link rel="stylesheet" href="/css/styles.css">
</head> </head>
<body> <body>
<aside class="sidebar"> <aside class="sidebar">
<div class="sidebar-header"> <div class="sidebar-header">
<h1>Parking Manager</h1> <h1>Gestione Parcheggi</h1>
</div> </div>
<nav class="sidebar-nav"></nav> <nav class="sidebar-nav"></nav>
<div class="sidebar-footer"> <div class="sidebar-footer">
<div class="user-menu"> <div class="user-menu">
<button class="user-button" id="userMenuButton"> <button class="user-button" id="userMenuButton">
<div class="user-avatar"> <div class="user-avatar">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor"
stroke-width="2">
<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"></path> <path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"></path>
<circle cx="12" cy="7" r="4"></circle> <circle cx="12" cy="7" r="4"></circle>
</svg> </svg>
</div> </div>
<div class="user-info"> <div class="user-info">
<div class="user-name" id="userName">Loading...</div> <div class="user-name" id="userName">Caricamento...</div>
<div class="user-role" id="userRole">-</div> <div class="user-role" id="userRole">-</div>
</div> </div>
</button> </button>
<div class="user-dropdown" id="userDropdown" style="display: none;"> <div class="user-dropdown" id="userDropdown" style="display: none;">
<a href="/profile" class="dropdown-item">Profile</a> <a href="/profile" class="dropdown-item">Profilo</a>
<a href="/settings" class="dropdown-item">Settings</a> <a href="/settings" class="dropdown-item">Impostazioni</a>
<hr class="dropdown-divider"> <hr class="dropdown-divider">
<button class="dropdown-item" id="logoutButton">Logout</button> <button class="dropdown-item" id="logoutButton">Esci</button>
</div> </div>
</div> </div>
</div> </div>
@@ -39,36 +42,46 @@
<main class="main-content"> <main class="main-content">
<header class="page-header"> <header class="page-header">
<h2>Team Calendar</h2> <h2>Calendario del Team</h2>
<div class="header-actions"> <div class="header-actions">
<select id="viewToggle" class="form-select" style="min-width: 100px;">
<option value="week">Week</option>
<option value="month">Month</option>
</select>
<select id="managerFilter" class="form-select">
<option value="">All Managers</option>
</select>
</div> </div>
</header> </header>
<div class="content-wrapper"> <div class="content-wrapper">
<div class="card"> <div class="card">
<div style="display: flex; gap: 1rem; margin-bottom: 1.5rem;">
<select id="viewToggle" class="form-select" style="min-width: 150px;">
<option value="week">Settimana</option>
<option value="month">Mese</option>
</select>
<select id="officeFilter" class="form-select" style="min-width: 200px;">
<option value="">Tutti gli Uffici</option>
</select>
</div>
<div id="office-display-header"
style="margin-bottom: 1rem; font-weight: 600; font-size: 1.1rem; color: var(--text);">
Ufficio: <span id="currentOfficeNameDisplay" style="color: var(--primary);">Loading...</span>
</div>
<div class="calendar-header"> <div class="calendar-header">
<button class="btn-icon" id="prevWeek"> <button class="btn-icon" id="prevWeek">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor"
stroke-width="2">
<polyline points="15 18 9 12 15 6"></polyline> <polyline points="15 18 9 12 15 6"></polyline>
</svg> </svg>
</button> </button>
<h3 id="currentWeek">Loading...</h3> <h3 id="currentWeek">Caricamento...</h3>
<button class="btn-icon" id="nextWeek"> <button class="btn-icon" id="nextWeek">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor"
stroke-width="2">
<polyline points="9 18 15 12 9 6"></polyline> <polyline points="9 18 15 12 9 6"></polyline>
</svg> </svg>
</button> </button>
</div> </div>
<div class="team-calendar-container"> <div class="team-calendar-container">
<table class="team-calendar-table" id="teamCalendarTable"> <table class="team-calendar team-calendar-table" id="teamCalendarTable">
<thead> <thead>
<tr id="calendarHeader"></tr> <tr id="calendarHeader"></tr>
</thead> </thead>
@@ -79,71 +92,71 @@
<div class="legend"> <div class="legend">
<div class="legend-item"> <div class="legend-item">
<div class="legend-color status-present"></div> <div class="legend-color status-present"></div>
<span>Present</span> <span>In sede</span>
</div> </div>
<div class="legend-item"> <div class="legend-item">
<div class="legend-color status-remote"></div> <div class="legend-color status-remote"></div>
<span>Remote</span> <span>Remoto</span>
</div> </div>
<div class="legend-item"> <div class="legend-item">
<div class="legend-color status-absent"></div> <div class="legend-color status-absent"></div>
<span>Absent</span> <span>Assente</span>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
</main> </main>
<!-- Day Status Modal --> <!-- Day Status Modal (Shared Structure) -->
<div class="modal" id="dayModal" style="display: none;"> <div class="modal" id="dayModal" style="display: none;">
<div class="modal-content modal-small"> <div class="modal-content modal-small">
<div class="modal-header"> <div class="modal-header">
<h3 id="dayModalTitle">Mark Presence</h3> <h3 id="dayModalTitle">Segna Presenza</h3>
<button class="modal-close" id="closeDayModal">&times;</button> <button class="modal-close" id="closeDayModal">&times;</button>
</div> </div>
<div class="modal-body"> <div class="modal-body">
<p id="dayModalUser" style="margin-bottom: 1rem; font-weight: 500;"></p> <p id="dayModalUser" style="display:none; margin-bottom: 1rem; font-weight: 500;"></p>
<div class="status-buttons"> <div class="status-buttons">
<button class="status-btn" data-status="present"> <button class="status-btn" data-status="present">
<div class="status-icon status-present"></div> <div class="status-icon status-present"></div>
<span>Present</span> <span>In sede</span>
</button> </button>
<button class="status-btn" data-status="remote"> <button class="status-btn" data-status="remote">
<div class="status-icon status-remote"></div> <div class="status-icon status-remote"></div>
<span>Remote</span> <span>Remoto</span>
</button> </button>
<button class="status-btn" data-status="absent"> <button class="status-btn" data-status="absent">
<div class="status-icon status-absent"></div> <div class="status-icon status-absent"></div>
<span>Absent</span> <span>Assente</span>
</button> </button>
</div> </div>
<div id="parkingSection" style="display: none; margin-top: 1rem; padding-top: 1rem; border-top: 1px solid var(--border);"> <button class="btn btn-secondary btn-full" id="clearDayBtn" style="margin-top: 1rem;">Cancella
<div id="parkingInfo" style="margin-bottom: 0.75rem; font-size: 0.9rem;"></div> Presenza</button>
<button class="btn btn-secondary btn-full" id="reassignParkingBtn">Reassign Spot</button>
</div>
<button class="btn btn-secondary btn-full" id="clearDayBtn" style="margin-top: 1rem;">Clear Presence</button>
</div>
</div>
</div>
<!-- Reassign Parking Modal --> <div id="parkingSection"
<div class="modal" id="reassignModal" style="display: none;"> style="display: none; margin-top: 1rem; padding-top: 1rem; border-top: 1px solid var(--border);">
<div class="modal-content modal-small"> <div id="parkingInfo" style="margin-bottom: 0.75rem; font-size: 0.9rem;"></div>
<div class="modal-header">
<h3>Reassign Parking Spot</h3> <div id="parkingActions" style="display: flex; gap: 0.5rem;">
<button class="modal-close" id="closeReassignModal">&times;</button> <button class="btn btn-secondary" style="flex: 1;" id="reassignParkingBtn">Cedi posto</button>
</div> <button class="btn btn-secondary" style="flex: 1;" id="releaseParkingBtn">Lascia libero</button>
<div class="modal-body"> </div>
<p id="reassignSpotInfo" style="margin-bottom: 1rem; font-size: 0.9rem;"></p>
<div class="form-group"> <div id="reassignForm"
<label for="reassignUser">Assign to</label> style="display: none; flex-direction: column; gap: 0.5rem; margin-top: 0.5rem;">
<select id="reassignUser" required> <div class="form-group" style="margin-bottom: 0.5rem;">
<option value="">Select user...</option> <label for="reassignUser" style="font-size: 0.9rem;">Assegna a</label>
</select> <select id="reassignUser" class="form-control" style="width: 100%;">
</div> <option value="">Seleziona utente...</option>
<div class="form-actions"> </select>
<button type="button" class="btn btn-secondary" id="cancelReassign">Cancel</button> </div>
<button type="button" class="btn btn-dark" id="confirmReassign">Reassign</button> <div style="display: flex; gap: 0.5rem;">
<button type="button" class="btn btn-secondary" style="flex: 1;"
id="cancelReassign">Annulla</button>
<button type="button" class="btn btn-dark" style="flex: 1;"
id="confirmReassign">Riassegna</button>
</div>
</div>
</div> </div>
</div> </div>
</div> </div>
@@ -152,6 +165,8 @@
<script src="/js/api.js"></script> <script src="/js/api.js"></script>
<script src="/js/utils.js"></script> <script src="/js/utils.js"></script>
<script src="/js/nav.js"></script> <script src="/js/nav.js"></script>
<script src="/js/modal-logic.js"></script>
<script src="/js/team-calendar.js"></script> <script src="/js/team-calendar.js"></script>
</body> </body>
</html>
</html>

View File

@@ -1,37 +1,40 @@
<!DOCTYPE html> <!DOCTYPE html>
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Team Rules - Parking Manager</title> <title>Regole Parcheggio - Parking Manager</title>
<link rel="icon" type="image/svg+xml" href="/favicon.svg"> <link rel="icon" type="image/svg+xml" href="/favicon.svg">
<link rel="stylesheet" href="/css/styles.css"> <link rel="stylesheet" href="/css/styles.css">
</head> </head>
<body> <body>
<aside class="sidebar"> <aside class="sidebar">
<div class="sidebar-header"> <div class="sidebar-header">
<h1>Parking Manager</h1> <h1>Gestione Parcheggi</h1>
</div> </div>
<nav class="sidebar-nav"></nav> <nav class="sidebar-nav"></nav>
<div class="sidebar-footer"> <div class="sidebar-footer">
<div class="user-menu"> <div class="user-menu">
<button class="user-button" id="userMenuButton"> <button class="user-button" id="userMenuButton">
<div class="user-avatar"> <div class="user-avatar">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor"
stroke-width="2">
<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"></path> <path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"></path>
<circle cx="12" cy="7" r="4"></circle> <circle cx="12" cy="7" r="4"></circle>
</svg> </svg>
</div> </div>
<div class="user-info"> <div class="user-info">
<div class="user-name" id="userName">Loading...</div> <div class="user-name" id="userName">Caricamento...</div>
<div class="user-role" id="userRole">-</div> <div class="user-role" id="userRole">-</div>
</div> </div>
</button> </button>
<div class="user-dropdown" id="userDropdown" style="display: none;"> <div class="user-dropdown" id="userDropdown" style="display: none;">
<a href="/profile" class="dropdown-item">Profile</a> <a href="/profile" class="dropdown-item">Profilo</a>
<a href="/settings" class="dropdown-item">Settings</a> <a href="/settings" class="dropdown-item">Impostazioni</a>
<hr class="dropdown-divider"> <hr class="dropdown-divider">
<button class="dropdown-item" id="logoutButton">Logout</button> <button class="dropdown-item" id="logoutButton">Esci</button>
</div> </div>
</div> </div>
</div> </div>
@@ -39,100 +42,120 @@
<main class="main-content"> <main class="main-content">
<header class="page-header"> <header class="page-header">
<h2>Team Rules</h2> <h2>Regole Parcheggio</h2>
<div class="header-actions"> <div class="header-actions">
<select id="managerSelect" class="form-select">
<option value="">Select Manager</option>
</select>
</div> </div>
</header> </header>
<div class="content-wrapper" id="rulesContent" style="display: none;"> <div class="content-wrapper">
<!-- Weekly Closing Days --> <!-- Office Selection Card -->
<div class="card"> <div class="card" id="officeSelectionCard" style="margin-bottom: 1.5rem;">
<div class="card-header"> <div style="display: flex; align-items: center; gap: 1rem;">
<h3>Weekly Closing Days</h3> <label for="officeSelect" style="font-weight: 500; min-width: max-content;">Seleziona
Ufficio:</label>
<select id="officeSelect" class="form-select" style="flex: 1; max-width: 300px;">
<option value="">Seleziona Ufficio</option>
</select>
</div> </div>
<div class="card-body"> </div>
<p class="text-muted" style="margin-bottom: 1rem;">Days of the week parking is unavailable</p>
<div class="weekday-checkboxes" id="weeklyClosingDays"> <div id="rulesContent" style="display: none;">
<label><input type="checkbox" data-weekday="0"> Sunday</label> <!-- Weekly Closing Days -->
<label><input type="checkbox" data-weekday="1"> Monday</label> <div class="card">
<label><input type="checkbox" data-weekday="2"> Tuesday</label> <div class="card-header">
<label><input type="checkbox" data-weekday="3"> Wednesday</label> <h3>Giorni di Chiusura Settimanale</h3>
<label><input type="checkbox" data-weekday="4"> Thursday</label> <button class="btn btn-primary btn-sm" id="saveWeeklyClosingDaysBtn">Salva</button>
<label><input type="checkbox" data-weekday="5"> Friday</label> </div>
<label><input type="checkbox" data-weekday="6"> Saturday</label> <div class="card-body">
<p class="text-muted" style="margin-bottom: 1rem;">Giorni della settimana in cui il parcheggio
non è
disponibile</p>
<div class="weekday-checkboxes" id="weeklyClosingDays">
<label><input type="checkbox" data-weekday="1"> Lunedì</label>
<label><input type="checkbox" data-weekday="2"> Martedì</label>
<label><input type="checkbox" data-weekday="3"> Mercoledì</label>
<label><input type="checkbox" data-weekday="4"> Giovedì</label>
<label><input type="checkbox" data-weekday="5"> Venerdì</label>
<label><input type="checkbox" data-weekday="6"> Sabato</label>
<label><input type="checkbox" data-weekday="0"> Domenica</label>
</div>
</div> </div>
</div> </div>
</div>
<!-- Specific Closing Days --> <!-- Specific Closing Days -->
<div class="card"> <div class="card">
<div class="card-header"> <div class="card-header">
<h3>Specific Closing Days</h3> <h3>Giorni di Chiusura Specifici</h3>
<button class="btn btn-secondary btn-sm" id="addClosingDayBtn">Add</button> <button class="btn btn-secondary btn-sm" id="addClosingDayBtn">Aggiungi</button>
</div>
<div class="card-body">
<p class="text-muted">Date specifiche in cui il parcheggio non è disponibile (festività, ecc.)
</p>
<div id="closingDaysList" class="rules-list"></div>
</div>
</div> </div>
<div class="card-body">
<p class="text-muted">Specific dates when parking is unavailable (holidays, etc.)</p>
<div id="closingDaysList" class="rules-list"></div>
</div>
</div>
<!-- Parking Guarantees --> <!-- Parking Guarantees -->
<div class="card"> <div class="card">
<div class="card-header"> <div class="card-header">
<h3>Parking Guarantees</h3> <h3>Garanzie di Parcheggio</h3>
<button class="btn btn-secondary btn-sm" id="addGuaranteeBtn">Add</button> <button class="btn btn-secondary btn-sm" id="addGuaranteeBtn">Aggiungi</button>
</div>
<div class="card-body">
<p class="text-muted">Utenti a cui è garantito un posto auto quando sono presenti</p>
<div id="guaranteesList" class="rules-list"></div>
</div>
</div> </div>
<div class="card-body">
<p class="text-muted">Users guaranteed a parking spot when present</p>
<div id="guaranteesList" class="rules-list"></div>
</div>
</div>
<!-- Parking Exclusions --> <!-- Parking Exclusions -->
<div class="card"> <div class="card">
<div class="card-header"> <div class="card-header">
<h3>Parking Exclusions</h3> <h3>Esclusioni Parcheggio</h3>
<button class="btn btn-secondary btn-sm" id="addExclusionBtn">Add</button> <button class="btn btn-secondary btn-sm" id="addExclusionBtn">Aggiungi</button>
</div> </div>
<div class="card-body"> <div class="card-body">
<p class="text-muted">Users excluded from parking assignment</p> <p class="text-muted">Utenti esclusi dall'assegnazione del parcheggio</p>
<div id="exclusionsList" class="rules-list"></div> <div id="exclusionsList" class="rules-list"></div>
</div>
</div> </div>
</div> </div>
</div> </div>
<div class="content-wrapper" id="noManagerMessage"> <div id="noOfficeMessage">
<div class="card"> <div class="card">
<div class="card-body text-center"> <div class="card-body text-center">
<p>Select a manager to manage their parking rules</p> <p>Seleziona un ufficio sopra per gestirne le regole di parcheggio</p>
</div> </div>
</div> </div>
</div> </div>
</div>
</main> </main>
<!-- Add Closing Day Modal --> <!-- Add Closing Day Modal -->
<div class="modal" id="closingDayModal" style="display: none;"> <div class="modal" id="closingDayModal" style="display: none;">
<div class="modal-content modal-small"> <div class="modal-content modal-small">
<div class="modal-header"> <div class="modal-header">
<h3>Add Closing Day</h3> <h3>Aggiungi Giorno di Chiusura</h3>
<button class="modal-close" id="closeClosingDayModal">&times;</button> <button class="modal-close" id="closeClosingDayModal">&times;</button>
</div> </div>
<div class="modal-body"> <div class="modal-body">
<form id="closingDayForm"> <form id="closingDayForm">
<div class="form-group"> <div class="form-group">
<label for="closingDate">Date</label> <label for="closingDate">Data Inizio</label>
<input type="date" id="closingDate" required> <input type="date" id="closingDate" required>
</div> </div>
<div class="form-group"> <div class="form-group">
<label for="closingReason">Reason (optional)</label> <label for="closingEndDate">Data Fine (opzionale)</label>
<input type="text" id="closingReason" placeholder="e.g., Company holiday"> <input type="date" id="closingEndDate">
<small>Lascia vuoto per singolo giorno</small>
</div>
<div class="form-group">
<label for="closingReason">Motivo (opzionale)</label>
<input type="text" id="closingReason" placeholder="es. Ferie aziendali">
</div> </div>
<div class="form-actions"> <div class="form-actions">
<button type="button" class="btn btn-secondary" id="cancelClosingDay">Cancel</button> <button type="button" class="btn btn-secondary" id="cancelClosingDay">Annulla</button>
<button type="submit" class="btn btn-dark">Add</button> <button type="submit" class="btn btn-dark">Aggiungi</button>
</div> </div>
</form> </form>
</div> </div>
@@ -143,30 +166,33 @@
<div class="modal" id="guaranteeModal" style="display: none;"> <div class="modal" id="guaranteeModal" style="display: none;">
<div class="modal-content modal-small"> <div class="modal-content modal-small">
<div class="modal-header"> <div class="modal-header">
<h3>Add Parking Guarantee</h3> <h3>Aggiungi Garanzia Parcheggio</h3>
<button class="modal-close" id="closeGuaranteeModal">&times;</button> <button class="modal-close" id="closeGuaranteeModal">&times;</button>
</div> </div>
<div class="modal-body"> <div class="modal-body">
<form id="guaranteeForm"> <form id="guaranteeForm">
<div class="form-group"> <div class="form-group">
<label for="guaranteeUser">User</label> <label for="guaranteeUser">Utente</label>
<select id="guaranteeUser" required> <select id="guaranteeUser" required>
<option value="">Select user...</option> <option value="">Seleziona utente...</option>
</select> </select>
</div> </div>
<div class="form-group"> <div class="form-group">
<label for="guaranteeStartDate">Start Date (optional)</label> <label for="guaranteeStartDate">Data Inizio (opzionale)</label>
<input type="date" id="guaranteeStartDate"> <input type="date" id="guaranteeStartDate">
<small>Leave empty for no start limit</small> <small>Lascia vuoto per nessun limite inziale</small>
</div> </div>
<div class="form-group"> <div class="form-group">
<label for="guaranteeEndDate">End Date (optional)</label>
<input type="date" id="guaranteeEndDate"> <input type="date" id="guaranteeEndDate">
<small>Leave empty for no end limit</small> <small>Lascia vuoto per nessun limite finale</small>
</div>
<div class="form-group">
<label for="guaranteeNotes">Note (opzionale)</label>
<textarea id="guaranteeNotes" class="form-control" rows="2"></textarea>
</div> </div>
<div class="form-actions"> <div class="form-actions">
<button type="button" class="btn btn-secondary" id="cancelGuarantee">Cancel</button> <button type="button" class="btn btn-secondary" id="cancelGuarantee">Annulla</button>
<button type="submit" class="btn btn-dark">Add</button> <button type="submit" class="btn btn-dark">Aggiungi</button>
</div> </div>
</form> </form>
</div> </div>
@@ -177,30 +203,33 @@
<div class="modal" id="exclusionModal" style="display: none;"> <div class="modal" id="exclusionModal" style="display: none;">
<div class="modal-content modal-small"> <div class="modal-content modal-small">
<div class="modal-header"> <div class="modal-header">
<h3>Add Parking Exclusion</h3> <h3>Aggiungi Esclusione Parcheggio</h3>
<button class="modal-close" id="closeExclusionModal">&times;</button> <button class="modal-close" id="closeExclusionModal">&times;</button>
</div> </div>
<div class="modal-body"> <div class="modal-body">
<form id="exclusionForm"> <form id="exclusionForm">
<div class="form-group"> <div class="form-group">
<label for="exclusionUser">User</label> <label for="exclusionUser">Utente</label>
<select id="exclusionUser" required> <select id="exclusionUser" required>
<option value="">Select user...</option> <option value="">Seleziona utente...</option>
</select> </select>
</div> </div>
<div class="form-group"> <div class="form-group">
<label for="exclusionStartDate">Start Date (optional)</label> <label for="exclusionStartDate">Data Inizio (opzionale)</label>
<input type="date" id="exclusionStartDate"> <input type="date" id="exclusionStartDate">
<small>Leave empty for no start limit</small> <small>Lascia vuoto per nessun limite iniziale</small>
</div> </div>
<div class="form-group"> <div class="form-group">
<label for="exclusionEndDate">End Date (optional)</label>
<input type="date" id="exclusionEndDate"> <input type="date" id="exclusionEndDate">
<small>Leave empty for no end limit</small> <small>Lascia vuoto per nessun limite finale</small>
</div>
<div class="form-group">
<label for="exclusionNotes">Note (opzionale)</label>
<textarea id="exclusionNotes" class="form-control" rows="2"></textarea>
</div> </div>
<div class="form-actions"> <div class="form-actions">
<button type="button" class="btn btn-secondary" id="cancelExclusion">Cancel</button> <button type="button" class="btn btn-secondary" id="cancelExclusion">Annulla</button>
<button type="submit" class="btn btn-dark">Add</button> <button type="submit" class="btn btn-dark">Aggiungi</button>
</div> </div>
</form> </form>
</div> </div>
@@ -212,4 +241,5 @@
<script src="/js/nav.js"></script> <script src="/js/nav.js"></script>
<script src="/js/team-rules.js"></script> <script src="/js/team-rules.js"></script>
</body> </body>
</html>
</html>

66
main.py
View File

@@ -1,3 +1,6 @@
from dotenv import load_dotenv
load_dotenv() # Carica le variabili dal file .env
""" """
Parking Manager Application Parking Manager Application
FastAPI + SQLite + Vanilla JS FastAPI + SQLite + Vanilla JS
@@ -10,11 +13,12 @@ from contextlib import asynccontextmanager
from slowapi import Limiter, _rate_limit_exceeded_handler from slowapi import Limiter, _rate_limit_exceeded_handler
from slowapi.util import get_remote_address from slowapi.util import get_remote_address
from slowapi.errors import RateLimitExceeded from slowapi.errors import RateLimitExceeded
from datetime import datetime
from app import config from app import config
from app.routes.auth import router as auth_router from app.routes.auth import router as auth_router
from app.routes.users import router as users_router from app.routes.users import router as users_router
from app.routes.managers import router as managers_router from app.routes.offices import router as offices_router
from app.routes.presence import router as presence_router from app.routes.presence import router as presence_router
from app.routes.parking import router as parking_router from app.routes.parking import router as parking_router
from database.connection import init_db from database.connection import init_db
@@ -26,11 +30,50 @@ limiter = Limiter(key_func=get_remote_address)
@asynccontextmanager @asynccontextmanager
async def lifespan(app: FastAPI): async def lifespan(app: FastAPI):
"""Initialize database on startup""" """Initialize database on startup"""
config.logger.info("Starting Parking Manager application") def log(msg):
config.logger.info(f"[{datetime.now().strftime('%Y-%m-%d %H:%M:%S')}] {msg}")
log("Starting Parking Manager application")
# Caddy Integration Logs
log("--- Caddy Integration & Handshake ---")
# Step 1: Auth / Forward Auth
log("1. Checking Caddy Forward Auth Configuration...")
status = "ENABLED (Authelia)" if config.AUTHELIA_ENABLED else "DISABLED (Internal Auth)"
log(f" - Auth Mode: {status}")
if config.AUTHELIA_ENABLED:
log(" - Configuring Trusted Headers from Caddy:")
log(f" * User: {config.AUTHELIA_HEADER_USER}")
log(f" * Name: {config.AUTHELIA_HEADER_NAME}")
log(f" * Email: {config.AUTHELIA_HEADER_EMAIL}")
log(f" * Groups: {config.AUTHELIA_HEADER_GROUPS}")
else:
log(" - No trusted headers configured (Standalone mode)")
# Step 2: CORS / Origins
log("2. Configuring Caddy CORS / Origins...")
for origin in config.ALLOWED_ORIGINS:
log(f" - Trusted Origin: {origin}")
init_db() init_db()
config.logger.info("Database initialized") log("3. Database Connection: ESTABLISHED")
# Step 3: Network / Reachability
local_url = f"http://{config.HOST}:{config.PORT}"
# Try to find a public URL from allowed origins (excluding localhost/ips)
public_candidates = [o for o in config.ALLOWED_ORIGINS if "localhost" not in o and "127.0.0.1" not in o and not o.startswith("*")]
reachable_url = public_candidates[0] if public_candidates else local_url.replace("0.0.0.0", "localhost")
log("4. Finalizing Caddy Handshake...")
log(f" - Listening on: {config.HOST}:{config.PORT}")
log("--- Handshake Complete ---")
log(f"feedback: App reachable via Caddy at {reachable_url}")
yield yield
config.logger.info("Shutting down Parking Manager application") log("Shutting down Parking Manager application")
app = FastAPI(title="Parking Manager", version="1.0.0", lifespan=lifespan) app = FastAPI(title="Parking Manager", version="1.0.0", lifespan=lifespan)
@@ -51,13 +94,14 @@ app.add_middleware(
# API Routes # API Routes
app.include_router(auth_router) app.include_router(auth_router)
app.include_router(users_router) app.include_router(users_router)
app.include_router(managers_router) app.include_router(offices_router)
app.include_router(presence_router) app.include_router(presence_router)
app.include_router(parking_router) app.include_router(parking_router)
# Static Files # Static Files
app.mount("/css", StaticFiles(directory=str(config.FRONTEND_DIR / "css")), name="css") app.mount("/css", StaticFiles(directory=str(config.FRONTEND_DIR / "css")), name="css")
app.mount("/js", StaticFiles(directory=str(config.FRONTEND_DIR / "js")), name="js") app.mount("/js", StaticFiles(directory=str(config.FRONTEND_DIR / "js")), name="js")
app.mount("/assets", StaticFiles(directory=str(config.FRONTEND_DIR / "assets")), name="assets")
# Page Routes # Page Routes
@@ -109,6 +153,12 @@ async def admin_users_page():
return FileResponse(config.FRONTEND_DIR / "pages" / "admin-users.html") return FileResponse(config.FRONTEND_DIR / "pages" / "admin-users.html")
@app.get("/admin/offices")
async def admin_offices_page():
"""Admin Offices page"""
return FileResponse(config.FRONTEND_DIR / "pages" / "admin-offices.html")
@app.get("/profile") @app.get("/profile")
async def profile_page(): async def profile_page():
"""Profile page""" """Profile page"""
@@ -121,6 +171,12 @@ async def settings_page():
return FileResponse(config.FRONTEND_DIR / "pages" / "settings.html") return FileResponse(config.FRONTEND_DIR / "pages" / "settings.html")
@app.get("/parking-settings")
async def parking_settings_page():
"""Parking Settings page"""
return FileResponse(config.FRONTEND_DIR / "pages" / "parking-settings.html")
@app.get("/favicon.svg") @app.get("/favicon.svg")
async def favicon(): async def favicon():
"""Favicon""" """Favicon"""

View File

@@ -1,7 +1,12 @@
fastapi==0.115.5 fastapi==0.109.2
uvicorn[standard]==0.32.1 uvicorn[standard]==0.27.1
pydantic[email]==2.10.3 sqlalchemy==2.0.27
sqlalchemy==2.0.36 pydantic[email]==2.6.1
python-jose[cryptography]==3.3.0 pydantic-settings==2.2.1
bcrypt==4.2.1 python-dotenv==1.0.1
slowapi==0.1.9 python-jose[cryptography]==3.3.0
bcrypt==4.1.2
slowapi==0.1.9
python-multipart==0.0.9
idna<4,>=2.5
email-validator>=2.1.0.post1

View File

@@ -1,32 +0,0 @@
#!/usr/bin/env python3
"""
Notification Scheduler Script
Run this script via cron every 5 minutes:
*/5 * * * * cd /path/to/app && .venv/bin/python run_notifications.py
This will:
- Send presence reminders on Thursday at 12:00 (repeat daily until compiled)
- Send weekly parking summaries on Friday at 12:00
- Send daily parking reminders at user-configured times
- Process queued parking change notifications
"""
import sys
from database.connection import SessionLocal
from services.notifications import run_scheduled_notifications
def main():
db = SessionLocal()
try:
print("Running scheduled notifications...")
run_scheduled_notifications(db)
print("Done.")
except Exception as e:
print(f"Error: {e}", file=sys.stderr)
sys.exit(1)
finally:
db.close()
if __name__ == "__main__":
main()

View File

@@ -61,17 +61,16 @@ def authenticate_user(db: Session, email: str, password: str) -> User | None:
return user return user
def create_user(db: Session, email: str, password: str, name: str, manager_id: str = None, role: str = "employee") -> User: def create_user(db: Session, email: str, password: str, name: str, role: str = "employee") -> User:
"""Create a new user""" """Create a new user"""
user = User( user = User(
id=str(uuid.uuid4()), id=str(uuid.uuid4()),
email=email, email=email,
password_hash=hash_password(password), password_hash=hash_password(password),
name=name, name=name,
manager_id=manager_id,
role=role, role=role,
created_at=datetime.utcnow().isoformat(), created_at=datetime.utcnow(),
updated_at=datetime.utcnow().isoformat() updated_at=datetime.utcnow()
) )
db.add(user) db.add(user)
db.commit() db.commit()

View File

@@ -6,11 +6,12 @@ Follows org-stack pattern: direct SMTP send with file fallback when disabled.
import smtplib import smtplib
from email.mime.text import MIMEText from email.mime.text import MIMEText
from email.mime.multipart import MIMEMultipart from email.mime.multipart import MIMEMultipart
from datetime import datetime, timedelta from datetime import datetime, timedelta, date
from typing import TYPE_CHECKING from typing import TYPE_CHECKING, List
from app import config from app import config
from utils.helpers import generate_uuid from utils.helpers import generate_uuid
from database.models import NotificationType
if TYPE_CHECKING: if TYPE_CHECKING:
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
@@ -72,35 +73,34 @@ def send_email(to_email: str, subject: str, body_html: str, body_text: str = Non
return False return False
def get_week_dates(reference_date: datetime) -> list[datetime]: def get_week_dates(reference_date: date) -> list[date]:
"""Get Monday-Sunday dates for the week containing reference_date""" """Get Monday-Sunday dates for the week containing reference_date"""
monday = reference_date - timedelta(days=reference_date.weekday()) monday = reference_date - timedelta(days=reference_date.weekday())
return [monday + timedelta(days=i) for i in range(7)] return [monday + timedelta(days=i) for i in range(7)]
def get_next_week_dates(reference_date: datetime) -> list[datetime]: def get_next_week_dates(reference_date: date) -> list[date]:
"""Get Monday-Sunday dates for the week after reference_date""" """Get Monday-Sunday dates for the week after reference_date"""
days_until_next_monday = 7 - reference_date.weekday() days_until_next_monday = 7 - reference_date.weekday()
next_monday = reference_date + timedelta(days=days_until_next_monday) next_monday = reference_date + timedelta(days=days_until_next_monday)
return [next_monday + timedelta(days=i) for i in range(7)] return [next_monday + timedelta(days=i) for i in range(7)]
def get_week_reference(date: datetime) -> str: def get_week_reference(date_obj: date) -> str:
"""Get ISO week reference string (e.g., 2024-W48)""" """Get ISO week reference string (e.g., 2024-W48)"""
return date.strftime("%Y-W%W") return date_obj.strftime("%Y-W%W")
# ============================================================================= # =============================================================================
# Notification sending functions # Notification sending functions
# ============================================================================= # =============================================================================
def notify_parking_assigned(user: "User", date: str, spot_name: str): def notify_parking_assigned(user: "User", assignment_date: date, spot_name: str):
"""Send notification when parking spot is assigned""" """Send notification when parking spot is assigned"""
if not user.notify_parking_changes: if not user.notify_parking_changes:
return return
date_obj = datetime.strptime(date, "%Y-%m-%d") day_name = assignment_date.strftime("%A, %B %d")
day_name = date_obj.strftime("%A, %B %d")
subject = f"Parking spot assigned for {day_name}" subject = f"Parking spot assigned for {day_name}"
body_html = f""" body_html = f"""
@@ -117,13 +117,12 @@ def notify_parking_assigned(user: "User", date: str, spot_name: str):
send_email(user.email, subject, body_html) send_email(user.email, subject, body_html)
def notify_parking_released(user: "User", date: str, spot_name: str): def notify_parking_released(user: "User", assignment_date: date, spot_name: str):
"""Send notification when parking spot is released""" """Send notification when parking spot is released"""
if not user.notify_parking_changes: if not user.notify_parking_changes:
return return
date_obj = datetime.strptime(date, "%Y-%m-%d") day_name = assignment_date.strftime("%A, %B %d")
day_name = date_obj.strftime("%A, %B %d")
subject = f"Parking spot released for {day_name}" subject = f"Parking spot released for {day_name}"
body_html = f""" body_html = f"""
@@ -139,13 +138,12 @@ def notify_parking_released(user: "User", date: str, spot_name: str):
send_email(user.email, subject, body_html) send_email(user.email, subject, body_html)
def notify_parking_reassigned(user: "User", date: str, spot_name: str, new_user_name: str): def notify_parking_reassigned(user: "User", assignment_date: date, spot_name: str, new_user_name: str):
"""Send notification when parking spot is reassigned to someone else""" """Send notification when parking spot is reassigned to someone else"""
if not user.notify_parking_changes: if not user.notify_parking_changes:
return return
date_obj = datetime.strptime(date, "%Y-%m-%d") day_name = assignment_date.strftime("%A, %B %d")
day_name = date_obj.strftime("%A, %B %d")
subject = f"Parking spot reassigned for {day_name}" subject = f"Parking spot reassigned for {day_name}"
body_html = f""" body_html = f"""
@@ -161,29 +159,29 @@ def notify_parking_reassigned(user: "User", date: str, spot_name: str, new_user_
send_email(user.email, subject, body_html) send_email(user.email, subject, body_html)
def send_presence_reminder(user: "User", next_week_dates: list[datetime], db: "Session") -> bool: def send_presence_reminder(user: "User", next_week_dates: List[date], db: "Session") -> bool:
"""Send presence compilation reminder for next week""" """Send presence compilation reminder for next week"""
from database.models import UserPresence, NotificationLog from database.models import UserPresence, NotificationLog
week_ref = get_week_reference(next_week_dates[0]) week_ref = get_week_reference(next_week_dates[0])
# Check if already sent today for this week # Check if already sent today for this week
today = datetime.now().strftime("%Y-%m-%d") today = datetime.now().date()
existing = db.query(NotificationLog).filter( existing = db.query(NotificationLog).filter(
NotificationLog.user_id == user.id, NotificationLog.user_id == user.id,
NotificationLog.notification_type == "presence_reminder", NotificationLog.notification_type == NotificationType.PRESENCE_REMINDER,
NotificationLog.reference_date == week_ref, NotificationLog.reference_date == week_ref,
NotificationLog.sent_at >= today NotificationLog.sent_at >= datetime.combine(today, datetime.min.time())
).first() ).first()
if existing: if existing:
return False return False
# Check if week is compiled (at least 5 days marked) # Check if week is compiled (at least 5 days marked)
date_strs = [d.strftime("%Y-%m-%d") for d in next_week_dates] # DB stores dates as Date objects now
presences = db.query(UserPresence).filter( presences = db.query(UserPresence).filter(
UserPresence.user_id == user.id, UserPresence.user_id == user.id,
UserPresence.date.in_(date_strs) UserPresence.date.in_(next_week_dates)
).all() ).all()
if len(presences) >= 5: if len(presences) >= 5:
@@ -211,9 +209,9 @@ def send_presence_reminder(user: "User", next_week_dates: list[datetime], db: "S
log = NotificationLog( log = NotificationLog(
id=generate_uuid(), id=generate_uuid(),
user_id=user.id, user_id=user.id,
notification_type="presence_reminder", notification_type=NotificationType.PRESENCE_REMINDER,
reference_date=week_ref, reference_date=week_ref,
sent_at=datetime.now().isoformat() sent_at=datetime.utcnow()
) )
db.add(log) db.add(log)
db.commit() db.commit()
@@ -222,7 +220,7 @@ def send_presence_reminder(user: "User", next_week_dates: list[datetime], db: "S
return False return False
def send_weekly_parking_summary(user: "User", next_week_dates: list[datetime], db: "Session") -> bool: def send_weekly_parking_summary(user: "User", next_week_dates: List[date], db: "Session") -> bool:
"""Send weekly parking assignment summary for next week (Friday at 12)""" """Send weekly parking assignment summary for next week (Friday at 12)"""
from database.models import DailyParkingAssignment, NotificationLog from database.models import DailyParkingAssignment, NotificationLog
from services.parking import get_spot_display_name from services.parking import get_spot_display_name
@@ -235,7 +233,7 @@ def send_weekly_parking_summary(user: "User", next_week_dates: list[datetime], d
# Check if already sent for this week # Check if already sent for this week
existing = db.query(NotificationLog).filter( existing = db.query(NotificationLog).filter(
NotificationLog.user_id == user.id, NotificationLog.user_id == user.id,
NotificationLog.notification_type == "weekly_parking", NotificationLog.notification_type == NotificationType.WEEKLY_PARKING,
NotificationLog.reference_date == week_ref NotificationLog.reference_date == week_ref
).first() ).first()
@@ -243,10 +241,9 @@ def send_weekly_parking_summary(user: "User", next_week_dates: list[datetime], d
return False return False
# Get parking assignments for next week # Get parking assignments for next week
date_strs = [d.strftime("%Y-%m-%d") for d in next_week_dates]
assignments = db.query(DailyParkingAssignment).filter( assignments = db.query(DailyParkingAssignment).filter(
DailyParkingAssignment.user_id == user.id, DailyParkingAssignment.user_id == user.id,
DailyParkingAssignment.date.in_(date_strs) DailyParkingAssignment.date.in_(next_week_dates)
).all() ).all()
if not assignments: if not assignments:
@@ -254,11 +251,11 @@ def send_weekly_parking_summary(user: "User", next_week_dates: list[datetime], d
# Build assignment list # Build assignment list
assignment_lines = [] assignment_lines = []
# a.date is now a date object
for a in sorted(assignments, key=lambda x: x.date): for a in sorted(assignments, key=lambda x: x.date):
date_obj = datetime.strptime(a.date, "%Y-%m-%d") day_name = a.date.strftime("%A")
day_name = date_obj.strftime("%A") spot_name = get_spot_display_name(a.spot_id, a.office_id, db)
spot_name = get_spot_display_name(a.spot_id, a.manager_id, db) assignment_lines.append(f"<li>{day_name}, {a.date.strftime('%B %d')}: Spot {spot_name}</li>")
assignment_lines.append(f"<li>{day_name}, {date_obj.strftime('%B %d')}: Spot {spot_name}</li>")
start_date = next_week_dates[0].strftime("%B %d") start_date = next_week_dates[0].strftime("%B %d")
end_date = next_week_dates[-1].strftime("%B %d, %Y") end_date = next_week_dates[-1].strftime("%B %d, %Y")
@@ -283,9 +280,9 @@ def send_weekly_parking_summary(user: "User", next_week_dates: list[datetime], d
log = NotificationLog( log = NotificationLog(
id=generate_uuid(), id=generate_uuid(),
user_id=user.id, user_id=user.id,
notification_type="weekly_parking", notification_type=NotificationType.WEEKLY_PARKING,
reference_date=week_ref, reference_date=week_ref,
sent_at=datetime.now().isoformat() sent_at=datetime.utcnow()
) )
db.add(log) db.add(log)
db.commit() db.commit()
@@ -294,7 +291,7 @@ def send_weekly_parking_summary(user: "User", next_week_dates: list[datetime], d
return False return False
def send_daily_parking_reminder(user: "User", date: datetime, db: "Session") -> bool: def send_daily_parking_reminder(user: "User", date_obj: datetime, db: "Session") -> bool:
"""Send daily parking reminder for a specific date""" """Send daily parking reminder for a specific date"""
from database.models import DailyParkingAssignment, NotificationLog from database.models import DailyParkingAssignment, NotificationLog
from services.parking import get_spot_display_name from services.parking import get_spot_display_name
@@ -302,12 +299,13 @@ def send_daily_parking_reminder(user: "User", date: datetime, db: "Session") ->
if not user.notify_daily_parking: if not user.notify_daily_parking:
return False return False
date_str = date.strftime("%Y-%m-%d") date_str = date_obj.strftime("%Y-%m-%d")
assignment_date = date_obj.date()
# Check if already sent for this date # Check if already sent for this date
existing = db.query(NotificationLog).filter( existing = db.query(NotificationLog).filter(
NotificationLog.user_id == user.id, NotificationLog.user_id == user.id,
NotificationLog.notification_type == "daily_parking", NotificationLog.notification_type == NotificationType.DAILY_PARKING,
NotificationLog.reference_date == date_str NotificationLog.reference_date == date_str
).first() ).first()
@@ -317,14 +315,14 @@ def send_daily_parking_reminder(user: "User", date: datetime, db: "Session") ->
# Get parking assignment for this date # Get parking assignment for this date
assignment = db.query(DailyParkingAssignment).filter( assignment = db.query(DailyParkingAssignment).filter(
DailyParkingAssignment.user_id == user.id, DailyParkingAssignment.user_id == user.id,
DailyParkingAssignment.date == date_str DailyParkingAssignment.date == assignment_date
).first() ).first()
if not assignment: if not assignment:
return False return False
spot_name = get_spot_display_name(assignment.spot_id, assignment.manager_id, db) spot_name = get_spot_display_name(assignment.spot_id, assignment.office_id, db)
day_name = date.strftime("%A, %B %d") day_name = date_obj.strftime("%A, %B %d")
subject = f"Parking reminder for {day_name}" subject = f"Parking reminder for {day_name}"
body_html = f""" body_html = f"""
@@ -343,9 +341,9 @@ def send_daily_parking_reminder(user: "User", date: datetime, db: "Session") ->
log = NotificationLog( log = NotificationLog(
id=generate_uuid(), id=generate_uuid(),
user_id=user.id, user_id=user.id,
notification_type="daily_parking", notification_type=NotificationType.DAILY_PARKING,
reference_date=date_str, reference_date=date_str,
sent_at=datetime.now().isoformat() sent_at=datetime.utcnow()
) )
db.add(log) db.add(log)
db.commit() db.commit()
@@ -369,18 +367,19 @@ def run_scheduled_notifications(db: "Session"):
current_hour = now.hour current_hour = now.hour
current_minute = now.minute current_minute = now.minute
current_weekday = now.weekday() # 0=Monday, 6=Sunday current_weekday = now.weekday() # 0=Monday, 6=Sunday
today_date = now.date()
users = db.query(User).all() users = db.query(User).all()
for user in users: for user in users:
# Thursday at 12: Presence reminder # Thursday at 12: Presence reminder
if current_weekday == 3 and current_hour == 12 and current_minute < 5: if current_weekday == 3 and current_hour == 12 and current_minute < 5:
next_week = get_next_week_dates(now) next_week = get_next_week_dates(today_date)
send_presence_reminder(user, next_week, db) send_presence_reminder(user, next_week, db)
# Friday at 12: Weekly parking summary # Friday at 12: Weekly parking summary
if current_weekday == 4 and current_hour == 12 and current_minute < 5: if current_weekday == 4 and current_hour == 12 and current_minute < 5:
next_week = get_next_week_dates(now) next_week = get_next_week_dates(today_date)
send_weekly_parking_summary(user, next_week, db) send_weekly_parking_summary(user, next_week, db)
# Daily parking reminder at user's preferred time (working days only) # Daily parking reminder at user's preferred time (working days only)

View File

@@ -1,49 +1,48 @@
""" """
Parking Assignment Service Parking Assignment Service
Manager-centric parking spot management with fairness algorithm Office-centric parking spot management with fairness algorithm
Key concepts: Key concepts:
- Managers own parking spots (defined by manager_parking_quota) - Offices own parking spots (defined by Office.parking_quota)
- Each manager has a spot prefix (A, B, C...) for display names - Each office has a spot prefix (A, B, C...) for display names
- Spots are named like A1, A2, B1, B2 based on manager prefix - Spots are named like A1, A2, B1, B2 based on office prefix
- Fairness: users with lowest parking_days/presence_days ratio get priority - Fairness: users with lowest parking_days/presence_days ratio get priority
""" """
from datetime import datetime, timezone from datetime import datetime, date, timezone, timedelta
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from sqlalchemy import or_ from sqlalchemy import or_
from database.models import ( from database.models import (
DailyParkingAssignment, User, UserPresence, DailyParkingAssignment, User, UserPresence, Office,
ParkingGuarantee, ParkingExclusion, ManagerClosingDay, ManagerWeeklyClosingDay ParkingGuarantee, ParkingExclusion, OfficeClosingDay, OfficeWeeklyClosingDay,
UserRole, PresenceStatus
) )
from utils.helpers import generate_uuid from utils.helpers import generate_uuid
from app import config from app import config
def get_spot_prefix(manager: User, db: Session) -> str: def get_spot_prefix(office: Office, db: Session) -> str:
"""Get the spot prefix for a manager (from manager_spot_prefix or auto-assign)""" """Get the spot prefix for an office (from office.spot_prefix or auto-assign)"""
if manager.manager_spot_prefix: if office.spot_prefix:
return manager.manager_spot_prefix return office.spot_prefix
# Auto-assign based on alphabetical order of managers without prefix # Auto-assign based on alphabetical order of offices without prefix
managers = db.query(User).filter( offices = db.query(Office).filter(
User.role == "manager", Office.spot_prefix == None
User.manager_spot_prefix == None ).order_by(Office.name).all()
).order_by(User.name).all()
# Find existing prefixes # Find existing prefixes
existing_prefixes = set( existing_prefixes = set(
m.manager_spot_prefix for m in db.query(User).filter( o.spot_prefix for o in db.query(Office).filter(
User.role == "manager", Office.spot_prefix != None
User.manager_spot_prefix != None
).all() ).all()
) )
# Find first available letter # Find first available letter
manager_index = next((i for i, m in enumerate(managers) if m.id == manager.id), 0) office_index = next((i for i, o in enumerate(offices) if o.id == office.id), 0)
letter = 'A' letter = 'A'
count = 0 count = 0
while letter in existing_prefixes or count < manager_index: while letter in existing_prefixes or count < office_index:
if letter not in existing_prefixes: if letter not in existing_prefixes:
count += 1 count += 1
letter = chr(ord(letter) + 1) letter = chr(ord(letter) + 1)
@@ -54,55 +53,58 @@ def get_spot_prefix(manager: User, db: Session) -> str:
return letter return letter
def get_spot_display_name(spot_id: str, manager_id: str, db: Session) -> str: def get_spot_display_name(spot_id: str, office_id: str, db: Session) -> str:
"""Get display name for a spot (e.g., 'A3' instead of 'spot-3')""" """Get display name for a spot (e.g., 'A3' instead of 'spot-3')"""
manager = db.query(User).filter(User.id == manager_id).first() office = db.query(Office).filter(Office.id == office_id).first()
if not manager: if not office:
return spot_id return spot_id
prefix = get_spot_prefix(manager, db) prefix = get_spot_prefix(office, db)
spot_number = spot_id.replace("spot-", "") spot_number = spot_id.replace("spot-", "")
return f"{prefix}{spot_number}" return f"{prefix}{spot_number}"
def is_closing_day(manager_id: str, date: str, db: Session) -> bool: def is_closing_day(office_id: str, check_date: date, db: Session) -> bool:
""" """
Check if date is a closing day for this manager. Check if date is a closing day for this office.
Checks both specific closing days and weekly recurring closing days. Checks both specific closing days and weekly recurring closing days.
""" """
# Check specific closing day # Check specific closing day (single day or range)
specific = db.query(ManagerClosingDay).filter( specific = db.query(OfficeClosingDay).filter(
ManagerClosingDay.manager_id == manager_id, OfficeClosingDay.office_id == office_id,
ManagerClosingDay.date == date or_(
OfficeClosingDay.date == check_date,
(OfficeClosingDay.end_date != None) & (OfficeClosingDay.date <= check_date) & (OfficeClosingDay.end_date >= check_date)
)
).first() ).first()
if specific: if specific:
return True return True
# Check weekly closing day # Check weekly closing day
date_obj = datetime.strptime(date, "%Y-%m-%d") # Python: 0=Monday, 6=Sunday
weekday = date_obj.weekday() # 0=Monday in Python # DB/API: 0=Sunday, 1=Monday... (Legacy convention)
# Convert to our format: 0=Sunday, 1=Monday, ..., 6=Saturday python_weekday = check_date.weekday()
weekday_sunday_start = (weekday + 1) % 7 db_weekday = (python_weekday + 1) % 7
weekly = db.query(ManagerWeeklyClosingDay).filter( weekly = db.query(OfficeWeeklyClosingDay).filter(
ManagerWeeklyClosingDay.manager_id == manager_id, OfficeWeeklyClosingDay.office_id == office_id,
ManagerWeeklyClosingDay.weekday == weekday_sunday_start OfficeWeeklyClosingDay.weekday == db_weekday
).first() ).first()
return weekly is not None return weekly is not None
def initialize_parking_pool(manager_id: str, quota: int, date: str, db: Session) -> int: def initialize_parking_pool(office_id: str, quota: int, pool_date: date, db: Session) -> int:
"""Initialize empty parking spots for a manager's pool on a given date. """Initialize empty parking spots for an office's pool on a given date.
Returns 0 if it's a closing day (no parking available). Returns 0 if it's a closing day (no parking available).
""" """
# Don't create pool on closing days # Don't create pool on closing days
if is_closing_day(manager_id, date, db): if is_closing_day(office_id, pool_date, db):
return 0 return 0
existing = db.query(DailyParkingAssignment).filter( existing = db.query(DailyParkingAssignment).filter(
DailyParkingAssignment.manager_id == manager_id, DailyParkingAssignment.office_id == office_id,
DailyParkingAssignment.date == date DailyParkingAssignment.date == pool_date
).count() ).count()
if existing > 0: if existing > 0:
@@ -111,20 +113,20 @@ def initialize_parking_pool(manager_id: str, quota: int, date: str, db: Session)
for i in range(1, quota + 1): for i in range(1, quota + 1):
spot = DailyParkingAssignment( spot = DailyParkingAssignment(
id=generate_uuid(), id=generate_uuid(),
date=date, date=pool_date,
spot_id=f"spot-{i}", spot_id=f"spot-{i}",
user_id=None, user_id=None,
manager_id=manager_id, office_id=office_id,
created_at=datetime.now(timezone.utc).isoformat() created_at=datetime.now(timezone.utc)
) )
db.add(spot) db.add(spot)
db.commit() db.commit()
config.logger.debug(f"Initialized {quota} parking spots for manager {manager_id} on {date}") config.logger.debug(f"Initialized {quota} parking spots for office {office_id} on {pool_date}")
return quota return quota
def get_user_parking_ratio(user_id: str, manager_id: str, db: Session) -> float: def get_user_parking_ratio(user_id: str, office_id: str, db: Session) -> float:
""" """
Calculate user's parking ratio: parking_days / presence_days Calculate user's parking ratio: parking_days / presence_days
Lower ratio = higher priority for next parking spot Lower ratio = higher priority for next parking spot
@@ -132,7 +134,7 @@ def get_user_parking_ratio(user_id: str, manager_id: str, db: Session) -> float:
# Count days user was present # Count days user was present
presence_days = db.query(UserPresence).filter( presence_days = db.query(UserPresence).filter(
UserPresence.user_id == user_id, UserPresence.user_id == user_id,
UserPresence.status == "present" UserPresence.status == PresenceStatus.PRESENT
).count() ).count()
if presence_days == 0: if presence_days == 0:
@@ -141,16 +143,16 @@ def get_user_parking_ratio(user_id: str, manager_id: str, db: Session) -> float:
# Count days user got parking # Count days user got parking
parking_days = db.query(DailyParkingAssignment).filter( parking_days = db.query(DailyParkingAssignment).filter(
DailyParkingAssignment.user_id == user_id, DailyParkingAssignment.user_id == user_id,
DailyParkingAssignment.manager_id == manager_id DailyParkingAssignment.office_id == office_id
).count() ).count()
return parking_days / presence_days return parking_days / presence_days
def is_user_excluded(user_id: str, manager_id: str, date: str, db: Session) -> bool: def is_user_excluded(user_id: str, office_id: str, check_date: date, db: Session) -> bool:
"""Check if user is excluded from parking for this date""" """Check if user is excluded from parking for this date"""
exclusion = db.query(ParkingExclusion).filter( exclusion = db.query(ParkingExclusion).filter(
ParkingExclusion.manager_id == manager_id, ParkingExclusion.office_id == office_id,
ParkingExclusion.user_id == user_id ParkingExclusion.user_id == user_id
).first() ).first()
@@ -158,18 +160,18 @@ def is_user_excluded(user_id: str, manager_id: str, date: str, db: Session) -> b
return False return False
# Check date range # Check date range
if exclusion.start_date and date < exclusion.start_date: if exclusion.start_date and check_date < exclusion.start_date:
return False return False
if exclusion.end_date and date > exclusion.end_date: if exclusion.end_date and check_date > exclusion.end_date:
return False return False
return True return True
def has_guarantee(user_id: str, manager_id: str, date: str, db: Session) -> bool: def has_guarantee(user_id: str, office_id: str, check_date: date, db: Session) -> bool:
"""Check if user has a parking guarantee for this date""" """Check if user has a parking guarantee for this date"""
guarantee = db.query(ParkingGuarantee).filter( guarantee = db.query(ParkingGuarantee).filter(
ParkingGuarantee.manager_id == manager_id, ParkingGuarantee.office_id == office_id,
ParkingGuarantee.user_id == user_id ParkingGuarantee.user_id == user_id
).first() ).first()
@@ -177,28 +179,25 @@ def has_guarantee(user_id: str, manager_id: str, date: str, db: Session) -> bool
return False return False
# Check date range # Check date range
if guarantee.start_date and date < guarantee.start_date: if guarantee.start_date and check_date < guarantee.start_date:
return False return False
if guarantee.end_date and date > guarantee.end_date: if guarantee.end_date and check_date > guarantee.end_date:
return False return False
return True return True
def get_users_wanting_parking(manager_id: str, date: str, db: Session) -> list[dict]: def get_users_wanting_parking(office_id: str, pool_date: date, db: Session) -> list[dict]:
""" """
Get all users who want parking for this date, sorted by fairness priority. Get all users who want parking for this date, sorted by fairness priority.
Returns list of {user_id, has_guarantee, ratio} Returns list of {user_id, has_guarantee, ratio}
Note: Manager is part of their own team and can get parking from their pool.
""" """
# Get users who marked "present" for this date: # Get users who marked "present" for this date:
# - Users managed by this manager (User.manager_id == manager_id) # - Users belonging to this office
# - The manager themselves (User.id == manager_id)
present_users = db.query(UserPresence).join(User).filter( present_users = db.query(UserPresence).join(User).filter(
UserPresence.date == date, UserPresence.date == pool_date,
UserPresence.status == "present", UserPresence.status == PresenceStatus.PRESENT,
or_(User.manager_id == manager_id, User.id == manager_id) User.office_id == office_id
).all() ).all()
candidates = [] candidates = []
@@ -206,12 +205,12 @@ def get_users_wanting_parking(manager_id: str, date: str, db: Session) -> list[d
user_id = presence.user_id user_id = presence.user_id
# Skip excluded users # Skip excluded users
if is_user_excluded(user_id, manager_id, date, db): if is_user_excluded(user_id, office_id, pool_date, db):
continue continue
# Skip users who already have a spot # Skip users who already have a spot
existing = db.query(DailyParkingAssignment).filter( existing = db.query(DailyParkingAssignment).filter(
DailyParkingAssignment.date == date, DailyParkingAssignment.date == pool_date,
DailyParkingAssignment.user_id == user_id DailyParkingAssignment.user_id == user_id
).first() ).first()
if existing: if existing:
@@ -219,8 +218,8 @@ def get_users_wanting_parking(manager_id: str, date: str, db: Session) -> list[d
candidates.append({ candidates.append({
"user_id": user_id, "user_id": user_id,
"has_guarantee": has_guarantee(user_id, manager_id, date, db), "has_guarantee": has_guarantee(user_id, office_id, pool_date, db),
"ratio": get_user_parking_ratio(user_id, manager_id, db) "ratio": get_user_parking_ratio(user_id, office_id, db)
}) })
# Sort: guaranteed users first, then by ratio (lowest first for fairness) # Sort: guaranteed users first, then by ratio (lowest first for fairness)
@@ -229,30 +228,30 @@ def get_users_wanting_parking(manager_id: str, date: str, db: Session) -> list[d
return candidates return candidates
def assign_parking_fairly(manager_id: str, date: str, db: Session) -> dict: def assign_parking_fairly(office_id: str, pool_date: date, db: Session) -> dict:
""" """
Assign parking spots fairly based on parking ratio. Assign parking spots fairly based on parking ratio.
Called after presence is set for a date. Called after presence is set for a date.
Returns {assigned: [...], waitlist: [...]} Returns {assigned: [...], waitlist: [...]}
""" """
manager = db.query(User).filter(User.id == manager_id).first() office = db.query(Office).filter(Office.id == office_id).first()
if not manager or not manager.manager_parking_quota: if not office or not office.parking_quota:
return {"assigned": [], "waitlist": []} return {"assigned": [], "waitlist": []}
# No parking on closing days # No parking on closing days
if is_closing_day(manager_id, date, db): if is_closing_day(office_id, pool_date, db):
return {"assigned": [], "waitlist": [], "closed": True} return {"assigned": [], "waitlist": [], "closed": True}
# Initialize pool # Initialize pool
initialize_parking_pool(manager_id, manager.manager_parking_quota, date, db) initialize_parking_pool(office_id, office.parking_quota, pool_date, db)
# Get candidates sorted by fairness # Get candidates sorted by fairness
candidates = get_users_wanting_parking(manager_id, date, db) candidates = get_users_wanting_parking(office_id, pool_date, db)
# Get available spots # Get available spots
free_spots = db.query(DailyParkingAssignment).filter( free_spots = db.query(DailyParkingAssignment).filter(
DailyParkingAssignment.manager_id == manager_id, DailyParkingAssignment.office_id == office_id,
DailyParkingAssignment.date == date, DailyParkingAssignment.date == pool_date,
DailyParkingAssignment.user_id == None DailyParkingAssignment.user_id == None
).all() ).all()
@@ -272,11 +271,11 @@ def assign_parking_fairly(manager_id: str, date: str, db: Session) -> dict:
return {"assigned": assigned, "waitlist": waitlist} return {"assigned": assigned, "waitlist": waitlist}
def release_user_spot(manager_id: str, user_id: str, date: str, db: Session) -> bool: def release_user_spot(office_id: str, user_id: str, pool_date: date, db: Session) -> bool:
"""Release a user's parking spot and reassign to next in fairness queue""" """Release a user's parking spot and reassign to next in fairness queue"""
assignment = db.query(DailyParkingAssignment).filter( assignment = db.query(DailyParkingAssignment).filter(
DailyParkingAssignment.manager_id == manager_id, DailyParkingAssignment.office_id == office_id,
DailyParkingAssignment.date == date, DailyParkingAssignment.date == pool_date,
DailyParkingAssignment.user_id == user_id DailyParkingAssignment.user_id == user_id
).first() ).first()
@@ -288,7 +287,7 @@ def release_user_spot(manager_id: str, user_id: str, date: str, db: Session) ->
db.commit() db.commit()
# Try to assign to next user in fairness queue # Try to assign to next user in fairness queue
candidates = get_users_wanting_parking(manager_id, date, db) candidates = get_users_wanting_parking(office_id, pool_date, db)
if candidates: if candidates:
assignment.user_id = candidates[0]["user_id"] assignment.user_id = candidates[0]["user_id"]
db.commit() db.commit()
@@ -296,29 +295,74 @@ def release_user_spot(manager_id: str, user_id: str, date: str, db: Session) ->
return True return True
def handle_presence_change(user_id: str, date: str, old_status: str, new_status: str, manager_id: str, db: Session): def handle_presence_change(user_id: str, change_date: date, old_status: PresenceStatus, new_status: PresenceStatus, office_id: str, db: Session):
""" """
Handle presence status change and update parking accordingly. Handle presence status change and update parking accordingly.
Uses fairness algorithm for assignment. Uses fairness algorithm for assignment.
manager_id is the user's manager (from User.manager_id).
""" """
# Don't process past dates # Don't process past dates
target_date = datetime.strptime(date, "%Y-%m-%d").date() if change_date < datetime.utcnow().date():
if target_date < datetime.now().date():
return return
# Get manager # Get office (must be valid)
manager = db.query(User).filter(User.id == manager_id, User.role == "manager").first() office = db.query(Office).filter(Office.id == office_id).first()
if not manager or not manager.manager_parking_quota: if not office or not office.parking_quota:
return return
# Initialize pool if needed # Initialize pool if needed
initialize_parking_pool(manager.id, manager.manager_parking_quota, date, db) initialize_parking_pool(office.id, office.parking_quota, change_date, db)
if old_status == "present" and new_status in ["remote", "absent"]: if old_status == PresenceStatus.PRESENT and new_status in [PresenceStatus.REMOTE, PresenceStatus.ABSENT]:
# User no longer coming - release their spot (will auto-reassign) # User no longer coming - release their spot (will auto-reassign)
release_user_spot(manager.id, user_id, date, db) release_user_spot(office.id, user_id, change_date, db)
elif new_status == "present": elif new_status == PresenceStatus.PRESENT:
# User coming in - run fair assignment for this date # Check booking window
assign_parking_fairly(manager.id, date, db) should_assign = True
if office.booking_window_enabled:
# Allocation time is Day-1 at cutoff hour
cutoff_dt = datetime.combine(change_date - timedelta(days=1), datetime.min.time())
cutoff_dt = cutoff_dt.replace(
hour=office.booking_window_end_hour,
minute=office.booking_window_end_minute
)
# If now is before cutoff, do not assign yet (wait for batch job)
if datetime.utcnow() < cutoff_dt:
should_assign = False
config.logger.debug(f"Queuing parking request for user {user_id} on {change_date} (Window open until {cutoff_dt})")
if should_assign:
# User coming in - run fair assignment for this date
assign_parking_fairly(office.id, change_date, db)
def clear_assignments_for_office_date(office_id: str, pool_date: date, db: Session) -> int:
"""
Clear all parking assignments for an office on a specific date.
Returns number of cleared spots.
"""
assignments = db.query(DailyParkingAssignment).filter(
DailyParkingAssignment.office_id == office_id,
DailyParkingAssignment.date == pool_date,
DailyParkingAssignment.user_id != None
).all()
count = len(assignments)
for a in assignments:
a.user_id = None
db.commit()
return count
def run_batch_allocation(office_id: str, pool_date: date, db: Session) -> dict:
"""
Run the batch allocation for a specific date.
Force clears existing assignments to ensure a fair clean-slate allocation.
"""
# 1. Clear existing assignments
clear_assignments_for_office_date(office_id, pool_date, db)
# 2. Run fair allocation
return assign_parking_fairly(office_id, pool_date, db)

View File

@@ -161,17 +161,18 @@ def require_manager_or_admin(user=Depends(get_current_user)):
def check_manager_access_to_user(current_user, target_user, db: Session) -> bool: def check_manager_access_to_user(current_user, target_user, db: Session) -> bool:
""" """
Check if current_user (manager) has access to target_user. Check if current_user (manager) has access to target_user.
Admins always have access. Managers can only access users they manage. Admins always have access. Managers can only access users in their Office.
Returns True if access granted, raises HTTPException if not. Returns True if access granted, raises HTTPException if not.
""" """
if current_user.role == "admin": if current_user.role == "admin":
return True return True
if current_user.role == "manager": if current_user.role == "manager":
if target_user.manager_id != current_user.id: # Access granted if they are in the same office
if not current_user.office_id or target_user.office_id != current_user.office_id:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN, status_code=status.HTTP_403_FORBIDDEN,
detail="User is not managed by you" detail="User is not in your office"
) )
return True return True

View File

@@ -5,6 +5,7 @@ Common helpers used across the application
import uuid import uuid
import re import re
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
from database.models import UserRole
from app import config from app import config
@@ -24,7 +25,7 @@ def is_ldap_user(user: "User") -> bool:
def is_ldap_admin(user: "User") -> bool: def is_ldap_admin(user: "User") -> bool:
"""Check if user is an LDAP-managed admin""" """Check if user is an LDAP-managed admin"""
return is_ldap_user(user) and user.role == "admin" return is_ldap_user(user) and user.role == UserRole.ADMIN
def validate_password(password: str) -> list[str]: def validate_password(password: str) -> list[str]:

28
utils/promote_admins.py Normal file
View File

@@ -0,0 +1,28 @@
import sys
import os
from dotenv import load_dotenv
# Add parent directory to path to allow importing from root
sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
# Load environment variables first
load_dotenv()
from database.connection import get_db_session
from database.models import User, UserRole
def promote_all_users():
print("Promoting all users to ADMIN...")
with get_db_session() as db:
users = db.query(User).all()
count = 0
for user in users:
if user.role != UserRole.ADMIN:
user.role = UserRole.ADMIN
count += 1
db.commit()
print(f"Promoted {count} users to ADMIN.")
if __name__ == "__main__":
promote_all_users()