piccoli fix

This commit is contained in:
2026-04-20 20:37:37 +02:00
parent 104ad53a9a
commit 5d30434dd5
13 changed files with 727 additions and 838 deletions

428
README.md
View File

@@ -1,214 +1,214 @@
# Org-Parking # 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. 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à ## Funzionalità
- **Modello Centrato sull'Ufficio**: I posti auto appartengono agli Uffici, non ai Manager o agli Utenti. - **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à. - **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). - **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. - **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. - **Garanzie ed Esclusioni**: Regole di parcheggio per utente gestite dagli amministratori dell'ufficio.
- **Accesso Basato sui Ruoli**: - **Accesso Basato sui Ruoli**:
- **Admin**: Controllo completo del sistema, creazione uffici, gestione utenti. - **Admin**: Controllo completo del sistema, creazione uffici, gestione utenti.
- **Manager**: Gestisce le impostazioni del proprio ufficio e il team. - **Manager**: Gestisce le impostazioni del proprio ufficio e il team.
- **Impiegato**: Segna presenza, visualizza calendario, controlla stato parcheggio. - **Impiegato**: Segna presenza, visualizza calendario, controlla stato parcheggio.
- **Basso Consumo di Memoria**: Ottimizzato per piccoli server (<500MB RAM). - **Basso Consumo di Memoria**: Ottimizzato per piccoli server (<500MB RAM).
- **Autenticazione**: Auth JWT integrata o integrazione SSO con Authelia. - **Autenticazione**: Auth JWT integrata o integrazione SSO con Authelia.
## Architettura ## Architettura
``` ```
app/ app/
├── routes/ # API endpoints ├── routes/ # API endpoints
│ ├── auth.py # Autenticazione │ ├── auth.py # Autenticazione
│ ├── users.py # Gestione utenti │ ├── users.py # Gestione utenti
│ ├── offices.py # Gestione uffici (quote, regole) │ ├── offices.py # Gestione uffici (quote, regole)
│ ├── presence.py # Marcatura presenze │ ├── presence.py # Marcatura presenze
│ └── parking.py # Logica di assegnazione │ └── parking.py # Logica di assegnazione
└── config.py # Configurazione └── config.py # Configurazione
database/ database/
├── models.py # Modelli SQLAlchemy ORM ├── models.py # Modelli SQLAlchemy ORM
└── connection.py # Setup Database └── connection.py # Setup Database
frontend/ # Frontend Vanilla JS pulito frontend/ # Frontend Vanilla JS pulito
├── pages/ # Viste HTML ├── pages/ # Viste HTML
├── js/ # Moduli logici ├── js/ # Moduli logici
└── css/ # Stili └── css/ # Stili
``` ```
## Guida Rapida ## Guida Rapida
### Sviluppo Locale ### Sviluppo Locale
1. **Setup Ambiente**: 1. **Setup Ambiente**:
```bash ```bash
python3 -m venv .venv python3 -m venv .venv
source .venv/bin/activate source .venv/bin/activate
pip install -r requirements.txt pip install -r requirements.txt
``` ```
2. **Avvio Server**: 2. **Avvio Server**:
```bash ```bash
python main.py python main.py
``` ```
Accedi a `http://localhost:8000` Accedi a `http://localhost:8000`
### Deployment Docker (Consigliato) ### Deployment Docker (Consigliato)
Questa applicazione è ottimizzata per ambienti con risorse limitate (es. Raspberry Pi, piccoli VPS). Questa applicazione è ottimizzata per ambienti con risorse limitate (es. Raspberry Pi, piccoli VPS).
1. **Build**: 1. **Build**:
```bash ```bash
docker compose build docker compose build
``` ```
2. **Run**: 2. **Run**:
```bash ```bash
docker compose up -d docker compose up -d
``` ```
**Nota sull'Uso della Memoria**: **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`. 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 ## Configurazione
Copia `.env.example` in `.env` e configura: Copia `.env.example` in `.env` e configura:
| Variabile | Descrizione | Default | | Variabile | Descrizione | Default |
|-----------|-------------|---------| |-----------|-------------|---------|
| `SECRET_KEY` | **Richiesto**. Chiave cripto per JWT. | `""` (Esce se mancante) | | `SECRET_KEY` | **Richiesto**. Chiave cripto per JWT. | `""` (Esce se mancante) |
| `DATABASE_URL` | Vedi docs SQLAlchemy. | `sqlite:///data/parking.db` | | `DATABASE_URL` | Vedi docs SQLAlchemy. | `sqlite:///data/parking.db` |
| `AUTHELIA_ENABLED`| Abilita supporto header SSO. | `false` | | `AUTHELIA_ENABLED`| Abilita supporto header SSO. | `false` |
| `SMTP_ENABLED` | Abilita notifiche email. | `false` | | `SMTP_ENABLED` | Abilita notifiche email. | `false` |
| `LOG_LEVEL` | Verbosità log. | `INFO` | | `LOG_LEVEL` | Verbosità log. | `INFO` |
## Algoritmo di Equità ## Algoritmo di Equità
I posti auto vengono assegnati giornalmente basandosi su un rapporto di equità: I posti auto vengono assegnati giornalmente basandosi su un rapporto di equità:
``` ```
Rapporto = (Giorni Parcheggiati con successo) / (Giorni di Presenza in ufficio) Rapporto = (Giorni Parcheggiati con successo) / (Giorni di Presenza in ufficio)
``` ```
- Gli utenti **Garantiti** vengono assegnati per primi. - Gli utenti **Garantiti** vengono assegnati per primi.
- I posti **Rimanenti** sono distribuiti agli utenti presenti iniziando da chi ha il rapporto più **basso**. - I posti **Rimanenti** sono distribuiti agli utenti presenti iniziando da chi ha il rapporto più **basso**.
- Gli utenti **Esclusi** non ricevono mai un posto. - Gli utenti **Esclusi** non ricevono mai un posto.
## API Endpoints ## API Endpoints
Di seguito la lista delle chiamate API disponibili suddivise per modulo. Di seguito la lista delle chiamate API disponibili suddivise per modulo.
### Auth (`/api/auth`) ### Auth (`/api/auth`)
Gestione autenticazione e sessione. Gestione autenticazione e sessione.
- `POST /register`: Registrazione nuovo utente (Disabilitato se Authelia è attivo). - `POST /register`: Registrazione nuovo utente (Disabilitato se Authelia è attivo).
- `POST /login`: Login con email e password (ritorna token JWT/cookie). - `POST /login`: Login con email e password (ritorna token JWT/cookie).
- `POST /logout`: Logout e invalidazione sessione. - `POST /logout`: Logout e invalidazione sessione.
- `GET /me`: Ritorna informazioni sull'utente corrente. - `GET /me`: Ritorna informazioni sull'utente corrente.
- `GET /config`: Ritorna la configurazione pubblica di autenticazione. - `GET /config`: Ritorna la configurazione pubblica di autenticazione.
- `GET /holidays/{year}`: Ritorna i giorni festivi per l'anno specificato. - `GET /holidays/{year}`: Ritorna i giorni festivi per l'anno specificato.
### Users (`/api/users`) ### Users (`/api/users`)
Gestione utenti e profili. Gestione utenti e profili.
- `GET /`: Lista di tutti gli utenti (Solo Admin). - `GET /`: Lista di tutti gli utenti (Solo Admin).
- `POST /`: Crea un nuovo utente (Solo Admin). - `POST /`: Crea un nuovo utente (Solo Admin).
- `GET /{user_id}`: Dettaglio di un utente specifico (Solo Admin). - `GET /{user_id}`: Dettaglio di un utente specifico (Solo Admin).
- `PUT /{user_id}`: Aggiorna dati di un utente (Solo Admin). - `PUT /{user_id}`: Aggiorna dati di un utente (Solo Admin).
- `DELETE /{user_id}`: Elimina un utente (Solo Admin). - `DELETE /{user_id}`: Elimina un utente (Solo Admin).
- `GET /me/profile`: Ottieni il proprio profilo. - `GET /me/profile`: Ottieni il proprio profilo.
- `PUT /me/profile`: Aggiorna il proprio profilo. - `PUT /me/profile`: Aggiorna il proprio profilo.
- `GET /me/settings`: Ottieni le proprie impostazioni. - `GET /me/settings`: Ottieni le proprie impostazioni.
- `PUT /me/settings`: Aggiorna le proprie impostazioni. - `PUT /me/settings`: Aggiorna le proprie impostazioni.
- `POST /me/change-password`: Modifica la propria password. - `POST /me/change-password`: Modifica la propria password.
### Offices (`/api/offices`) ### Offices (`/api/offices`)
Gestione uffici, regole di chiusura e quote. Gestione uffici, regole di chiusura e quote.
- `GET /`: Lista di tutti gli uffici. - `GET /`: Lista di tutti gli uffici.
- `POST /`: Crea un nuovo ufficio (Solo Admin). - `POST /`: Crea un nuovo ufficio (Solo Admin).
- `GET /{office_id}`: Dettagli di un ufficio. - `GET /{office_id}`: Dettagli di un ufficio.
- `PUT /{office_id}`: Aggiorna configurazione ufficio (Solo Admin). - `PUT /{office_id}`: Aggiorna configurazione ufficio (Solo Admin).
- `DELETE /{office_id}`: Elimina un ufficio (Solo Admin). - `DELETE /{office_id}`: Elimina un ufficio (Solo Admin).
- `GET /{office_id}/users`: Lista utenti assegnati all'ufficio. - `GET /{office_id}/users`: Lista utenti assegnati all'ufficio.
- `GET /{office_id}/closing-days`: Lista giorni di chiusura specifici. - `GET /{office_id}/closing-days`: Lista giorni di chiusura specifici.
- `POST /{office_id}/closing-days`: Aggiungi giorno di chiusura. - `POST /{office_id}/closing-days`: Aggiungi giorno di chiusura.
- `DELETE /{office_id}/closing-days/{id}`: Rimuovi 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). - `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. - `POST /{office_id}/weekly-closing-days`: Aggiungi giorno di chiusura settimanale.
- `DELETE /{office_id}/weekly-closing-days/{id}`: Rimuovi 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. - `GET /{office_id}/guarantees`: Lista utenti con posto garantito.
- `POST /{office_id}/guarantees`: Aggiungi garanzia posto. - `POST /{office_id}/guarantees`: Aggiungi garanzia posto.
- `DELETE /{office_id}/guarantees/{id}`: Rimuovi garanzia. - `DELETE /{office_id}/guarantees/{id}`: Rimuovi garanzia.
- `GET /{office_id}/exclusions`: Lista utenti esclusi dal parcheggio. - `GET /{office_id}/exclusions`: Lista utenti esclusi dal parcheggio.
- `POST /{office_id}/exclusions`: Aggiungi esclusione. - `POST /{office_id}/exclusions`: Aggiungi esclusione.
- `DELETE /{office_id}/exclusions/{id}`: Rimuovi esclusione. - `DELETE /{office_id}/exclusions/{id}`: Rimuovi esclusione.
### Presence (`/api/presence`) ### Presence (`/api/presence`)
Gestione presenze giornaliere. Gestione presenze giornaliere.
- `POST /mark`: Segna la propria presenza per una data (Presente/Remoto/Assente). - `POST /mark`: Segna la propria presenza per una data (Presente/Remoto/Assente).
- `GET /my-presences`: Lista delle proprie presenze. - `GET /my-presences`: Lista delle proprie presenze.
- `DELETE /{date}`: Rimuovi la propria presenza per una data. - `DELETE /{date}`: Rimuovi la propria presenza per una data.
- `POST /admin/mark`: Segna presenza per un altro utente (Manager/Admin). - `POST /admin/mark`: Segna presenza per un altro utente (Manager/Admin).
- `DELETE /admin/{user_id}/{date}`: Rimuovi presenza di 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 /team`: Visualizza presenze e stato parcheggio del team.
- `GET /admin/{user_id}`: Storico presenze di un utente. - `GET /admin/{user_id}`: Storico presenze di un utente.
### Parking (`/api/parking`) ### Parking (`/api/parking`)
Gestione assegnazioni posti auto. Gestione assegnazioni posti auto.
- `POST /init-office-pool`: Inizializza i posti disponibili per un giorno. - `POST /init-office-pool`: Inizializza i posti disponibili per un giorno.
- `GET /assignments/{date}`: Lista assegnazioni per una data. - `GET /assignments/{date}`: Lista assegnazioni per una data.
- `GET /my-assignments`: Le mie assegnazioni parcheggio. - `GET /my-assignments`: Le mie assegnazioni parcheggio.
- `POST /run-allocation`: Esegui manualmente l'algoritmo di assegnazione per una data. - `POST /run-allocation`: Esegui manualmente l'algoritmo di assegnazione per una data.
- `POST /clear-assignments`: Cancella tutte le assegnazioni per una data. - `POST /clear-assignments`: Cancella tutte le assegnazioni per una data.
- `POST /manual-assign`: Assegna manualmente un posto a un utente. - `POST /manual-assign`: Assegna manualmente un posto a un utente.
- `POST /reassign-spot`: Riassegna o libera un posto già assegnato. - `POST /reassign-spot`: Riassegna o libera un posto già assegnato.
- `POST /release-my-spot/{id}`: Rilascia il proprio posto assegnato. - `POST /release-my-spot/{id}`: Rilascia il proprio posto assegnato.
- `GET /eligible-users/{id}`: Lista utenti idonei a ricevere un posto riassegnato. - `GET /eligible-users/{id}`: Lista utenti idonei a ricevere un posto riassegnato.
## Utilizzo con AUTHELIA ## 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. 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 ### Configurazione
1. **Abilita Authelia**: 1. **Abilita Authelia**:
Nel file `.env`, imposta `AUTHELIA_ENABLED=true`. Nel file `.env`, imposta `AUTHELIA_ENABLED=true`.
2. **Configura gli Header del Proxy**: 2. **Configura gli Header del Proxy**:
Assicurati che il tuo reverse proxy (es. Traefik, Nginx) passi i seguenti header all'applicazione dopo l'autenticazione: 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-User`: Username dell'utente (spesso uguale all'email).
* `Remote-Email`: Email dell'utente. * `Remote-Email`: Email dell'utente.
* `Remote-Name`: Nome completo dell'utente (Opzionale). * `Remote-Name`: Nome completo dell'utente (Opzionale).
* `Remote-Groups`: Gruppi di appartenenza (separati da virgola). * `Remote-Groups`: Gruppi di appartenenza (separati da virgola).
3. **Gestione Admin**: 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`). 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. * 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. * Gli altri ruoli (Manager, Employee) e l'assegnazione agli uffici vengono gestiti manualmente all'interno di Org-Parking da un amministratore.
### Comportamento ### Comportamento
* **Creazione Utenti**: Gli utenti vengono creati automaticamente nel database di Org-Parking al loro primo accesso riuscito tramite Authelia. * **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. * **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. * **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. * **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 ## Note di Deployment
- **Database**: SQLite è usato di default per semplicità e basso overhead. I dati persistono in `./data/parking.db`. - **Database**: SQLite è usato di default per semplicità e basso overhead. I dati persistono in `./data/parking.db`.
- **Sicurezza**: - **Sicurezza**:
- Rate limiting è attivo sugli endpoint sensibili (Login/Register). - Rate limiting è attivo sugli endpoint sensibili (Login/Register).
- Le password sono hashate con Bcrypt. - Le password sono hashate con Bcrypt.
- L'autenticazione via cookie è sicura di default. - L'autenticazione via cookie è sicura di default.
### Risoluzione Problemi Comuni ### Risoluzione Problemi Comuni
**Errore: "Il reindirizzamento è stato determinato per essere non sicuro"** **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. 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. **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 ## Licenza
MIT MIT

View File

@@ -26,10 +26,10 @@ router = APIRouter(prefix="/api/offices", tags=["offices"])
class ValidOfficeCreate(BaseModel): class ValidOfficeCreate(BaseModel):
name: str name: str
parking_quota: int = 0 parking_quota: int = 0
booking_window_enabled: bool = True booking_window_enabled: bool = False
booking_window_end_hour: int = 18 booking_window_end_hour: int = 18
booking_window_end_minute: int = 0 booking_window_end_minute: int = 0
assignment_mode: str = "fairness" assignment_mode: str = "random"
class ClosingDayCreate(BaseModel): class ClosingDayCreate(BaseModel):
@@ -92,7 +92,11 @@ def list_offices(db: Session = Depends(get_db), user=Depends(require_manager_or_
"name": office.name, "name": office.name,
"parking_quota": office.parking_quota, "parking_quota": office.parking_quota,
"spot_prefix": office.spot_prefix, "spot_prefix": office.spot_prefix,
"user_count": user_counts.get(office.id, 0) "user_count": user_counts.get(office.id, 0),
"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,
"assignment_mode": office.assignment_mode
} }
for office in offices for office in offices
] ]

View File

@@ -7,6 +7,7 @@ let currentUser = null;
let offices = []; let offices = [];
document.addEventListener('DOMContentLoaded', async () => { document.addEventListener('DOMContentLoaded', async () => {
populateTimeSelects();
currentUser = await api.requireAuth(); currentUser = await api.requireAuth();
if (!currentUser) return; if (!currentUser) return;
@@ -17,7 +18,6 @@ document.addEventListener('DOMContentLoaded', async () => {
await loadOffices(); await loadOffices();
setupEventListeners(); setupEventListeners();
populateTimeSelects();
}); });
function populateTimeSelects() { function populateTimeSelects() {
@@ -48,7 +48,7 @@ function populateTimeSelects() {
} }
async function loadOffices() { async function loadOffices() {
const response = await api.getCached('/api/offices', 60); const response = await api.get('/api/offices'); // Force fresh load
if (response && response.ok) { if (response && response.ok) {
offices = await response.json(); offices = await response.json();
renderOffices(); renderOffices();
@@ -98,14 +98,30 @@ async function editOffice(officeId) {
document.getElementById('officeName').value = office.name; document.getElementById('officeName').value = office.name;
document.getElementById('officeQuota').value = office.parking_quota; document.getElementById('officeQuota').value = office.parking_quota;
// Set booking window settings // Set assignment mode mapping
document.getElementById('officeWindowEnabled').checked = office.booking_window_enabled !== false; const modeSelect = document.getElementById('assignmentModeSelect');
if (office.booking_window_enabled === false) {
modeSelect.value = 'realtime';
} else {
modeSelect.value = office.assignment_mode || 'random';
}
// Set cutoff time
document.getElementById('officeCutoffHour').value = office.booking_window_end_hour != null ? office.booking_window_end_hour : 18; 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; document.getElementById('officeCutoffMinute').value = office.booking_window_end_minute != null ? office.booking_window_end_minute : 0;
updateCutoffVisibility();
openModal('Modifica Gruppo'); openModal('Modifica Gruppo');
} }
function updateCutoffVisibility() {
const mode = document.getElementById('assignmentModeSelect').value;
const group = document.getElementById('cutoffGroup');
if (group) {
group.style.display = (mode === 'realtime') ? 'none' : 'block';
}
}
async function deleteOffice(officeId) { async function deleteOffice(officeId) {
const office = offices.find(o => o.id === officeId); const office = offices.find(o => o.id === officeId);
if (!office) return; if (!office) return;
@@ -143,6 +159,9 @@ function setupEventListeners() {
// Form submit // Form submit
const form = document.getElementById('officeForm'); const form = document.getElementById('officeForm');
form.addEventListener('submit', handleOfficeSubmit); form.addEventListener('submit', handleOfficeSubmit);
// Assignment mode change
document.getElementById('assignmentModeSelect').addEventListener('change', updateCutoffVisibility);
} }
async function handleOfficeSubmit(e) { async function handleOfficeSubmit(e) {
@@ -155,12 +174,15 @@ async function handleOfficeSubmit(e) {
saveBtn.innerHTML = 'Salvataggio...'; saveBtn.innerHTML = 'Salvataggio...';
const officeId = document.getElementById('officeId').value; const officeId = document.getElementById('officeId').value;
const mode = document.getElementById('assignmentModeSelect').value;
const data = { const data = {
name: document.getElementById('officeName').value, name: document.getElementById('officeName').value,
parking_quota: parseInt(document.getElementById('officeQuota').value) || 0, parking_quota: parseInt(document.getElementById('officeQuota').value) || 0,
booking_window_enabled: document.getElementById('officeWindowEnabled').checked, booking_window_enabled: mode !== 'realtime',
booking_window_end_hour: parseInt(document.getElementById('officeCutoffHour').value), booking_window_end_hour: parseInt(document.getElementById('officeCutoffHour').value),
booking_window_end_minute: parseInt(document.getElementById('officeCutoffMinute').value) booking_window_end_minute: parseInt(document.getElementById('officeCutoffMinute').value),
assignment_mode: mode === 'realtime' ? 'random' : mode
}; };
console.log('Payload:', data); console.log('Payload:', data);

View File

@@ -75,7 +75,7 @@ const ModalLogic = {
}, },
openModal(data) { openModal(data) {
const { dateStr, userName, presence, parking, userId, isReadOnly } = data; const { dateStr, userName, presence, parking, userId, isParkingOnly } = data;
this.currentDate = dateStr; this.currentDate = dateStr;
this.currentUserId = userId; // Optional, for team view this.currentUserId = userId; // Optional, for team view
@@ -84,8 +84,9 @@ const ModalLogic = {
const modal = document.getElementById('dayModal'); const modal = document.getElementById('dayModal');
const title = document.getElementById('dayModalTitle'); const title = document.getElementById('dayModalTitle');
const userLabel = document.getElementById('dayModalUser'); const userLabel = document.getElementById('dayModalUser');
const statusButtons = document.querySelector('#dayModal .status-buttons');
const clearBtn = document.getElementById('clearDayBtn');
title.textContent = utils.formatDateDisplay(dateStr);
// Show/Hide User Name (for Team Calendar) // Show/Hide User Name (for Team Calendar)
if (userName && userLabel) { if (userName && userLabel) {
@@ -95,22 +96,26 @@ const ModalLogic = {
userLabel.style.display = 'none'; userLabel.style.display = 'none';
} }
// Highlight status // Presence Logic
document.querySelectorAll('#dayModal .status-btn').forEach(btn => { if (isParkingOnly) {
const status = btn.dataset.status; if (statusButtons) statusButtons.style.display = 'none';
if (presence && presence.status === status) { if (clearBtn) clearBtn.style.display = 'none';
btn.classList.add('active'); title.textContent = `Gestione Parcheggio - ${utils.formatDateDisplay(dateStr)}`;
} else {
btn.classList.remove('active');
}
});
// Clear button visibility
const clearBtn = document.getElementById('clearDayBtn');
if (presence) {
clearBtn.style.display = 'block';
} else { } else {
clearBtn.style.display = 'none'; if (statusButtons) statusButtons.style.display = 'grid';
if (clearBtn) clearBtn.style.display = presence ? 'block' : 'none';
title.textContent = utils.formatDateDisplay(dateStr);
// 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');
}
});
} }
// Parking Section & Reset Form // Parking Section & Reset Form
@@ -122,7 +127,8 @@ const ModalLogic = {
if (parking) { if (parking) {
parkingSection.style.display = 'block'; parkingSection.style.display = 'block';
const spotName = parking.spot_display_name || parking.spot_id; const spotName = parking.spot_display_name || parking.spot_id;
parkingInfo.innerHTML = `<strong>Parcheggio:</strong> Posto ${spotName}`; const occupantInfo = userName ? ` (Occupato da ${userName})` : '';
parkingInfo.innerHTML = `<strong>Posto ${spotName}</strong>${occupantInfo}`;
} else { } else {
parkingSection.style.display = 'none'; parkingSection.style.display = 'none';
} }

View File

@@ -64,8 +64,7 @@ const NAV_ITEMS = [
{ href: '/team-calendar', icon: 'users', label: 'Calendario del team' }, { href: '/team-calendar', icon: 'users', label: 'Calendario del team' },
{ href: '/team-rules', icon: 'rules', label: 'Regole parcheggio', roles: ['admin', 'manager'] }, { href: '/team-rules', icon: 'rules', label: 'Regole parcheggio', roles: ['admin', 'manager'] },
{ href: '/admin/users', icon: 'user', label: 'Gestione Utenti', roles: ['admin'] }, { href: '/admin/users', icon: 'user', label: 'Gestione Utenti', roles: ['admin'] },
{ href: '/admin/offices', icon: 'building', label: 'Gestione Gruppi', roles: ['admin'] }, { href: '/admin/offices', icon: 'building', label: 'Gestione Gruppi', roles: ['admin'] }
{ href: '/parking-settings', icon: 'settings', label: 'Impostazioni Gruppi', roles: ['admin', 'manager'] }
]; ];
function getIcon(name) { function getIcon(name) {

View File

@@ -1,373 +0,0 @@
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 gruppi', '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 gruppo 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);
}
const minuteSelect = document.getElementById('bookingWindowMinute');
minuteSelect.innerHTML = '';
for (let m = 0; m < 60; m++) {
const option = document.createElement('option');
option.value = m;
option.textContent = m.toString().padStart(2, '0');
minuteSelect.appendChild(option);
}
}
async function loadOfficeSettings(id) {
const officeId = id;
if (!officeId) {
utils.showMessage('Nessun gruppo 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('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;
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 = {
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)
};
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
// 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 = utils.formatDate(current);
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');
// Loop is fine, but maybe redundant if we could batch clean?
// Backend clear-assignments is per day.
while (current <= end) {
const dateStr = utils.formatDate(current);
try {
const res = await api.post('/api/parking/clear-assignments', {
date: dateStr,
office_id: currentOffice.id
});
if (res && res.ok) {
const data = await res.json();
totalRemoved += (data.count || 0);
}
} catch (e) {
console.error(`Error clearing ${dateStr}`, e);
}
current.setDate(current.getDate() + 1);
}
utils.showMessage(`Operazione eseguita.`, 'warning');
});
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 del gruppo nel periodo selezionato. \n\nQuesta azione è irreversibile. 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');
// Validate office
if (!currentOffice || !currentOffice.id) {
return utils.showMessage('Errore: Nessun gruppo selezionato', 'error');
}
const endDateVal = dateEnd || dateStart;
utils.showMessage('Rimozione stati in corso...', 'warning');
try {
const res = await api.post('/api/presence/admin/clear-office-presence', {
start_date: dateStart,
end_date: endDateVal,
office_id: currentOffice.id
});
if (res && res.ok) {
const data = await res.json();
utils.showMessage(`Operazione completata. Rimossi ${data.count_presence} stati e ${data.count_parking} assegnazioni.`, 'success');
} else {
const err = await res.json();
utils.showMessage('Errore: ' + (err.detail || 'Operazione fallita'), 'error');
}
} catch (e) {
console.error(e);
utils.showMessage('Errore di comunicazione col server', 'error');
}
});
}
const testEmailBtn = document.getElementById('testEmailBtn');
if (testEmailBtn) {
testEmailBtn.addEventListener('click', async () => {
const dateVal = document.getElementById('testEmailDate').value;
// Validate office
if (!currentOffice || !currentOffice.id) {
return utils.showMessage('Errore: Nessun gruppo selezionato', 'error');
}
utils.showMessage('Invio mail di test in corso...', 'warning');
try {
const res = await api.post('/api/parking/test-email', {
date: dateVal || null,
office_id: currentOffice.id
});
if (res && res.status >= 200 && res.status < 300) {
const data = await res.json();
if (data.success) {
let msg = `Email inviata con successo per la data: ${data.date}.`;
if (data.mode === 'FILE') {
msg += ' (SMTP disabilitato: Loggato su file)';
}
utils.showMessage(msg, 'success');
} else {
utils.showMessage('Invio fallito. Controlla i log del server.', 'error');
}
} else {
const err = res ? await res.json() : {};
console.error("Test Email Error:", err);
const errMsg = err.detail ? (typeof err.detail === 'object' ? JSON.stringify(err.detail) : err.detail) : 'Invio fallito';
utils.showMessage('Errore: ' + errMsg, 'error');
}
} catch (e) {
console.error(e);
utils.showMessage('Errore di comunicazione col server', 'error');
}
});
}
const bulkEmailBtn = document.getElementById('bulkEmailBtn');
if (bulkEmailBtn) {
bulkEmailBtn.addEventListener('click', async () => {
const dateVal = document.getElementById('testEmailDate').value;
// Validate office
if (!currentOffice || !currentOffice.id) {
return utils.showMessage('Errore: Nessun gruppo selezionato', 'error');
}
if (!dateVal) {
return utils.showMessage('Per il test a TUTTI è obbligatorio selezionare una data specifica.', 'error');
}
if (!confirm(`Sei sicuro di voler inviare una mail di promemoria a TUTTI gli utenti con parcheggio assegnato per il giorno ${dateVal}?\n\nQuesta azione invierà vere email.`)) return;
utils.showMessage('Invio mail massive in corso...', 'warning');
try {
const res = await api.post('/api/parking/test-email', {
date: dateVal,
office_id: currentOffice.id,
bulk_send: true
});
if (res && res.status >= 200 && res.status < 300) {
const data = await res.json();
if (data.success) {
let msg = `Processo completato per il ${data.date}. Inviate: ${data.count || 0}, Fallite: ${data.failed || 0}.`;
if (data.mode === 'BULK' && (data.count || 0) === 0) msg += " (Nessun assegnatario trovato)";
utils.showMessage(msg, 'success');
} else {
utils.showMessage('Errore durante l\'invio.', 'error');
}
} else {
const err = res ? await res.json() : {};
console.error("Bulk Test Email Error:", err);
const errMsg = err.detail ? (typeof err.detail === 'object' ? JSON.stringify(err.detail) : err.detail) : 'Invio fallito';
utils.showMessage('Errore: ' + errMsg, 'error');
}
} catch (e) {
console.error(e);
utils.showMessage('Errore di comunicazione col server: ' + e.message, 'error');
}
});
}
}

View File

@@ -234,36 +234,44 @@ async function handleClearPresence(date) {
async function handleReleaseParking(assignmentId) { async function handleReleaseParking(assignmentId) {
if (!confirm('Rilasciare il parcheggio per questa data?')) return; if (!confirm('Rilasciare il parcheggio per questa data?')) return;
const response = await api.post(`/api/parking/release-my-spot/${assignmentId}`); utils.showMessage('Rilascio in corso...', 'warning');
const response = await api.post('/api/parking/reassign-spot', {
assignment_id: assignmentId,
new_user_id: null
});
if (response && response.ok) { if (response && response.ok) {
await loadParkingAssignments(); utils.showMessage('Posto liberato con successo', 'success');
await Promise.all([loadParkingAssignments(), loadDailyStatus()]);
renderCalendar(); renderCalendar();
ModalLogic.closeModal(); ModalLogic.closeModal();
} else { } else {
const error = await response.json(); const error = await response.json();
alert(error.detail || 'Impossibile rilasciare il parcheggio'); utils.showMessage(error.detail || 'Impossibile rilasciare il parcheggio', 'error');
} }
} }
async function handleReassignParking(assignmentId, newUserId) { async function handleReassignParking(assignmentId, newUserId) {
// Basic validation handled by select; confirm // Basic validation handled by select; confirm
if (!assignmentId || !newUserId) { if (!assignmentId || !newUserId) {
alert('Seleziona un utente'); utils.showMessage('Seleziona un utente', 'error');
return; return;
} }
utils.showMessage('Riassegnazione in corso...', 'warning');
const response = await api.post('/api/parking/reassign-spot', { const response = await api.post('/api/parking/reassign-spot', {
assignment_id: assignmentId, assignment_id: assignmentId,
new_user_id: newUserId new_user_id: newUserId
}); });
if (response && response.ok) { if (response && response.ok) {
await loadParkingAssignments(); utils.showMessage('Posto riassegnato con successo', 'success');
await Promise.all([loadParkingAssignments(), loadDailyStatus()]);
renderCalendar(); renderCalendar();
ModalLogic.closeModal(); ModalLogic.closeModal();
} else { } else {
const error = await response.json(); const error = await response.json();
alert(error.detail || 'Impossibile riassegnare il parcheggio'); utils.showMessage(error.detail || 'Impossibile riassegnare il parcheggio', 'error');
} }
} }
@@ -576,25 +584,14 @@ function renderParkingStatus(assignments) {
el.style.cursor = 'pointer'; el.style.cursor = 'pointer';
el.addEventListener('mouseenter', () => el.style.boxShadow = '0 4px 6px rgba(0,0,0,0.1)'); el.addEventListener('mouseenter', () => el.style.boxShadow = '0 4px 6px rgba(0,0,0,0.1)');
el.addEventListener('mouseleave', () => el.style.boxShadow = 'none'); el.addEventListener('mouseleave', () => el.style.boxShadow = 'none');
el.title = "Clicca per liberare questo posto"; el.title = "Clicca per gestire questo posto";
el.addEventListener('click', async () => { el.addEventListener('click', () => {
if (!confirm(`Vuoi liberare il posto ${spotName} occupato da ${statusText}?`)) return; ModalLogic.openModal({
dateStr: a.date,
utils.showMessage('Rilascio in corso...', 'warning'); parking: a,
const response = await api.post('/api/parking/reassign-spot', { userName: a.user_name,
assignment_id: a.id, isParkingOnly: true
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');
}
}); });
} }

View File

@@ -26,7 +26,6 @@ document.addEventListener('DOMContentLoaded', async () => {
await loadOffices(); await loadOffices();
await loadTeamData(); await loadTeamData();
await loadTeamData();
// Initialize Modal Logic // Initialize Modal Logic
ModalLogic.init({ ModalLogic.init({
@@ -79,7 +78,10 @@ async function loadOffices() {
if (currentUser.role !== 'admin') { if (currentUser.role !== 'admin') {
select.style.display = 'none'; select.style.display = 'none';
// Employees stop here, Managers continue to allow auto-selection logic below // Employees stop here, Managers continue to allow auto-selection logic below
if (currentUser.role === 'employee') return; if (currentUser.role === 'employee') {
updateOfficeDisplay();
return;
}
} }
// Cache offices list // Cache offices list
@@ -312,7 +314,9 @@ function renderCalendar() {
// Build header row // Build header row
const dayNames = ['Dom', 'Lun', 'Mar', 'Mer', 'Gio', 'Ven', 'Sab']; const dayNames = ['Dom', 'Lun', 'Mar', 'Mer', 'Gio', 'Ven', 'Sab'];
let headerHtml = '<th>Nome</th><th>Gruppo</th>'; const showGroup = currentUser.role === 'admin';
let headerHtml = '<th>Nome</th>';
if (showGroup) headerHtml += '<th>Gruppo</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);
@@ -342,7 +346,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">Nessun membro del team trovato</td></tr>`; body.innerHTML = `<tr><td colspan="${dayCount + (showGroup ? 2 : 1)}" class="text-center">Nessun membro del team trovato</td></tr>`;
return; return;
} }
@@ -361,8 +365,11 @@ function renderCalendar() {
} }
bodyHtml += `<tr> bodyHtml += `<tr>
<td class="member-name" style="text-align: left; padding-left: 1rem;">${nameHtml}</td> <td class="member-name" style="text-align: left; padding-left: 1rem;">${nameHtml}</td>`;
<td class="member-manager">${member.office_name || '-'}</td>`;
if (showGroup) {
bodyHtml += `<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);

View File

@@ -7,6 +7,7 @@
let currentUser = null; let currentUser = null;
let offices = []; let offices = [];
let currentOfficeId = null; let currentOfficeId = null;
let currentOffice = null;
let officeUsers = []; let officeUsers = [];
let currentWeeklyClosingDays = []; let currentWeeklyClosingDays = [];
@@ -20,6 +21,7 @@ document.addEventListener('DOMContentLoaded', async () => {
return; return;
} }
populateHourSelect();
await loadOffices(); await loadOffices();
setupEventListeners(); setupEventListeners();
}); });
@@ -73,6 +75,27 @@ async function loadOfficeRules(officeId) {
document.getElementById('rulesContent').style.display = 'block'; document.getElementById('rulesContent').style.display = 'block';
document.getElementById('noOfficeMessage').style.display = 'none'; document.getElementById('noOfficeMessage').style.display = 'none';
// Load full office object for algorithm settings
try {
const response = await api.get(`/api/offices/${officeId}`);
if (response && response.ok) {
currentOffice = await response.json();
// Populate algorithm form
const modeSelect = document.getElementById('assignmentModeSelect');
if (currentOffice.booking_window_enabled === false) {
modeSelect.value = 'realtime';
} else {
modeSelect.value = currentOffice.assignment_mode || 'random';
}
document.getElementById('bookingWindowHour').value = currentOffice.booking_window_end_hour ?? 18;
document.getElementById('bookingWindowMinute').value = currentOffice.booking_window_end_minute ?? 0;
updateAlgorithmVisibility();
}
} catch (e) {
console.error("Error loading office details:", e);
}
// Load users for this office (for dropdowns) // Load users for this office (for dropdowns)
await loadOfficeUsers(officeId); await loadOfficeUsers(officeId);
@@ -91,6 +114,67 @@ async function loadOfficeUsers(officeId) {
} }
} }
function populateHourSelect() {
const hourSelect = document.getElementById('bookingWindowHour');
const minuteSelect = document.getElementById('bookingWindowMinute');
if (!hourSelect || !minuteSelect) return;
hourSelect.innerHTML = '';
for (let h = 0; h < 24; h++) {
const option = document.createElement('option');
option.value = h;
option.textContent = h.toString().padStart(2, '0');
hourSelect.appendChild(option);
}
minuteSelect.innerHTML = '';
for (let m = 0; m < 60; m++) {
const option = document.createElement('option');
option.value = m;
option.textContent = m.toString().padStart(2, '0');
minuteSelect.appendChild(option);
}
}
function updateAlgorithmVisibility() {
const mode = document.getElementById('assignmentModeSelect').value;
const group = document.getElementById('cutoffTimeGroup');
if (group) group.style.display = (mode === 'realtime') ? 'none' : 'block';
}
async function saveAlgorithmSettings(e) {
e.preventDefault();
if (!currentOfficeId) return;
const btn = e.target.querySelector('button[type="submit"]');
const originalText = btn.textContent;
btn.disabled = true;
btn.textContent = 'Salvataggio...';
const mode = document.getElementById('assignmentModeSelect').value;
const data = {
assignment_mode: mode === 'realtime' ? 'random' : mode,
booking_window_enabled: mode !== 'realtime',
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/${currentOfficeId}`, data);
if (res) {
utils.showMessage('Impostazioni algoritmo salvate', 'success');
currentOffice = res;
}
} catch (e) {
console.error(e);
utils.showMessage('Errore nel salvataggio impostazioni', 'error');
} finally {
btn.disabled = false;
btn.textContent = originalText;
}
}
// Weekly Closing Days // Weekly Closing Days
async function loadWeeklyClosingDays(officeId) { async function loadWeeklyClosingDays(officeId) {
const response = await api.get(`/api/offices/${officeId}/weekly-closing-days`); const response = await api.get(`/api/offices/${officeId}/weekly-closing-days`);
@@ -426,6 +510,230 @@ function setupEventListeners() {
notes: document.getElementById('exclusionNotes').value || null notes: document.getElementById('exclusionNotes').value || null
}); });
}); });
// Algorithm settings events
document.getElementById('assignmentModeSelect').addEventListener('change', updateAlgorithmVisibility);
document.getElementById('algorithmForm').addEventListener('submit', saveAlgorithmSettings);
// Test Tools Logic
// Set default date to tomorrow
const tomorrow = new Date();
tomorrow.setDate(tomorrow.getDate() + 1);
const testDateStart = document.getElementById('testDateStart');
if (testDateStart) testDateStart.valueAsDate = tomorrow;
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 = utils.formatDate(current);
try {
await api.post('/api/parking/run-allocation', {
date: dateStr,
office_id: currentOfficeId
});
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 = utils.formatDate(current);
try {
const res = await api.post('/api/parking/clear-assignments', {
date: dateStr,
office_id: currentOfficeId
});
if (res && res.ok) {
const data = await res.json();
totalRemoved += (data.count || 0);
}
} catch (e) {
console.error(`Error clearing ${dateStr}`, e);
}
current.setDate(current.getDate() + 1);
}
utils.showMessage(`Operazione eseguita.`, 'warning');
});
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 del gruppo nel periodo selezionato. \n\nQuesta azione è irreversibile. 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');
// Validate office
if (!currentOfficeId) {
return utils.showMessage('Errore: Nessun gruppo selezionato', 'error');
}
const endDateVal = dateEnd || dateStart;
utils.showMessage('Rimozione stati in corso...', 'warning');
try {
const res = await api.post('/api/presence/admin/clear-office-presence', {
start_date: dateStart,
end_date: endDateVal,
office_id: currentOfficeId
});
if (res && res.ok) {
const data = await res.json();
utils.showMessage(`Operazione completata. Rimossi ${data.count_presence} stati e ${data.count_parking} assegnazioni.`, 'success');
} else {
const err = await res.json();
utils.showMessage('Errore: ' + (err.detail || 'Operazione fallita'), 'error');
}
} catch (e) {
console.error(e);
utils.showMessage('Errore di comunicazione col server', 'error');
}
});
}
const testEmailBtn = document.getElementById('testEmailBtn');
if (testEmailBtn) {
testEmailBtn.addEventListener('click', async () => {
const dateVal = document.getElementById('testEmailDate').value;
// Validate office
if (!currentOfficeId) {
return utils.showMessage('Errore: Nessun gruppo selezionato', 'error');
}
utils.showMessage('Invio mail di test in corso...', 'warning');
try {
const res = await api.post('/api/parking/test-email', {
date: dateVal || null,
office_id: currentOfficeId
});
if (res && res.status >= 200 && res.status < 300) {
const data = await res.json();
if (data.success) {
let msg = `Email inviata con successo per la data: ${data.date}.`;
if (data.mode === 'FILE') {
msg += ' (SMTP disabilitato: Loggato su file)';
}
utils.showMessage(msg, 'success');
} else {
utils.showMessage('Invio fallito. Controlla i log del server.', 'error');
}
} else {
const err = res ? await res.json() : {};
console.error("Test Email Error:", err);
const errMsg = err.detail ? (typeof err.detail === 'object' ? JSON.stringify(err.detail) : err.detail) : 'Invio fallito';
utils.showMessage('Errore: ' + errMsg, 'error');
}
} catch (e) {
console.error(e);
utils.showMessage('Errore di comunicazione col server', 'error');
}
});
}
const bulkEmailBtn = document.getElementById('bulkEmailBtn');
if (bulkEmailBtn) {
bulkEmailBtn.addEventListener('click', async () => {
const dateVal = document.getElementById('testEmailDate').value;
// Validate office
if (!currentOfficeId) {
return utils.showMessage('Errore: Nessun gruppo selezionato', 'error');
}
if (!dateVal) {
return utils.showMessage('Per il test a TUTTI è obbligatorio selezionare una data specifica.', 'error');
}
if (!confirm(`Sei sicuro di voler inviare una mail di promemoria a TUTTI gli utenti con parcheggio assegnato per il giorno ${dateVal}?\n\nQuesta azione invierà vere email.`)) return;
utils.showMessage('Invio mail massive in corso...', 'warning');
try {
const res = await api.post('/api/parking/test-email', {
date: dateVal,
office_id: currentOfficeId,
bulk_send: true
});
if (res && res.status >= 200 && res.status < 300) {
const data = await res.json();
if (data.success) {
let msg = `Processo completato per il ${data.date}. Inviate: ${data.count || 0}, Fallite: ${data.failed || 0}.`;
if (data.mode === 'BULK' && (data.count || 0) === 0) msg += " (Nessun assegnatario trovato)";
utils.showMessage(msg, 'success');
} else {
utils.showMessage('Errore durante l\'invio.', 'error');
}
} else {
const err = res ? await res.json() : {};
console.error("Bulk Test Email Error:", err);
const errMsg = err.detail ? (typeof err.detail === 'object' ? JSON.stringify(err.detail) : err.detail) : 'Invio fallito';
utils.showMessage('Errore: ' + errMsg, 'error');
}
} catch (e) {
console.error(e);
utils.showMessage('Errore di comunicazione col server: ' + e.message, 'error');
}
});
}
} }
// Global functions // Global functions

View File

@@ -94,6 +94,16 @@
</div> </div>
<div class="form-group"> <div class="form-group">
<label for="assignmentModeSelect">Metodo di Assegnazione</label>
<select id="assignmentModeSelect" class="form-control">
<option value="realtime">In Tempo Reale (FIFO)</option>
<option value="random">Automatico - Casuale (Batch)</option>
<option value="fairness">Automatico - Punteggio (Batch)</option>
</select>
<small class="text-muted">FIFO: primo che arriva prende il posto. Automatico: assegnazione collettiva dopo il cut-off.</small>
</div>
<div class="form-group" id="cutoffGroup">
<label>Orario di Cut-off (Giorno Precedente)</label> <label>Orario di Cut-off (Giorno Precedente)</label>
<div style="display: flex; gap: 10px; align-items: center;"> <div style="display: flex; gap: 10px; align-items: center;">
<select id="officeCutoffHour" class="form-control" style="width: 80px;"> <select id="officeCutoffHour" class="form-control" style="width: 80px;">
@@ -106,12 +116,8 @@
<option value="30">30</option> <option value="30">30</option>
<option value="45">45</option> <option value="45">45</option>
</select> </select>
<label style="margin-left: 10px; display: flex; align-items: center; gap: 5px;">
<input type="checkbox" id="officeWindowEnabled">
Abilita Assegnazione Automatica
</label>
</div> </div>
<small class="text-muted">Orario limite per la prenotazione del giorno successivo</small> <small class="text-muted">Orario limite per la prenotazione del giorno successivo (solo per modalità Automatica)</small>
</div> </div>

View File

@@ -1,183 +0,0 @@
<!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 Gruppo</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: Algorithm Settings -->
<div class="card">
<div class="card-header">
<h3>Impostazioni Algoritmo Parcheggio</h3>
</div>
<div class="card-body">
<form id="scheduleForm">
<div class="form-group" style="padding-bottom: 1rem; border-bottom: 1px solid #e5e7eb; margin-bottom: 1rem;">
<label style="font-weight: 500; display: block; margin-bottom: 0.5rem;">Modalità Assegnazione</label>
<p class="text-muted" style="margin-bottom: 0.5rem;">Scegli la modalità con cui assegnare i posti auto ai membri del gruppo.</p>
<select id="assignmentModeSelect" class="form-select" style="max-width: 300px;">
<option value="fairness">Punteggio (Fairness)</option>
<option value="random">Completamente Random</option>
</select>
</div>
<div class="form-group">
<label class="toggle-label">
<span style="font-weight: 500;">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 style="font-weight: 500;">Orario di Cut-off (Giorno Precedente)</label>
<div style="display: flex; gap: 0.5rem; align-items: center; margin-top: 0.5rem;">
<select id="bookingWindowHour" style="width: 80px;">
</select>
<span>:</span>
<select id="bookingWindowMinute" style="width: 80px;">
</select>
</div>
<small class="text-muted">Le presenze inserite prima di questo orario saranno messe in attesa.</small>
</div>
<div class="form-actions" style="margin-top: 1.5rem;">
<button type="submit" class="btn btn-dark">Salva Impostazioni</button>
</div>
</form>
</div>
</div>
<!-- Card: 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>
<button id="clearPresenceBtn" class="btn btn-danger"
title="Elimina stati e assegnazioni per i giorni selezionati">
Elimina Stati
</button>
</div>
<hr style="margin: 1.5rem 0; border: 0; border-top: 1px solid #e5e7eb;">
<div class="form-group">
<label>Test Invio Email</label>
<div style="display: flex; gap: 1rem; align-items: flex-end;">
<div style="flex: 1;">
<small>Data di Riferimento (Opzionale):</small>
<input type="date" id="testEmailDate" class="form-control">
<small class="text-muted" style="display: block; margin-top: 0.25rem;">
Se non specificata, verrà usato il primo giorno lavorativo disponibile.
</small>
</div>
<button id="testEmailBtn" class="btn btn-secondary">
Test (Solo a Me)
</button>
<button id="bulkEmailBtn" class="btn btn-warning"
title="Invia mail reale a tutti gli assegnatari">
Test (A Tutti)
</button>
</div>
</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

@@ -60,6 +60,42 @@
</div> </div>
<div id="rulesContent" style="display: none;"> <div id="rulesContent" style="display: none;">
<!-- Card: Algorithm Settings -->
<div class="card" style="margin-bottom: 1.5rem;">
<div class="card-header">
<h3>Impostazioni Algoritmo Parcheggio</h3>
</div>
<div class="card-body">
<form id="algorithmForm">
<div class="form-group" style="padding-bottom: 1rem; border-bottom: 1px solid #e5e7eb; margin-bottom: 1rem;">
<label style="font-weight: 500; display: block; margin-bottom: 0.5rem;">Metodo di Assegnazione</label>
<p class="text-muted" style="margin-bottom: 0.5rem;">Scegli come assegnare i posti auto ai membri del gruppo.</p>
<select id="assignmentModeSelect" class="form-select" style="max-width: 300px;">
<option value="realtime">In Tempo Reale (FIFO)</option>
<option value="random">Automatico - Casuale (Batch)</option>
<option value="fairness">Automatico - Punteggio (Batch)</option>
</select>
<small class="text-muted" style="display: block; margin-top: 0.5rem;">FIFO: assegnazione immediata. Automatico: assegnazione collettiva dopo il cut-off.</small>
</div>
<div class="form-group" id="cutoffTimeGroup">
<label style="font-weight: 500;">Orario di Cut-off (Giorno Precedente)</label>
<div style="display: flex; gap: 0.5rem; align-items: center; margin-top: 0.5rem;">
<select id="bookingWindowHour" style="width: 80px;">
</select>
<span>:</span>
<select id="bookingWindowMinute" style="width: 80px;">
</select>
</div>
<small class="text-muted">Orario limite per la prenotazione del giorno successivo (solo per modalità Automatica)</small>
</div>
<div class="form-actions" style="margin-top: 1.5rem;">
<button type="submit" class="btn btn-dark">Salva Impostazioni</button>
</div>
</form>
</div>
</div>
<!-- Weekly Closing Days --> <!-- Weekly Closing Days -->
<div class="card"> <div class="card">
@@ -119,6 +155,69 @@
<div id="exclusionsList" class="rules-list"></div> <div id="exclusionsList" class="rules-list"></div>
</div> </div>
</div> </div>
<!-- Card: Testing Tools -->
<div class="card" style="margin-top: 1.5rem;">
<div class="card-header"
style="display: flex; justify-content: space-between; align-items: center;">
<h3>Strumenti di Test</h3>
<span class="badge" style="background: #fff7ed; color: #c2410c; border: 1px solid #ffedd5; padding: 0.25rem 0.5rem; font-size: 0.75rem;">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>
<button id="clearPresenceBtn" class="btn btn-danger"
title="Elimina stati e assegnazioni per i giorni selezionati">
Elimina Stati
</button>
</div>
<hr style="margin: 1.5rem 0; border: 0; border-top: 1px solid #e5e7eb;">
<div class="form-group">
<label>Test Invio Email</label>
<div style="display: flex; gap: 1rem; align-items: flex-end;">
<div style="flex: 1;">
<small>Data di Riferimento (Opzionale):</small>
<input type="date" id="testEmailDate" class="form-control">
<small class="text-muted" style="display: block; margin-top: 0.25rem;">
Se non specificata, verrà usato il primo giorno lavorativo disponibile.
</small>
</div>
<button id="testEmailBtn" class="btn btn-secondary">
Test (Solo a Me)
</button>
<button id="bulkEmailBtn" class="btn btn-warning"
title="Invia mail reale a tutti gli assegnatari">
Test (A Tutti)
</button>
</div>
</div>
</div>
</div>
</div> </div>
</div> </div>

View File

@@ -195,10 +195,7 @@ 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")