From 104ad53a9a4f81bca3177299b45e3235766c008b Mon Sep 17 00:00:00 2001 From: StefanoSalemi Date: Fri, 17 Apr 2026 18:27:37 +0200 Subject: [PATCH] feat: aggiunti: loggica random, tema scuro, correzioni mail, miglioramenti generali, cache; --- README.md | 214 +++++++++++++++++++++++++++ app/routes/offices.py | 35 ++++- app/routes/parking.py | 4 + app/routes/presence.py | 14 +- database/models.py | 2 + frontend/css/styles.css | 167 +++++++++++++++------ frontend/js/admin-offices.js | 16 +- frontend/js/admin-users.js | 2 +- frontend/js/nav.js | 56 ++++++- frontend/js/parking-settings.js | 16 +- frontend/js/presence.js | 175 ++++++++++++++++++++-- frontend/js/team-calendar.js | 24 ++- frontend/js/team-rules.js | 3 + frontend/pages/admin-offices.html | 14 +- frontend/pages/admin-users.html | 2 +- frontend/pages/parking-settings.html | 35 +++-- frontend/pages/presence.html | 35 ++++- frontend/pages/profile.html | 4 +- frontend/pages/settings.html | 31 +++- frontend/pages/team-calendar.html | 4 +- frontend/pages/team-rules.html | 3 +- migrate_db.py | 25 ++++ rename_script.py | 26 ++++ services/notifications.py | 51 ++++--- services/parking.py | 71 +++++---- upgrade_db_v1.py | 48 ------ 26 files changed, 861 insertions(+), 216 deletions(-) create mode 100644 migrate_db.py create mode 100644 rename_script.py delete mode 100644 upgrade_db_v1.py diff --git a/README.md b/README.md index e69de29..eb8d8f9 100644 --- a/README.md +++ b/README.md @@ -0,0 +1,214 @@ +# Org-Parking + +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. + +## Funzionalità + +- **Modello Centrato sull'Ufficio**: I posti auto appartengono agli Uffici, non ai Manager o agli Utenti. +- **Algoritmo di Assegnazione Equa**: Gli utenti con il rapporto parcheggi/presenze più basso hanno la priorità. +- **Tracciamento Presenze**: Marcatura delle presenze basata su calendario (presente/remoto/assente). +- **Regole di Chiusura Flessibili**: Supporto per chiusure in date specifiche e chiusure settimanali ricorrenti per ufficio. +- **Garanzie ed Esclusioni**: Regole di parcheggio per utente gestite dagli amministratori dell'ufficio. +- **Accesso Basato sui Ruoli**: + - **Admin**: Controllo completo del sistema, creazione uffici, gestione utenti. + - **Manager**: Gestisce le impostazioni del proprio ufficio e il team. + - **Impiegato**: Segna presenza, visualizza calendario, controlla stato parcheggio. +- **Basso Consumo di Memoria**: Ottimizzato per piccoli server (<500MB RAM). +- **Autenticazione**: Auth JWT integrata o integrazione SSO con Authelia. + +## Architettura + +``` +app/ +├── routes/ # API endpoints +│ ├── auth.py # Autenticazione +│ ├── users.py # Gestione utenti +│ ├── offices.py # Gestione uffici (quote, regole) +│ ├── presence.py # Marcatura presenze +│ └── parking.py # Logica di assegnazione +└── config.py # Configurazione +database/ +├── models.py # Modelli SQLAlchemy ORM +└── connection.py # Setup Database +frontend/ # Frontend Vanilla JS pulito +├── pages/ # Viste HTML +├── js/ # Moduli logici +└── css/ # Stili +``` + +## Guida Rapida + +### Sviluppo Locale + +1. **Setup Ambiente**: + ```bash + python3 -m venv .venv + source .venv/bin/activate + pip install -r requirements.txt + ``` + +2. **Avvio Server**: + ```bash + python main.py + ``` + Accedi a `http://localhost:8000` + +### Deployment Docker (Consigliato) + +Questa applicazione è ottimizzata per ambienti con risorse limitate (es. Raspberry Pi, piccoli VPS). + +1. **Build**: + ```bash + docker compose build + ``` + +2. **Run**: + ```bash + docker compose up -d + ``` + + **Nota sull'Uso della Memoria**: + Il `Dockerfile` è configurato per eseguire `uvicorn` con `--workers 1` per minimizzare l'uso della memoria (~50MB in idle). Se in esecuzione su un server più grande, puoi rimuovere questo flag nel `Dockerfile`. + +## Configurazione + +Copia `.env.example` in `.env` e configura: + +| Variabile | Descrizione | Default | +|-----------|-------------|---------| +| `SECRET_KEY` | **Richiesto**. Chiave cripto per JWT. | `""` (Esce se mancante) | +| `DATABASE_URL` | Vedi docs SQLAlchemy. | `sqlite:///data/parking.db` | +| `AUTHELIA_ENABLED`| Abilita supporto header SSO. | `false` | +| `SMTP_ENABLED` | Abilita notifiche email. | `false` | +| `LOG_LEVEL` | Verbosità log. | `INFO` | + +## Algoritmo di Equità + +I posti auto vengono assegnati giornalmente basandosi su un rapporto di equità: +``` +Rapporto = (Giorni Parcheggiati con successo) / (Giorni di Presenza in ufficio) +``` +- Gli utenti **Garantiti** vengono assegnati per primi. +- I posti **Rimanenti** sono distribuiti agli utenti presenti iniziando da chi ha il rapporto più **basso**. +- Gli utenti **Esclusi** non ricevono mai un posto. + +## API Endpoints + +Di seguito la lista delle chiamate API disponibili suddivise per modulo. + +### Auth (`/api/auth`) +Gestione autenticazione e sessione. + +- `POST /register`: Registrazione nuovo utente (Disabilitato se Authelia è attivo). +- `POST /login`: Login con email e password (ritorna token JWT/cookie). +- `POST /logout`: Logout e invalidazione sessione. +- `GET /me`: Ritorna informazioni sull'utente corrente. +- `GET /config`: Ritorna la configurazione pubblica di autenticazione. +- `GET /holidays/{year}`: Ritorna i giorni festivi per l'anno specificato. + +### Users (`/api/users`) +Gestione utenti e profili. + +- `GET /`: Lista di tutti gli utenti (Solo Admin). +- `POST /`: Crea un nuovo utente (Solo Admin). +- `GET /{user_id}`: Dettaglio di un utente specifico (Solo Admin). +- `PUT /{user_id}`: Aggiorna dati di un utente (Solo Admin). +- `DELETE /{user_id}`: Elimina un utente (Solo Admin). +- `GET /me/profile`: Ottieni il proprio profilo. +- `PUT /me/profile`: Aggiorna il proprio profilo. +- `GET /me/settings`: Ottieni le proprie impostazioni. +- `PUT /me/settings`: Aggiorna le proprie impostazioni. +- `POST /me/change-password`: Modifica la propria password. + +### Offices (`/api/offices`) +Gestione uffici, regole di chiusura e quote. + +- `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`) +Gestione presenze giornaliere. + +- `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. + +### Parking (`/api/parking`) +Gestione assegnazioni posti auto. + +- `POST /init-office-pool`: Inizializza i posti disponibili per un giorno. +- `GET /assignments/{date}`: Lista assegnazioni per una data. +- `GET /my-assignments`: Le mie assegnazioni parcheggio. +- `POST /run-allocation`: Esegui manualmente l'algoritmo di assegnazione per una data. +- `POST /clear-assignments`: Cancella tutte le assegnazioni per una data. +- `POST /manual-assign`: Assegna manualmente un posto a un utente. +- `POST /reassign-spot`: Riassegna o libera un posto già assegnato. +- `POST /release-my-spot/{id}`: Rilascia il proprio posto assegnato. +- `GET /eligible-users/{id}`: Lista utenti idonei a ricevere un posto riassegnato. + +## Utilizzo con AUTHELIA + +Org-Parking supporta l'integrazione nativa con **Authelia** (o altri provider SSO compatibili con Forward Auth). Questo permette il Single Sign-On (SSO) e la gestione centralizzata degli utenti. + +### Configurazione + +1. **Abilita Authelia**: + Nel file `.env`, imposta `AUTHELIA_ENABLED=true`. + +2. **Configura gli Header del Proxy**: + Assicurati che il tuo reverse proxy (es. Traefik, Nginx) passi i seguenti header all'applicazione dopo l'autenticazione: + * `Remote-User`: Username dell'utente (spesso uguale all'email). + * `Remote-Email`: Email dell'utente. + * `Remote-Name`: Nome completo dell'utente (Opzionale). + * `Remote-Groups`: Gruppi di appartenenza (separati da virgola). + +3. **Gestione Admin**: + L'applicazione assegna automaticamente il ruolo di **Admin** agli utenti che appartengono al gruppo specificato nella variabile `AUTHELIA_ADMIN_GROUP` (default: `parking_admins`). + * Se un utente viene rimosso da questo gruppo su Authelia, perderà i privilegi di admin al login successivo. + * Gli altri ruoli (Manager, Employee) e l'assegnazione agli uffici vengono gestiti manualmente all'interno di Org-Parking da un amministratore. + +### Comportamento + +* **Creazione Utenti**: Gli utenti vengono creati automaticamente nel database di Org-Parking al loro primo accesso riuscito tramite Authelia. +* **Login/Logout**: Le pagine di login e registrazione interne vengono disabilitate o reindirizzano al portale SSO. +* **Password**: Org-Parking non gestisce le password in questa modalità; l'autenticazione è interamente delegata al provider esterno. +* **Sicurezza**: L'applicazione si fida degli header `Remote-*` solo se `AUTHELIA_ENABLED` è attivo. Assicurarsi che il container non sia esposto direttamente a internet senza passare per il proxy di autenticazione. + +## Note di Deployment + +- **Database**: SQLite è usato di default per semplicità e basso overhead. I dati persistono in `./data/parking.db`. +- **Sicurezza**: + - Rate limiting è attivo sugli endpoint sensibili (Login/Register). + - Le password sono hashate con Bcrypt. + - L'autenticazione via cookie è sicura di default. + +### Risoluzione Problemi Comuni + +**Errore: "Il reindirizzamento è stato determinato per essere non sicuro"** + +Questo errore appare se si tenta di accedere all'applicazione tramite HTTP (es. `http://lvh.me:8000`) mentre Authelia è su HTTPS. Authelia blocca i reindirizzamenti verso protocolli non sicuri. +**Soluzione**: Accedere all'applicazione tramite il Reverse Proxy (Caddy) usando HTTPS, ad esempio `https://parking.lvh.me`. Assicurarsi che Caddy sia configurato per gestire il dominio e l'autenticazione. + +## Licenza + +MIT diff --git a/app/routes/offices.py b/app/routes/offices.py index 3aa0a63..fac42dd 100644 --- a/app/routes/offices.py +++ b/app/routes/offices.py @@ -29,6 +29,7 @@ class ValidOfficeCreate(BaseModel): booking_window_enabled: bool = True booking_window_end_hour: int = 18 booking_window_end_minute: int = 0 + assignment_mode: str = "fairness" class ClosingDayCreate(BaseModel): @@ -61,6 +62,7 @@ class OfficeSettingsUpdate(BaseModel): booking_window_enabled: bool | None = None booking_window_end_hour: int | None = None booking_window_end_minute: int | None = None + assignment_mode: str | None = None # Helper check @@ -127,6 +129,7 @@ def create_office(data: ValidOfficeCreate, db: Session = Depends(get_db), user=D booking_window_enabled=data.booking_window_enabled, booking_window_end_hour=data.booking_window_end_hour, booking_window_end_minute=data.booking_window_end_minute, + assignment_mode=data.assignment_mode, created_at=datetime.utcnow() ) db.add(office) @@ -157,7 +160,8 @@ def get_office_details(office_id: str, db: Session = Depends(get_db), user=Depen "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 + "booking_window_end_minute": office.booking_window_end_minute, + "assignment_mode": getattr(office, 'assignment_mode', 'fairness') } @@ -194,6 +198,9 @@ def update_office_settings(office_id: str, data: OfficeSettingsUpdate, db: Sessi raise HTTPException(status_code=400, detail="Minute must be 0-59") office.booking_window_end_minute = data.booking_window_end_minute + if data.assignment_mode is not None: + office.assignment_mode = data.assignment_mode + office.updated_at = datetime.utcnow() db.commit() @@ -208,7 +215,8 @@ def update_office_settings(office_id: str, data: OfficeSettingsUpdate, db: Sessi "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 + "booking_window_end_minute": office.booking_window_end_minute, + "assignment_mode": getattr(office, 'assignment_mode', 'fairness') } @router.delete("/{office_id}") @@ -277,6 +285,29 @@ def add_office_closing_day(office_id: str, data: ClosingDayCreate, db: Session = ) db.add(closing_day) db.commit() + + # Clear presence and parking assignments for this new closed period + end_date = data.end_date or data.date + users = db.query(User).filter(User.office_id == office_id).all() + user_ids = [u.id for u in users] + + if user_ids: + # Delete Parking Assignments + db.query(DailyParkingAssignment).filter( + DailyParkingAssignment.user_id.in_(user_ids), + DailyParkingAssignment.date >= data.date, + DailyParkingAssignment.date <= end_date + ).delete(synchronize_session=False) + + # Delete Presence + db.query(UserPresence).filter( + UserPresence.user_id.in_(user_ids), + UserPresence.date >= data.date, + UserPresence.date <= end_date + ).delete(synchronize_session=False) + + db.commit() + return {"id": closing_day.id, "message": "Closing day added"} diff --git a/app/routes/parking.py b/app/routes/parking.py index e4700bd..a71ee51 100644 --- a/app/routes/parking.py +++ b/app/routes/parking.py @@ -30,6 +30,7 @@ from services.parking import ( run_batch_allocation, clear_assignments_for_office_date ) from services.notifications import notify_parking_assigned, notify_parking_released, notify_parking_reassigned +from utils.helpers import generate_uuid from app import config router = APIRouter(prefix="/api/parking", tags=["parking"]) @@ -271,6 +272,9 @@ def manual_assign(data: ManualAssignRequest, db: Session = Depends(get_db), curr db.add(new_assignment) db.commit() + # Send Notification + notify_parking_assigned(user, assign_date, spot_def.name) + return {"message": "Spot assigned", "spot_id": data.spot_id, "spot_display_name": spot_def.name} diff --git a/app/routes/presence.py b/app/routes/presence.py index e989222..14f2460 100644 --- a/app/routes/presence.py +++ b/app/routes/presence.py @@ -268,9 +268,20 @@ def get_team_presences(start_date: date, end_date: date, office_id: str = None, office_lookup = {o.id: o.name for o in offices} # Build response + from services.parking import get_user_parking_ratio result = [] for user in users: user_presences = [p for p in presences if p.user_id == user.id] + + office_mode = "fairness" + if user.office_id: + office = next((o for o in offices if o.id == user.office_id), None) + if office: + office_mode = office.assignment_mode or "fairness" + + user_ratio = None + if office_mode == "fairness" and user.office_id: + user_ratio = get_user_parking_ratio(user.id, user.office_id, db) result.append({ "id": user.id, @@ -279,7 +290,8 @@ def get_team_presences(start_date: date, end_date: date, office_id: str = None, "office_name": office_lookup.get(user.office_id), "presences": [{"date": p.date, "status": p.status} for p in user_presences], "parking_dates": parking_lookup.get(user.id, []), - "parking_info": parking_info_lookup.get(user.id, []) + "parking_info": parking_info_lookup.get(user.id, []), + "ratio": user_ratio }) return result diff --git a/database/models.py b/database/models.py index 8eae5cb..27b0eb5 100644 --- a/database/models.py +++ b/database/models.py @@ -61,6 +61,8 @@ class Office(Base): 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 + + assignment_mode = Column(String, default="fairness") created_at = Column(DateTime, default=datetime.utcnow) updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) diff --git a/frontend/css/styles.css b/frontend/css/styles.css index 421d002..86f5deb 100644 --- a/frontend/css/styles.css +++ b/frontend/css/styles.css @@ -16,6 +16,8 @@ --warning-bg: #fde68a; --danger: #dc2626; --danger-bg: #fee2e2; + --info: #3b82f6; + --info-bg: #dbeafe; --text: #1f1f1f; --text-secondary: #666; --text-muted: #999; @@ -25,6 +27,49 @@ --bg-white: #fff; --sidebar-width: 260px; --header-height: 64px; + --bg-weekend: #f5f5f5; + --bg-holiday: #fff7ed; + --bg-closed: #e5e7eb; + --text-closed: #9ca3af; + --border-closed: #d1d5db; + --spot-free-bg: #f0fdf4; + --spot-free-border: #22c55e; + --spot-free-text: #15803d; + --spot-occ-bg: #fefce8; + --spot-occ-border: #eab308; + --spot-occ-text: #a16207; +} + +[data-theme='dark'] { + --primary: #60a5fa; + --primary-hover: #93c5fd; + --secondary: #9ca3af; + --success: #22c55e; + --success-bg: #064e3b; + --warning: #fbbf24; + --warning-bg: #78350f; + --danger: #ef4444; + --danger-bg: #7f1d1d; + --info: #60a5fa; + --info-bg: #1e3a8a; + --text: #f3f4f6; + --text-secondary: #9ca3af; + --text-muted: #6b7280; + --border: #374151; + --border-dark: #4b5563; + --bg: #111827; + --bg-white: #1f2937; + --bg-weekend: #111827; /* Dark background for weekend */ + --bg-holiday: #451a03; /* Dark brown/orange for holiday */ + --bg-closed: #374151; /* Gray-700 for closed */ + --text-closed: #6b7280; /* Gray-500 for closed date text */ + --border-closed: #4b5563; /* Gray-600 for closed border */ + --spot-free-bg: #064e3b; + --spot-free-border: #059669; + --spot-free-text: #4ade80; + --spot-occ-bg: #422006; + --spot-occ-border: #ca8a04; + --spot-occ-text: #fde047; } /* ============================================================================ @@ -46,6 +91,31 @@ body { min-height: 100vh; } +/* ============================================================================ + Dark mode global overrides for native browser elements + ============================================================================ */ +[data-theme='dark'] input, +[data-theme='dark'] select, +[data-theme='dark'] textarea { + background: var(--bg-white); + color: var(--text); + border-color: var(--border-dark); + color-scheme: dark; +} + +[data-theme='dark'] input[type="date"]::-webkit-calendar-picker-indicator { + filter: invert(1); +} + +[data-theme='dark'] input::placeholder, +[data-theme='dark'] textarea::placeholder { + color: var(--text-muted); +} + +[data-theme='dark'] .user-button:hover { + background: var(--bg); +} + a { color: inherit; text-decoration: none; @@ -72,7 +142,7 @@ textarea { top: 0; bottom: 0; width: var(--sidebar-width); - background: white; + background: var(--bg-white); color: var(--text); border-right: 1px solid var(--border); display: flex; @@ -183,7 +253,7 @@ textarea { left: 0; right: 0; margin-bottom: 0.5rem; - background: white; + background: var(--bg-white); border-radius: 8px; box-shadow: 0 4px 20px rgba(0, 0, 0, 0.15); overflow: hidden; @@ -226,7 +296,7 @@ textarea { justify-content: space-between; padding: 0 1.5rem; min-height: 53px; - background: white; + background: var(--bg-white); border-bottom: 1px solid var(--border); gap: 0.75rem; } @@ -253,7 +323,7 @@ textarea { Cards ============================================================================ */ .card { - background: white; + background: var(--bg-white); border: 1px solid var(--border); border-radius: 8px; padding: 1rem; @@ -302,7 +372,7 @@ textarea { } .btn-secondary { - background: white; + background: var(--bg-white); color: var(--text); border: 1px solid var(--border-dark); } @@ -330,10 +400,12 @@ textarea { display: flex; align-items: center; justify-content: center; + color: var(--text); } .btn-icon:hover { background: var(--bg); + color: var(--text); } .btn-full { @@ -364,7 +436,8 @@ textarea { font-size: 0.9rem; border: 1px solid var(--border-dark); border-radius: 6px; - background: white; + background: var(--bg-white); + color: var(--text); } .form-input:focus, @@ -430,7 +503,7 @@ textarea { } .modal-content { - background: white; + background: var(--bg-white); border-radius: 12px; width: 100%; max-width: 480px; @@ -490,12 +563,12 @@ textarea { .message.success { background: var(--success-bg); - color: #166534; + color: var(--success); } .message.error { background: var(--danger-bg); - color: #991b1b; + color: var(--danger); } .badge { @@ -508,17 +581,17 @@ textarea { .badge-success { background: var(--success-bg); - color: #166534; + color: var(--success); } .badge-warning { background: var(--warning-bg); - color: #92400e; + color: var(--warning); } .badge-danger { background: var(--danger-bg); - color: #991b1b; + color: var(--danger); } /* ============================================================================ @@ -591,11 +664,11 @@ textarea { } .calendar-day.weekend { - background: #f5f5f5; + background: var(--bg-weekend); } .calendar-day.holiday { - background: #fff7ed; + background: var(--bg-holiday); } .calendar-day.today { @@ -643,8 +716,8 @@ textarea { } .status-remote { - background: #dbeafe !important; - border-color: #3b82f6 !important; + background: var(--info-bg) !important; + border-color: var(--info) !important; } .status-absent { @@ -658,19 +731,19 @@ textarea { } .status-nodata { - background: white; + background: var(--bg-white); } /* Closed Day */ .calendar-day.closed { - background: #e5e7eb; - color: #9ca3af; + background: var(--bg-closed); + color: var(--text-closed); cursor: not-allowed; - border-color: #d1d5db; + border-color: var(--border-closed); } .calendar-day.closed:hover { - border-color: #d1d5db; + border-color: var(--border-closed); } .calendar-day.closed .day-number { @@ -678,8 +751,8 @@ textarea { } .team-calendar td.closed { - background: #e5e7eb; - color: #9ca3af; + background: var(--bg-closed); + color: var(--text-closed); cursor: not-allowed; } @@ -723,7 +796,7 @@ textarea { align-items: center; gap: 0.5rem; padding: 1rem; - background: white; + background: var(--bg-white); border: 2px solid var(--border); border-radius: 8px; cursor: pointer; @@ -789,12 +862,12 @@ textarea { .team-calendar th.weekend, .team-calendar td.weekend { - background: #f5f5f5; + background: var(--bg-weekend); } .team-calendar th.holiday, .team-calendar td.holiday { - background: #fff7ed; + background: var(--bg-holiday); } .team-calendar th.today { @@ -820,7 +893,7 @@ textarea { min-width: 150px; position: sticky; left: 0; - background: white; + background: var(--bg-white); z-index: 1; } @@ -828,7 +901,7 @@ textarea { text-align: left !important; position: sticky; left: 0; - background: white; + background: var(--bg-white); z-index: 1; } @@ -880,7 +953,7 @@ textarea { } .parking-spot { - background: white; + background: var(--bg-white); border: 2px solid var(--border); border-radius: 8px; padding: 1rem; @@ -1009,7 +1082,7 @@ textarea { width: 20px; left: 3px; bottom: 3px; - background-color: white; + background-color: var(--bg-white); transition: 0.3s; border-radius: 50%; } @@ -1034,7 +1107,7 @@ textarea { } .auth-card { - background: white; + background: var(--bg-white); border-radius: 12px; box-shadow: 0 4px 20px rgba(0, 0, 0, 0.1); padding: 2.5rem; @@ -1078,7 +1151,7 @@ textarea { .settings-section, .profile-section { - background: white; + background: var(--bg-white); border: 1px solid var(--border); border-radius: 8px; padding: 1.5rem; @@ -1136,6 +1209,8 @@ textarea { border-radius: 4px; font-size: 0.9rem; min-width: 140px; + background: var(--bg-white); + color: var(--text); } .profile-field { @@ -1194,7 +1269,7 @@ textarea { } .rule-section { - background: white; + background: var(--bg-white); border: 1px solid var(--border); border-radius: 8px; padding: 1.5rem; @@ -1265,7 +1340,7 @@ textarea { justify-content: space-between; align-items: center; padding: 0.75rem; - background: white; + background: var(--bg-white); border: 1px solid var(--border); border-radius: 4px; } @@ -1298,14 +1373,15 @@ textarea { border: 1px solid var(--border-dark); border-radius: 4px; font-size: 0.9rem; - background: white; + background: var(--bg-white); + color: var(--text); } /* ============================================================================ Admin Tables ============================================================================ */ .admin-table { - background: white; + background: var(--bg-white); border: 1px solid var(--border); border-radius: 8px; overflow: hidden; @@ -1433,12 +1509,18 @@ textarea { .team-calendar-table th.weekend, .team-calendar-table td.weekend { - background: #f5f5f5; + background: var(--bg-weekend); } .team-calendar-table th.holiday, .team-calendar-table td.holiday { - background: #fff7ed; + background: var(--bg-holiday); +} + +.team-calendar-table th.closed, +.team-calendar-table td.closed { + background: var(--bg-closed); + color: var(--text-closed); } .team-calendar-table .member-name { @@ -1482,7 +1564,7 @@ textarea { } .team-calendar-table .calendar-cell.status-remote { - background: #dbeafe !important; + background: var(--info-bg) !important; border-color: var(--border) !important; } @@ -1530,8 +1612,8 @@ textarea { .parking-badge-sm { display: inline-block; - background: #dbeafe; - color: #1e40af; + background: var(--info-bg); + color: var(--info); font-size: 0.55rem; font-weight: 600; padding: 0.1rem 0.25rem; @@ -1647,7 +1729,8 @@ textarea { border: 1px solid var(--border-dark); border-radius: 6px; font-size: 0.9rem; - background: white; + background: var(--bg-white); + color: var(--text); min-width: 150px; } diff --git a/frontend/js/admin-offices.js b/frontend/js/admin-offices.js index 418ed98..1c600a6 100644 --- a/frontend/js/admin-offices.js +++ b/frontend/js/admin-offices.js @@ -59,7 +59,7 @@ function renderOffices() { const tbody = document.getElementById('officesBody'); if (offices.length === 0) { - tbody.innerHTML = 'Nessun ufficio trovato'; + tbody.innerHTML = 'Nessun gruppo trovato'; return; } @@ -72,7 +72,7 @@ function renderOffices() { ${office.user_count || 0} utenti - + `; @@ -103,30 +103,30 @@ async function editOffice(officeId) { document.getElementById('officeCutoffHour').value = office.booking_window_end_hour != null ? office.booking_window_end_hour : 18; document.getElementById('officeCutoffMinute').value = office.booking_window_end_minute != null ? office.booking_window_end_minute : 0; - openModal('Modifica Ufficio'); + openModal('Modifica Gruppo'); } async function deleteOffice(officeId) { const office = offices.find(o => o.id === officeId); if (!office) return; - if (!confirm(`Eliminare l'ufficio "${office.name}"?`)) return; + if (!confirm(`Eliminare il gruppo "${office.name}"?`)) return; const response = await api.delete(`/api/offices/${officeId}`); if (response && response.ok) { - utils.showMessage('Ufficio eliminato', 'success'); + utils.showMessage('Gruppo eliminato', 'success'); api.invalidateCache('/api/offices'); // Clear cache await loadOffices(); } else { const error = await response.json(); - utils.showMessage(error.detail || 'Impossibile eliminare l\'ufficio', 'error'); + utils.showMessage(error.detail || 'Impossibile eliminare il gruppo', 'error'); } } function setupEventListeners() { // Add button document.getElementById('addOfficeBtn').addEventListener('click', () => { - openModal('Nuovo Ufficio'); + openModal('Nuovo Gruppo'); }); // Modal close @@ -177,7 +177,7 @@ async function handleOfficeSubmit(e) { if (response && response.ok) { closeModal(); - utils.showMessage(officeId ? 'Ufficio aggiornato' : 'Ufficio creato', 'success'); + utils.showMessage(officeId ? 'Gruppo aggiornato' : 'Gruppo creato', 'success'); api.invalidateCache('/api/offices'); // Clear cache await loadOffices(); } else { diff --git a/frontend/js/admin-users.js b/frontend/js/admin-users.js index abae291..d7e48f1 100644 --- a/frontend/js/admin-users.js +++ b/frontend/js/admin-users.js @@ -129,7 +129,7 @@ async function editUser(userId) { // Populate office dropdown const officeSelect = document.getElementById('editOffice'); - officeSelect.innerHTML = ''; + officeSelect.innerHTML = ''; offices.forEach(o => { const option = document.createElement('option'); option.value = o.id; diff --git a/frontend/js/nav.js b/frontend/js/nav.js index 6f81416..3e3fd95 100644 --- a/frontend/js/nav.js +++ b/frontend/js/nav.js @@ -42,6 +42,20 @@ const ICONS = { settings: ` + `, + sun: ` + + + + + + + + + + `, + moon: ` + ` }; @@ -50,8 +64,8 @@ const NAV_ITEMS = [ { href: '/team-calendar', icon: 'users', label: 'Calendario del team' }, { href: '/team-rules', icon: 'rules', label: 'Regole parcheggio', roles: ['admin', 'manager'] }, { href: '/admin/users', icon: 'user', label: 'Gestione Utenti', roles: ['admin'] }, - { href: '/admin/offices', icon: 'building', label: 'Gestione Uffici', roles: ['admin'] }, - { href: '/parking-settings', icon: 'settings', label: 'Impostazioni Ufficio', roles: ['admin', 'manager'] } + { href: '/admin/offices', icon: 'building', label: 'Gestione Gruppi', roles: ['admin'] }, + { href: '/parking-settings', icon: 'settings', label: 'Impostazioni Gruppi', roles: ['admin', 'manager'] } ]; function getIcon(name) { @@ -98,9 +112,10 @@ async function initNav() { // Setup user menu (logout) & mobile menu setupUserMenu(); setupMobileMenu(); + setupThemeToggle(); // CHECK: Block access if user has no office (and is not admin) - // Admins are allowed to access "Gestione Uffici" even without an office + // Admins are allowed to access "Gestione Gruppi" even without an office if (currentUser && !currentUser.office_id && currentUser.role !== 'admin') { navContainer.innerHTML = ''; // Clear nav @@ -124,14 +139,14 @@ async function initNav() { -

Ufficio non assegnato

+

Gruppo non assegnato

- Il tuo account ${currentUser.email} è attivo, ma non sei ancora stato assegnato a nessuno ufficio. + Il tuo account ${currentUser.email} è attivo, ma non sei ancora stato assegnato a nessuno gruppo.

Cosa fare?
- Contatta l'amministratore di sistema per richiedere l'assegnazione al tuo ufficio di competenza.
+ Contatta l'amministratore di sistema per richiedere l'assegnazione al tuo gruppo di competenza.
s.salemi@sielte.it
@@ -212,6 +227,35 @@ function setupUserMenu() { } } +function setupThemeToggle() { + // Apply immediate theme to avoid flash + const savedTheme = localStorage.getItem('theme') || 'system'; + applyTheme(savedTheme); + + // Watch for system theme changes if set to system + window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', e => { + if (localStorage.getItem('theme') === 'system') { + applyTheme('system'); + } + }); +} + +function applyTheme(theme) { + let isDark = false; + if (theme === 'system') { + isDark = window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches; + } else { + isDark = theme === 'dark'; + } + + if (isDark) { + document.documentElement.setAttribute('data-theme', 'dark'); + } else { + document.documentElement.removeAttribute('data-theme'); + } + localStorage.setItem('theme', theme); +} + // Export for use in other scripts window.getIcon = getIcon; diff --git a/frontend/js/parking-settings.js b/frontend/js/parking-settings.js index b5396a1..6138fe0 100644 --- a/frontend/js/parking-settings.js +++ b/frontend/js/parking-settings.js @@ -49,7 +49,7 @@ async function loadOffices() { } } catch (e) { console.error(e); - utils.showMessage('Errore caricamento uffici', 'error'); + utils.showMessage('Errore caricamento gruppi', 'error'); } } else { // Manager uses their own office @@ -58,7 +58,7 @@ async function loadOffices() { if (currentUser.office_id) { await loadOfficeSettings(currentUser.office_id); } else { - utils.showMessage('Nessun ufficio assegnato al manager', 'error'); + utils.showMessage('Nessun gruppo assegnato al manager', 'error'); } } } @@ -86,7 +86,7 @@ function populateHourSelect() { async function loadOfficeSettings(id) { const officeId = id; if (!officeId) { - utils.showMessage('Nessun ufficio selezionato', 'error'); + utils.showMessage('Nessun gruppo selezionato', 'error'); return; } @@ -98,6 +98,7 @@ async function loadOfficeSettings(id) { currentOffice = office; // Populate form + document.getElementById('assignmentModeSelect').value = office.assignment_mode || 'fairness'; 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; @@ -136,6 +137,7 @@ function setupEventListeners() { if (!currentOffice) return; const data = { + assignment_mode: document.getElementById('assignmentModeSelect').value, 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) @@ -242,7 +244,7 @@ function setupEventListeners() { const clearPresenceBtn = document.getElementById('clearPresenceBtn'); if (clearPresenceBtn) { clearPresenceBtn.addEventListener('click', async () => { - if (!confirm('ATTENZIONE: Stai per eliminare TUTTI GLI STATI (Presente/Assente/ecc) e relative assegnazioni per tutti gli utenti dell\'ufficio nel periodo selezionato. \n\nQuesta azione è irreversibile. Procedere?')) return; + if (!confirm('ATTENZIONE: Stai per eliminare TUTTI GLI STATI (Presente/Assente/ecc) e relative assegnazioni per tutti gli utenti del gruppo nel periodo selezionato. \n\nQuesta azione è irreversibile. Procedere?')) return; const dateStart = document.getElementById('testDateStart').value; const dateEnd = document.getElementById('testDateEnd').value; @@ -251,7 +253,7 @@ function setupEventListeners() { // Validate office if (!currentOffice || !currentOffice.id) { - return utils.showMessage('Errore: Nessun ufficio selezionato', 'error'); + return utils.showMessage('Errore: Nessun gruppo selezionato', 'error'); } const endDateVal = dateEnd || dateStart; @@ -286,7 +288,7 @@ function setupEventListeners() { // Validate office if (!currentOffice || !currentOffice.id) { - return utils.showMessage('Errore: Nessun ufficio selezionato', 'error'); + return utils.showMessage('Errore: Nessun gruppo selezionato', 'error'); } utils.showMessage('Invio mail di test in corso...', 'warning'); @@ -329,7 +331,7 @@ function setupEventListeners() { // Validate office if (!currentOffice || !currentOffice.id) { - return utils.showMessage('Errore: Nessun ufficio selezionato', 'error'); + return utils.showMessage('Errore: Nessun gruppo selezionato', 'error'); } if (!dateVal) { diff --git a/frontend/js/presence.js b/frontend/js/presence.js index 3b0c12d..536400a 100644 --- a/frontend/js/presence.js +++ b/frontend/js/presence.js @@ -175,11 +175,12 @@ function renderCalendar() { return date >= start && date <= end; }); - const isClosed = isWeeklyClosed || isSpecificClosed; + const isClosed = isHoliday || isWeeklyClosed || isSpecificClosed; if (isClosed) { cell.classList.add('closed'); - cell.title = "Ufficio Chiuso"; + if (isHoliday) cell.title = "Festività"; + else cell.title = "Gruppo Chiuso"; } else if (presence) { cell.classList.add(`status-${presence.status}`); } @@ -418,10 +419,10 @@ function initParkingStatus() { if (headerDisplay) headerDisplay.textContent = currentUser.office_name; } else { const nameDisplay = document.getElementById('statusOfficeName'); - if (nameDisplay) nameDisplay.textContent = 'Tuo Ufficio'; + if (nameDisplay) nameDisplay.textContent = 'Tuo Gruppo'; const headerDisplay = document.getElementById('currentOfficeDisplay'); - if (headerDisplay) headerDisplay.textContent = 'Tuo Ufficio'; + if (headerDisplay) headerDisplay.textContent = 'Tuo Gruppo'; } } @@ -455,6 +456,34 @@ async function loadDailyStatus() { const officeId = currentUser.office_id; const grid = document.getElementById('spotsGrid'); + const badge = document.getElementById('spotsCountBadge'); + + // Check if it's a closing day + const dayOfWeek = statusDate.getDay(); + const isHoliday = utils.isItalianHoliday(statusDate); + 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; + start.setHours(0, 0, 0, 0); end.setHours(0, 0, 0, 0); + const check = new Date(statusDate); check.setHours(0, 0, 0, 0); + return check >= start && check <= end; + }); + + if (isHoliday || isWeeklyClosed || isSpecificClosed) { + if (grid) { + grid.innerHTML = ` +
+
Ufficio Chiuso
+
Nessun parcheggio disponibile in questa data.
+
`; + } + if (badge) badge.style.display = 'none'; + return; + } else { + if (badge) badge.style.display = 'inline-block'; + } + // Keep grid height to avoid jump if possible, or just loading styling if (grid) grid.innerHTML = '
Caricamento...
'; @@ -505,10 +534,9 @@ function renderParkingStatus(assignments) { // 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 borderColor = isFree ? 'var(--spot-free-border)' : 'var(--spot-occ-border)'; + const bgColor = isFree ? 'var(--spot-free-bg)' : 'var(--spot-occ-bg)'; + const textColor = isFree ? 'var(--spot-free-text)' : 'var(--spot-occ-text)'; const el = document.createElement('div'); el.className = 'spot-card'; @@ -527,15 +555,49 @@ function renderParkingStatus(assignments) { transition: all 0.2s; `; - // New Car Icon (Front Facing Sedan style or similar simple shape) - // Using a cleaner SVG path el.innerHTML = ` -
${spotName}
+
${spotName}
${statusText}
`; + if (isFree && (currentUser.role === 'admin' || currentUser.role === 'manager')) { + el.style.cursor = 'pointer'; + el.addEventListener('mouseenter', () => el.style.boxShadow = '0 4px 6px rgba(0,0,0,0.1)'); + el.addEventListener('mouseleave', () => el.style.boxShadow = 'none'); + el.title = "Clicca per assegnare manualmente"; + el.addEventListener('click', () => { + openAdminAssignModal(a.spot_id, spotName); + }); + } + + if (!isFree && (currentUser.role === 'admin' || currentUser.role === 'manager')) { + el.style.cursor = 'pointer'; + el.addEventListener('mouseenter', () => el.style.boxShadow = '0 4px 6px rgba(0,0,0,0.1)'); + el.addEventListener('mouseleave', () => el.style.boxShadow = 'none'); + el.title = "Clicca per liberare questo posto"; + el.addEventListener('click', async () => { + if (!confirm(`Vuoi liberare il posto ${spotName} occupato da ${statusText}?`)) return; + + utils.showMessage('Rilascio in corso...', 'warning'); + const response = await api.post('/api/parking/reassign-spot', { + assignment_id: a.id, + new_user_id: null + }); + + if (response && response.ok) { + utils.showMessage('Posto liberato con successo', 'success'); + loadDailyStatus(); + loadParkingAssignments(); + renderCalendar(); + } else { + const err = await response.json(); + utils.showMessage(err.detail || 'Impossibile liberare il posto', 'error'); + } + }); + } + grid.appendChild(el); }); @@ -569,6 +631,97 @@ function setupStatusListeners() { loadDailyStatus(); } }); + + setupAdminAssignModal(); +} + +// ---------------------------------------------------------------------------- +// Admin Manual Assign Logic +// ---------------------------------------------------------------------------- + +let currentManualSpotId = null; + +function setupAdminAssignModal() { + const closeBtn = document.getElementById('closeAdminAssignModal'); + const cancelBtn = document.getElementById('cancelAdminAssign'); + const form = document.getElementById('adminAssignForm'); + const modal = document.getElementById('adminAssignSpotModal'); + + if (closeBtn) closeBtn.addEventListener('click', () => modal.style.display = 'none'); + if (cancelBtn) cancelBtn.addEventListener('click', () => modal.style.display = 'none'); + + if (form) { + form.addEventListener('submit', async (e) => { + e.preventDefault(); + const targetUserId = document.getElementById('adminAssignUser').value; + if (!targetUserId || !currentManualSpotId) return; + + const dateStr = utils.formatDate(statusDate); + + utils.showMessage('Assegnazione in corso...', 'warning'); + const response = await api.post('/api/parking/manual-assign', { + date: dateStr, + user_id: targetUserId, + spot_id: currentManualSpotId, + office_id: currentUser.office_id + }); + + if (response && response.ok) { + utils.showMessage('Posto assegnato con successo', 'success'); + modal.style.display = 'none'; + loadDailyStatus(); // refresh parking status grid + // optionally refresh my presences if it affected the logged in admin + loadParkingAssignments(); + } else { + const err = await response.json(); + utils.showMessage(err.detail || 'Impossibile assegnare il parcheggio', 'error'); + } + }); + } +} + +async function openAdminAssignModal(spotId, spotName) { + currentManualSpotId = spotId; + const modal = document.getElementById('adminAssignSpotModal'); + const infoDisplay = document.getElementById('adminAssignSpotInfo'); + const selectUser = document.getElementById('adminAssignUser'); + const dateStr = utils.formatDate(statusDate); + + infoDisplay.innerHTML = `Seleziona l'utente a cui assegnare il posto ${spotName} per la giornata del ${dateStr}.`; + selectUser.innerHTML = ''; + modal.style.display = 'flex'; + + // Fetch team presences for the office effectively identifying people physically present but without parking + try { + const response = await api.get(`/api/presence/team?start_date=${dateStr}&end_date=${dateStr}&office_id=${currentUser.office_id}`); + if (response && response.ok) { + const result = await response.json(); + const members = Array.isArray(result) ? result : []; + + // Filter out people who already have parking + const availableMembers = members.filter(m => { + const presence = m.presences && m.presences.find(p => p.date === dateStr); + const hasParking = m.parking_dates && m.parking_dates.includes(dateStr); + return presence && presence.status === 'present' && !hasParking; + }); + + if (availableMembers.length === 0) { + selectUser.innerHTML = ''; + } else { + selectUser.innerHTML = ''; + availableMembers.forEach(user => { + const option = document.createElement('option'); + option.value = user.id; + option.textContent = `${user.name}`; + selectUser.appendChild(option); + }); + } + } + } catch (e) { + console.error("Errore fetch team presences:", e); + console.error(e); + selectUser.innerHTML = ''; + } } diff --git a/frontend/js/team-calendar.js b/frontend/js/team-calendar.js index 4d82e6e..c2c648f 100644 --- a/frontend/js/team-calendar.js +++ b/frontend/js/team-calendar.js @@ -48,7 +48,7 @@ function updateOfficeDisplay() { // If user is employee, show their office name directly if (currentUser.role === 'employee') { - display.textContent = currentUser.office_name || "Mio Ufficio"; + display.textContent = currentUser.office_name || "Mio Gruppo"; return; } @@ -63,10 +63,10 @@ function updateOfficeDisplay() { // let text = option.textContent.split('(')[0].trim(); display.textContent = option.textContent; } else { - display.textContent = "Tutti gli Uffici"; + display.textContent = "Tutti i Gruppi"; } } else { - display.textContent = "Tutti gli Uffici"; + display.textContent = "Tutti i Gruppi"; } } @@ -312,7 +312,7 @@ function renderCalendar() { // Build header row const dayNames = ['Dom', 'Lun', 'Mar', 'Mer', 'Gio', 'Ven', 'Sab']; - let headerHtml = 'NomeUfficio'; + let headerHtml = 'NomeGruppo'; for (let i = 0; i < dayCount; i++) { const date = new Date(startDate); @@ -348,8 +348,20 @@ function renderCalendar() { let bodyHtml = ''; teamData.forEach(member => { + let nameHtml = member.name || 'Unknown'; + if (member.ratio !== undefined && member.ratio !== null) { + nameHtml += ` + + + + + ${member.ratio.toFixed(2)} + + `; + } + bodyHtml += ` - ${member.name || 'Unknown'} + ${nameHtml} ${member.office_name || '-'}`; for (let i = 0; i < dayCount; i++) { @@ -384,7 +396,7 @@ function renderCalendar() { // (Already have dateStr) const memberRules = officeClosingRules[member.office_id]; - let isClosed = false; + let isClosed = isHoliday; if (memberRules) { // Check weekly diff --git a/frontend/js/team-rules.js b/frontend/js/team-rules.js index 3582d36..ca900cf 100644 --- a/frontend/js/team-rules.js +++ b/frontend/js/team-rules.js @@ -134,6 +134,7 @@ async function saveWeeklyClosingDays() { await Promise.all(promises); utils.showMessage('Giorni di chiusura aggiornati', 'success'); + api.invalidateCache(`/api/offices/${currentOfficeId}/weekly-closing-days`); await loadWeeklyClosingDays(currentOfficeId); } catch (error) { console.error(error); @@ -176,6 +177,7 @@ async function loadClosingDays(officeId) { async function addClosingDay(data) { const response = await api.post(`/api/offices/${currentOfficeId}/closing-days`, data); if (response && response.ok) { + api.invalidateCache(`/api/offices/${currentOfficeId}/closing-days`); await loadClosingDays(currentOfficeId); document.getElementById('closingDayModal').style.display = 'none'; document.getElementById('closingDayForm').reset(); @@ -189,6 +191,7 @@ async function deleteClosingDay(id) { if (!confirm('Eliminare questo giorno di chiusura?')) return; const response = await api.delete(`/api/offices/${currentOfficeId}/closing-days/${id}`); if (response && response.ok) { + api.invalidateCache(`/api/offices/${currentOfficeId}/closing-days`); await loadClosingDays(currentOfficeId); } } diff --git a/frontend/pages/admin-offices.html b/frontend/pages/admin-offices.html index 282fa7b..b5879ae 100644 --- a/frontend/pages/admin-offices.html +++ b/frontend/pages/admin-offices.html @@ -4,7 +4,7 @@ - Gestione Uffici - Parking Manager + Gestione Gruppi - Parking Manager @@ -42,7 +42,7 @@
@@ -50,8 +50,8 @@
-

Lista Uffici

- +

Lista Gruppi

+
@@ -75,7 +75,7 @@ - diff --git a/frontend/pages/parking-settings.html b/frontend/pages/parking-settings.html index 7e3cc89..661006a 100644 --- a/frontend/pages/parking-settings.html +++ b/frontend/pages/parking-settings.html @@ -42,7 +42,7 @@
@@ -60,49 +60,54 @@
Ruolo Ufficio Gruppo Punteggio