feat: aggiunti: loggica random, tema scuro, correzioni mail, miglioramenti generali, cache;
This commit is contained in:
214
README.md
214
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
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ class ValidOfficeCreate(BaseModel):
|
|||||||
booking_window_enabled: bool = True
|
booking_window_enabled: bool = True
|
||||||
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"
|
||||||
|
|
||||||
|
|
||||||
class ClosingDayCreate(BaseModel):
|
class ClosingDayCreate(BaseModel):
|
||||||
@@ -61,6 +62,7 @@ class OfficeSettingsUpdate(BaseModel):
|
|||||||
booking_window_enabled: bool | None = None
|
booking_window_enabled: bool | None = None
|
||||||
booking_window_end_hour: int | None = None
|
booking_window_end_hour: int | None = None
|
||||||
booking_window_end_minute: int | None = None
|
booking_window_end_minute: int | None = None
|
||||||
|
assignment_mode: str | None = None
|
||||||
|
|
||||||
|
|
||||||
# Helper check
|
# 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_enabled=data.booking_window_enabled,
|
||||||
booking_window_end_hour=data.booking_window_end_hour,
|
booking_window_end_hour=data.booking_window_end_hour,
|
||||||
booking_window_end_minute=data.booking_window_end_minute,
|
booking_window_end_minute=data.booking_window_end_minute,
|
||||||
|
assignment_mode=data.assignment_mode,
|
||||||
created_at=datetime.utcnow()
|
created_at=datetime.utcnow()
|
||||||
)
|
)
|
||||||
db.add(office)
|
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,
|
"user_count": user_count,
|
||||||
"booking_window_enabled": office.booking_window_enabled,
|
"booking_window_enabled": office.booking_window_enabled,
|
||||||
"booking_window_end_hour": office.booking_window_end_hour,
|
"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")
|
raise HTTPException(status_code=400, detail="Minute must be 0-59")
|
||||||
office.booking_window_end_minute = data.booking_window_end_minute
|
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()
|
office.updated_at = datetime.utcnow()
|
||||||
db.commit()
|
db.commit()
|
||||||
|
|
||||||
@@ -208,7 +215,8 @@ def update_office_settings(office_id: str, data: OfficeSettingsUpdate, db: Sessi
|
|||||||
"spot_prefix": office.spot_prefix,
|
"spot_prefix": office.spot_prefix,
|
||||||
"booking_window_enabled": office.booking_window_enabled,
|
"booking_window_enabled": office.booking_window_enabled,
|
||||||
"booking_window_end_hour": office.booking_window_end_hour,
|
"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}")
|
@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.add(closing_day)
|
||||||
db.commit()
|
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"}
|
return {"id": closing_day.id, "message": "Closing day added"}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ from services.parking import (
|
|||||||
run_batch_allocation, clear_assignments_for_office_date
|
run_batch_allocation, clear_assignments_for_office_date
|
||||||
)
|
)
|
||||||
from services.notifications import notify_parking_assigned, notify_parking_released, notify_parking_reassigned
|
from services.notifications import notify_parking_assigned, notify_parking_released, notify_parking_reassigned
|
||||||
|
from utils.helpers import generate_uuid
|
||||||
from app import config
|
from app import config
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/parking", tags=["parking"])
|
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.add(new_assignment)
|
||||||
db.commit()
|
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}
|
return {"message": "Spot assigned", "spot_id": data.spot_id, "spot_display_name": spot_def.name}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -268,10 +268,21 @@ def get_team_presences(start_date: date, end_date: date, office_id: str = None,
|
|||||||
office_lookup = {o.id: o.name for o in offices}
|
office_lookup = {o.id: o.name for o in offices}
|
||||||
|
|
||||||
# Build response
|
# Build response
|
||||||
|
from services.parking import get_user_parking_ratio
|
||||||
result = []
|
result = []
|
||||||
for user in users:
|
for user in users:
|
||||||
user_presences = [p for p in presences if p.user_id == user.id]
|
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({
|
result.append({
|
||||||
"id": user.id,
|
"id": user.id,
|
||||||
"name": user.name,
|
"name": user.name,
|
||||||
@@ -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),
|
"office_name": office_lookup.get(user.office_id),
|
||||||
"presences": [{"date": p.date, "status": p.status} for p in user_presences],
|
"presences": [{"date": p.date, "status": p.status} for p in user_presences],
|
||||||
"parking_dates": parking_lookup.get(user.id, []),
|
"parking_dates": parking_lookup.get(user.id, []),
|
||||||
"parking_info": parking_info_lookup.get(user.id, [])
|
"parking_info": parking_info_lookup.get(user.id, []),
|
||||||
|
"ratio": user_ratio
|
||||||
})
|
})
|
||||||
|
|
||||||
return result
|
return result
|
||||||
|
|||||||
@@ -62,6 +62,8 @@ class Office(Base):
|
|||||||
booking_window_end_hour = Column(Integer, default=18) # 0-23
|
booking_window_end_hour = Column(Integer, default=18) # 0-23
|
||||||
booking_window_end_minute = Column(Integer, default=0) # 0-59
|
booking_window_end_minute = Column(Integer, default=0) # 0-59
|
||||||
|
|
||||||
|
assignment_mode = Column(String, default="fairness")
|
||||||
|
|
||||||
created_at = Column(DateTime, default=datetime.utcnow)
|
created_at = Column(DateTime, default=datetime.utcnow)
|
||||||
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
||||||
|
|
||||||
|
|||||||
@@ -16,6 +16,8 @@
|
|||||||
--warning-bg: #fde68a;
|
--warning-bg: #fde68a;
|
||||||
--danger: #dc2626;
|
--danger: #dc2626;
|
||||||
--danger-bg: #fee2e2;
|
--danger-bg: #fee2e2;
|
||||||
|
--info: #3b82f6;
|
||||||
|
--info-bg: #dbeafe;
|
||||||
--text: #1f1f1f;
|
--text: #1f1f1f;
|
||||||
--text-secondary: #666;
|
--text-secondary: #666;
|
||||||
--text-muted: #999;
|
--text-muted: #999;
|
||||||
@@ -25,6 +27,49 @@
|
|||||||
--bg-white: #fff;
|
--bg-white: #fff;
|
||||||
--sidebar-width: 260px;
|
--sidebar-width: 260px;
|
||||||
--header-height: 64px;
|
--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;
|
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 {
|
a {
|
||||||
color: inherit;
|
color: inherit;
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
@@ -72,7 +142,7 @@ textarea {
|
|||||||
top: 0;
|
top: 0;
|
||||||
bottom: 0;
|
bottom: 0;
|
||||||
width: var(--sidebar-width);
|
width: var(--sidebar-width);
|
||||||
background: white;
|
background: var(--bg-white);
|
||||||
color: var(--text);
|
color: var(--text);
|
||||||
border-right: 1px solid var(--border);
|
border-right: 1px solid var(--border);
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -183,7 +253,7 @@ textarea {
|
|||||||
left: 0;
|
left: 0;
|
||||||
right: 0;
|
right: 0;
|
||||||
margin-bottom: 0.5rem;
|
margin-bottom: 0.5rem;
|
||||||
background: white;
|
background: var(--bg-white);
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.15);
|
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.15);
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
@@ -226,7 +296,7 @@ textarea {
|
|||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
padding: 0 1.5rem;
|
padding: 0 1.5rem;
|
||||||
min-height: 53px;
|
min-height: 53px;
|
||||||
background: white;
|
background: var(--bg-white);
|
||||||
border-bottom: 1px solid var(--border);
|
border-bottom: 1px solid var(--border);
|
||||||
gap: 0.75rem;
|
gap: 0.75rem;
|
||||||
}
|
}
|
||||||
@@ -253,7 +323,7 @@ textarea {
|
|||||||
Cards
|
Cards
|
||||||
============================================================================ */
|
============================================================================ */
|
||||||
.card {
|
.card {
|
||||||
background: white;
|
background: var(--bg-white);
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
padding: 1rem;
|
padding: 1rem;
|
||||||
@@ -302,7 +372,7 @@ textarea {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.btn-secondary {
|
.btn-secondary {
|
||||||
background: white;
|
background: var(--bg-white);
|
||||||
color: var(--text);
|
color: var(--text);
|
||||||
border: 1px solid var(--border-dark);
|
border: 1px solid var(--border-dark);
|
||||||
}
|
}
|
||||||
@@ -330,10 +400,12 @@ textarea {
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
|
color: var(--text);
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-icon:hover {
|
.btn-icon:hover {
|
||||||
background: var(--bg);
|
background: var(--bg);
|
||||||
|
color: var(--text);
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-full {
|
.btn-full {
|
||||||
@@ -364,7 +436,8 @@ textarea {
|
|||||||
font-size: 0.9rem;
|
font-size: 0.9rem;
|
||||||
border: 1px solid var(--border-dark);
|
border: 1px solid var(--border-dark);
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
background: white;
|
background: var(--bg-white);
|
||||||
|
color: var(--text);
|
||||||
}
|
}
|
||||||
|
|
||||||
.form-input:focus,
|
.form-input:focus,
|
||||||
@@ -430,7 +503,7 @@ textarea {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.modal-content {
|
.modal-content {
|
||||||
background: white;
|
background: var(--bg-white);
|
||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
max-width: 480px;
|
max-width: 480px;
|
||||||
@@ -490,12 +563,12 @@ textarea {
|
|||||||
|
|
||||||
.message.success {
|
.message.success {
|
||||||
background: var(--success-bg);
|
background: var(--success-bg);
|
||||||
color: #166534;
|
color: var(--success);
|
||||||
}
|
}
|
||||||
|
|
||||||
.message.error {
|
.message.error {
|
||||||
background: var(--danger-bg);
|
background: var(--danger-bg);
|
||||||
color: #991b1b;
|
color: var(--danger);
|
||||||
}
|
}
|
||||||
|
|
||||||
.badge {
|
.badge {
|
||||||
@@ -508,17 +581,17 @@ textarea {
|
|||||||
|
|
||||||
.badge-success {
|
.badge-success {
|
||||||
background: var(--success-bg);
|
background: var(--success-bg);
|
||||||
color: #166534;
|
color: var(--success);
|
||||||
}
|
}
|
||||||
|
|
||||||
.badge-warning {
|
.badge-warning {
|
||||||
background: var(--warning-bg);
|
background: var(--warning-bg);
|
||||||
color: #92400e;
|
color: var(--warning);
|
||||||
}
|
}
|
||||||
|
|
||||||
.badge-danger {
|
.badge-danger {
|
||||||
background: var(--danger-bg);
|
background: var(--danger-bg);
|
||||||
color: #991b1b;
|
color: var(--danger);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ============================================================================
|
/* ============================================================================
|
||||||
@@ -591,11 +664,11 @@ textarea {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.calendar-day.weekend {
|
.calendar-day.weekend {
|
||||||
background: #f5f5f5;
|
background: var(--bg-weekend);
|
||||||
}
|
}
|
||||||
|
|
||||||
.calendar-day.holiday {
|
.calendar-day.holiday {
|
||||||
background: #fff7ed;
|
background: var(--bg-holiday);
|
||||||
}
|
}
|
||||||
|
|
||||||
.calendar-day.today {
|
.calendar-day.today {
|
||||||
@@ -643,8 +716,8 @@ textarea {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.status-remote {
|
.status-remote {
|
||||||
background: #dbeafe !important;
|
background: var(--info-bg) !important;
|
||||||
border-color: #3b82f6 !important;
|
border-color: var(--info) !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.status-absent {
|
.status-absent {
|
||||||
@@ -658,19 +731,19 @@ textarea {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.status-nodata {
|
.status-nodata {
|
||||||
background: white;
|
background: var(--bg-white);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Closed Day */
|
/* Closed Day */
|
||||||
.calendar-day.closed {
|
.calendar-day.closed {
|
||||||
background: #e5e7eb;
|
background: var(--bg-closed);
|
||||||
color: #9ca3af;
|
color: var(--text-closed);
|
||||||
cursor: not-allowed;
|
cursor: not-allowed;
|
||||||
border-color: #d1d5db;
|
border-color: var(--border-closed);
|
||||||
}
|
}
|
||||||
|
|
||||||
.calendar-day.closed:hover {
|
.calendar-day.closed:hover {
|
||||||
border-color: #d1d5db;
|
border-color: var(--border-closed);
|
||||||
}
|
}
|
||||||
|
|
||||||
.calendar-day.closed .day-number {
|
.calendar-day.closed .day-number {
|
||||||
@@ -678,8 +751,8 @@ textarea {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.team-calendar td.closed {
|
.team-calendar td.closed {
|
||||||
background: #e5e7eb;
|
background: var(--bg-closed);
|
||||||
color: #9ca3af;
|
color: var(--text-closed);
|
||||||
cursor: not-allowed;
|
cursor: not-allowed;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -723,7 +796,7 @@ textarea {
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 0.5rem;
|
gap: 0.5rem;
|
||||||
padding: 1rem;
|
padding: 1rem;
|
||||||
background: white;
|
background: var(--bg-white);
|
||||||
border: 2px solid var(--border);
|
border: 2px solid var(--border);
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
@@ -789,12 +862,12 @@ textarea {
|
|||||||
|
|
||||||
.team-calendar th.weekend,
|
.team-calendar th.weekend,
|
||||||
.team-calendar td.weekend {
|
.team-calendar td.weekend {
|
||||||
background: #f5f5f5;
|
background: var(--bg-weekend);
|
||||||
}
|
}
|
||||||
|
|
||||||
.team-calendar th.holiday,
|
.team-calendar th.holiday,
|
||||||
.team-calendar td.holiday {
|
.team-calendar td.holiday {
|
||||||
background: #fff7ed;
|
background: var(--bg-holiday);
|
||||||
}
|
}
|
||||||
|
|
||||||
.team-calendar th.today {
|
.team-calendar th.today {
|
||||||
@@ -820,7 +893,7 @@ textarea {
|
|||||||
min-width: 150px;
|
min-width: 150px;
|
||||||
position: sticky;
|
position: sticky;
|
||||||
left: 0;
|
left: 0;
|
||||||
background: white;
|
background: var(--bg-white);
|
||||||
z-index: 1;
|
z-index: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -828,7 +901,7 @@ textarea {
|
|||||||
text-align: left !important;
|
text-align: left !important;
|
||||||
position: sticky;
|
position: sticky;
|
||||||
left: 0;
|
left: 0;
|
||||||
background: white;
|
background: var(--bg-white);
|
||||||
z-index: 1;
|
z-index: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -880,7 +953,7 @@ textarea {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.parking-spot {
|
.parking-spot {
|
||||||
background: white;
|
background: var(--bg-white);
|
||||||
border: 2px solid var(--border);
|
border: 2px solid var(--border);
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
padding: 1rem;
|
padding: 1rem;
|
||||||
@@ -1009,7 +1082,7 @@ textarea {
|
|||||||
width: 20px;
|
width: 20px;
|
||||||
left: 3px;
|
left: 3px;
|
||||||
bottom: 3px;
|
bottom: 3px;
|
||||||
background-color: white;
|
background-color: var(--bg-white);
|
||||||
transition: 0.3s;
|
transition: 0.3s;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
}
|
}
|
||||||
@@ -1034,7 +1107,7 @@ textarea {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.auth-card {
|
.auth-card {
|
||||||
background: white;
|
background: var(--bg-white);
|
||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.1);
|
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.1);
|
||||||
padding: 2.5rem;
|
padding: 2.5rem;
|
||||||
@@ -1078,7 +1151,7 @@ textarea {
|
|||||||
|
|
||||||
.settings-section,
|
.settings-section,
|
||||||
.profile-section {
|
.profile-section {
|
||||||
background: white;
|
background: var(--bg-white);
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
padding: 1.5rem;
|
padding: 1.5rem;
|
||||||
@@ -1136,6 +1209,8 @@ textarea {
|
|||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
font-size: 0.9rem;
|
font-size: 0.9rem;
|
||||||
min-width: 140px;
|
min-width: 140px;
|
||||||
|
background: var(--bg-white);
|
||||||
|
color: var(--text);
|
||||||
}
|
}
|
||||||
|
|
||||||
.profile-field {
|
.profile-field {
|
||||||
@@ -1194,7 +1269,7 @@ textarea {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.rule-section {
|
.rule-section {
|
||||||
background: white;
|
background: var(--bg-white);
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
padding: 1.5rem;
|
padding: 1.5rem;
|
||||||
@@ -1265,7 +1340,7 @@ textarea {
|
|||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
padding: 0.75rem;
|
padding: 0.75rem;
|
||||||
background: white;
|
background: var(--bg-white);
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
}
|
}
|
||||||
@@ -1298,14 +1373,15 @@ textarea {
|
|||||||
border: 1px solid var(--border-dark);
|
border: 1px solid var(--border-dark);
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
font-size: 0.9rem;
|
font-size: 0.9rem;
|
||||||
background: white;
|
background: var(--bg-white);
|
||||||
|
color: var(--text);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ============================================================================
|
/* ============================================================================
|
||||||
Admin Tables
|
Admin Tables
|
||||||
============================================================================ */
|
============================================================================ */
|
||||||
.admin-table {
|
.admin-table {
|
||||||
background: white;
|
background: var(--bg-white);
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
@@ -1433,12 +1509,18 @@ textarea {
|
|||||||
|
|
||||||
.team-calendar-table th.weekend,
|
.team-calendar-table th.weekend,
|
||||||
.team-calendar-table td.weekend {
|
.team-calendar-table td.weekend {
|
||||||
background: #f5f5f5;
|
background: var(--bg-weekend);
|
||||||
}
|
}
|
||||||
|
|
||||||
.team-calendar-table th.holiday,
|
.team-calendar-table th.holiday,
|
||||||
.team-calendar-table td.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 {
|
.team-calendar-table .member-name {
|
||||||
@@ -1482,7 +1564,7 @@ textarea {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.team-calendar-table .calendar-cell.status-remote {
|
.team-calendar-table .calendar-cell.status-remote {
|
||||||
background: #dbeafe !important;
|
background: var(--info-bg) !important;
|
||||||
border-color: var(--border) !important;
|
border-color: var(--border) !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1530,8 +1612,8 @@ textarea {
|
|||||||
|
|
||||||
.parking-badge-sm {
|
.parking-badge-sm {
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
background: #dbeafe;
|
background: var(--info-bg);
|
||||||
color: #1e40af;
|
color: var(--info);
|
||||||
font-size: 0.55rem;
|
font-size: 0.55rem;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
padding: 0.1rem 0.25rem;
|
padding: 0.1rem 0.25rem;
|
||||||
@@ -1647,7 +1729,8 @@ textarea {
|
|||||||
border: 1px solid var(--border-dark);
|
border: 1px solid var(--border-dark);
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
font-size: 0.9rem;
|
font-size: 0.9rem;
|
||||||
background: white;
|
background: var(--bg-white);
|
||||||
|
color: var(--text);
|
||||||
min-width: 150px;
|
min-width: 150px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -59,7 +59,7 @@ function renderOffices() {
|
|||||||
const tbody = document.getElementById('officesBody');
|
const tbody = document.getElementById('officesBody');
|
||||||
|
|
||||||
if (offices.length === 0) {
|
if (offices.length === 0) {
|
||||||
tbody.innerHTML = '<tr><td colspan="5" class="text-center">Nessun ufficio trovato</td></tr>';
|
tbody.innerHTML = '<tr><td colspan="5" class="text-center">Nessun gruppo trovato</td></tr>';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -72,7 +72,7 @@ function renderOffices() {
|
|||||||
<td>${office.user_count || 0} utenti</td>
|
<td>${office.user_count || 0} utenti</td>
|
||||||
<td>
|
<td>
|
||||||
<button class="btn btn-sm btn-secondary" onclick="editOffice('${office.id}')">Modifica</button>
|
<button class="btn btn-sm btn-secondary" onclick="editOffice('${office.id}')">Modifica</button>
|
||||||
<button class="btn btn-sm btn-danger" onclick="deleteOffice('${office.id}')" ${office.user_count > 0 ? 'title="Impossibile eliminare uffici con utenti" disabled' : ''}>Elimina</button>
|
<button class="btn btn-sm btn-danger" onclick="deleteOffice('${office.id}')" ${office.user_count > 0 ? 'title="Impossibile eliminare gruppi con utenti" disabled' : ''}>Elimina</button>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
`;
|
`;
|
||||||
@@ -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('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;
|
||||||
|
|
||||||
openModal('Modifica Ufficio');
|
openModal('Modifica Gruppo');
|
||||||
}
|
}
|
||||||
|
|
||||||
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;
|
||||||
|
|
||||||
if (!confirm(`Eliminare l'ufficio "${office.name}"?`)) return;
|
if (!confirm(`Eliminare il gruppo "${office.name}"?`)) return;
|
||||||
|
|
||||||
const response = await api.delete(`/api/offices/${officeId}`);
|
const response = await api.delete(`/api/offices/${officeId}`);
|
||||||
if (response && response.ok) {
|
if (response && response.ok) {
|
||||||
utils.showMessage('Ufficio eliminato', 'success');
|
utils.showMessage('Gruppo eliminato', 'success');
|
||||||
api.invalidateCache('/api/offices'); // Clear cache
|
api.invalidateCache('/api/offices'); // Clear cache
|
||||||
await loadOffices();
|
await loadOffices();
|
||||||
} else {
|
} else {
|
||||||
const error = await response.json();
|
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() {
|
function setupEventListeners() {
|
||||||
// Add button
|
// Add button
|
||||||
document.getElementById('addOfficeBtn').addEventListener('click', () => {
|
document.getElementById('addOfficeBtn').addEventListener('click', () => {
|
||||||
openModal('Nuovo Ufficio');
|
openModal('Nuovo Gruppo');
|
||||||
});
|
});
|
||||||
|
|
||||||
// Modal close
|
// Modal close
|
||||||
@@ -177,7 +177,7 @@ async function handleOfficeSubmit(e) {
|
|||||||
|
|
||||||
if (response && response.ok) {
|
if (response && response.ok) {
|
||||||
closeModal();
|
closeModal();
|
||||||
utils.showMessage(officeId ? 'Ufficio aggiornato' : 'Ufficio creato', 'success');
|
utils.showMessage(officeId ? 'Gruppo aggiornato' : 'Gruppo creato', 'success');
|
||||||
api.invalidateCache('/api/offices'); // Clear cache
|
api.invalidateCache('/api/offices'); // Clear cache
|
||||||
await loadOffices();
|
await loadOffices();
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -129,7 +129,7 @@ async function editUser(userId) {
|
|||||||
|
|
||||||
// Populate office dropdown
|
// Populate office dropdown
|
||||||
const officeSelect = document.getElementById('editOffice');
|
const officeSelect = document.getElementById('editOffice');
|
||||||
officeSelect.innerHTML = '<option value="">Nessun ufficio</option>';
|
officeSelect.innerHTML = '<option value="">Nessun gruppo</option>';
|
||||||
offices.forEach(o => {
|
offices.forEach(o => {
|
||||||
const option = document.createElement('option');
|
const option = document.createElement('option');
|
||||||
option.value = o.id;
|
option.value = o.id;
|
||||||
|
|||||||
@@ -42,6 +42,20 @@ const ICONS = {
|
|||||||
settings: `<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
settings: `<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
<circle cx="12" cy="12" r="3"></circle>
|
<circle cx="12" cy="12" r="3"></circle>
|
||||||
<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"></path>
|
<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"></path>
|
||||||
|
</svg>`,
|
||||||
|
sun: `<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
|
<circle cx="12" cy="12" r="5"></circle>
|
||||||
|
<line x1="12" y1="1" x2="12" y2="3"></line>
|
||||||
|
<line x1="12" y1="21" x2="12" y2="23"></line>
|
||||||
|
<line x1="4.22" y1="4.22" x2="5.64" y2="5.64"></line>
|
||||||
|
<line x1="18.36" y1="18.36" x2="19.78" y2="19.78"></line>
|
||||||
|
<line x1="1" y1="12" x2="3" y2="12"></line>
|
||||||
|
<line x1="21" y1="12" x2="23" y2="12"></line>
|
||||||
|
<line x1="4.22" y1="19.78" x2="5.64" y2="18.36"></line>
|
||||||
|
<line x1="18.36" y1="5.64" x2="19.78" y2="4.22"></line>
|
||||||
|
</svg>`,
|
||||||
|
moon: `<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
|
<path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"></path>
|
||||||
</svg>`
|
</svg>`
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -50,8 +64,8 @@ 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 Uffici', roles: ['admin'] },
|
{ href: '/admin/offices', icon: 'building', label: 'Gestione Gruppi', roles: ['admin'] },
|
||||||
{ href: '/parking-settings', icon: 'settings', label: 'Impostazioni Ufficio', roles: ['admin', 'manager'] }
|
{ href: '/parking-settings', icon: 'settings', label: 'Impostazioni Gruppi', roles: ['admin', 'manager'] }
|
||||||
];
|
];
|
||||||
|
|
||||||
function getIcon(name) {
|
function getIcon(name) {
|
||||||
@@ -98,9 +112,10 @@ async function initNav() {
|
|||||||
// Setup user menu (logout) & mobile menu
|
// Setup user menu (logout) & mobile menu
|
||||||
setupUserMenu();
|
setupUserMenu();
|
||||||
setupMobileMenu();
|
setupMobileMenu();
|
||||||
|
setupThemeToggle();
|
||||||
|
|
||||||
// CHECK: Block access if user has no office (and is not admin)
|
// 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') {
|
if (currentUser && !currentUser.office_id && currentUser.role !== 'admin') {
|
||||||
navContainer.innerHTML = ''; // Clear nav
|
navContainer.innerHTML = ''; // Clear nav
|
||||||
|
|
||||||
@@ -124,14 +139,14 @@ async function initNav() {
|
|||||||
<line x1="12" y1="16" x2="12.01" y2="16"></line>
|
<line x1="12" y1="16" x2="12.01" y2="16"></line>
|
||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
<h2 style="margin-bottom: 1rem;">Ufficio non assegnato</h2>
|
<h2 style="margin-bottom: 1rem;">Gruppo non assegnato</h2>
|
||||||
<p class="text-secondary" style="margin-bottom: 1.5rem; line-height: 1.6;">
|
<p class="text-secondary" style="margin-bottom: 1.5rem; line-height: 1.6;">
|
||||||
Il tuo account <strong>${currentUser.email}</strong> è attivo, ma non sei ancora stato assegnato a nessuno ufficio.
|
Il tuo account <strong>${currentUser.email}</strong> è attivo, ma non sei ancora stato assegnato a nessuno gruppo.
|
||||||
</p>
|
</p>
|
||||||
<div style="padding: 1.5rem; background: #f8fafc; border-radius: 8px; text-align: left;">
|
<div style="padding: 1.5rem; background: #f8fafc; border-radius: 8px; text-align: left;">
|
||||||
<div style="font-weight: 600; margin-bottom: 0.5rem; color: var(--text);">Cosa fare?</div>
|
<div style="font-weight: 600; margin-bottom: 0.5rem; color: var(--text);">Cosa fare?</div>
|
||||||
<div style="font-size: 0.95rem; color: var(--text-secondary);">
|
<div style="font-size: 0.95rem; color: var(--text-secondary);">
|
||||||
Contatta l'amministratore di sistema per richiedere l'assegnazione al tuo ufficio di competenza.<br>
|
Contatta l'amministratore di sistema per richiedere l'assegnazione al tuo gruppo di competenza.<br>
|
||||||
<a href="mailto:s.salemi@sielte.it" style="color: var(--primary); text-decoration: none; font-weight: 500; margin-top: 0.5rem; display: inline-block;">s.salemi@sielte.it</a>
|
<a href="mailto:s.salemi@sielte.it" style="color: var(--primary); text-decoration: none; font-weight: 500; margin-top: 0.5rem; display: inline-block;">s.salemi@sielte.it</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -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
|
// Export for use in other scripts
|
||||||
window.getIcon = getIcon;
|
window.getIcon = getIcon;
|
||||||
|
|
||||||
|
|||||||
@@ -49,7 +49,7 @@ async function loadOffices() {
|
|||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error(e);
|
console.error(e);
|
||||||
utils.showMessage('Errore caricamento uffici', 'error');
|
utils.showMessage('Errore caricamento gruppi', 'error');
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Manager uses their own office
|
// Manager uses their own office
|
||||||
@@ -58,7 +58,7 @@ async function loadOffices() {
|
|||||||
if (currentUser.office_id) {
|
if (currentUser.office_id) {
|
||||||
await loadOfficeSettings(currentUser.office_id);
|
await loadOfficeSettings(currentUser.office_id);
|
||||||
} else {
|
} 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) {
|
async function loadOfficeSettings(id) {
|
||||||
const officeId = id;
|
const officeId = id;
|
||||||
if (!officeId) {
|
if (!officeId) {
|
||||||
utils.showMessage('Nessun ufficio selezionato', 'error');
|
utils.showMessage('Nessun gruppo selezionato', 'error');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -98,6 +98,7 @@ async function loadOfficeSettings(id) {
|
|||||||
currentOffice = office;
|
currentOffice = office;
|
||||||
|
|
||||||
// Populate form
|
// Populate form
|
||||||
|
document.getElementById('assignmentModeSelect').value = office.assignment_mode || 'fairness';
|
||||||
document.getElementById('bookingWindowEnabled').checked = office.booking_window_enabled || false;
|
document.getElementById('bookingWindowEnabled').checked = office.booking_window_enabled || false;
|
||||||
document.getElementById('bookingWindowHour').value = office.booking_window_end_hour ?? 18; // Default 18
|
document.getElementById('bookingWindowHour').value = office.booking_window_end_hour ?? 18; // Default 18
|
||||||
document.getElementById('bookingWindowMinute').value = office.booking_window_end_minute ?? 0;
|
document.getElementById('bookingWindowMinute').value = office.booking_window_end_minute ?? 0;
|
||||||
@@ -136,6 +137,7 @@ function setupEventListeners() {
|
|||||||
if (!currentOffice) return;
|
if (!currentOffice) return;
|
||||||
|
|
||||||
const data = {
|
const data = {
|
||||||
|
assignment_mode: document.getElementById('assignmentModeSelect').value,
|
||||||
booking_window_enabled: document.getElementById('bookingWindowEnabled').checked,
|
booking_window_enabled: document.getElementById('bookingWindowEnabled').checked,
|
||||||
booking_window_end_hour: parseInt(document.getElementById('bookingWindowHour').value),
|
booking_window_end_hour: parseInt(document.getElementById('bookingWindowHour').value),
|
||||||
booking_window_end_minute: parseInt(document.getElementById('bookingWindowMinute').value)
|
booking_window_end_minute: parseInt(document.getElementById('bookingWindowMinute').value)
|
||||||
@@ -242,7 +244,7 @@ function setupEventListeners() {
|
|||||||
const clearPresenceBtn = document.getElementById('clearPresenceBtn');
|
const clearPresenceBtn = document.getElementById('clearPresenceBtn');
|
||||||
if (clearPresenceBtn) {
|
if (clearPresenceBtn) {
|
||||||
clearPresenceBtn.addEventListener('click', async () => {
|
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 dateStart = document.getElementById('testDateStart').value;
|
||||||
const dateEnd = document.getElementById('testDateEnd').value;
|
const dateEnd = document.getElementById('testDateEnd').value;
|
||||||
@@ -251,7 +253,7 @@ function setupEventListeners() {
|
|||||||
|
|
||||||
// Validate office
|
// Validate office
|
||||||
if (!currentOffice || !currentOffice.id) {
|
if (!currentOffice || !currentOffice.id) {
|
||||||
return utils.showMessage('Errore: Nessun ufficio selezionato', 'error');
|
return utils.showMessage('Errore: Nessun gruppo selezionato', 'error');
|
||||||
}
|
}
|
||||||
|
|
||||||
const endDateVal = dateEnd || dateStart;
|
const endDateVal = dateEnd || dateStart;
|
||||||
@@ -286,7 +288,7 @@ function setupEventListeners() {
|
|||||||
|
|
||||||
// Validate office
|
// Validate office
|
||||||
if (!currentOffice || !currentOffice.id) {
|
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');
|
utils.showMessage('Invio mail di test in corso...', 'warning');
|
||||||
@@ -329,7 +331,7 @@ function setupEventListeners() {
|
|||||||
|
|
||||||
// Validate office
|
// Validate office
|
||||||
if (!currentOffice || !currentOffice.id) {
|
if (!currentOffice || !currentOffice.id) {
|
||||||
return utils.showMessage('Errore: Nessun ufficio selezionato', 'error');
|
return utils.showMessage('Errore: Nessun gruppo selezionato', 'error');
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!dateVal) {
|
if (!dateVal) {
|
||||||
|
|||||||
@@ -175,11 +175,12 @@ function renderCalendar() {
|
|||||||
return date >= start && date <= end;
|
return date >= start && date <= end;
|
||||||
});
|
});
|
||||||
|
|
||||||
const isClosed = isWeeklyClosed || isSpecificClosed;
|
const isClosed = isHoliday || isWeeklyClosed || isSpecificClosed;
|
||||||
|
|
||||||
if (isClosed) {
|
if (isClosed) {
|
||||||
cell.classList.add('closed');
|
cell.classList.add('closed');
|
||||||
cell.title = "Ufficio Chiuso";
|
if (isHoliday) cell.title = "Festività";
|
||||||
|
else cell.title = "Gruppo Chiuso";
|
||||||
} else if (presence) {
|
} else if (presence) {
|
||||||
cell.classList.add(`status-${presence.status}`);
|
cell.classList.add(`status-${presence.status}`);
|
||||||
}
|
}
|
||||||
@@ -418,10 +419,10 @@ function initParkingStatus() {
|
|||||||
if (headerDisplay) headerDisplay.textContent = currentUser.office_name;
|
if (headerDisplay) headerDisplay.textContent = currentUser.office_name;
|
||||||
} else {
|
} else {
|
||||||
const nameDisplay = document.getElementById('statusOfficeName');
|
const nameDisplay = document.getElementById('statusOfficeName');
|
||||||
if (nameDisplay) nameDisplay.textContent = 'Tuo Ufficio';
|
if (nameDisplay) nameDisplay.textContent = 'Tuo Gruppo';
|
||||||
|
|
||||||
const headerDisplay = document.getElementById('currentOfficeDisplay');
|
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 officeId = currentUser.office_id;
|
||||||
|
|
||||||
const grid = document.getElementById('spotsGrid');
|
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 = `
|
||||||
|
<div style="width:100%; text-align:center; padding:1rem 1rem; color: var(--text-secondary); background: var(--bg-hover); border-radius: 8px;">
|
||||||
|
<div style="font-size: 1.2rem; font-weight: 500;">Ufficio Chiuso</div>
|
||||||
|
<div style="font-size: 0.9rem; margin-top: 0.5rem; opacity: 0.8;">Nessun parcheggio disponibile in questa data.</div>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
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
|
// Keep grid height to avoid jump if possible, or just loading styling
|
||||||
if (grid) grid.innerHTML = '<div style="width:100%; text-align:center; padding:2rem; color:var(--text-secondary);">Caricamento...</div>';
|
if (grid) grid.innerHTML = '<div style="width:100%; text-align:center; padding:2rem; color:var(--text-secondary);">Caricamento...</div>';
|
||||||
|
|
||||||
@@ -505,10 +534,9 @@ function renderParkingStatus(assignments) {
|
|||||||
// Colors: Free = Green (default), Occupied = Yellow (requested)
|
// Colors: Free = Green (default), Occupied = Yellow (requested)
|
||||||
// Yellow palette: Border #eab308, bg #fefce8, text #a16207, icon #eab308
|
// Yellow palette: Border #eab308, bg #fefce8, text #a16207, icon #eab308
|
||||||
|
|
||||||
const borderColor = isFree ? '#22c55e' : '#eab308';
|
const borderColor = isFree ? 'var(--spot-free-border)' : 'var(--spot-occ-border)';
|
||||||
const bgColor = isFree ? '#f0fdf4' : '#fefce8';
|
const bgColor = isFree ? 'var(--spot-free-bg)' : 'var(--spot-occ-bg)';
|
||||||
const textColor = isFree ? '#15803d' : '#a16207';
|
const textColor = isFree ? 'var(--spot-free-text)' : 'var(--spot-occ-text)';
|
||||||
const iconColor = isFree ? '#22c55e' : '#eab308';
|
|
||||||
|
|
||||||
const el = document.createElement('div');
|
const el = document.createElement('div');
|
||||||
el.className = 'spot-card';
|
el.className = 'spot-card';
|
||||||
@@ -527,15 +555,49 @@ function renderParkingStatus(assignments) {
|
|||||||
transition: all 0.2s;
|
transition: all 0.2s;
|
||||||
`;
|
`;
|
||||||
|
|
||||||
// New Car Icon (Front Facing Sedan style or similar simple shape)
|
|
||||||
// Using a cleaner SVG path
|
|
||||||
el.innerHTML = `
|
el.innerHTML = `
|
||||||
<div style="font-weight: 600; font-size: 1.1rem; color: #1f2937;">${spotName}</div>
|
<div style="font-weight: 600; font-size: 1.1rem; color: var(--text);">${spotName}</div>
|
||||||
<div style="color: ${textColor}; font-size: 0.85rem; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; max-width: 100%;" title="${statusText}">
|
<div style="color: ${textColor}; font-size: 0.85rem; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; max-width: 100%;" title="${statusText}">
|
||||||
${statusText}
|
${statusText}
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
|
|
||||||
|
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);
|
grid.appendChild(el);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -569,6 +631,97 @@ function setupStatusListeners() {
|
|||||||
loadDailyStatus();
|
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 <strong>${spotName}</strong> per la giornata del <strong>${dateStr}</strong>.`;
|
||||||
|
selectUser.innerHTML = '<option value="">Caricamento...</option>';
|
||||||
|
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 = '<option value="">Nessun utente presente e senza parcheggio oggi...</option>';
|
||||||
|
} else {
|
||||||
|
selectUser.innerHTML = '<option value="">Seleziona utente...</option>';
|
||||||
|
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 = '<option value="">Errore caricamento utenti</option>';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -48,7 +48,7 @@ function updateOfficeDisplay() {
|
|||||||
|
|
||||||
// If user is employee, show their office name directly
|
// If user is employee, show their office name directly
|
||||||
if (currentUser.role === 'employee') {
|
if (currentUser.role === 'employee') {
|
||||||
display.textContent = currentUser.office_name || "Mio Ufficio";
|
display.textContent = currentUser.office_name || "Mio Gruppo";
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -63,10 +63,10 @@ function updateOfficeDisplay() {
|
|||||||
// let text = option.textContent.split('(')[0].trim();
|
// let text = option.textContent.split('(')[0].trim();
|
||||||
display.textContent = option.textContent;
|
display.textContent = option.textContent;
|
||||||
} else {
|
} else {
|
||||||
display.textContent = "Tutti gli Uffici";
|
display.textContent = "Tutti i Gruppi";
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
display.textContent = "Tutti gli Uffici";
|
display.textContent = "Tutti i Gruppi";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -312,7 +312,7 @@ 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>Ufficio</th>';
|
let headerHtml = '<th>Nome</th><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);
|
||||||
@@ -348,8 +348,20 @@ function renderCalendar() {
|
|||||||
|
|
||||||
let bodyHtml = '';
|
let bodyHtml = '';
|
||||||
teamData.forEach(member => {
|
teamData.forEach(member => {
|
||||||
|
let nameHtml = member.name || 'Unknown';
|
||||||
|
if (member.ratio !== undefined && member.ratio !== null) {
|
||||||
|
nameHtml += `
|
||||||
|
<span style="margin-left: 6px; font-size: 0.8rem; color: var(--text-secondary); cursor: help;" title="(Giorni in cui hai avuto parcheggio) / (Giorni in sede)">
|
||||||
|
<svg style="vertical-align: text-bottom; margin-right: 2px;" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
|
<circle cx="12" cy="12" r="10"></circle><line x1="12" y1="16" x2="12" y2="12"></line><line x1="12" y1="8" x2="12.01" y2="8"></line>
|
||||||
|
</svg>
|
||||||
|
${member.ratio.toFixed(2)}
|
||||||
|
</span>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
bodyHtml += `<tr>
|
bodyHtml += `<tr>
|
||||||
<td class="member-name">${member.name || 'Unknown'}</td>
|
<td class="member-name" style="text-align: left; padding-left: 1rem;">${nameHtml}</td>
|
||||||
<td class="member-manager">${member.office_name || '-'}</td>`;
|
<td class="member-manager">${member.office_name || '-'}</td>`;
|
||||||
|
|
||||||
for (let i = 0; i < dayCount; i++) {
|
for (let i = 0; i < dayCount; i++) {
|
||||||
@@ -384,7 +396,7 @@ function renderCalendar() {
|
|||||||
// (Already have dateStr)
|
// (Already have dateStr)
|
||||||
|
|
||||||
const memberRules = officeClosingRules[member.office_id];
|
const memberRules = officeClosingRules[member.office_id];
|
||||||
let isClosed = false;
|
let isClosed = isHoliday;
|
||||||
|
|
||||||
if (memberRules) {
|
if (memberRules) {
|
||||||
// Check weekly
|
// Check weekly
|
||||||
|
|||||||
@@ -134,6 +134,7 @@ async function saveWeeklyClosingDays() {
|
|||||||
|
|
||||||
await Promise.all(promises);
|
await Promise.all(promises);
|
||||||
utils.showMessage('Giorni di chiusura aggiornati', 'success');
|
utils.showMessage('Giorni di chiusura aggiornati', 'success');
|
||||||
|
api.invalidateCache(`/api/offices/${currentOfficeId}/weekly-closing-days`);
|
||||||
await loadWeeklyClosingDays(currentOfficeId);
|
await loadWeeklyClosingDays(currentOfficeId);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(error);
|
console.error(error);
|
||||||
@@ -176,6 +177,7 @@ async function loadClosingDays(officeId) {
|
|||||||
async function addClosingDay(data) {
|
async function addClosingDay(data) {
|
||||||
const response = await api.post(`/api/offices/${currentOfficeId}/closing-days`, data);
|
const response = await api.post(`/api/offices/${currentOfficeId}/closing-days`, data);
|
||||||
if (response && response.ok) {
|
if (response && response.ok) {
|
||||||
|
api.invalidateCache(`/api/offices/${currentOfficeId}/closing-days`);
|
||||||
await loadClosingDays(currentOfficeId);
|
await loadClosingDays(currentOfficeId);
|
||||||
document.getElementById('closingDayModal').style.display = 'none';
|
document.getElementById('closingDayModal').style.display = 'none';
|
||||||
document.getElementById('closingDayForm').reset();
|
document.getElementById('closingDayForm').reset();
|
||||||
@@ -189,6 +191,7 @@ async function deleteClosingDay(id) {
|
|||||||
if (!confirm('Eliminare questo giorno di chiusura?')) return;
|
if (!confirm('Eliminare questo giorno di chiusura?')) return;
|
||||||
const response = await api.delete(`/api/offices/${currentOfficeId}/closing-days/${id}`);
|
const response = await api.delete(`/api/offices/${currentOfficeId}/closing-days/${id}`);
|
||||||
if (response && response.ok) {
|
if (response && response.ok) {
|
||||||
|
api.invalidateCache(`/api/offices/${currentOfficeId}/closing-days`);
|
||||||
await loadClosingDays(currentOfficeId);
|
await loadClosingDays(currentOfficeId);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>Gestione Uffici - Parking Manager</title>
|
<title>Gestione Gruppi - Parking Manager</title>
|
||||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg">
|
<link rel="icon" type="image/svg+xml" href="/favicon.svg">
|
||||||
<link rel="stylesheet" href="/css/styles.css">
|
<link rel="stylesheet" href="/css/styles.css">
|
||||||
</head>
|
</head>
|
||||||
@@ -42,7 +42,7 @@
|
|||||||
|
|
||||||
<main class="main-content">
|
<main class="main-content">
|
||||||
<header class="page-header">
|
<header class="page-header">
|
||||||
<h2>Gestione Uffici</h2>
|
<h2>Gestione Gruppi</h2>
|
||||||
<div class="header-actions">
|
<div class="header-actions">
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
@@ -50,8 +50,8 @@
|
|||||||
<div class="content-wrapper">
|
<div class="content-wrapper">
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<div style="padding-bottom: 1rem; display: flex; justify-content: space-between; align-items: center;">
|
<div style="padding-bottom: 1rem; display: flex; justify-content: space-between; align-items: center;">
|
||||||
<h3 style="margin: 0;">Lista Uffici</h3>
|
<h3 style="margin: 0;">Lista Gruppi</h3>
|
||||||
<button class="btn btn-dark" id="addOfficeBtn">Nuovo Ufficio</button>
|
<button class="btn btn-dark" id="addOfficeBtn">Nuovo Gruppo</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="data-table-container">
|
<div class="data-table-container">
|
||||||
<table class="data-table" id="officesTable">
|
<table class="data-table" id="officesTable">
|
||||||
@@ -75,7 +75,7 @@
|
|||||||
<div class="modal" id="officeModal" style="display: none;">
|
<div class="modal" id="officeModal" style="display: none;">
|
||||||
<div class="modal-content">
|
<div class="modal-content">
|
||||||
<div class="modal-header">
|
<div class="modal-header">
|
||||||
<h3 id="officeModalTitle">Nuovo Ufficio</h3>
|
<h3 id="officeModalTitle">Nuovo Gruppo</h3>
|
||||||
<button class="modal-close" id="closeOfficeModal">×</button>
|
<button class="modal-close" id="closeOfficeModal">×</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-body">
|
<div class="modal-body">
|
||||||
@@ -83,14 +83,14 @@
|
|||||||
<input type="hidden" id="officeId">
|
<input type="hidden" id="officeId">
|
||||||
|
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="officeName">Nome Ufficio</label>
|
<label for="officeName">Nome Gruppo</label>
|
||||||
<input type="text" id="officeName" required>
|
<input type="text" id="officeName" required>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="officeQuota">Quota Parcheggio</label>
|
<label for="officeQuota">Quota Parcheggio</label>
|
||||||
<input type="number" id="officeQuota" min="0" value="0" required>
|
<input type="number" id="officeQuota" min="0" value="0" required>
|
||||||
<small class="text-muted">Numero totale di posti auto assegnati a questo ufficio</small>
|
<small class="text-muted">Numero totale di posti auto assegnati a questo gruppo</small>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
|
|||||||
@@ -64,7 +64,7 @@
|
|||||||
class="sort-icon"></span></th>
|
class="sort-icon"></span></th>
|
||||||
<th class="sortable" data-sort="role" style="cursor: pointer;">Ruolo <span
|
<th class="sortable" data-sort="role" style="cursor: pointer;">Ruolo <span
|
||||||
class="sort-icon"></span></th>
|
class="sort-icon"></span></th>
|
||||||
<th class="sortable" data-sort="office_name" style="cursor: pointer;">Ufficio <span
|
<th class="sortable" data-sort="office_name" style="cursor: pointer;">Gruppo <span
|
||||||
class="sort-icon"></span></th>
|
class="sort-icon"></span></th>
|
||||||
<th class="sortable" data-sort="parking_ratio" style="cursor: pointer;">Punteggio <span
|
<th class="sortable" data-sort="parking_ratio" style="cursor: pointer;">Punteggio <span
|
||||||
class="sort-icon"></span></th>
|
class="sort-icon"></span></th>
|
||||||
|
|||||||
@@ -42,7 +42,7 @@
|
|||||||
|
|
||||||
<main class="main-content">
|
<main class="main-content">
|
||||||
<header class="page-header">
|
<header class="page-header">
|
||||||
<h2>Impostazioni Ufficio</h2>
|
<h2>Impostazioni Gruppo</h2>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div class="content-wrapper">
|
<div class="content-wrapper">
|
||||||
@@ -60,49 +60,54 @@
|
|||||||
|
|
||||||
<div id="settingsContent" style="display: none;">
|
<div id="settingsContent" style="display: none;">
|
||||||
|
|
||||||
<!-- Card 1: Batch Scheduling Settings -->
|
|
||||||
|
<!-- Card: Algorithm Settings -->
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<div class="card-header">
|
<div class="card-header">
|
||||||
<h3>Schedulazione Automatica</h3>
|
<h3>Impostazioni Algoritmo Parcheggio</h3>
|
||||||
</div>
|
</div>
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<form id="scheduleForm">
|
<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">
|
<div class="form-group">
|
||||||
<label class="toggle-label">
|
<label class="toggle-label">
|
||||||
<span>Abilita Assegnazione Batch</span>
|
<span style="font-weight: 500;">Abilita Assegnazione Batch</span>
|
||||||
<label class="toggle-switch">
|
<label class="toggle-switch">
|
||||||
<input type="checkbox" id="bookingWindowEnabled">
|
<input type="checkbox" id="bookingWindowEnabled">
|
||||||
<span class="toggle-slider"></span>
|
<span class="toggle-slider"></span>
|
||||||
</label>
|
</label>
|
||||||
</label>
|
</label>
|
||||||
<small class="text-muted">Se abilitato, l'assegnazione dei posti avverrà solo dopo
|
<small class="text-muted">Se abilitato, l'assegnazione dei posti avverrà solo dopo l'orario di cut-off del giorno precedente.</small>
|
||||||
l'orario
|
|
||||||
di cut-off del giorno precedente.</small>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="form-group" id="cutoffTimeGroup">
|
<div class="form-group" id="cutoffTimeGroup">
|
||||||
<label>Orario di Cut-off (Giorno Precedente)</label>
|
<label style="font-weight: 500;">Orario di Cut-off (Giorno Precedente)</label>
|
||||||
<div style="display: flex; gap: 0.5rem; align-items: center;">
|
<div style="display: flex; gap: 0.5rem; align-items: center; margin-top: 0.5rem;">
|
||||||
<select id="bookingWindowHour" style="width: 80px;">
|
<select id="bookingWindowHour" style="width: 80px;">
|
||||||
<!-- Populated by JS -->
|
|
||||||
</select>
|
</select>
|
||||||
<span>:</span>
|
<span>:</span>
|
||||||
<select id="bookingWindowMinute" style="width: 80px;">
|
<select id="bookingWindowMinute" style="width: 80px;">
|
||||||
<!-- Populated by JS -->
|
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<small class="text-muted">Le presenze inserite prima di questo orario saranno messe in
|
<small class="text-muted">Le presenze inserite prima di questo orario saranno messe in attesa.</small>
|
||||||
attesa.</small>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="form-actions">
|
<div class="form-actions" style="margin-top: 1.5rem;">
|
||||||
<button type="submit" class="btn btn-dark">Salva Impostazioni</button>
|
<button type="submit" class="btn btn-dark">Salva Impostazioni</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Card 2: Testing Tools -->
|
<!-- Card: Testing Tools -->
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<div class="card-header"
|
<div class="card-header"
|
||||||
style="display: flex; justify-content: space-between; align-items: center;">
|
style="display: flex; justify-content: space-between; align-items: center;">
|
||||||
|
|||||||
@@ -108,7 +108,7 @@
|
|||||||
<!-- Date Navigation (Centered) -->
|
<!-- Date Navigation (Centered) -->
|
||||||
<div style="display: flex; justify-content: center; margin-bottom: 2rem;">
|
<div style="display: flex; justify-content: center; margin-bottom: 2rem;">
|
||||||
<div
|
<div
|
||||||
style="display: flex; align-items: center; gap: 0.5rem; background: white; padding: 0.5rem; border-radius: 8px; border: 1px solid var(--border);">
|
style="display: flex; align-items: center; gap: 0.5rem; background: transparent; padding: 0.5rem; border-radius: 8px;">
|
||||||
<button class="btn-icon" id="statusPrevDay"
|
<button class="btn-icon" id="statusPrevDay"
|
||||||
style="border: none; width: 32px; height: 32px;">
|
style="border: none; width: 32px; height: 32px;">
|
||||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor"
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor"
|
||||||
@@ -117,11 +117,11 @@
|
|||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<div style="position: relative; text-align: center; min-width: 200px;">
|
<div style="position: relative; text-align: center; min-width: 250px; cursor: pointer;">
|
||||||
<div id="statusDateDisplay"
|
<div id="statusDateDisplay"
|
||||||
style="font-weight: 600; font-size: 1rem; text-transform: capitalize;"></div>
|
style="font-weight: 600; font-size: 1.1rem; text-transform: capitalize; color: var(--text);"></div>
|
||||||
<input type="date" id="statusDatePicker"
|
<input type="date" id="statusDatePicker"
|
||||||
style="position: absolute; inset: 0; opacity: 0; cursor: pointer;">
|
style="position: absolute; inset: 0; opacity: 0; cursor: pointer; width: 100%;">
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button class="btn-icon" id="statusNextDay"
|
<button class="btn-icon" id="statusNextDay"
|
||||||
@@ -139,7 +139,7 @@
|
|||||||
<div
|
<div
|
||||||
style="display: flex; align-items: center; justify-content: flex-start; gap: 1rem; margin-bottom: 1.5rem;">
|
style="display: flex; align-items: center; justify-content: flex-start; gap: 1rem; margin-bottom: 1.5rem;">
|
||||||
<div style="font-weight: 600; font-size: 1.1rem; color: var(--text);">
|
<div style="font-weight: 600; font-size: 1.1rem; color: var(--text);">
|
||||||
Ufficio: <span id="currentOfficeDisplay" style="color: var(--primary);">...</span>
|
Gruppo: <span id="currentOfficeDisplay" style="color: var(--primary);">...</span>
|
||||||
</div>
|
</div>
|
||||||
<span class="badge"
|
<span class="badge"
|
||||||
style="background: #eff6ff; color: #1d4ed8; border: 1px solid #dbeafe; padding: 0.35rem 0.75rem; font-size: 0.85rem;">
|
style="background: #eff6ff; color: #1d4ed8; border: 1px solid #dbeafe; padding: 0.35rem 0.75rem; font-size: 0.85rem;">
|
||||||
@@ -367,7 +367,30 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
<!-- Admin Manual Assign Spot Modal -->
|
||||||
|
<div class="modal" id="adminAssignSpotModal" style="display: none;">
|
||||||
|
<div class="modal-content modal-small">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h3>Assegna Posto Manualmente</h3>
|
||||||
|
<button class="modal-close" id="closeAdminAssignModal">×</button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<p id="adminAssignSpotInfo" style="margin-bottom: 1rem;"></p>
|
||||||
|
<form id="adminAssignForm">
|
||||||
|
<div class="form-group" style="margin-bottom: 1rem;">
|
||||||
|
<label for="adminAssignUser">Seleziona Utente (Presente in Sede)</label>
|
||||||
|
<select id="adminAssignUser" class="form-control" required style="width: 100%;">
|
||||||
|
<option value="">Caricamento utenti...</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="form-actions">
|
||||||
|
<button type="button" class="btn btn-secondary" id="cancelAdminAssign">Annulla</button>
|
||||||
|
<button type="submit" class="btn btn-dark">Assegna</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<script src="/js/api.js"></script>
|
<script src="/js/api.js"></script>
|
||||||
<script src="/js/utils.js"></script>
|
<script src="/js/utils.js"></script>
|
||||||
<script src="/js/nav.js"></script>
|
<script src="/js/nav.js"></script>
|
||||||
|
|||||||
@@ -74,9 +74,9 @@
|
|||||||
<small class="text-muted">Il ruolo è assegnato dal tuo amministratore</small>
|
<small class="text-muted">Il ruolo è assegnato dal tuo amministratore</small>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="manager">Ufficio</label>
|
<label for="manager">Gruppo</label>
|
||||||
<input type="text" id="manager" disabled>
|
<input type="text" id="manager" disabled>
|
||||||
<small class="text-muted">Il tuo ufficio è assegnato dall'amministratore</small>
|
<small class="text-muted">Il tuo gruppo è assegnato dall'amministratore</small>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-actions" id="profileActions">
|
<div class="form-actions" id="profileActions">
|
||||||
<button type="submit" class="btn btn-dark">Salva Modifiche</button>
|
<button type="submit" class="btn btn-dark">Salva Modifiche</button>
|
||||||
|
|||||||
@@ -47,7 +47,23 @@
|
|||||||
|
|
||||||
<div class="content-wrapper">
|
<div class="content-wrapper">
|
||||||
|
|
||||||
|
<!-- Theme Settings Card -->
|
||||||
|
<div class="card" style="margin-bottom: 2rem;">
|
||||||
|
<div class="card-header">
|
||||||
|
<h3>Tema</h3>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="form-group">
|
||||||
|
<label style="font-weight: 500; display: block; margin-bottom: 0.5rem;">Tema dell'applicazione</label>
|
||||||
|
<select id="themeSelect" class="form-select" style="max-width: 300px;">
|
||||||
|
<option value="system">Sistema (Predefinito)</option>
|
||||||
|
<option value="light">Chiaro</option>
|
||||||
|
<option value="dark">Scuro</option>
|
||||||
|
</select>
|
||||||
|
<small class="text-muted" style="display: block; margin-top: 0.5rem;">Scegli il tema preferito da utilizzare nell'applicazione.</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<div class="card-header">
|
<div class="card-header">
|
||||||
<h3>Notifiche Parcheggio</h3>
|
<h3>Notifiche Parcheggio</h3>
|
||||||
@@ -138,6 +154,10 @@
|
|||||||
document.getElementById('notifyParkingChanges').checked = currentUser.notify_parking_changes !== 0;
|
document.getElementById('notifyParkingChanges').checked = currentUser.notify_parking_changes !== 0;
|
||||||
|
|
||||||
updateDailyTimeVisibility();
|
updateDailyTimeVisibility();
|
||||||
|
|
||||||
|
// Populate Theme
|
||||||
|
const savedTheme = localStorage.getItem('theme') || 'system';
|
||||||
|
document.getElementById('themeSelect').value = savedTheme;
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateDailyTimeVisibility() {
|
function updateDailyTimeVisibility() {
|
||||||
@@ -173,6 +193,15 @@
|
|||||||
|
|
||||||
// Toggle daily time visibility
|
// Toggle daily time visibility
|
||||||
document.getElementById('notifyDailyParking').addEventListener('change', updateDailyTimeVisibility);
|
document.getElementById('notifyDailyParking').addEventListener('change', updateDailyTimeVisibility);
|
||||||
|
|
||||||
|
// Save Theme dynamically
|
||||||
|
document.getElementById('themeSelect').addEventListener('change', (e) => {
|
||||||
|
const theme = e.target.value;
|
||||||
|
if (typeof applyTheme === 'function') {
|
||||||
|
applyTheme(theme);
|
||||||
|
utils.showMessage('Tema aggiornato', 'success');
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
@@ -55,14 +55,14 @@
|
|||||||
<option value="month">Mese</option>
|
<option value="month">Mese</option>
|
||||||
</select>
|
</select>
|
||||||
<select id="officeFilter" class="form-select" style="min-width: 200px;">
|
<select id="officeFilter" class="form-select" style="min-width: 200px;">
|
||||||
<option value="">Tutti gli Uffici</option>
|
<option value="">Tutti i Gruppi</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
||||||
<div id="office-display-header"
|
<div id="office-display-header"
|
||||||
style="margin-bottom: 1rem; font-weight: 600; font-size: 1.1rem; color: var(--text);">
|
style="margin-bottom: 1rem; font-weight: 600; font-size: 1.1rem; color: var(--text);">
|
||||||
Ufficio: <span id="currentOfficeNameDisplay" style="color: var(--primary);">Loading...</span>
|
Gruppo: <span id="currentOfficeNameDisplay" style="color: var(--primary);">Loading...</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="calendar-header">
|
<div class="calendar-header">
|
||||||
|
|||||||
@@ -60,6 +60,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div id="rulesContent" style="display: none;">
|
<div id="rulesContent" style="display: none;">
|
||||||
|
|
||||||
<!-- Weekly Closing Days -->
|
<!-- Weekly Closing Days -->
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<div class="card-header">
|
<div class="card-header">
|
||||||
@@ -124,7 +125,7 @@
|
|||||||
<div id="noOfficeMessage">
|
<div id="noOfficeMessage">
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<div class="card-body text-center">
|
<div class="card-body text-center">
|
||||||
<p>Seleziona un ufficio sopra per gestirne le regole di parcheggio</p>
|
<p>Seleziona un gruppo sopra per gestirne le regole di parcheggio</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
25
migrate_db.py
Normal file
25
migrate_db.py
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
import sqlite3
|
||||||
|
|
||||||
|
def migrate():
|
||||||
|
db_path = "data/parking.db"
|
||||||
|
try:
|
||||||
|
conn = sqlite3.connect(db_path)
|
||||||
|
cursor = conn.cursor()
|
||||||
|
|
||||||
|
# Add the new column
|
||||||
|
cursor.execute("ALTER TABLE offices ADD COLUMN assignment_mode VARCHAR DEFAULT 'fairness'")
|
||||||
|
|
||||||
|
print("Success: Added assignment_mode to offices table.")
|
||||||
|
|
||||||
|
conn.commit()
|
||||||
|
except sqlite3.OperationalError as e:
|
||||||
|
if "duplicate column name" in str(e):
|
||||||
|
print("Info: Column assignment_mode already exists.")
|
||||||
|
else:
|
||||||
|
print(f"Error: {e}")
|
||||||
|
finally:
|
||||||
|
if conn:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
migrate()
|
||||||
26
rename_script.py
Normal file
26
rename_script.py
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
import os
|
||||||
|
|
||||||
|
frontend_dir = r"\\wsl.localhost\Ubuntu\home\ssalemi\Progetti\Rocketscale\org-parking\frontend"
|
||||||
|
|
||||||
|
targets = [
|
||||||
|
("Ufficio", "Gruppo"),
|
||||||
|
("Uffici", "Gruppi"),
|
||||||
|
("ufficio", "gruppo"),
|
||||||
|
("uffici", "gruppi")
|
||||||
|
]
|
||||||
|
|
||||||
|
for root, _, files in os.walk(frontend_dir):
|
||||||
|
for filename in files:
|
||||||
|
if filename.endswith(".html") or filename.endswith(".js"):
|
||||||
|
filepath = os.path.join(root, filename)
|
||||||
|
with open(filepath, "r", encoding="utf-8") as f:
|
||||||
|
content = f.read()
|
||||||
|
original_content = content
|
||||||
|
# Replacing case sensitive
|
||||||
|
for old, new in targets:
|
||||||
|
content = content.replace(old, new)
|
||||||
|
|
||||||
|
if content != original_content:
|
||||||
|
with open(filepath, "w", encoding="utf-8") as f:
|
||||||
|
f.write(content)
|
||||||
|
print(f"Updated {filepath}")
|
||||||
@@ -138,6 +138,33 @@ def notify_parking_released(user: "User", assignment_date: date, spot_name: str)
|
|||||||
send_email(user.email, subject, body_html)
|
send_email(user.email, subject, body_html)
|
||||||
|
|
||||||
|
|
||||||
|
def notify_parking_released_to_user(user: "User", assignment_date: date, spot_name: str, previous_user_name: str = None):
|
||||||
|
"""Send notification when a parking spot is granted due to someone else releasing it"""
|
||||||
|
if not user.notify_parking_changes:
|
||||||
|
return
|
||||||
|
|
||||||
|
day_name = assignment_date.strftime("%d/%m/%Y")
|
||||||
|
|
||||||
|
subject = f"Assegnazione Riparatoria - {day_name}"
|
||||||
|
|
||||||
|
if previous_user_name:
|
||||||
|
message = f"Ti è stato ceduto il posto da {previous_user_name} per il giorno {day_name}:"
|
||||||
|
else:
|
||||||
|
message = f"Hai ottenuto un posto in ritardo per una rinuncia per il giorno {day_name}:"
|
||||||
|
|
||||||
|
body_html = f"""
|
||||||
|
<html>
|
||||||
|
<body>
|
||||||
|
<h2>Posto Auto Assegnato (Rinuncia)</h2>
|
||||||
|
<p>Ciao {user.name},</p>
|
||||||
|
<p>{message}</p>
|
||||||
|
<p style="font-size: 18px; font-weight: bold;">Posto {spot_name}</p>
|
||||||
|
<p>Cordiali saluti,<br>Team Parking Manager</p>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
"""
|
||||||
|
send_email(user.email, subject, body_html)
|
||||||
|
|
||||||
def notify_parking_reassigned(user: "User", assignment_date: date, spot_name: str, new_user_name: str):
|
def notify_parking_reassigned(user: "User", assignment_date: date, spot_name: str, new_user_name: str):
|
||||||
"""Send notification when parking spot is reassigned to someone else"""
|
"""Send notification when parking spot is reassigned to someone else"""
|
||||||
if not user.notify_parking_changes:
|
if not user.notify_parking_changes:
|
||||||
@@ -225,7 +252,7 @@ def send_presence_reminder(user: "User", next_week_dates: List[date], db: "Sessi
|
|||||||
|
|
||||||
def send_daily_parking_reminder(user: "User", date_obj: datetime, db: "Session") -> bool:
|
def send_daily_parking_reminder(user: "User", date_obj: datetime, db: "Session") -> bool:
|
||||||
"""Send daily parking reminder for a specific date"""
|
"""Send daily parking reminder for a specific date"""
|
||||||
from database.models import DailyParkingAssignment, NotificationLog
|
from database.models import DailyParkingAssignment
|
||||||
from services.parking import get_spot_display_name
|
from services.parking import get_spot_display_name
|
||||||
|
|
||||||
config.logger.info(f"[SCHEDULER] Checking daily parking reminder for user {user.email}")
|
config.logger.info(f"[SCHEDULER] Checking daily parking reminder for user {user.email}")
|
||||||
@@ -234,19 +261,8 @@ def send_daily_parking_reminder(user: "User", date_obj: datetime, db: "Session")
|
|||||||
config.logger.debug(f"[SCHEDULER] User {user.email} has disabled daily parking notifications")
|
config.logger.debug(f"[SCHEDULER] User {user.email} has disabled daily parking notifications")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
date_str = date_obj.strftime("%Y-%m-%d")
|
|
||||||
assignment_date = date_obj.date()
|
assignment_date = date_obj.date()
|
||||||
|
|
||||||
# Check if already sent for this date
|
|
||||||
existing = db.query(NotificationLog).filter(
|
|
||||||
NotificationLog.user_id == user.id,
|
|
||||||
NotificationLog.notification_type == NotificationType.DAILY_PARKING,
|
|
||||||
NotificationLog.reference_date == date_str
|
|
||||||
).first()
|
|
||||||
|
|
||||||
if existing:
|
|
||||||
return False
|
|
||||||
|
|
||||||
# Get parking assignment for this date
|
# Get parking assignment for this date
|
||||||
assignment = db.query(DailyParkingAssignment).filter(
|
assignment = db.query(DailyParkingAssignment).filter(
|
||||||
DailyParkingAssignment.user_id == user.id,
|
DailyParkingAssignment.user_id == user.id,
|
||||||
@@ -273,15 +289,6 @@ def send_daily_parking_reminder(user: "User", date_obj: datetime, db: "Session")
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
if send_email(user.email, subject, body_html):
|
if send_email(user.email, subject, body_html):
|
||||||
log = NotificationLog(
|
|
||||||
id=generate_uuid(),
|
|
||||||
user_id=user.id,
|
|
||||||
notification_type=NotificationType.DAILY_PARKING,
|
|
||||||
reference_date=date_str,
|
|
||||||
sent_at=datetime.utcnow()
|
|
||||||
)
|
|
||||||
db.add(log)
|
|
||||||
db.commit()
|
|
||||||
return True
|
return True
|
||||||
|
|
||||||
return False
|
return False
|
||||||
@@ -321,7 +328,7 @@ def run_scheduled_notifications(db: "Session"):
|
|||||||
user_minute = user.notify_daily_parking_minute or 0
|
user_minute = user.notify_daily_parking_minute or 0
|
||||||
|
|
||||||
# Check if it's the right time for this user
|
# Check if it's the right time for this user
|
||||||
if current_hour == user_hour and abs(current_minute - user_minute) < 5:
|
if current_hour == user_hour and current_minute == user_minute:
|
||||||
config.logger.info(f"[SCHEDULER] Triggering Daily Parking Reminder check for user {user.email} (Scheduled: {user_hour}:{user_minute})")
|
config.logger.info(f"[SCHEDULER] Triggering Daily Parking Reminder check for user {user.email} (Scheduled: {user_hour}:{user_minute})")
|
||||||
# Check if Office is OPEN today
|
# Check if Office is OPEN today
|
||||||
is_office_open = True
|
is_office_open = True
|
||||||
|
|||||||
@@ -216,15 +216,29 @@ def get_users_wanting_parking(office_id: str, pool_date: date, db: Session) -> l
|
|||||||
|
|
||||||
def assign_parking_fairly(office_id: str, pool_date: date, db: Session) -> dict:
|
def assign_parking_fairly(office_id: str, pool_date: date, db: Session) -> dict:
|
||||||
"""
|
"""
|
||||||
Assign parking spots fairly based on parking ratio.
|
Assign parking spots based on the office's assignment_mode (fairness or random).
|
||||||
Creates new DailyParkingAssignment rows only for assigned users.
|
Creates new DailyParkingAssignment rows only for assigned users.
|
||||||
"""
|
"""
|
||||||
if is_closing_day(office_id, pool_date, db):
|
if is_closing_day(office_id, pool_date, db):
|
||||||
return {"assigned": [], "waitlist": [], "closed": True}
|
return {"assigned": [], "waitlist": [], "closed": True}
|
||||||
|
|
||||||
# Get candidates sorted by fairness
|
# Retrieve office to check assignment mode
|
||||||
|
office = db.query(Office).filter(Office.id == office_id).first()
|
||||||
|
mode = office.assignment_mode if office and getattr(office, 'assignment_mode', None) else "fairness"
|
||||||
|
|
||||||
|
# Get candidates sorted by fairness (guaranteed first, then by ratio)
|
||||||
candidates = get_users_wanting_parking(office_id, pool_date, db)
|
candidates = get_users_wanting_parking(office_id, pool_date, db)
|
||||||
|
|
||||||
|
if mode == "random":
|
||||||
|
import random
|
||||||
|
guaranteed = [c for c in candidates if c["has_guarantee"]]
|
||||||
|
non_guaranteed = [c for c in candidates if not c["has_guarantee"]]
|
||||||
|
|
||||||
|
# Shuffle non-guaranteed users to pick randomly
|
||||||
|
random.shuffle(non_guaranteed)
|
||||||
|
|
||||||
|
candidates = guaranteed + non_guaranteed
|
||||||
|
|
||||||
# Get available spots (OfficeSpots not yet in assignments table)
|
# Get available spots (OfficeSpots not yet in assignments table)
|
||||||
free_spots = get_available_spots(office_id, pool_date, db)
|
free_spots = get_available_spots(office_id, pool_date, db)
|
||||||
|
|
||||||
@@ -281,6 +295,10 @@ def release_user_spot(office_id: str, user_id: str, pool_date: date, db: Session
|
|||||||
if not assignment:
|
if not assignment:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
# Get old user name for notification
|
||||||
|
old_user = db.query(User).filter(User.id == user_id).first()
|
||||||
|
old_user_name = old_user.name if old_user else "un collega"
|
||||||
|
|
||||||
# Capture spot ID before deletion
|
# Capture spot ID before deletion
|
||||||
spot_id = assignment.spot_id
|
spot_id = assignment.spot_id
|
||||||
|
|
||||||
@@ -305,6 +323,13 @@ def release_user_spot(office_id: str, user_id: str, pool_date: date, db: Session
|
|||||||
db.add(new_assignment)
|
db.add(new_assignment)
|
||||||
db.commit()
|
db.commit()
|
||||||
|
|
||||||
|
# Notify the lucky user
|
||||||
|
from services.notifications import notify_parking_released_to_user
|
||||||
|
top_user = db.query(User).filter(User.id == top_candidate["user_id"]).first()
|
||||||
|
if top_user:
|
||||||
|
spot_name = get_spot_display_name(spot_id, office_id, db)
|
||||||
|
notify_parking_released_to_user(top_user, pool_date, spot_name, old_user_name)
|
||||||
|
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
|
||||||
@@ -328,31 +353,6 @@ def handle_presence_change(user_id: str, change_date: date, old_status: Presence
|
|||||||
# User no longer coming - release their spot (will auto-reassign)
|
# User no longer coming - release their spot (will auto-reassign)
|
||||||
release_user_spot(office.id, user_id, change_date, db)
|
release_user_spot(office.id, user_id, change_date, db)
|
||||||
|
|
||||||
elif new_status == PresenceStatus.PRESENT:
|
|
||||||
# Check booking window
|
|
||||||
should_assign = True
|
|
||||||
if office.booking_window_enabled:
|
|
||||||
from zoneinfo import ZoneInfo
|
|
||||||
tz = ZoneInfo(config.TIMEZONE)
|
|
||||||
now = datetime.now(tz)
|
|
||||||
|
|
||||||
# Allocation time is Day-1 at cutoff hour
|
|
||||||
cutoff_dt = datetime.combine(change_date - timedelta(days=1), datetime.min.time())
|
|
||||||
cutoff_dt = cutoff_dt.replace(
|
|
||||||
hour=office.booking_window_end_hour,
|
|
||||||
minute=office.booking_window_end_minute,
|
|
||||||
tzinfo=tz
|
|
||||||
)
|
|
||||||
|
|
||||||
# If now is before cutoff, do not assign yet (wait for batch job)
|
|
||||||
if now < cutoff_dt:
|
|
||||||
should_assign = False
|
|
||||||
config.logger.debug(f"Queuing parking request for user {user_id} on {change_date} (Window open until {cutoff_dt})")
|
|
||||||
|
|
||||||
if should_assign:
|
|
||||||
# User coming in - run fair assignment for this date
|
|
||||||
assign_parking_fairly(office.id, change_date, db)
|
|
||||||
|
|
||||||
|
|
||||||
def clear_assignments_for_office_date(office_id: str, pool_date: date, db: Session) -> int:
|
def clear_assignments_for_office_date(office_id: str, pool_date: date, db: Session) -> int:
|
||||||
"""
|
"""
|
||||||
@@ -405,7 +405,24 @@ def process_daily_allocations(db: Session):
|
|||||||
# Cutoff is defined as "Previous Day" (today) at Booking End Hour
|
# Cutoff is defined as "Previous Day" (today) at Booking End Hour
|
||||||
# If NOW matches the cutoff time, we run allocation for TOMORROW
|
# If NOW matches the cutoff time, we run allocation for TOMORROW
|
||||||
if now.hour == office.booking_window_end_hour and now.minute == office.booking_window_end_minute:
|
if now.hour == office.booking_window_end_hour and now.minute == office.booking_window_end_minute:
|
||||||
|
|
||||||
|
# Non eseguiamo l'assegnazione se oggi è un giorno di chiusura
|
||||||
|
# (è già stata fatta l'assegnazione per i giorni futuri nell'ultimo giorno lavorativo)
|
||||||
|
if is_closing_day(office.id, now.date(), db):
|
||||||
|
config.logger.info(f"[SCHEDULER] Skipping batch allocation for {office.name} because today ({now.date()}) is a closing day.")
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Troviamo il prossimo giorno lavorativo a partire da "domani"
|
||||||
target_date = now.date() + timedelta(days=1)
|
target_date = now.date() + timedelta(days=1)
|
||||||
|
days_ahead = 1
|
||||||
|
while is_closing_day(office.id, target_date, db) and days_ahead <= 30:
|
||||||
|
target_date += timedelta(days=1)
|
||||||
|
days_ahead += 1
|
||||||
|
|
||||||
|
if days_ahead > 30:
|
||||||
|
config.logger.warning(f"[SCHEDULER] Could not find a working day within 30 days for {office.name}.")
|
||||||
|
continue
|
||||||
|
|
||||||
config.logger.info(f"[SCHEDULER] CUTOFF REACHED for {office.name} (Cutoff: {office.booking_window_end_hour}:{office.booking_window_end_minute}). Starting Assegnazione Giornaliera parcheggi for {target_date}")
|
config.logger.info(f"[SCHEDULER] CUTOFF REACHED for {office.name} (Cutoff: {office.booking_window_end_hour}:{office.booking_window_end_minute}). Starting Assegnazione Giornaliera parcheggi for {target_date}")
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -1,48 +0,0 @@
|
|||||||
import sqlite3
|
|
||||||
import os
|
|
||||||
import sys
|
|
||||||
|
|
||||||
# Default path inside Docker container
|
|
||||||
DEFAULT_DB_PATH = "/app/data/parking.db"
|
|
||||||
|
|
||||||
def migrate():
|
|
||||||
# Allow overriding DB path via env var or argument
|
|
||||||
db_path = os.getenv("DATABASE_PATH", DEFAULT_DB_PATH)
|
|
||||||
if len(sys.argv) > 1:
|
|
||||||
db_path = sys.argv[1]
|
|
||||||
|
|
||||||
if not os.path.exists(db_path):
|
|
||||||
print(f"Error: Database file not found at {db_path}")
|
|
||||||
print("Usage: python upgrade_db_v1.py [path_to_db]")
|
|
||||||
sys.exit(1)
|
|
||||||
|
|
||||||
print(f"Migrating database at: {db_path}")
|
|
||||||
conn = sqlite3.connect(db_path)
|
|
||||||
cursor = conn.cursor()
|
|
||||||
|
|
||||||
columns_to_add = [
|
|
||||||
("booking_window_enabled", "BOOLEAN", "0"),
|
|
||||||
("booking_window_end_hour", "INTEGER", "18"),
|
|
||||||
("booking_window_end_minute", "INTEGER", "0")
|
|
||||||
]
|
|
||||||
|
|
||||||
for col_name, col_type, default_val in columns_to_add:
|
|
||||||
try:
|
|
||||||
# Check if column exists
|
|
||||||
cursor.execute(f"SELECT {col_name} FROM offices LIMIT 1")
|
|
||||||
except sqlite3.OperationalError:
|
|
||||||
# Column doesn't exist, add it
|
|
||||||
print(f"Adding column: {col_name} ({col_type})")
|
|
||||||
try:
|
|
||||||
cursor.execute(f"ALTER TABLE offices ADD COLUMN {col_name} {col_type} DEFAULT {default_val}")
|
|
||||||
except Exception as e:
|
|
||||||
print(f"Failed to add column {col_name}: {e}")
|
|
||||||
else:
|
|
||||||
print(f"Column {col_name} already exists. Skipping.")
|
|
||||||
|
|
||||||
conn.commit()
|
|
||||||
conn.close()
|
|
||||||
print("Migration complete.")
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
migrate()
|
|
||||||
Reference in New Issue
Block a user