From 5f4ef6faee88fd167f14530c1b6affe52dcaef78 Mon Sep 17 00:00:00 2001 From: Stefano Date: Wed, 4 Feb 2026 12:55:04 +0100 Subject: [PATCH] aggiunti trasferte, export excel, miglioramenti generali --- Caddyfile.snippet | 13 -- README.md | 13 +- app/config.py | 1 + app/routes/auth.py | 1 + app/routes/offices.py | 25 ++- app/routes/parking.py | 281 ++++++++++++++++++++------- app/routes/presence.py | 46 +++++ app/routes/reports.py | 195 +++++++++++++++++++ app/routes/users.py | 120 +++++++++++- compose.yml | 9 +- database/models.py | 34 +++- fix_db_index.py | 61 ++++++ frontend/css/styles.css | 7 + frontend/js/api.js | 16 +- frontend/js/nav.js | 53 ++++- frontend/js/parking-settings.js | 99 +++++++++- frontend/js/presence.js | 257 +++++++++++++++++++++++- frontend/js/team-calendar.js | 71 +++++++ frontend/js/team-rules.js | 67 ++++++- frontend/pages/parking-settings.html | 22 +++ frontend/pages/presence.html | 97 ++++++++- frontend/pages/profile.html | 6 +- frontend/pages/settings.html | 16 +- frontend/pages/team-calendar.html | 37 +++- main.py | 2 + requirements.txt | 1 + services/notifications.py | 73 ------- services/offices.py | 52 +++++ services/parking.py | 188 +++++++++--------- utils/auth_middleware.py | 20 +- 30 files changed, 1558 insertions(+), 325 deletions(-) delete mode 100644 Caddyfile.snippet create mode 100644 app/routes/reports.py create mode 100644 fix_db_index.py create mode 100644 services/offices.py diff --git a/Caddyfile.snippet b/Caddyfile.snippet deleted file mode 100644 index db04d5f..0000000 --- a/Caddyfile.snippet +++ /dev/null @@ -1,13 +0,0 @@ -parking.lvh.me { - # Integrazione Authelia per autenticazione - forward_auth authelia:9091 { - uri /api/verify?rd=https://parking.lvh.me/ - copy_headers Remote-User Remote-Groups Remote-Name Remote-Email - } - - # Proxy inverso verso il container parking sulla porta 8000 - reverse_proxy parking:8000 - - # Usa certificati gestiti internamente per lvh.me (locale) - tls internal -} diff --git a/README.md b/README.md index eb8d8f9..8f603f6 100644 --- a/README.md +++ b/README.md @@ -119,6 +119,10 @@ Gestione utenti e profili. - `GET /me/settings`: Ottieni le proprie impostazioni. - `PUT /me/settings`: Aggiorna le proprie impostazioni. - `POST /me/change-password`: Modifica la propria password. +- `GET /me/exclusion`: Lista delle proprie auto-esclusioni. +- `POST /me/exclusion`: Crea una nuova auto-esclusione. +- `PUT /me/exclusion/{exclusion_id}`: Modifica una auto-esclusione. +- `DELETE /me/exclusion/{exclusion_id}`: Elimina una auto-esclusione. ### Offices (`/api/offices`) Gestione uffici, regole di chiusura e quote. @@ -151,7 +155,8 @@ Gestione presenze giornaliere. - `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. +- `GET /admin/{user_id}`: Storico presenze di un utente (Manager/Admin). +- `POST /admin/clear-office-presence`: Pulisce presenze e parcheggi di un ufficio per un range di date (Test/Admin). ### Parking (`/api/parking`) Gestione assegnazioni posti auto. @@ -165,6 +170,12 @@ Gestione assegnazioni posti auto. - `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. +- `POST /test-email`: Invia email di test (Test Tool). + +### Reports (`/api/reports`) +Esportazione dati. + +- `GET /team-export`: Esporta dati presenze e parcheggi del team in Excel. ## Utilizzo con AUTHELIA diff --git a/app/config.py b/app/config.py index eefe2dd..0fba526 100644 --- a/app/config.py +++ b/app/config.py @@ -68,6 +68,7 @@ AUTHELIA_ADMIN_GROUP = os.getenv("AUTHELIA_ADMIN_GROUP", "parking_admins") # External URLs for Authelia mode # When AUTHELIA_ENABLED, login redirects to Authelia and register to external portal AUTHELIA_LOGIN_URL = os.getenv("AUTHELIA_LOGIN_URL", "") # e.g., https://auth.rocketscale.it +AUTHELIA_LOGOUT_URL = os.getenv("AUTHELIA_LOGOUT_URL", "") # e.g., https://auth.rocketscale.it/logout REGISTRATION_URL = os.getenv("REGISTRATION_URL", "") # e.g., https://register.rocketscale.it # Email configuration (following org-stack pattern) diff --git a/app/routes/auth.py b/app/routes/auth.py index 22571a3..d274752 100644 --- a/app/routes/auth.py +++ b/app/routes/auth.py @@ -145,6 +145,7 @@ def get_auth_config(): return { "authelia_enabled": config.AUTHELIA_ENABLED, "login_url": config.AUTHELIA_LOGIN_URL if config.AUTHELIA_ENABLED else None, + "logout_url": config.AUTHELIA_LOGOUT_URL if config.AUTHELIA_ENABLED else None, "registration_url": config.REGISTRATION_URL if config.AUTHELIA_ENABLED else None } diff --git a/app/routes/offices.py b/app/routes/offices.py index e236bc6..553d9d1 100644 --- a/app/routes/offices.py +++ b/app/routes/offices.py @@ -125,6 +125,11 @@ def create_office(data: ValidOfficeCreate, db: Session = Depends(get_db), user=D ) db.add(office) db.commit() + + # Sync spots + from services.offices import sync_office_spots + sync_office_spots(office.id, office.parking_quota, office.spot_prefix, db) + return office @router.get("/{office_id}") @@ -186,6 +191,10 @@ def update_office_settings(office_id: str, data: OfficeSettingsUpdate, db: Sessi office.updated_at = datetime.utcnow() db.commit() + # Sync spots + from services.offices import sync_office_spots + sync_office_spots(office.id, office.parking_quota, office.spot_prefix, db) + return { "id": office.id, "name": office.name, @@ -459,16 +468,20 @@ def add_office_exclusion(office_id: str, data: ExclusionCreate, db: Session = De if not db.query(User).filter(User.id == data.user_id).first(): raise HTTPException(status_code=404, detail="User not found") - existing = db.query(ParkingExclusion).filter( - ParkingExclusion.office_id == office_id, - ParkingExclusion.user_id == data.user_id - ).first() - if existing: - raise HTTPException(status_code=400, detail="User already has a parking exclusion") + # Relaxed unique check - user can have multiple exclusions (different periods) + # existing = db.query(ParkingExclusion).filter( + # ParkingExclusion.office_id == office_id, + # ParkingExclusion.user_id == data.user_id + # ).first() + # if existing: + # raise HTTPException(status_code=400, detail="User already has a parking exclusion") if data.start_date and data.end_date and data.end_date < data.start_date: raise HTTPException(status_code=400, detail="End date must be after start date") + if data.end_date and not data.start_date: + raise HTTPException(status_code=400, detail="Start date is required if an end date is specified") + exclusion = ParkingExclusion( id=generate_uuid(), office_id=office_id, diff --git a/app/routes/parking.py b/app/routes/parking.py index 8cd2c36..792feac 100644 --- a/app/routes/parking.py +++ b/app/routes/parking.py @@ -23,7 +23,7 @@ from pydantic import BaseModel from sqlalchemy.orm import Session from database.connection import get_db -from database.models import DailyParkingAssignment, User, UserRole, Office +from database.models import DailyParkingAssignment, User, UserRole, Office, OfficeSpot from utils.auth_middleware import get_current_user, require_manager_or_admin from services.parking import ( initialize_parking_pool, get_spot_display_name, release_user_spot, @@ -91,35 +91,70 @@ def init_office_pool(request: InitPoolRequest, db: Session = Depends(get_db), cu @router.get("/assignments/{date_val}", response_model=List[AssignmentResponse]) -def get_assignments(date_val: date, office_id: str = None, db: Session = Depends(get_db), current_user=Depends(get_current_user)): - """Get parking assignments for a date, optionally filtered by office""" +def get_assignments(date_val: date, office_id: str | None = None, db: Session = Depends(get_db), current_user=Depends(get_current_user)): + """Get parking assignments for a date, merging active assignments with empty spots""" query_date = date_val + + # Defaults to user's office if not specified + target_office_id = office_id or current_user.office_id + + if not target_office_id: + # Admin looking at all? Or error? + # If no office_id, we might fetch all spots from all offices? + # Let's support specific office filtering primarily as per UI use case + # If office_id is None, we proceed with caution (maybe all offices) + pass - query = db.query(DailyParkingAssignment).filter(DailyParkingAssignment.date == query_date) - if office_id: - query = query.filter(DailyParkingAssignment.office_id == office_id) + # 1. Get ALL spots for the target office(s) + # Note: Sorting by spot_number for consistent display order + spot_query = db.query(OfficeSpot).filter(OfficeSpot.is_unavailable == False) + if target_office_id: + spot_query = spot_query.filter(OfficeSpot.office_id == target_office_id) + spots = spot_query.order_by(OfficeSpot.spot_number).all() - assignments = query.all() + # 2. Get EXISTING assignments + assign_query = db.query(DailyParkingAssignment).filter(DailyParkingAssignment.date == query_date) + if target_office_id: + assign_query = assign_query.filter(DailyParkingAssignment.office_id == target_office_id) + active_assignments = assign_query.all() + + # Map assignment by spot_id for O(1) lookup + assignment_map = {a.spot_id: a for a in active_assignments} + results = [] - - for assignment in assignments: - # Get display name using office's spot prefix - spot_display_name = get_spot_display_name(assignment.spot_id, assignment.office_id, db) - - result = AssignmentResponse( - id=assignment.id, - date=assignment.date, - spot_id=assignment.spot_id, - spot_display_name=spot_display_name, - user_id=assignment.user_id, - office_id=assignment.office_id - ) - - if assignment.user_id: - user = db.query(User).filter(User.id == assignment.user_id).first() - if user: - result.user_name = user.name - result.user_email = user.email + + # 3. Merge + for spot in spots: + assignment = assignment_map.get(spot.id) + + if assignment: + # Active assignment + result = AssignmentResponse( + id=assignment.id, + date=assignment.date, + spot_id=spot.id, # The FK + spot_display_name=spot.name, + user_id=assignment.user_id, + office_id=spot.office_id + ) + + if assignment.user_id: + user = db.query(User).filter(User.id == assignment.user_id).first() + if user: + result.user_name = user.name + result.user_email = user.email + else: + # Empty spot (Virtual assignment response) + # We use "virtual" ID or just None? Schema says ID is str. + # Frontend might need an ID for keys. Let's use "virtual-{spot.id}" + result = AssignmentResponse( + id=f"virtual-{spot.id}", + date=query_date, + spot_id=spot.id, + spot_display_name=spot.name, + user_id=None, + office_id=spot.office_id + ) results.append(result) @@ -158,9 +193,6 @@ def get_my_assignments(start_date: date = None, end_date: date = None, db: Sessi return results - return results - - @router.post("/run-allocation") def run_fair_allocation(data: RunAllocationRequest, db: Session = Depends(get_db), current_user=Depends(require_manager_or_admin)): """Manually trigger fair allocation for a date (Test Tool)""" @@ -203,32 +235,43 @@ def manual_assign(data: ManualAssignRequest, db: Session = Depends(get_db), curr if current_user.role != UserRole.ADMIN and not is_manager: raise HTTPException(status_code=403, detail="Not authorized to assign spots for this office") - # Check if spot exists and is free - spot = db.query(DailyParkingAssignment).filter( + # Check if spot exists (OfficeSpot) + spot_def = db.query(OfficeSpot).filter(OfficeSpot.id == data.spot_id).first() + if not spot_def: + raise HTTPException(status_code=404, detail="Spot definition not found") + + # Check if spot is already assigned + existing_assignment = db.query(DailyParkingAssignment).filter( DailyParkingAssignment.office_id == data.office_id, DailyParkingAssignment.date == assign_date, DailyParkingAssignment.spot_id == data.spot_id ).first() - if not spot: - raise HTTPException(status_code=404, detail="Spot not found") - if spot.user_id: + if existing_assignment: raise HTTPException(status_code=400, detail="Spot already assigned") # Check if user already has a spot for this date (from any manager) - existing = db.query(DailyParkingAssignment).filter( + user_has_spot = db.query(DailyParkingAssignment).filter( DailyParkingAssignment.date == assign_date, DailyParkingAssignment.user_id == data.user_id ).first() - if existing: + if user_has_spot: raise HTTPException(status_code=400, detail="User already has a spot for this date") - spot.user_id = data.user_id + # Create Assignment + new_assignment = DailyParkingAssignment( + id=generate_uuid(), + date=assign_date, + spot_id=data.spot_id, + user_id=data.user_id, + office_id=data.office_id, + created_at=datetime.utcnow() + ) + db.add(new_assignment) db.commit() - spot_display_name = get_spot_display_name(data.spot_id, data.office_id, db) - return {"message": "Spot assigned", "spot_id": data.spot_id, "spot_display_name": spot_display_name} + return {"message": "Spot assigned", "spot_id": data.spot_id, "spot_display_name": spot_def.name} @router.post("/release-my-spot/{assignment_id}") @@ -245,15 +288,16 @@ def release_my_spot(assignment_id: str, db: Session = Depends(get_db), current_u raise HTTPException(status_code=403, detail="You can only release your own parking spot") # Get spot display name for notification - spot_display_name = get_spot_display_name(assignment.spot_id, assignment.office_id, db) + spot_name = assignment.spot.name if assignment.spot else "Unknown" - assignment.user_id = None + # Delete assignment (Release) + db.delete(assignment) db.commit() # Send notification (self-release, so just confirmation) - notify_parking_released(current_user, assignment.date, spot_display_name) + notify_parking_released(current_user, assignment.date, spot_name) - config.logger.info(f"User {current_user.email} released parking spot {spot_display_name} on {assignment.date}") + config.logger.info(f"User {current_user.email} released parking spot {spot_name} on {assignment.date}") return {"message": "Parking spot released"} @@ -282,7 +326,7 @@ def reassign_spot(data: ReassignSpotRequest, db: Session = Depends(get_db), curr old_user = db.query(User).filter(User.id == old_user_id).first() if old_user_id else None # Get spot display name for notifications - spot_display_name = get_spot_display_name(assignment.spot_id, assignment.office_id, db) + spot_name = assignment.spot.name if assignment.spot else "Unknown" if data.new_user_id == "auto": # "Auto assign" means releasing the spot so the system picks the next person @@ -308,46 +352,49 @@ def reassign_spot(data: ReassignSpotRequest, db: Session = Depends(get_db), curr if existing: raise HTTPException(status_code=400, detail="User already has a spot for this date") + # Update assignment to new user assignment.user_id = data.new_user_id # Send notifications # Notify old user that spot was reassigned if old_user and old_user.id != new_user.id: - notify_parking_reassigned(old_user, assignment.date, spot_display_name, new_user.name) + notify_parking_reassigned(old_user, assignment.date, spot_name, new_user.name) # Notify new user that spot was assigned - notify_parking_assigned(new_user, assignment.date, spot_display_name) + notify_parking_assigned(new_user, assignment.date, spot_name) - config.logger.info(f"Parking spot {spot_display_name} on {assignment.date} reassigned from {old_user.email if old_user else 'unassigned'} to {new_user.email}") + config.logger.info(f"Parking spot {spot_name} on {assignment.date} reassigned from {old_user.email if old_user else 'unassigned'} to {new_user.email}") + + db.commit() + db.refresh(assignment) + + result = AssignmentResponse( + id=assignment.id, + date=assignment.date, + spot_id=assignment.spot_id, + spot_display_name=spot_name, + user_id=assignment.user_id, + office_id=assignment.office_id + ) + + if assignment.user_id: + result.user_name = new_user.name + result.user_email = new_user.email + + return result + else: - assignment.user_id = None + # Release (Delete assignment) + db.delete(assignment) + # Notify old user that spot was released if old_user: - notify_parking_released(old_user, assignment.date, spot_display_name) + notify_parking_released(old_user, assignment.date, spot_name) - config.logger.info(f"Parking spot {spot_display_name} on {assignment.date} released by {old_user.email if old_user else 'unknown'}") - - db.commit() - db.refresh(assignment) - - # Build response - spot_display_name = get_spot_display_name(assignment.spot_id, assignment.office_id, db) - - result = AssignmentResponse( - id=assignment.id, - date=assignment.date, - spot_id=assignment.spot_id, - spot_display_name=spot_display_name, - user_id=assignment.user_id, - office_id=assignment.office_id - ) - - if assignment.user_id: - user = db.query(User).filter(User.id == assignment.user_id).first() - if user: - result.user_name = user.name - result.user_email = user.email - - return result + config.logger.info(f"Parking spot {spot_name} on {assignment.date} released by {old_user.email if old_user else 'unknown'}") + + db.commit() + + return {"message": "Spot released"} @router.get("/eligible-users/{assignment_id}") @@ -394,3 +441,91 @@ def get_eligible_users(assignment_id: str, db: Session = Depends(get_db), curren }) return result + + +class TestEmailRequest(BaseModel): + date: date = None + office_id: str + + +@router.post("/test-email") +def send_test_email_tool(data: TestEmailRequest, db: Session = Depends(get_db), current_user=Depends(require_manager_or_admin)): + """Send a test email to the current user (Test Tool)""" + from services.notifications import send_email + from database.models import OfficeClosingDay, OfficeWeeklyClosingDay + from datetime import timedelta + + # Verify office access + if current_user.role == UserRole.MANAGER and current_user.office_id != data.office_id: + raise HTTPException(status_code=403, detail="Not authorized for this office") + + target_date = data.date + if not target_date: + # Find next open day + # Start from tomorrow (or today? Prompt says "dopo il giorno corrente" -> after today) + check_date = date.today() + timedelta(days=1) + + # Load closing rules + weekly_closed = db.query(OfficeWeeklyClosingDay.weekday).filter( + OfficeWeeklyClosingDay.office_id == data.office_id + ).all() + weekly_closed_set = {w[0] for w in weekly_closed} + + specific_closed = db.query(OfficeClosingDay).filter( + OfficeClosingDay.office_id == data.office_id, + OfficeClosingDay.date >= check_date + ).all() + + # Max lookahead 30 days to avoid infinite loop + found = False + for _ in range(30): + # Check weekly + if check_date.weekday() in weekly_closed_set: + check_date += timedelta(days=1) + continue + + # Check specific + is_specific = False + for d in specific_closed: + s = d.date + e = d.end_date or d.date + if s <= check_date <= e: + is_specific = True + break + + if is_specific: + check_date += timedelta(days=1) + continue + + found = True + break + + if found: + target_date = check_date + else: + # Fallback + target_date = date.today() + timedelta(days=1) + + # Send Email + subject = f"Test Email - Parking System ({target_date})" + body_html = f""" + + +

Parking System Test Email

+

Hi {current_user.name},

+

This is a test email triggered from the Parking Manager Test Tools.

+

Selected Date: {target_date}

+

SMTP Status: {'Enabled' if config.SMTP_ENABLED else 'Disabled (File Logging)'}

+

If you received this, the notification system is working.

+ + + """ + + success = send_email(current_user.email, subject, body_html) + + return { + "success": success, + "mode": "SMTP" if config.SMTP_ENABLED else "FILE", + "date": target_date, + "message": "Email sent successfully" if success else "Failed to send email" + } diff --git a/app/routes/presence.py b/app/routes/presence.py index 1e073eb..e989222 100644 --- a/app/routes/presence.py +++ b/app/routes/presence.py @@ -329,3 +329,49 @@ def get_user_presences(user_id: str, start_date: date = None, end_date: date = N }) return result + + +class ClearOfficePresenceRequest(BaseModel): + start_date: date + end_date: date + office_id: str + + +@router.post("/admin/clear-office-presence") +def clear_office_presence(data: ClearOfficePresenceRequest, db: Session = Depends(get_db), current_user=Depends(require_manager_or_admin)): + """Clear all presence and parking for an office in a date range (Test Tool)""" + + # Verify office access + if current_user.role == UserRole.MANAGER and current_user.office_id != data.office_id: + raise HTTPException(status_code=403, detail="Not authorized for this office") + + # Get all users in the office + users = db.query(User).filter(User.office_id == data.office_id).all() + user_ids = [u.id for u in users] + + if not user_ids: + return {"message": "No users in office", "count_presence": 0, "count_parking": 0} + + # 1. Delete Parking Assignments + parking_delete = db.query(DailyParkingAssignment).filter( + DailyParkingAssignment.user_id.in_(user_ids), + DailyParkingAssignment.date >= data.start_date, + DailyParkingAssignment.date <= data.end_date + ) + parking_count = parking_delete.delete(synchronize_session=False) + + # 2. Delete Presence + presence_delete = db.query(UserPresence).filter( + UserPresence.user_id.in_(user_ids), + UserPresence.date >= data.start_date, + UserPresence.date <= data.end_date + ) + presence_count = presence_delete.delete(synchronize_session=False) + + db.commit() + + return { + "message": "Cleared office presence and parking", + "count_presence": presence_count, + "count_parking": parking_count + } diff --git a/app/routes/reports.py b/app/routes/reports.py new file mode 100644 index 0000000..4801d33 --- /dev/null +++ b/app/routes/reports.py @@ -0,0 +1,195 @@ +from datetime import date +from typing import Optional +from fastapi import APIRouter, Depends, HTTPException, Query, Response +from sqlalchemy.orm import Session +from openpyxl import Workbook +from openpyxl.styles import Font, PatternFill, Alignment, Border, Side +from io import BytesIO + +from database.connection import get_db +from database.models import User, UserPresence, DailyParkingAssignment, UserRole, Office +from utils.auth_middleware import require_manager_or_admin + +router = APIRouter(prefix="/api/reports", tags=["reports"]) + +@router.get("/team-export") +def export_team_data( + start_date: date, + end_date: date, + office_id: Optional[str] = None, + db: Session = Depends(get_db), + current_user: User = Depends(require_manager_or_admin) +): + """ + Export team presence and parking data to Excel. + """ + # 1. Determine Scope (Admin vs Manager) + target_office_id = office_id + if current_user.role == UserRole.MANAGER: + # Manager is restricted to their own office + if office_id and office_id != current_user.office_id: + raise HTTPException(status_code=403, detail="Cannot export data for other offices") + target_office_id = current_user.office_id + + # 2. Fetch Users + query = db.query(User) + if target_office_id: + query = query.filter(User.office_id == target_office_id) + users = query.all() + user_ids = [u.id for u in users] + + # Map users for quick lookup + user_map = {u.id: u for u in users} + + # 3. Fetch Presences + presences = db.query(UserPresence).filter( + UserPresence.user_id.in_(user_ids), + UserPresence.date >= start_date, + UserPresence.date <= end_date + ).all() + + # 4. Fetch Parking Assignments + assignments = db.query(DailyParkingAssignment).filter( + DailyParkingAssignment.user_id.in_(user_ids), + DailyParkingAssignment.date >= start_date, + DailyParkingAssignment.date <= end_date + ).all() + + # Organize data by Date -> User -> Info + # Structure: data_map[date_str][user_id] = { presence: ..., parking: ... } + data_map = {} + + for p in presences: + d_str = p.date.isoformat() + if d_str not in data_map: data_map[d_str] = {} + if p.user_id not in data_map[d_str]: data_map[d_str][p.user_id] = {} + data_map[d_str][p.user_id]['presence'] = p.status.value # 'present', 'remote', etc. + + for a in assignments: + d_str = a.date.isoformat() + if d_str not in data_map: data_map[d_str] = {} + if a.user_id not in data_map[d_str]: data_map[d_str][a.user_id] = {} + if a.spot: + data_map[d_str][a.user_id]['parking'] = a.spot.name + else: + data_map[d_str][a.user_id]['parking'] = "Unknown" + + # 5. Generate Excel + wb = Workbook() + ws = wb.active + ws.title = "Report Presenze Matrix" + + # --- Header Row (Dates) --- + # Column A: "Utente" + ws.cell(row=1, column=1, value="Utente") + + # Generate date range + from datetime import timedelta + date_cols = {} # date_str -> col_index + col_idx = 2 + + curr = start_date + while curr <= end_date: + d_str = curr.isoformat() + # Header: DD/MM + header_val = f"{curr.day}/{curr.month}" + + cell = ws.cell(row=1, column=col_idx, value=header_val) + date_cols[d_str] = col_idx + + # Style Header + cell.font = Font(bold=True) + cell.alignment = Alignment(horizontal="center") + + col_idx += 1 + curr += timedelta(days=1) + + # Style First Header (Utente) + first_header = ws.cell(row=1, column=1) + first_header.font = Font(bold=True) + first_header.alignment = Alignment(horizontal="left") + + # Define Fills + fill_present = PatternFill(start_color="dcfce7", end_color="dcfce7", fill_type="solid") # Light Green + fill_remote = PatternFill(start_color="dbeafe", end_color="dbeafe", fill_type="solid") # Light Blue + fill_absent = PatternFill(start_color="fee2e2", end_color="fee2e2", fill_type="solid") # Light Red + fill_trip = PatternFill(start_color="fef3c7", end_color="fef3c7", fill_type="solid") # Light Orange (matching frontend warning-bg) + + # --- User Rows --- + row_idx = 2 + for user in users: + # User Name in Col 1 + name_cell = ws.cell(row=row_idx, column=1, value=user.name) + name_cell.font = Font(bold=True) + + # Determine Office label (optional append?) + # name_cell.value = f"{user.name} ({user.office.name})" if user.office else user.name + + # Fill Dates + curr = start_date + while curr <= end_date: + d_str = curr.isoformat() + if d_str in date_cols: + c_idx = date_cols[d_str] + + # Get Data + u_data = data_map.get(d_str, {}).get(user.id, {}) + presence = u_data.get('presence', '') + parking = u_data.get('parking', '') + + cell_val = "" + fill = None + + if presence == 'present': + cell_val = "In Sede" + fill = fill_present + elif presence == 'remote': + cell_val = "Remoto" + fill = fill_remote + elif presence == 'absent': + cell_val = "Ferie" + fill = fill_absent + elif presence == 'business_trip': + cell_val = "Trasferta" + fill = fill_trip + + # Append Parking info if present + if parking: + if cell_val: + cell_val += f" ({parking})" + else: + cell_val = f"({parking})" # Parking without presence? Unusual but possible + + cell = ws.cell(row=row_idx, column=c_idx, value=cell_val) + if fill: + cell.fill = fill + cell.alignment = Alignment(horizontal="center", vertical="center", wrap_text=True) + + curr += timedelta(days=1) + + row_idx += 1 + + # Adjust column widths + ws.column_dimensions['A'].width = 25 # User name column + + # Auto-width for date columns (approx) + for i in range(2, col_idx): + col_letter = ws.cell(row=1, column=i).column_letter + ws.column_dimensions[col_letter].width = 12 + + # Save to buffer + output = BytesIO() + wb.save(output) + output.seek(0) + + filename = f"report_parking_matrix_{start_date}_{end_date}.xlsx" + + headers = { + 'Content-Disposition': f'attachment; filename="{filename}"' + } + + return Response( + content=output.getvalue(), + media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + headers=headers + ) diff --git a/app/routes/users.py b/app/routes/users.py index 76e2040..a258961 100644 --- a/app/routes/users.py +++ b/app/routes/users.py @@ -2,13 +2,13 @@ User Management Routes Admin user CRUD and user self-service (profile, settings, password) """ -from datetime import datetime +from datetime import datetime, date from fastapi import APIRouter, Depends, HTTPException from pydantic import BaseModel, EmailStr from sqlalchemy.orm import Session from database.connection import get_db -from database.models import User, UserRole, Office +from database.models import User, UserRole, Office, ParkingExclusion from utils.auth_middleware import get_current_user, require_admin from utils.helpers import ( generate_uuid, is_ldap_user, is_ldap_admin, @@ -54,6 +54,20 @@ class ChangePasswordRequest(BaseModel): new_password: str +class UserExclusionCreate(BaseModel): + start_date: date | None = None + end_date: date | None = None + notes: str | None = None + + +class UserExclusionResponse(BaseModel): + id: str + start_date: date | None + end_date: date | None + notes: str | None + is_excluded: bool = True + + class UserResponse(BaseModel): id: str email: str @@ -332,3 +346,105 @@ def change_password(data: ChangePasswordRequest, db: Session = Depends(get_db), db.commit() config.logger.info(f"User {current_user.email} changed password") return {"message": "Password changed"} + + +# Exclusion Management (Self-Service) +# Exclusion Management (Self-Service) +@router.get("/me/exclusion", response_model=list[UserExclusionResponse]) +def get_my_exclusions(db: Session = Depends(get_db), current_user=Depends(get_current_user)): + """Get current user's parking exclusions""" + if not current_user.office_id: + return [] + + exclusions = db.query(ParkingExclusion).filter( + ParkingExclusion.user_id == current_user.id, + ParkingExclusion.office_id == current_user.office_id + ).all() + + return [ + { + "id": e.id, + "start_date": e.start_date, + "end_date": e.end_date, + "notes": e.notes, + "is_excluded": True + } + for e in exclusions + ] + + +@router.post("/me/exclusion") +def create_my_exclusion(data: UserExclusionCreate, db: Session = Depends(get_db), current_user=Depends(get_current_user)): + """Create new parking exclusion for current user""" + if not current_user.office_id: + raise HTTPException(status_code=400, detail="User is not assigned to an office") + + if data.start_date and data.end_date and data.end_date < data.start_date: + raise HTTPException(status_code=400, detail="End date must be after start date") + + if data.end_date and not data.start_date: + raise HTTPException(status_code=400, detail="Start date is required if an end date is specified") + + exclusion = ParkingExclusion( + id=generate_uuid(), + office_id=current_user.office_id, + user_id=current_user.id, + start_date=data.start_date, + end_date=data.end_date, + notes=data.notes or "Auto-esclusione utente", + created_at=datetime.utcnow() + ) + db.add(exclusion) + db.commit() + return {"message": "Exclusion created", "id": exclusion.id} + + +@router.put("/me/exclusion/{exclusion_id}") +def update_my_exclusion(exclusion_id: str, data: UserExclusionCreate, db: Session = Depends(get_db), current_user=Depends(get_current_user)): + """Update specific parking exclusion for current user""" + if not current_user.office_id: + raise HTTPException(status_code=400, detail="User is not assigned to an office") + + exclusion = db.query(ParkingExclusion).filter( + ParkingExclusion.id == exclusion_id, + ParkingExclusion.user_id == current_user.id, + ParkingExclusion.office_id == current_user.office_id + ).first() + + if not exclusion: + raise HTTPException(status_code=404, detail="Exclusion not found") + + if data.start_date and data.end_date and data.end_date < data.start_date: + raise HTTPException(status_code=400, detail="End date must be after start date") + + if data.end_date and not data.start_date: + raise HTTPException(status_code=400, detail="Start date is required if an end date is specified") + + exclusion.start_date = data.start_date + exclusion.end_date = data.end_date + if data.notes is not None: + exclusion.notes = data.notes + + exclusion.updated_at = datetime.utcnow() + db.commit() + return {"message": "Exclusion updated", "id": exclusion.id} + + +@router.delete("/me/exclusion/{exclusion_id}") +def delete_my_exclusion(exclusion_id: str, db: Session = Depends(get_db), current_user=Depends(get_current_user)): + """Remove specific parking exclusion for current user""" + if not current_user.office_id: + raise HTTPException(status_code=400, detail="User is not assigned to an office") + + exclusion = db.query(ParkingExclusion).filter( + ParkingExclusion.id == exclusion_id, + ParkingExclusion.user_id == current_user.id, + ParkingExclusion.office_id == current_user.office_id + ).first() + + if not exclusion: + raise HTTPException(status_code=404, detail="Exclusion not found") + + db.delete(exclusion) + db.commit() + return {"message": "Exclusion removed"} diff --git a/compose.yml b/compose.yml index cd61b3e..28d6289 100644 --- a/compose.yml +++ b/compose.yml @@ -5,6 +5,12 @@ services: restart: unless-stopped volumes: - ./data:/app/data + - ./app:/app/app + - ./database:/app/database + - ./services:/app/services + - ./utils:/app/utils + - ./frontend:/app/frontend + - ./main.py:/app/main.py env_file: - .env environment: @@ -29,5 +35,4 @@ services: networks: org-network: - external: true - name: org-stack_org-network + external: true \ No newline at end of file diff --git a/database/models.py b/database/models.py index 9644375..76d0494 100644 --- a/database/models.py +++ b/database/models.py @@ -20,6 +20,8 @@ class PresenceStatus(str, enum.Enum): PRESENT = "present" REMOTE = "remote" ABSENT = "absent" + BUSINESS_TRIP = "business_trip" + class NotificationType(str, enum.Enum): @@ -67,6 +69,7 @@ class Office(Base): users = relationship("User", back_populates="office") closing_days = relationship("OfficeClosingDay", back_populates="office", cascade="all, delete-orphan") weekly_closing_days = relationship("OfficeWeeklyClosingDay", back_populates="office", cascade="all, delete-orphan") + spots = relationship("OfficeSpot", back_populates="office", cascade="all, delete-orphan") class User(Base): @@ -130,7 +133,7 @@ class DailyParkingAssignment(Base): id = Column(Text, primary_key=True) date = Column(Date, nullable=False) - spot_id = Column(Text, nullable=False) # A1, A2, B1, B2, etc. (prefix from office) + spot_id = Column(Text, ForeignKey("office_spots.id", ondelete="CASCADE"), nullable=False) user_id = Column(Text, ForeignKey("users.id", ondelete="SET NULL")) office_id = Column(Text, ForeignKey("offices.id", ondelete="CASCADE"), nullable=False) # Office that owns the spot created_at = Column(DateTime, default=datetime.utcnow) @@ -138,6 +141,7 @@ class DailyParkingAssignment(Base): # Relationships user = relationship("User", back_populates="assignments", foreign_keys=[user_id]) office = relationship("Office") + spot = relationship("OfficeSpot", back_populates="assignments") __table_args__ = ( Index('idx_assignment_office_date', 'office_id', 'date'), @@ -218,7 +222,7 @@ class ParkingExclusion(Base): user = relationship("User", foreign_keys=[user_id]) __table_args__ = ( - Index('idx_exclusion_office_user', 'office_id', 'user_id', unique=True), + Index('idx_exclusion_office_user', 'office_id', 'user_id'), ) @@ -237,12 +241,6 @@ class NotificationLog(Base): ) -class NotificationQueue(Base): - """Queue for pending notifications (for immediate parking change notifications)""" - __tablename__ = "notification_queue" - - id = Column(Text, primary_key=True) - user_id = Column(Text, ForeignKey("users.id", ondelete="CASCADE"), nullable=False) notification_type = Column(Enum(NotificationType, values_callable=lambda obj: [e.value for e in obj]), nullable=False) # parking_change subject = Column(Text, nullable=False) body = Column(Text, nullable=False) @@ -252,3 +250,23 @@ class NotificationQueue(Base): __table_args__ = ( Index('idx_queue_pending', 'sent_at'), ) + + +class OfficeSpot(Base): + """Specific parking spot definitions (e.g., A1, A2) linked to an office""" + __tablename__ = "office_spots" + + id = Column(Text, primary_key=True) + office_id = Column(Text, ForeignKey("offices.id", ondelete="CASCADE"), nullable=False) + name = Column(Text, nullable=False) # Display name: A1, A2, etc. + spot_number = Column(Integer, nullable=False) # Numeric part for sorting/filtering (1, 2, 3...) + is_unavailable = Column(Boolean, default=False) # If spot is temporarily out of service + + # Relationships + office = relationship("Office", back_populates="spots") + assignments = relationship("DailyParkingAssignment", back_populates="spot", cascade="all, delete-orphan") + + __table_args__ = ( + Index('idx_office_spot_number', 'office_id', 'spot_number', unique=True), + Index('idx_office_spot_name', 'office_id', 'name', unique=True), + ) diff --git a/fix_db_index.py b/fix_db_index.py new file mode 100644 index 0000000..7502c5a --- /dev/null +++ b/fix_db_index.py @@ -0,0 +1,61 @@ +import sqlite3 +import os +import time + +# Function to find the db file +def find_db(): + candidates = [ + 'data/parking.db', + '/home/ssalemi/org-parking/data/parking.db', + './data/parking.db' + ] + for path in candidates: + if os.path.exists(path): + return path + return None + +db_path = find_db() + +if not db_path: + print("Error: Could not find data/parking.db. Make sure you are in the project root.") + exit(1) + +print(f"Target Database: {db_path}") +print("Attempting to fix 'parking_exclusions' index...") + +try: + conn = sqlite3.connect(db_path) + cursor = conn.cursor() + + # Turn off foreign keys temporarily to avoid issues during schema modification if needed + cursor.execute("PRAGMA foreign_keys=OFF") + + # 1. Drop the existing unique index + print("Dropping index idx_exclusion_office_user...") + try: + cursor.execute("DROP INDEX IF EXISTS idx_exclusion_office_user") + print("Index dropped successfully.") + except Exception as e: + print(f"Warning during drop: {e}") + + # 2. Recreate it as non-unique + print("Creating non-unique index idx_exclusion_office_user...") + try: + cursor.execute("CREATE INDEX idx_exclusion_office_user ON parking_exclusions (office_id, user_id)") + print("Index created successfully.") + except Exception as e: + print(f"Error creating index: {e}") + exit(1) + + conn.commit() + conn.close() + print("\nSUCCESS: Database updated. You can now define multiple exclusions per user.") + +except sqlite3.OperationalError as e: + if "locked" in str(e): + print("\nERROR: Database is LOCKED.") + print("Please STOP the running application (Docker) and try again.") + else: + print(f"\nError: {e}") +except Exception as e: + print(f"\nUnexpected Error: {e}") diff --git a/frontend/css/styles.css b/frontend/css/styles.css index 3a36154..a77af61 100644 --- a/frontend/css/styles.css +++ b/frontend/css/styles.css @@ -652,6 +652,11 @@ textarea { border-color: var(--danger) !important; } +.status-business_trip { + background: var(--warning-bg) !important; + border-color: var(--warning) !important; +} + .status-nodata { background: white; } @@ -1788,6 +1793,7 @@ textarea { transform: translate(-50%, 100%); opacity: 0; } + to { transform: translate(-50%, 0); opacity: 1; @@ -1798,6 +1804,7 @@ textarea { from { opacity: 1; } + to { opacity: 0; } diff --git a/frontend/js/api.js b/frontend/js/api.js index 8fa4dad..4ffec65 100644 --- a/frontend/js/api.js +++ b/frontend/js/api.js @@ -185,9 +185,23 @@ const api = { * Logout */ async logout() { + // Fetch config to check for external logout URL + let logoutUrl = '/login'; + try { + const configRes = await this.get('/api/auth/config'); + if (configRes && configRes.ok) { + const config = await configRes.json(); + if (config.authelia_enabled && config.logout_url) { + logoutUrl = config.logout_url; + } + } + } catch (e) { + console.error('Error fetching logout config', e); + } + await this.post('/api/auth/logout', {}); this.clearToken(); - window.location.href = '/login'; + window.location.href = logoutUrl; } }; diff --git a/frontend/js/nav.js b/frontend/js/nav.js index c73c865..6f81416 100644 --- a/frontend/js/nav.js +++ b/frontend/js/nav.js @@ -87,9 +87,6 @@ async function initNav() { // Get user info (works with both JWT and Authelia) const currentUser = await api.checkAuth(); - // Render navigation - navContainer.innerHTML = renderNav(currentPath, currentUser?.role); - // Update user info in sidebar if (currentUser) { const userNameEl = document.getElementById('userName'); @@ -98,11 +95,55 @@ async function initNav() { if (userRoleEl) userRoleEl.textContent = currentUser.role || '-'; } - // Setup user menu + // Setup user menu (logout) & mobile menu setupUserMenu(); - - // Setup mobile menu setupMobileMenu(); + + // CHECK: Block access if user has no office (and is not admin) + // Admins are allowed to access "Gestione Uffici" even without an office + if (currentUser && !currentUser.office_id && currentUser.role !== 'admin') { + navContainer.innerHTML = ''; // Clear nav + + const mainContent = document.querySelector('.main-content'); + if (mainContent) { + mainContent.innerHTML = ` +
+
+
+ + + + + +
+

Ufficio non assegnato

+

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

+
+
Cosa fare?
+
+ Contatta l'amministratore di sistema per richiedere l'assegnazione al tuo ufficio di competenza.
+ s.salemi@sielte.it +
+
+
+
+ `; + } + return; // STOP rendering nav + } + + // Render navigation (Normal Flow) + navContainer.innerHTML = renderNav(currentPath, currentUser?.role); } function setupMobileMenu() { diff --git a/frontend/js/parking-settings.js b/frontend/js/parking-settings.js index c4b6081..00632e4 100644 --- a/frontend/js/parking-settings.js +++ b/frontend/js/parking-settings.js @@ -143,6 +143,7 @@ function setupEventListeners() { } }); + // Test Tools // Test Tools document.getElementById('runAllocationBtn').addEventListener('click', async () => { if (!confirm('Sei sicuro di voler avviare l\'assegnazione ORA? Questo potrebbe sovrascrivere le assegnazioni esistenti per la data selezionata.')) return; @@ -166,7 +167,7 @@ function setupEventListeners() { utils.showMessage('Avvio assegnazione...', 'success'); while (current <= end) { - const dateStr = current.toISOString().split('T')[0]; + const dateStr = utils.formatDate(current); try { await api.post('/api/parking/run-allocation', { date: dateStr, @@ -207,20 +208,110 @@ function setupEventListeners() { utils.showMessage('Rimozione in corso...', 'warning'); + // Loop is fine, but maybe redundant if we could batch clean? + // Backend clear-assignments is per day. while (current <= end) { - const dateStr = current.toISOString().split('T')[0]; + const dateStr = utils.formatDate(current); try { const res = await api.post('/api/parking/clear-assignments', { date: dateStr, office_id: currentOffice.id }); - totalRemoved += (res.count || 0); + if (res && res.ok) { + const data = await res.json(); + totalRemoved += (data.count || 0); + } } catch (e) { console.error(`Error clearing ${dateStr}`, e); } current.setDate(current.getDate() + 1); } - utils.showMessage(`Operazione eseguita. ${totalRemoved} assegnazioni rimosse in totale.`, 'warning'); + utils.showMessage(`Operazione eseguita.`, 'warning'); }); + + const clearPresenceBtn = document.getElementById('clearPresenceBtn'); + if (clearPresenceBtn) { + clearPresenceBtn.addEventListener('click', async () => { + if (!confirm('ATTENZIONE: Stai per eliminare TUTTI GLI STATI (Presente/Assente/ecc) e relative assegnazioni per tutti gli utenti dell\'ufficio nel periodo selezionato. \n\nQuesta azione è irreversibile. Procedere?')) return; + + const dateStart = document.getElementById('testDateStart').value; + const dateEnd = document.getElementById('testDateEnd').value; + + if (!dateStart) return utils.showMessage('Seleziona una data di inizio', 'error'); + + // Validate office + if (!currentOffice || !currentOffice.id) { + return utils.showMessage('Errore: Nessun ufficio selezionato', 'error'); + } + + const endDateVal = dateEnd || dateStart; + + utils.showMessage('Rimozione stati in corso...', 'warning'); + + try { + const res = await api.post('/api/presence/admin/clear-office-presence', { + start_date: dateStart, + end_date: endDateVal, + office_id: currentOffice.id + }); + + if (res && res.ok) { + const data = await res.json(); + utils.showMessage(`Operazione completata. Rimossi ${data.count_presence} stati e ${data.count_parking} assegnazioni.`, 'success'); + } else { + const err = await res.json(); + utils.showMessage('Errore: ' + (err.detail || 'Operazione fallita'), 'error'); + } + } catch (e) { + console.error(e); + utils.showMessage('Errore di comunicazione col server', 'error'); + } + }); + } + + const testEmailBtn = document.getElementById('testEmailBtn'); + if (testEmailBtn) { + testEmailBtn.addEventListener('click', async () => { + const dateVal = document.getElementById('testEmailDate').value; + + // Validate office + if (!currentOffice || !currentOffice.id) { + return utils.showMessage('Errore: Nessun ufficio selezionato', 'error'); + } + + utils.showMessage('Invio mail di test in corso...', 'warning'); + + try { + const res = await api.post('/api/parking/test-email', { + date: dateVal || null, + office_id: currentOffice.id + }); + + if (res && res.status !== 403 && res.status !== 500 && res.ok !== false) { + // API wrapper usually returns response object or parses JSON? + // api.post returns response object if 200-299, but wrapper handles some. + // Let's assume standard fetch response or check wrapper. + // api.js Wrapper returns fetch Response. + const data = await res.json(); + + if (data.success) { + let msg = `Email inviata con successo per la data: ${data.date}.`; + if (data.mode === 'FILE') { + msg += ' (SMTP disabilitato: Loggato su file)'; + } + utils.showMessage(msg, 'success'); + } else { + utils.showMessage('Invio fallito. Controlla i log del server.', 'error'); + } + } else { + const err = res ? await res.json() : {}; + utils.showMessage('Errore: ' + (err.detail || 'Invio fallito'), 'error'); + } + } catch (e) { + console.error(e); + utils.showMessage('Errore di comunicazione col server', 'error'); + } + }); + } } diff --git a/frontend/js/presence.js b/frontend/js/presence.js index c1dc210..3b0c12d 100644 --- a/frontend/js/presence.js +++ b/frontend/js/presence.js @@ -33,6 +33,9 @@ document.addEventListener('DOMContentLoaded', async () => { // Initialize Parking Status initParkingStatus(); setupStatusListeners(); + + // Initialize Exclusion Logic + initExclusionLogic(); }); async function loadPresences() { @@ -337,19 +340,57 @@ function setupEventListeners() { const promises = []; let current = new Date(startDate); + // Validate filtering + let skippedCount = 0; + while (current <= endDate) { const dStr = current.toISOString().split('T')[0]; - if (status === 'clear') { - promises.push(api.delete(`/api/presence/${dStr}`)); + + // Create local date for rules check (matches renderCalendar logic) + const localCurrent = new Date(dStr + 'T00:00:00'); + const dayOfWeek = localCurrent.getDay(); // 0-6 + + // Check closing days + // Only enforce rules if we are not clearing (or should we enforce for clearing too? + // Usually clearing is allowed always, but "Inserimento" implies adding. + // Ensuring we don't ADD presence on closed days is the main goal.) + let isClosed = false; + + if (status !== 'clear') { + 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); + + // localCurrent is already set to 00:00:00 local + return localCurrent >= start && localCurrent <= end; + }); + + if (isWeeklyClosed || isSpecificClosed) isClosed = true; + } + + if (isClosed) { + skippedCount++; } else { - promises.push(api.post('/api/presence/mark', { date: dStr, status: status })); + if (status === 'clear') { + promises.push(api.delete(`/api/presence/${dStr}`)); + } else { + promises.push(api.post('/api/presence/mark', { date: dStr, status: status })); + } } current.setDate(current.getDate() + 1); } try { await Promise.all(promises); - utils.showMessage('Inserimento completato!', 'success'); + if (skippedCount > 0) { + utils.showMessage(`Inserimento completato! (${skippedCount} giorni chiusi ignorati)`, 'warning'); + } else { + utils.showMessage('Inserimento completato!', 'success'); + } await Promise.all([loadPresences(), loadParkingAssignments()]); renderCalendar(); } catch (err) { @@ -531,3 +572,211 @@ function setupStatusListeners() { } + +// ---------------------------------------------------------------------------- +// Exclusion Logic +// ---------------------------------------------------------------------------- + +async function initExclusionLogic() { + await loadExclusionStatus(); + setupExclusionListeners(); +} + +async function loadExclusionStatus() { + try { + const response = await api.get('/api/users/me/exclusion'); + if (response && response.ok) { + const data = await response.json(); + updateExclusionUI(data); // data is now a list + } + } catch (e) { + console.error("Error loading exclusion status", e); + } +} + +function updateExclusionUI(exclusions) { + const statusDiv = document.getElementById('exclusionStatusDisplay'); + const manageBtn = document.getElementById('manageExclusionBtn'); + + // Always show manage button as "Aggiungi Esclusione" + manageBtn.textContent = 'Aggiungi Esclusione'; + // Clear previous binding to avoid duplicates or simply use a new function + // But specific listeners are set in setupExclusionListeners. + // Actually, manageBtn logic was resetting UI. + + if (exclusions && exclusions.length > 0) { + statusDiv.style.display = 'block'; + + let html = '
'; + + exclusions.forEach(ex => { + let period = 'Tempo Indeterminato'; + if (ex.start_date && ex.end_date) { + period = `${utils.formatDate(new Date(ex.start_date))} - ${utils.formatDate(new Date(ex.end_date))}`; + } else if (ex.start_date) { + period = `Dal ${utils.formatDate(new Date(ex.start_date))}`; + } + + html += ` +
+
+
${period}
+ ${ex.notes ? `
${ex.notes}
` : ''} +
+
+ + +
+
`; + }); + + html += '
'; + statusDiv.innerHTML = html; + + // Update container style for list + statusDiv.style.backgroundColor = '#f9fafb'; + statusDiv.style.color = 'inherit'; + statusDiv.style.border = 'none'; // remove border from container, items have border + statusDiv.style.padding = '0'; // reset padding + + } else { + statusDiv.style.display = 'none'; + statusDiv.innerHTML = ''; + } +} + +// Global for edit +let myEditingExclusionId = null; + +function openEditMyExclusion(id, data) { + myEditingExclusionId = id; + const modal = document.getElementById('userExclusionModal'); + const radioForever = document.querySelector('input[name="exclusionType"][value="forever"]'); + const radioRange = document.querySelector('input[name="exclusionType"][value="range"]'); + const rangeDiv = document.getElementById('exclusionDateRange'); + const deleteBtn = document.getElementById('deleteExclusionBtn'); // Hide in edit mode (we have icon) or keep? + // User requested "matita a destra per la modifica ed eliminazione". + // I added trash icon to the list. So modal "Rimuovi" is redundant but harmless. + // I'll hide it for clarity. + if (deleteBtn) deleteBtn.style.display = 'none'; + + if (data.start_date || data.end_date) { + radioRange.checked = true; + rangeDiv.style.display = 'block'; + if (data.start_date) document.getElementById('ueStartDate').value = data.start_date; + if (data.end_date) document.getElementById('ueEndDate').value = data.end_date; + } else { + radioForever.checked = true; + rangeDiv.style.display = 'none'; + document.getElementById('ueStartDate').value = ''; + document.getElementById('ueEndDate').value = ''; + } + document.getElementById('ueNotes').value = data.notes || ''; + + document.querySelector('#userExclusionModal h3').textContent = 'Modifica Esclusione'; + modal.style.display = 'flex'; +} + +async function deleteMyExclusion(id) { + if (!confirm('Rimuovere questa esclusione?')) return; + const response = await api.delete(`/api/users/me/exclusion/${id}`); + if (response && response.ok) { + utils.showMessage('Esclusione rimossa con successo', 'success'); + loadExclusionStatus(); + } else { + const err = await response.json(); + utils.showMessage(err.detail || 'Errore rimozione', 'error'); + } +} + +function resetMyExclusionForm() { + document.getElementById('userExclusionForm').reset(); + myEditingExclusionId = null; + document.querySelector('#userExclusionModal h3').textContent = 'Nuova Esclusione'; + + const rangeDiv = document.getElementById('exclusionDateRange'); + rangeDiv.style.display = 'none'; + document.querySelector('input[name="exclusionType"][value="forever"]').checked = true; + + // Hide delete btn in modal (using list icon instead) + const deleteBtn = document.getElementById('deleteExclusionBtn'); + if (deleteBtn) deleteBtn.style.display = 'none'; +} + +function setupExclusionListeners() { + const modal = document.getElementById('userExclusionModal'); + const manageBtn = document.getElementById('manageExclusionBtn'); + const closeBtn = document.getElementById('closeUserExclusionModal'); + const cancelBtn = document.getElementById('cancelUserExclusion'); + const form = document.getElementById('userExclusionForm'); + + const radioForever = document.querySelector('input[name="exclusionType"][value="forever"]'); + const radioRange = document.querySelector('input[name="exclusionType"][value="range"]'); + const rangeDiv = document.getElementById('exclusionDateRange'); + + if (manageBtn) { + manageBtn.addEventListener('click', () => { + resetMyExclusionForm(); + modal.style.display = 'flex'; + }); + } + + if (closeBtn) closeBtn.addEventListener('click', () => modal.style.display = 'none'); + if (cancelBtn) cancelBtn.addEventListener('click', () => modal.style.display = 'none'); + + // Radio logic + radioForever.addEventListener('change', () => rangeDiv.style.display = 'none'); + radioRange.addEventListener('change', () => rangeDiv.style.display = 'block'); + + // Save + if (form) { + form.addEventListener('submit', async (e) => { + e.preventDefault(); + + const type = document.querySelector('input[name="exclusionType"]:checked').value; + const payload = { + notes: document.getElementById('ueNotes').value + }; + + if (type === 'range') { + const start = document.getElementById('ueStartDate').value; + const end = document.getElementById('ueEndDate').value; + + if (start) payload.start_date = start; + if (end) payload.end_date = end; + + if (start && end && new Date(end) < new Date(start)) { + return utils.showMessage('La data di fine deve essere dopo la data di inizio', 'error'); + } + } else { + payload.start_date = null; + payload.end_date = null; + } + + let response; + if (myEditingExclusionId) { + response = await api.put(`/api/users/me/exclusion/${myEditingExclusionId}`, payload); + } else { + response = await api.post('/api/users/me/exclusion', payload); + } + + if (response && response.ok) { + utils.showMessage('Esclusione salvata', 'success'); + modal.style.display = 'none'; + loadExclusionStatus(); + } else { + const err = await response.json(); + utils.showMessage(err.detail || 'Errore salvataggio', 'error'); + } + }); + } +} +// Globals +window.openEditMyExclusion = openEditMyExclusion; +window.deleteMyExclusion = deleteMyExclusion; diff --git a/frontend/js/team-calendar.js b/frontend/js/team-calendar.js index 9c6684a..9cfb828 100644 --- a/frontend/js/team-calendar.js +++ b/frontend/js/team-calendar.js @@ -113,6 +113,77 @@ async function loadOffices() { // Initial update of office display updateOfficeDisplay(); + + // Show export card for Admin/Manager + if (['admin', 'manager'].includes(currentUser.role)) { + const exportCard = document.getElementById('exportCard'); + if (exportCard) { + exportCard.style.display = 'block'; + + // Set defaults (current month) + const today = new Date(); + const firstDay = new Date(today.getFullYear(), today.getMonth(), 1); + const lastDay = new Date(today.getFullYear(), today.getMonth() + 1, 0); + + document.getElementById('exportStartDate').valueAsDate = firstDay; + document.getElementById('exportEndDate').valueAsDate = lastDay; + + document.getElementById('exportBtn').addEventListener('click', handleExport); + } + } +} + +async function handleExport() { + const startStr = document.getElementById('exportStartDate').value; + const endStr = document.getElementById('exportEndDate').value; + const officeId = document.getElementById('officeFilter').value; + + if (!startStr || !endStr) { + alert('Seleziona le date di inizio e fine'); + return; + } + + // Construct URL + let url = `/api/reports/team-export?start_date=${startStr}&end_date=${endStr}`; + if (officeId) { + url += `&office_id=${officeId}`; + } + + try { + const btn = document.getElementById('exportBtn'); + const originalText = btn.innerHTML; + btn.disabled = true; + btn.textContent = 'Generazione...'; + + // Use fetch directly to handle blob + const token = api.getToken(); + const headers = token ? { 'Authorization': `Bearer ${token}` } : {}; + + const response = await fetch(url, { headers }); + + if (response.ok) { + const blob = await response.blob(); + const downloadUrl = window.URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = downloadUrl; + a.download = `report_presenze_${startStr}_${endStr}.xlsx`; + document.body.appendChild(a); + a.click(); + window.URL.revokeObjectURL(downloadUrl); + a.remove(); + } else { + const err = await response.json(); + alert('Errore export: ' + (err.detail || 'Sconosciuto')); + } + + btn.disabled = false; + btn.innerHTML = originalText; + } catch (e) { + console.error(e); + alert('Errore durante l\'export'); + document.getElementById('exportBtn').disabled = false; + document.getElementById('exportBtn').textContent = 'Esporta Excel'; + } } function getDateRange() { diff --git a/frontend/js/team-rules.js b/frontend/js/team-rules.js index f7a8642..3582d36 100644 --- a/frontend/js/team-rules.js +++ b/frontend/js/team-rules.js @@ -267,26 +267,70 @@ async function loadExclusions(officeId) { ${e.notes ? `${e.notes}` : ''} - +
+ + +
`).join(''); } } -async function addExclusion(data) { - const response = await api.post(`/api/offices/${currentOfficeId}/exclusions`, data); +// Global variable to track edit mode +let editingExclusionId = null; + +async function openEditExclusion(id, data) { + editingExclusionId = id; + + // Populate form + populateUserSelects(); + document.getElementById('exclusionUser').value = data.user_id; + // Disable user select in edit mode usually? Or allow change? API allows it. + + document.getElementById('exclusionStartDate').value = data.start_date || ''; + document.getElementById('exclusionEndDate').value = data.end_date || ''; + document.getElementById('exclusionNotes').value = data.notes || ''; + + // Change modal title/button + document.querySelector('#exclusionModal h3').textContent = 'Modifica Esclusione'; + document.querySelector('#exclusionForm button[type="submit"]').textContent = 'Salva Modifiche'; + + document.getElementById('exclusionModal').style.display = 'flex'; +} + +async function saveExclusion(data) { + let response; + if (editingExclusionId) { + response = await api.put(`/api/offices/${currentOfficeId}/exclusions/${editingExclusionId}`, data); + } else { + response = await api.post(`/api/offices/${currentOfficeId}/exclusions`, data); + } + if (response && response.ok) { await loadExclusions(currentOfficeId); document.getElementById('exclusionModal').style.display = 'none'; - document.getElementById('exclusionForm').reset(); + resetExclusionForm(); } else { const error = await response.json(); - alert(error.detail || 'Impossibile aggiungere l\'esclusione'); + alert(error.detail || 'Impossibile salvare l\'esclusione'); } } +function resetExclusionForm() { + document.getElementById('exclusionForm').reset(); + editingExclusionId = null; + document.querySelector('#exclusionModal h3').textContent = 'Aggiungi Esclusione Parcheggio'; + document.querySelector('#exclusionForm button[type="submit"]').textContent = 'Aggiungi'; +} + + + async function deleteExclusion(id) { if (!confirm('Eliminare questa esclusione?')) return; const response = await api.delete(`/api/offices/${currentOfficeId}/exclusions/${id}`); @@ -335,6 +379,10 @@ function setupEventListeners() { modals.forEach(m => { document.getElementById(m.btn).addEventListener('click', () => { if (m.id !== 'closingDayModal') populateUserSelects(); + + // Special handling for exclusion to reset edit mode + if (m.id === 'exclusionModal') resetExclusionForm(); + document.getElementById(m.id).style.display = 'flex'; }); document.getElementById(m.close).addEventListener('click', () => { @@ -368,7 +416,7 @@ function setupEventListeners() { document.getElementById('exclusionForm').addEventListener('submit', (e) => { e.preventDefault(); - addExclusion({ + saveExclusion({ user_id: document.getElementById('exclusionUser').value, start_date: document.getElementById('exclusionStartDate').value || null, end_date: document.getElementById('exclusionEndDate').value || null, @@ -380,4 +428,7 @@ function setupEventListeners() { // Global functions window.deleteClosingDay = deleteClosingDay; window.deleteGuarantee = deleteGuarantee; +window.deleteClosingDay = deleteClosingDay; +window.deleteGuarantee = deleteGuarantee; window.deleteExclusion = deleteExclusion; +window.openEditExclusion = openEditExclusion; diff --git a/frontend/pages/parking-settings.html b/frontend/pages/parking-settings.html index b4bcd03..8dbe295 100644 --- a/frontend/pages/parking-settings.html +++ b/frontend/pages/parking-settings.html @@ -138,6 +138,28 @@ + + + +
+ +
+ +
+
+ Data di Riferimento (Opzionale): + + + Se non specificata, verrà usato il primo giorno lavorativo disponibile. + +
+ +
diff --git a/frontend/pages/presence.html b/frontend/pages/presence.html index 7a0089f..3481bb6 100644 --- a/frontend/pages/presence.html +++ b/frontend/pages/presence.html @@ -87,7 +87,11 @@
- Assente + Ferie +
+
+
+ Trasferta
@@ -155,11 +159,35 @@ + +
+
+

Esclusione Assegnazione

+
+
+

+ Puoi decidere di escluderti automaticamente dalla logica di assegnazione dei posti auto. + Le richieste di esclusione sono visibili agli amministratori. +

+ + + +
+ +
+
+
+

Mappa Parcheggio

Mappa Parcheggio -
+ + + @@ -199,7 +227,11 @@ + + @@ -276,6 +312,59 @@ + + + + diff --git a/frontend/pages/profile.html b/frontend/pages/profile.html index 3012591..e30be17 100644 --- a/frontend/pages/profile.html +++ b/frontend/pages/profile.html @@ -74,9 +74,9 @@ Il ruolo è assegnato dal tuo amministratore
- + - Il tuo manager è assegnato dall'amministratore + Il tuo ufficio è assegnato dall'amministratore
@@ -139,7 +139,7 @@ document.getElementById('name').value = profile.name || ''; document.getElementById('email').value = profile.email; document.getElementById('role').value = profile.role; - document.getElementById('manager').value = profile.manager_name || 'Nessuno'; + document.getElementById('manager').value = profile.office_name || 'Nessuno'; // LDAP mode adjustments if (isLdapUser) { diff --git a/frontend/pages/settings.html b/frontend/pages/settings.html index 839e354..86ca882 100644 --- a/frontend/pages/settings.html +++ b/frontend/pages/settings.html @@ -54,17 +54,7 @@
-
- - Ricevi il riepilogo settimanale delle assegnazioni parcheggio ogni - Venerdì alle 12:00 -
+
+
Ufficio: Loading... @@ -100,8 +101,36 @@
- Assente + Ferie
+
+
+ Trasferta +
+
+ + + + @@ -127,7 +156,11 @@ +