Compare commits

...

10 Commits

Author SHA1 Message Date
5d30434dd5 piccoli fix 2026-04-20 20:37:37 +02:00
StefanoSalemi
104ad53a9a feat: aggiunti: loggica random, tema scuro, correzioni mail, miglioramenti generali, cache; 2026-04-17 18:27:37 +02:00
a7ef46640d added cache system 2026-02-12 22:44:44 +01:00
991569d9eb Update compose.yml 2026-02-12 19:37:37 +00:00
8f5c1e1f94 fix scheduler orario start, immagine parcheggio, added more logs function 2026-02-12 19:57:00 +01:00
a94ec11c80 ad TIMEZONE and more 2026-02-09 12:31:06 +01:00
efa7533179 fix mail sand 2 2026-02-08 19:18:58 +01:00
e0b18fd3c3 fixing mail 2026-02-08 18:08:19 +01:00
ae099f04cf fixing 2026-02-08 17:31:32 +01:00
5f4ef6faee aggiunti trasferte, export excel, miglioramenti generali 2026-02-04 12:55:04 +01:00
40 changed files with 3158 additions and 1153 deletions

81
.env.prod Normal file
View File

@@ -0,0 +1,81 @@
# Parking Manager Configuration
# =============================================================================
# REQUIRED - Security
# =============================================================================
# MUST be set to a random string of at least 32 characters
# Generate with: openssl rand -hex 32
SECRET_KEY=766299d3235f79a2a9a35aafbc90bec7102f250dfe4aba83500b98e568289b7a
# =============================================================================
# Server
# =============================================================================
# Usa 0.0.0.0 per permettere connessioni dall'esterno del container (essenziale per Docker/Traefik)
HOST=0.0.0.0
PORT=8000
# Timezone per l'applicazione (cronjobs, notifiche, ecc.)
TIMEZONE=Europe/Rome
# Database (SQLite path)
# Percorso assoluto nel container
DATABASE_PATH=/app/data/parking.db
# Lascia vuoto DATABASE_URL per costruirlo automaticamente da DATABASE_PATH
# Oppure usa: DATABASE_URL=sqlite:////app/data/parking.db
# CORS (comma-separated origins)
#ALLOWED_ORIGINS=https://parking.rocketscale.it
# JWT token expiration (minutes, default 24 hours)
ACCESS_TOKEN_EXPIRE_MINUTES=1440
COOKIE_SECURE=true
# Logging level (DEBUG, INFO, WARNING, ERROR)
LOG_LEVEL=INFO
# =============================================================================
# Rate Limiting
# =============================================================================
# Number of requests allowed per window for sensitive endpoints (login, register)
RATE_LIMIT_REQUESTS=5
# Window size in seconds
RATE_LIMIT_WINDOW=60
# =============================================================================
# Authentication
# =============================================================================
# Set to true when behind Authelia reverse proxy
AUTHELIA_ENABLED=true
# Header names (only change if your proxy uses different headers)
AUTHELIA_HEADER_USER=Remote-User
AUTHELIA_HEADER_NAME=Remote-Name
AUTHELIA_HEADER_EMAIL=Remote-Email
AUTHELIA_HEADER_GROUPS=Remote-Groups
# LLDAP group that maps to admin role
AUTHELIA_ADMIN_GROUP=parking_admins
# External URLs for Authelia mode (used for landing page buttons)
# Login URL - Authelia's login page (users are redirected here to authenticate)
AUTHELIA_LOGIN_URL=https://auth.rocketscale.it
# Registration URL - External registration portal (org-stack self-registration)
REGISTRATION_URL=https://register.rocketscale.it
# Logout URL
AUTHELIA_LOGOUT_URL=https://auth.rocketscale.it/logout
# =============================================================================
# Email Notifications
# =============================================================================
# Set to true to enable email sending
SMTP_ENABLED=true
# SMTP server configuration
SMTP_HOST="smtp.email.eu-milan-1.oci.oraclecloud.com"
SMTP_PORT=587
SMTP_USER="ocid1.user.oc1..aaaaaaaa6bollovnlx4vxoq2eh7pzgxxhludqitgxsp6fevpllmqynug2uiq@ocid1.tenancy.oc1..aaaaaaaa6veuezxddkzbxmxnjp5thywdjz42z5qfrd6mmosmqehvebrewola.hj.com"
SMTP_PASSWORD="3)J2E9_Np:}#kozD2Wed"
SMTP_FROM="noreply@rocketscale.it"
SMTP_USE_TLS=true
# When SMTP is disabled, emails are logged to this file
EMAIL_LOG_FILE=/tmp/parking-emails.log

1
.gitignore vendored
View File

@@ -25,7 +25,6 @@ ENV/
# Environment variables # Environment variables
.env .env
.env.local .env.local
.env.production
# IDE # IDE
.idea/ .idea/

View File

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

View File

@@ -46,10 +46,12 @@ if SECRET_KEY == "change-me-in-production":
ALGORITHM = "HS256" ALGORITHM = "HS256"
ACCESS_TOKEN_EXPIRE_MINUTES = int(os.getenv("ACCESS_TOKEN_EXPIRE_MINUTES", "1440")) # 24 hours ACCESS_TOKEN_EXPIRE_MINUTES = int(os.getenv("ACCESS_TOKEN_EXPIRE_MINUTES", "1440")) # 24 hours
COOKIE_SECURE = os.getenv("COOKIE_SECURE", "false").lower() == "true"
# Server # Server
HOST = os.getenv("HOST", "0.0.0.0") HOST = os.getenv("HOST", "0.0.0.0")
PORT = int(os.getenv("PORT", "8000")) PORT = int(os.getenv("PORT", "8000"))
TIMEZONE = os.getenv("TIMEZONE", "UTC")
# CORS # CORS
ALLOWED_ORIGINS = os.getenv("ALLOWED_ORIGINS", "http://localhost:8000,http://127.0.0.1:8000,http://lvh.me").split(",") ALLOWED_ORIGINS = os.getenv("ALLOWED_ORIGINS", "http://localhost:8000,http://127.0.0.1:8000,http://lvh.me").split(",")
@@ -68,6 +70,7 @@ AUTHELIA_ADMIN_GROUP = os.getenv("AUTHELIA_ADMIN_GROUP", "parking_admins")
# External URLs for Authelia mode # External URLs for Authelia mode
# When AUTHELIA_ENABLED, login redirects to Authelia and register to external portal # 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_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 REGISTRATION_URL = os.getenv("REGISTRATION_URL", "") # e.g., https://register.rocketscale.it
# Email configuration (following org-stack pattern) # Email configuration (following org-stack pattern)

View File

@@ -104,7 +104,8 @@ def login(request: Request, data: LoginRequest, response: Response, db: Session
value=token, value=token,
httponly=True, httponly=True,
max_age=config.ACCESS_TOKEN_EXPIRE_MINUTES * 60, max_age=config.ACCESS_TOKEN_EXPIRE_MINUTES * 60,
samesite="lax" samesite="lax",
secure=config.COOKIE_SECURE
) )
config.logger.info(f"User logged in: {data.email}") config.logger.info(f"User logged in: {data.email}")
@@ -114,7 +115,12 @@ def login(request: Request, data: LoginRequest, response: Response, db: Session
@router.post("/logout") @router.post("/logout")
def logout(response: Response): def logout(response: Response):
"""Logout and clear session""" """Logout and clear session"""
response.delete_cookie("session_token") response.delete_cookie(
key="session_token",
httponly=True,
samesite="lax",
secure=config.COOKIE_SECURE
)
return {"message": "Logged out"} return {"message": "Logged out"}
@@ -145,6 +151,7 @@ def get_auth_config():
return { return {
"authelia_enabled": config.AUTHELIA_ENABLED, "authelia_enabled": config.AUTHELIA_ENABLED,
"login_url": config.AUTHELIA_LOGIN_URL if config.AUTHELIA_ENABLED else None, "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 "registration_url": config.REGISTRATION_URL if config.AUTHELIA_ENABLED else None
} }

View File

@@ -26,6 +26,10 @@ router = APIRouter(prefix="/api/offices", tags=["offices"])
class ValidOfficeCreate(BaseModel): class ValidOfficeCreate(BaseModel):
name: str name: str
parking_quota: int = 0 parking_quota: int = 0
booking_window_enabled: bool = False
booking_window_end_hour: int = 18
booking_window_end_minute: int = 0
assignment_mode: str = "random"
class ClosingDayCreate(BaseModel): class ClosingDayCreate(BaseModel):
@@ -58,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
@@ -87,7 +92,11 @@ def list_offices(db: Session = Depends(get_db), user=Depends(require_manager_or_
"name": office.name, "name": office.name,
"parking_quota": office.parking_quota, "parking_quota": office.parking_quota,
"spot_prefix": office.spot_prefix, "spot_prefix": office.spot_prefix,
"user_count": user_counts.get(office.id, 0) "user_count": user_counts.get(office.id, 0),
"booking_window_enabled": office.booking_window_enabled,
"booking_window_end_hour": office.booking_window_end_hour,
"booking_window_end_minute": office.booking_window_end_minute,
"assignment_mode": office.assignment_mode
} }
for office in offices for office in offices
] ]
@@ -121,10 +130,19 @@ def create_office(data: ValidOfficeCreate, db: Session = Depends(get_db), user=D
name=data.name, name=data.name,
parking_quota=data.parking_quota, parking_quota=data.parking_quota,
spot_prefix=get_next_available_prefix(db), spot_prefix=get_next_available_prefix(db),
booking_window_enabled=data.booking_window_enabled,
booking_window_end_hour=data.booking_window_end_hour,
booking_window_end_minute=data.booking_window_end_minute,
assignment_mode=data.assignment_mode,
created_at=datetime.utcnow() created_at=datetime.utcnow()
) )
db.add(office) db.add(office)
db.commit() 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 return office
@router.get("/{office_id}") @router.get("/{office_id}")
@@ -146,7 +164,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')
} }
@@ -175,17 +194,24 @@ def update_office_settings(office_id: str, data: OfficeSettingsUpdate, db: Sessi
if data.booking_window_end_hour is not None: if data.booking_window_end_hour is not None:
if not (0 <= data.booking_window_end_hour <= 23): if not (0 <= data.booking_window_end_hour <= 23):
raise HTTPException(status_code=400, detail="Hour must be 0-23") raise HTTPException(status_code=400, detail="Hour must be 0-23")
office.booking_window_end_hour = data.booking_window_end_hour office.booking_window_end_hour = data.booking_window_end_hour
if data.booking_window_end_minute is not None: if data.booking_window_end_minute is not None:
if not (0 <= data.booking_window_end_minute <= 59): if not (0 <= data.booking_window_end_minute <= 59):
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()
# Sync spots
from services.offices import sync_office_spots
sync_office_spots(office.id, office.parking_quota, office.spot_prefix, db)
return { return {
"id": office.id, "id": office.id,
"name": office.name, "name": office.name,
@@ -193,7 +219,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}")
@@ -262,6 +289,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"}
@@ -459,16 +509,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(): if not db.query(User).filter(User.id == data.user_id).first():
raise HTTPException(status_code=404, detail="User not found") raise HTTPException(status_code=404, detail="User not found")
existing = db.query(ParkingExclusion).filter( # Relaxed unique check - user can have multiple exclusions (different periods)
ParkingExclusion.office_id == office_id, # existing = db.query(ParkingExclusion).filter(
ParkingExclusion.user_id == data.user_id # ParkingExclusion.office_id == office_id,
).first() # ParkingExclusion.user_id == data.user_id
if existing: # ).first()
raise HTTPException(status_code=400, detail="User already has a parking exclusion") # 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: 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") 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( exclusion = ParkingExclusion(
id=generate_uuid(), id=generate_uuid(),
office_id=office_id, office_id=office_id,

View File

@@ -23,13 +23,14 @@ from pydantic import BaseModel
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from database.connection import get_db from database.connection import get_db
from database.models import DailyParkingAssignment, User, UserRole, Office from database.models import DailyParkingAssignment, User, UserRole, Office, OfficeSpot
from utils.auth_middleware import get_current_user, require_manager_or_admin from utils.auth_middleware import get_current_user, require_manager_or_admin
from services.parking import ( from services.parking import (
initialize_parking_pool, get_spot_display_name, release_user_spot, initialize_parking_pool, get_spot_display_name, release_user_spot,
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"])
@@ -91,35 +92,70 @@ def init_office_pool(request: InitPoolRequest, db: Session = Depends(get_db), cu
@router.get("/assignments/{date_val}", response_model=List[AssignmentResponse]) @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)): 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, optionally filtered by office""" """Get parking assignments for a date, merging active assignments with empty spots"""
query_date = date_val query_date = date_val
query = db.query(DailyParkingAssignment).filter(DailyParkingAssignment.date == query_date) # Defaults to user's office if not specified
if office_id: target_office_id = office_id or current_user.office_id
query = query.filter(DailyParkingAssignment.office_id == 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
# 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()
# 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}
assignments = query.all()
results = [] results = []
for assignment in assignments: # 3. Merge
# Get display name using office's spot prefix for spot in spots:
spot_display_name = get_spot_display_name(assignment.spot_id, assignment.office_id, db) assignment = assignment_map.get(spot.id)
result = AssignmentResponse( if assignment:
id=assignment.id, # Active assignment
date=assignment.date, result = AssignmentResponse(
spot_id=assignment.spot_id, id=assignment.id,
spot_display_name=spot_display_name, date=assignment.date,
user_id=assignment.user_id, spot_id=spot.id, # The FK
office_id=assignment.office_id spot_display_name=spot.name,
) user_id=assignment.user_id,
office_id=spot.office_id
)
if assignment.user_id: if assignment.user_id:
user = db.query(User).filter(User.id == assignment.user_id).first() user = db.query(User).filter(User.id == assignment.user_id).first()
if user: if user:
result.user_name = user.name result.user_name = user.name
result.user_email = user.email 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) results.append(result)
@@ -158,9 +194,6 @@ def get_my_assignments(start_date: date = None, end_date: date = None, db: Sessi
return results return results
return results
@router.post("/run-allocation") @router.post("/run-allocation")
def run_fair_allocation(data: RunAllocationRequest, db: Session = Depends(get_db), current_user=Depends(require_manager_or_admin)): 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)""" """Manually trigger fair allocation for a date (Test Tool)"""
@@ -203,32 +236,46 @@ def manual_assign(data: ManualAssignRequest, db: Session = Depends(get_db), curr
if current_user.role != UserRole.ADMIN and not is_manager: if current_user.role != UserRole.ADMIN and not is_manager:
raise HTTPException(status_code=403, detail="Not authorized to assign spots for this office") raise HTTPException(status_code=403, detail="Not authorized to assign spots for this office")
# Check if spot exists and is free # Check if spot exists (OfficeSpot)
spot = db.query(DailyParkingAssignment).filter( 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.office_id == data.office_id,
DailyParkingAssignment.date == assign_date, DailyParkingAssignment.date == assign_date,
DailyParkingAssignment.spot_id == data.spot_id DailyParkingAssignment.spot_id == data.spot_id
).first() ).first()
if not spot: if existing_assignment:
raise HTTPException(status_code=404, detail="Spot not found")
if spot.user_id:
raise HTTPException(status_code=400, detail="Spot already assigned") raise HTTPException(status_code=400, detail="Spot already assigned")
# Check if user already has a spot for this date (from any manager) # Check if user already has a spot for this date (from any manager)
existing = db.query(DailyParkingAssignment).filter( user_has_spot = db.query(DailyParkingAssignment).filter(
DailyParkingAssignment.date == assign_date, DailyParkingAssignment.date == assign_date,
DailyParkingAssignment.user_id == data.user_id DailyParkingAssignment.user_id == data.user_id
).first() ).first()
if existing: if user_has_spot:
raise HTTPException(status_code=400, detail="User already has a spot for this date") 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() db.commit()
spot_display_name = get_spot_display_name(data.spot_id, data.office_id, db) # Send Notification
return {"message": "Spot assigned", "spot_id": data.spot_id, "spot_display_name": spot_display_name} notify_parking_assigned(user, assign_date, spot_def.name)
return {"message": "Spot assigned", "spot_id": data.spot_id, "spot_display_name": spot_def.name}
@router.post("/release-my-spot/{assignment_id}") @router.post("/release-my-spot/{assignment_id}")
@@ -245,15 +292,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") raise HTTPException(status_code=403, detail="You can only release your own parking spot")
# Get spot display name for notification # Get spot display name for notification
spot_display_name = get_spot_display_name(assignment.spot_id, assignment.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() db.commit()
# Send notification (self-release, so just confirmation) # 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"} return {"message": "Parking spot released"}
@@ -282,7 +330,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 old_user = db.query(User).filter(User.id == old_user_id).first() if old_user_id else None
# Get spot display name for notifications # Get spot display name for notifications
spot_display_name = get_spot_display_name(assignment.spot_id, assignment.office_id, db) spot_name = assignment.spot.name if assignment.spot else "Unknown"
if data.new_user_id == "auto": if data.new_user_id == "auto":
# "Auto assign" means releasing the spot so the system picks the next person # "Auto assign" means releasing the spot so the system picks the next person
@@ -308,46 +356,49 @@ def reassign_spot(data: ReassignSpotRequest, db: Session = Depends(get_db), curr
if existing: if existing:
raise HTTPException(status_code=400, detail="User already has a spot for this date") 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 assignment.user_id = data.new_user_id
# Send notifications # Send notifications
# Notify old user that spot was reassigned # Notify old user that spot was reassigned
if old_user and old_user.id != new_user.id: 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 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_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
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}")
else: else:
assignment.user_id = None # Release (Delete assignment)
db.delete(assignment)
# Notify old user that spot was released # Notify old user that spot was released
if old_user: 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'}") config.logger.info(f"Parking spot {spot_name} on {assignment.date} released by {old_user.email if old_user else 'unknown'}")
db.commit() db.commit()
db.refresh(assignment)
# Build response return {"message": "Spot released"}
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
@router.get("/eligible-users/{assignment_id}") @router.get("/eligible-users/{assignment_id}")
@@ -394,3 +445,153 @@ def get_eligible_users(assignment_id: str, db: Session = Depends(get_db), curren
}) })
return result return result
from typing import Optional, Union
class TestEmailRequest(BaseModel):
date: Optional[str] = None
office_id: str
bulk_send: bool = False # New flag
@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 OR bulk reminder to all (Test Tool)"""
from services.notifications import send_email, send_daily_parking_reminder
from database.models import OfficeClosingDay, OfficeWeeklyClosingDay, User
from datetime import timedelta, datetime
# 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 = None
if data.date:
try:
target_date = datetime.strptime(data.date, "%Y-%m-%d").date()
except ValueError:
raise HTTPException(status_code=422, detail="Invalid date format. Use YYYY-MM-DD")
if not target_date:
# Find next open day logic (same as before)
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()
found = False
for _ in range(30):
if check_date.weekday() in weekly_closed_set:
check_date += timedelta(days=1)
continue
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
target_date = check_date if found else date.today() + timedelta(days=1)
# BULK MODE
if data.bulk_send:
# Get all assignments for this date/office
assignments = db.query(DailyParkingAssignment).filter(
DailyParkingAssignment.office_id == data.office_id,
DailyParkingAssignment.date == target_date,
DailyParkingAssignment.user_id.isnot(None)
).all()
count = 0
failed = 0
# Convert date to datetime for the existing function signature if needed, or update function
# send_daily_parking_reminder takes datetime
target_datetime = datetime.combine(target_date, datetime.min.time().replace(hour=8)) # default 8am
for assignment in assignments:
user = db.query(User).filter(User.id == assignment.user_id).first()
if user:
# Force send (bypass preference check? User said "invia mail uguale a quella di promemoria")
# We'll use the existing function but maybe bypass checks?
# send_daily_parking_reminder checks preferences & log.
# Let's bypass log check by deleting log first? Or just implement direct send here.
# User wants to "accertarsi che il sistmea funziona", so using the real function is best.
# BUT we must ensure it sends even if already sent?
# Let's clean logs for this specific test run first to ensure send?
# No, better just call send_email directly with the template logic to strictly test EMAIL sending,
# or use the function to test full LOGIC.
# "invia una mail uguale a quella di promemoria" -> Use logic.
# To force send, we can modify the function or just build the email here manually like the function does.
# Manual build is safer for a "Test Tool" to avoid messing with production logs state.
spot_name = get_spot_display_name(assignment.spot_id, assignment.office_id, db)
day_name = target_date.strftime("%d/%m/%Y")
subject = f"Promemoria Parcheggio - {day_name} (Test)"
body_html = f"""
<html>
<body>
<h2>Promemoria Parcheggio Giornaliero</h2>
<p>Ciao {user.name},</p>
<p>Hai un posto auto assegnato per il giorno {day_name}:</p>
<p style="font-size: 18px; font-weight: bold;">Posto {spot_name}</p>
<p>Cordiali saluti,<br>Team Parking Manager</p>
<p><em>(Email di test inviata manualmente dall'amministrazione)</em></p>
</body>
</html>
"""
if send_email(user.email, subject, body_html):
count += 1
else:
failed += 1
return {
"success": True,
"mode": "BULK",
"count": count,
"failed": failed,
"date": target_date
}
# SINGLE USER TEST MODE (Existing)
formatted_date = target_date.strftime("%d/%m/%Y")
subject = f"Test Email - Sistema Parcheggi ({formatted_date})"
body_html = f"""
<html>
<body>
<h2>Email di Test - Sistema Parcheggi</h2>
<p>Ciao {current_user.name},</p>
<p>Questa è una email di test inviata dagli strumenti di amministrazione.</p>
<p><strong>Data Selezionata:</strong> {formatted_date}</p>
<p><strong>Stato SMTP:</strong> {'Abilitato' if config.SMTP_ENABLED else 'Disabilitato (File Logging)'}</p>
<p>Se hai ricevuto questa email, il sistema di notifiche funziona correttamente.</p>
</body>
</html>
"""
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"
}

View File

@@ -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
@@ -329,3 +341,49 @@ def get_user_presences(user_id: str, start_date: date = None, end_date: date = N
}) })
return result 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
}

195
app/routes/reports.py Normal file
View File

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

View File

@@ -2,13 +2,13 @@
User Management Routes User Management Routes
Admin user CRUD and user self-service (profile, settings, password) 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 fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel, EmailStr from pydantic import BaseModel, EmailStr
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from database.connection import get_db from database.connection import get_db
from database.models import User, UserRole, Office from database.models import User, UserRole, Office, ParkingExclusion
from utils.auth_middleware import get_current_user, require_admin from utils.auth_middleware import get_current_user, require_admin
from utils.helpers import ( from utils.helpers import (
generate_uuid, is_ldap_user, is_ldap_admin, generate_uuid, is_ldap_user, is_ldap_admin,
@@ -54,6 +54,20 @@ class ChangePasswordRequest(BaseModel):
new_password: str 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): class UserResponse(BaseModel):
id: str id: str
email: str email: str
@@ -332,3 +346,105 @@ def change_password(data: ChangePasswordRequest, db: Session = Depends(get_db),
db.commit() db.commit()
config.logger.info(f"User {current_user.email} changed password") config.logger.info(f"User {current_user.email} changed password")
return {"message": "Password changed"} return {"message": "Password changed"}
# 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"}

View File

@@ -5,6 +5,12 @@ services:
restart: unless-stopped restart: unless-stopped
volumes: volumes:
- ./data:/app/data - ./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_file:
- .env - .env
environment: environment:
@@ -12,7 +18,7 @@ services:
- PORT=8000 - PORT=8000
- DATABASE_PATH=/app/data/parking.db - DATABASE_PATH=/app/data/parking.db
healthcheck: healthcheck:
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8000/health')"] test: [ "CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8000/health')" ]
interval: 30s interval: 30s
timeout: 10s timeout: 10s
retries: 3 retries: 3
@@ -20,14 +26,13 @@ services:
networks: networks:
- org-network - org-network
labels: labels:
- "caddy=parking.lvh.me" - "caddy=parcheggio.rocketscale.it"
- "caddy.reverse_proxy={{upstreams 8000}}" - "caddy.reverse_proxy={{upstreams 8000}}"
- "caddy.forward_auth=authelia:9091" - "caddy.forward_auth=authelia:9091"
- "caddy.forward_auth.uri=/api/verify?rd=https://parking.lvh.me/" - "caddy.forward_auth.uri=/api/verify?rd=https://parcheggio.rocketscale.it/"
- "caddy.forward_auth.copy_headers=Remote-User Remote-Groups Remote-Name Remote-Email" - "caddy.forward_auth.copy_headers=Remote-User Remote-Groups Remote-Name Remote-Email"
# cambiare l'url delle label per il reverse proxy
networks: networks:
org-network: org-network:
external: true external: true
name: org-stack_org-network

View File

@@ -20,6 +20,8 @@ class PresenceStatus(str, enum.Enum):
PRESENT = "present" PRESENT = "present"
REMOTE = "remote" REMOTE = "remote"
ABSENT = "absent" ABSENT = "absent"
BUSINESS_TRIP = "business_trip"
class NotificationType(str, enum.Enum): class NotificationType(str, enum.Enum):
@@ -60,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)
@@ -67,6 +71,7 @@ class Office(Base):
users = relationship("User", back_populates="office") users = relationship("User", back_populates="office")
closing_days = relationship("OfficeClosingDay", back_populates="office", cascade="all, delete-orphan") closing_days = relationship("OfficeClosingDay", back_populates="office", cascade="all, delete-orphan")
weekly_closing_days = relationship("OfficeWeeklyClosingDay", 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): class User(Base):
@@ -130,7 +135,7 @@ class DailyParkingAssignment(Base):
id = Column(Text, primary_key=True) id = Column(Text, primary_key=True)
date = Column(Date, nullable=False) 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")) 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 office_id = Column(Text, ForeignKey("offices.id", ondelete="CASCADE"), nullable=False) # Office that owns the spot
created_at = Column(DateTime, default=datetime.utcnow) created_at = Column(DateTime, default=datetime.utcnow)
@@ -138,6 +143,7 @@ class DailyParkingAssignment(Base):
# Relationships # Relationships
user = relationship("User", back_populates="assignments", foreign_keys=[user_id]) user = relationship("User", back_populates="assignments", foreign_keys=[user_id])
office = relationship("Office") office = relationship("Office")
spot = relationship("OfficeSpot", back_populates="assignments")
__table_args__ = ( __table_args__ = (
Index('idx_assignment_office_date', 'office_id', 'date'), Index('idx_assignment_office_date', 'office_id', 'date'),
@@ -218,7 +224,7 @@ class ParkingExclusion(Base):
user = relationship("User", foreign_keys=[user_id]) user = relationship("User", foreign_keys=[user_id])
__table_args__ = ( __table_args__ = (
Index('idx_exclusion_office_user', 'office_id', 'user_id', unique=True), Index('idx_exclusion_office_user', 'office_id', 'user_id'),
) )
@@ -237,18 +243,24 @@ class NotificationLog(Base):
) )
class NotificationQueue(Base):
"""Queue for pending notifications (for immediate parking change notifications)"""
__tablename__ = "notification_queue"
class OfficeSpot(Base):
"""Specific parking spot definitions (e.g., A1, A2) linked to an office"""
__tablename__ = "office_spots"
id = Column(Text, primary_key=True) id = Column(Text, primary_key=True)
user_id = Column(Text, ForeignKey("users.id", ondelete="CASCADE"), nullable=False) office_id = Column(Text, ForeignKey("offices.id", ondelete="CASCADE"), nullable=False)
notification_type = Column(Enum(NotificationType, values_callable=lambda obj: [e.value for e in obj]), nullable=False) # parking_change name = Column(Text, nullable=False) # Display name: A1, A2, etc.
subject = Column(Text, nullable=False) spot_number = Column(Integer, nullable=False) # Numeric part for sorting/filtering (1, 2, 3...)
body = Column(Text, nullable=False) is_unavailable = Column(Boolean, default=False) # If spot is temporarily out of service
created_at = Column(DateTime, default=datetime.utcnow)
sent_at = Column(DateTime) # null = not sent yet # Relationships
office = relationship("Office", back_populates="spots")
assignments = relationship("DailyParkingAssignment", back_populates="spot", cascade="all, delete-orphan")
__table_args__ = ( __table_args__ = (
Index('idx_queue_pending', 'sent_at'), Index('idx_office_spot_number', 'office_id', 'spot_number', unique=True),
Index('idx_office_spot_name', 'office_id', 'name', unique=True),
) )

Binary file not shown.

Before

Width:  |  Height:  |  Size: 786 KiB

After

Width:  |  Height:  |  Size: 829 KiB

View File

@@ -13,9 +13,11 @@
--success: #16a34a; --success: #16a34a;
--success-bg: #dcfce7; --success-bg: #dcfce7;
--warning: #f59e0b; --warning: #f59e0b;
--warning-bg: #fef3c7; --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 {
@@ -652,20 +725,25 @@ textarea {
border-color: var(--danger) !important; border-color: var(--danger) !important;
} }
.status-business_trip {
background: var(--warning-bg) !important;
border-color: var(--warning) !important;
}
.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 {
@@ -673,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;
} }
@@ -718,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;
@@ -784,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 {
@@ -815,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;
} }
@@ -823,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;
} }
@@ -875,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;
@@ -1004,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%;
} }
@@ -1029,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;
@@ -1073,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;
@@ -1131,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 {
@@ -1189,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;
@@ -1260,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;
} }
@@ -1293,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;
@@ -1428,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 {
@@ -1477,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;
} }
@@ -1525,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;
@@ -1642,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;
} }
@@ -1788,6 +1876,7 @@ textarea {
transform: translate(-50%, 100%); transform: translate(-50%, 100%);
opacity: 0; opacity: 0;
} }
to { to {
transform: translate(-50%, 0); transform: translate(-50%, 0);
opacity: 1; opacity: 1;
@@ -1798,6 +1887,7 @@ textarea {
from { from {
opacity: 1; opacity: 1;
} }
to { to {
opacity: 0; opacity: 0;
} }

View File

@@ -7,6 +7,7 @@ let currentUser = null;
let offices = []; let offices = [];
document.addEventListener('DOMContentLoaded', async () => { document.addEventListener('DOMContentLoaded', async () => {
populateTimeSelects();
currentUser = await api.requireAuth(); currentUser = await api.requireAuth();
if (!currentUser) return; if (!currentUser) return;
@@ -19,8 +20,35 @@ document.addEventListener('DOMContentLoaded', async () => {
setupEventListeners(); setupEventListeners();
}); });
function populateTimeSelects() {
const hoursSelect = document.getElementById('officeCutoffHour');
const minutesSelect = document.getElementById('officeCutoffMinute');
// Clear existing
hoursSelect.innerHTML = '';
minutesSelect.innerHTML = ''; // Re-creating to allow 0-59 range
// Populate Hours 0-23
for (let i = 0; i < 24; i++) {
const val = i.toString().padStart(2, '0');
const opt = document.createElement('option');
opt.value = i;
opt.textContent = val;
hoursSelect.appendChild(opt);
}
// Populate Minutes 0-59
for (let i = 0; i < 60; i++) {
const val = i.toString().padStart(2, '0');
const opt = document.createElement('option');
opt.value = i;
opt.textContent = val;
minutesSelect.appendChild(opt);
}
}
async function loadOffices() { async function loadOffices() {
const response = await api.get('/api/offices'); const response = await api.get('/api/offices'); // Force fresh load
if (response && response.ok) { if (response && response.ok) {
offices = await response.json(); offices = await response.json();
renderOffices(); renderOffices();
@@ -31,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;
} }
@@ -44,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>
`; `;
@@ -70,29 +98,51 @@ async function editOffice(officeId) {
document.getElementById('officeName').value = office.name; document.getElementById('officeName').value = office.name;
document.getElementById('officeQuota').value = office.parking_quota; document.getElementById('officeQuota').value = office.parking_quota;
openModal('Modifica Ufficio'); // Set assignment mode mapping
const modeSelect = document.getElementById('assignmentModeSelect');
if (office.booking_window_enabled === false) {
modeSelect.value = 'realtime';
} else {
modeSelect.value = office.assignment_mode || 'random';
}
// Set cutoff time
document.getElementById('officeCutoffHour').value = office.booking_window_end_hour != null ? office.booking_window_end_hour : 18;
document.getElementById('officeCutoffMinute').value = office.booking_window_end_minute != null ? office.booking_window_end_minute : 0;
updateCutoffVisibility();
openModal('Modifica Gruppo');
}
function updateCutoffVisibility() {
const mode = document.getElementById('assignmentModeSelect').value;
const group = document.getElementById('cutoffGroup');
if (group) {
group.style.display = (mode === 'realtime') ? 'none' : 'block';
}
} }
async function deleteOffice(officeId) { async function deleteOffice(officeId) {
const office = offices.find(o => o.id === officeId); const office = offices.find(o => o.id === officeId);
if (!office) return; if (!office) return;
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
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
@@ -109,6 +159,9 @@ function setupEventListeners() {
// Form submit // Form submit
const form = document.getElementById('officeForm'); const form = document.getElementById('officeForm');
form.addEventListener('submit', handleOfficeSubmit); form.addEventListener('submit', handleOfficeSubmit);
// Assignment mode change
document.getElementById('assignmentModeSelect').addEventListener('change', updateCutoffVisibility);
} }
async function handleOfficeSubmit(e) { async function handleOfficeSubmit(e) {
@@ -121,9 +174,15 @@ async function handleOfficeSubmit(e) {
saveBtn.innerHTML = 'Salvataggio...'; saveBtn.innerHTML = 'Salvataggio...';
const officeId = document.getElementById('officeId').value; const officeId = document.getElementById('officeId').value;
const mode = document.getElementById('assignmentModeSelect').value;
const data = { const data = {
name: document.getElementById('officeName').value, name: document.getElementById('officeName').value,
parking_quota: parseInt(document.getElementById('officeQuota').value) || 0 parking_quota: parseInt(document.getElementById('officeQuota').value) || 0,
booking_window_enabled: mode !== 'realtime',
booking_window_end_hour: parseInt(document.getElementById('officeCutoffHour').value),
booking_window_end_minute: parseInt(document.getElementById('officeCutoffMinute').value),
assignment_mode: mode === 'realtime' ? 'random' : mode
}; };
console.log('Payload:', data); console.log('Payload:', data);
@@ -140,7 +199,8 @@ 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
await loadOffices(); await loadOffices();
} else { } else {
let errorMessage = 'Errore operazione'; let errorMessage = 'Errore operazione';

View File

@@ -23,14 +23,16 @@ document.addEventListener('DOMContentLoaded', async () => {
}); });
async function loadOffices() { async function loadOffices() {
const response = await api.get('/api/offices'); // Cache offices for dropdown (60 min)
const response = await api.getCached('/api/offices', 60);
if (response && response.ok) { if (response && response.ok) {
offices = await response.json(); offices = await response.json();
} }
} }
async function loadUsers() { async function loadUsers() {
const response = await api.get('/api/users'); // Cache users list (15 min)
const response = await api.getCached('/api/users', 15);
if (response && response.ok) { if (response && response.ok) {
users = await response.json(); users = await response.json();
renderUsers(); renderUsers();
@@ -127,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;
@@ -166,6 +168,8 @@ async function deleteUser(userId) {
const response = await api.delete(`/api/users/${userId}`); const response = await api.delete(`/api/users/${userId}`);
if (response && response.ok) { if (response && response.ok) {
utils.showMessage('Utente eliminato', 'success'); utils.showMessage('Utente eliminato', 'success');
api.invalidateCache('/api/users');
api.invalidateCache('/api/offices'); // Invalidate office counts
await loadUsers(); await loadUsers();
} else { } else {
const error = await response.json(); const error = await response.json();
@@ -210,6 +214,8 @@ function setupEventListeners() {
if (response && response.ok) { if (response && response.ok) {
document.getElementById('userModal').style.display = 'none'; document.getElementById('userModal').style.display = 'none';
utils.showMessage('Utente aggiornato', 'success'); utils.showMessage('Utente aggiornato', 'success');
api.invalidateCache('/api/users');
api.invalidateCache('/api/offices'); // Invalidate office counts
await loadUsers(); await loadUsers();
} else { } else {
const error = await response.json(); const error = await response.json();

View File

@@ -23,6 +23,80 @@ const api = {
*/ */
clearToken() { clearToken() {
localStorage.removeItem('access_token'); localStorage.removeItem('access_token');
this.clearCache();
},
/**
* Get data with caching - Returns Response obj or Mock Response
* @param {string} url - API endpoint
* @param {number} ttlMinutes - Time to live in minutes (default 60)
*/
async getCached(url, ttlMinutes = 60) {
const cacheKey = 'cache_' + url;
const cachedItem = localStorage.getItem(cacheKey);
if (cachedItem) {
try {
const { data, timestamp } = JSON.parse(cachedItem);
const age = (Date.now() - timestamp) / 1000 / 60;
if (age < ttlMinutes) {
console.log(`[Cache] Hit for ${url}`);
// Return a mock response-like object
return {
ok: true,
status: 200,
json: async () => data
};
} else {
console.log(`[Cache] Expired for ${url}`);
localStorage.removeItem(cacheKey);
}
} catch (e) {
console.error('[Cache] Error parsing cache', e);
localStorage.removeItem(cacheKey);
}
}
console.log(`[Cache] Miss for ${url}`);
const response = await this.get(url);
if (response && response.ok) {
try {
// Clone response to read body and still return it to caller
const clone = response.clone();
const data = await clone.json();
localStorage.setItem(cacheKey, JSON.stringify({
data: data,
timestamp: Date.now()
}));
} catch (e) {
console.warn('[Cache] failed to save to localStorage', e);
}
}
return response;
},
/**
* Invalidate specific cache key
*/
invalidateCache(url) {
localStorage.removeItem('cache_' + url);
console.log(`[Cache] Invalidated ${url}`);
},
/**
* Clear all API cache
*/
clearCache() {
Object.keys(localStorage).forEach(key => {
if (key.startsWith('cache_')) {
localStorage.removeItem(key);
}
});
console.log('[Cache] Cleared all cache');
}, },
/** /**
@@ -37,14 +111,36 @@ const api = {
* Call this on page load to verify auth status * Call this on page load to verify auth status
*/ */
async checkAuth() { async checkAuth() {
// Try to get current user - works with Authelia headers or JWT const url = '/api/auth/me';
const response = await fetch('/api/auth/me', { // 1. Try Cache (Short TTL: 5 min)
const cacheKey = 'cache_' + url;
const cachedItem = localStorage.getItem(cacheKey);
if (cachedItem) {
try {
const { data, timestamp } = JSON.parse(cachedItem);
if ((Date.now() - timestamp) / 1000 / 60 < 5) {
this._autheliaAuth = true;
return data;
}
} catch (e) { localStorage.removeItem(cacheKey); }
}
// 2. Fetch from Network
const response = await fetch(url, {
headers: this.getToken() ? { 'Authorization': `Bearer ${this.getToken()}` } : {} headers: this.getToken() ? { 'Authorization': `Bearer ${this.getToken()}` } : {}
}); });
if (response.ok) { if (response.ok) {
this._autheliaAuth = true; this._autheliaAuth = true;
return await response.json(); const data = await response.json();
// Save to Cache
localStorage.setItem(cacheKey, JSON.stringify({
data: data,
timestamp: Date.now()
}));
return data;
} }
this._autheliaAuth = false; this._autheliaAuth = false;
@@ -185,9 +281,23 @@ const api = {
* Logout * Logout
*/ */
async 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', {}); await this.post('/api/auth/logout', {});
this.clearToken(); this.clearToken();
window.location.href = '/login'; window.location.href = logoutUrl;
} }
}; };

View File

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

View File

@@ -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,7 @@ const NAV_ITEMS = [
{ href: '/team-calendar', icon: 'users', label: 'Calendario del team' }, { href: '/team-calendar', icon: 'users', label: 'Calendario del team' },
{ href: '/team-rules', icon: 'rules', label: 'Regole parcheggio', roles: ['admin', 'manager'] }, { href: '/team-rules', icon: 'rules', label: 'Regole parcheggio', roles: ['admin', 'manager'] },
{ href: '/admin/users', icon: 'user', label: 'Gestione Utenti', roles: ['admin'] }, { href: '/admin/users', icon: 'user', label: 'Gestione Utenti', roles: ['admin'] },
{ href: '/admin/offices', icon: 'building', label: 'Gestione Uffici', roles: ['admin'] }, { href: '/admin/offices', icon: 'building', label: 'Gestione Gruppi', roles: ['admin'] }
{ href: '/parking-settings', icon: 'settings', label: 'Impostazioni Ufficio', roles: ['admin', 'manager'] }
]; ];
function getIcon(name) { function getIcon(name) {
@@ -87,9 +100,6 @@ async function initNav() {
// Get user info (works with both JWT and Authelia) // Get user info (works with both JWT and Authelia)
const currentUser = await api.checkAuth(); const currentUser = await api.checkAuth();
// Render navigation
navContainer.innerHTML = renderNav(currentPath, currentUser?.role);
// Update user info in sidebar // Update user info in sidebar
if (currentUser) { if (currentUser) {
const userNameEl = document.getElementById('userName'); const userNameEl = document.getElementById('userName');
@@ -98,11 +108,56 @@ async function initNav() {
if (userRoleEl) userRoleEl.textContent = currentUser.role || '-'; if (userRoleEl) userRoleEl.textContent = currentUser.role || '-';
} }
// Setup user menu // Setup user menu (logout) & mobile menu
setupUserMenu(); setupUserMenu();
// Setup mobile menu
setupMobileMenu(); setupMobileMenu();
setupThemeToggle();
// CHECK: Block access if user has no office (and is not admin)
// Admins are allowed to access "Gestione Gruppi" 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 = `
<div style="
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 80vh;
padding: 2rem;
text-align: center;
">
<div class="card" style="max-width: 500px; padding: 2.5rem; border-top: 4px solid #ef4444;">
<div style="color: #ef4444; margin-bottom: 1.5rem;">
<svg width="64" height="64" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="12" cy="12" r="10"></circle>
<line x1="12" y1="8" x2="12" y2="12"></line>
<line x1="12" y1="16" x2="12.01" y2="16"></line>
</svg>
</div>
<h2 style="margin-bottom: 1rem;">Gruppo non assegnato</h2>
<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 gruppo.
</p>
<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-size: 0.95rem; color: var(--text-secondary);">
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>
</div>
</div>
</div>
</div>
`;
}
return; // STOP rendering nav
}
// Render navigation (Normal Flow)
navContainer.innerHTML = renderNav(currentPath, currentUser?.role);
} }
function setupMobileMenu() { function setupMobileMenu() {
@@ -171,6 +226,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;

View File

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

View File

@@ -33,6 +33,9 @@ document.addEventListener('DOMContentLoaded', async () => {
// Initialize Parking Status // Initialize Parking Status
initParkingStatus(); initParkingStatus();
setupStatusListeners(); setupStatusListeners();
// Initialize Exclusion Logic
initExclusionLogic();
}); });
async function loadPresences() { async function loadPresences() {
@@ -172,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}`);
} }
@@ -230,36 +234,44 @@ async function handleClearPresence(date) {
async function handleReleaseParking(assignmentId) { async function handleReleaseParking(assignmentId) {
if (!confirm('Rilasciare il parcheggio per questa data?')) return; if (!confirm('Rilasciare il parcheggio per questa data?')) return;
const response = await api.post(`/api/parking/release-my-spot/${assignmentId}`); utils.showMessage('Rilascio in corso...', 'warning');
const response = await api.post('/api/parking/reassign-spot', {
assignment_id: assignmentId,
new_user_id: null
});
if (response && response.ok) { if (response && response.ok) {
await loadParkingAssignments(); utils.showMessage('Posto liberato con successo', 'success');
await Promise.all([loadParkingAssignments(), loadDailyStatus()]);
renderCalendar(); renderCalendar();
ModalLogic.closeModal(); ModalLogic.closeModal();
} else { } else {
const error = await response.json(); const error = await response.json();
alert(error.detail || 'Impossibile rilasciare il parcheggio'); utils.showMessage(error.detail || 'Impossibile rilasciare il parcheggio', 'error');
} }
} }
async function handleReassignParking(assignmentId, newUserId) { async function handleReassignParking(assignmentId, newUserId) {
// Basic validation handled by select; confirm // Basic validation handled by select; confirm
if (!assignmentId || !newUserId) { if (!assignmentId || !newUserId) {
alert('Seleziona un utente'); utils.showMessage('Seleziona un utente', 'error');
return; return;
} }
utils.showMessage('Riassegnazione in corso...', 'warning');
const response = await api.post('/api/parking/reassign-spot', { const response = await api.post('/api/parking/reassign-spot', {
assignment_id: assignmentId, assignment_id: assignmentId,
new_user_id: newUserId new_user_id: newUserId
}); });
if (response && response.ok) { if (response && response.ok) {
await loadParkingAssignments(); utils.showMessage('Posto riassegnato con successo', 'success');
await Promise.all([loadParkingAssignments(), loadDailyStatus()]);
renderCalendar(); renderCalendar();
ModalLogic.closeModal(); ModalLogic.closeModal();
} else { } else {
const error = await response.json(); const error = await response.json();
alert(error.detail || 'Impossibile riassegnare il parcheggio'); utils.showMessage(error.detail || 'Impossibile riassegnare il parcheggio', 'error');
} }
} }
@@ -337,19 +349,57 @@ function setupEventListeners() {
const promises = []; const promises = [];
let current = new Date(startDate); let current = new Date(startDate);
// Validate filtering
let skippedCount = 0;
while (current <= endDate) { while (current <= endDate) {
const dStr = current.toISOString().split('T')[0]; 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 { } 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); current.setDate(current.getDate() + 1);
} }
try { try {
await Promise.all(promises); 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()]); await Promise.all([loadPresences(), loadParkingAssignments()]);
renderCalendar(); renderCalendar();
} catch (err) { } catch (err) {
@@ -377,10 +427,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';
} }
} }
@@ -414,6 +464,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>';
@@ -464,10 +542,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';
@@ -486,15 +563,38 @@ 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 gestire questo posto";
el.addEventListener('click', () => {
ModalLogic.openModal({
dateStr: a.date,
parking: a,
userName: a.user_name,
isParkingOnly: true
});
});
}
grid.appendChild(el); grid.appendChild(el);
}); });
@@ -528,6 +628,305 @@ 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>';
}
} }
// ----------------------------------------------------------------------------
// 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 = '<div style="display:flex; flex-direction:column; gap:0.5rem;">';
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 += `
<div style="background: white; border: 1px solid #e5e7eb; padding: 0.75rem; border-radius: 4px; display: flex; justify-content: space-between; align-items: center;">
<div>
<div style="font-weight: 500; font-size: 0.9rem;">${period}</div>
${ex.notes ? `<div style="font-size: 0.8rem; color: #6b7280;">${ex.notes}</div>` : ''}
</div>
<div style="display: flex; gap: 0.5rem;">
<button class="btn-icon" onclick='openEditMyExclusion("${ex.id}", ${JSON.stringify(ex).replace(/'/g, "&#39;")})' title="Modifica">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M12 20h9"></path><path d="M16.5 3.5a2.121 2.121 0 0 1 3 3L7 19l-4 1 1-4L16.5 3.5z"></path>
</svg>
</button>
<button class="btn-icon btn-danger" onclick="deleteMyExclusion('${ex.id}')" title="Rimuovi">
&times;
</button>
</div>
</div>`;
});
html += '</div>';
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;

View File

@@ -26,7 +26,6 @@ document.addEventListener('DOMContentLoaded', async () => {
await loadOffices(); await loadOffices();
await loadTeamData(); await loadTeamData();
await loadTeamData();
// Initialize Modal Logic // Initialize Modal Logic
ModalLogic.init({ ModalLogic.init({
@@ -48,7 +47,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 +62,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";
} }
} }
@@ -79,18 +78,20 @@ async function loadOffices() {
if (currentUser.role !== 'admin') { if (currentUser.role !== 'admin') {
select.style.display = 'none'; select.style.display = 'none';
// Employees stop here, Managers continue to allow auto-selection logic below // Employees stop here, Managers continue to allow auto-selection logic below
if (currentUser.role === 'employee') return; if (currentUser.role === 'employee') {
updateOfficeDisplay();
return;
}
} }
const response = await api.get('/api/offices'); // Cache offices list
const response = await api.getCached('/api/offices', 60);
if (response && response.ok) { if (response && response.ok) {
offices = await response.json(); offices = await response.json();
let filteredOffices = offices; let filteredOffices = offices;
if (currentUser.role === 'manager') { if (currentUser.role === 'manager') {
// Manager only sees their own office in the filter? // Manager only sees their own office in the filter?
// Actually managers might want to filter if they (hypothetically) managed multiple,
// but currently User has 1 office.
if (currentUser.office_id) { if (currentUser.office_id) {
filteredOffices = offices.filter(o => o.id === currentUser.office_id); filteredOffices = offices.filter(o => o.id === currentUser.office_id);
} else { } else {
@@ -113,6 +114,77 @@ async function loadOffices() {
// Initial update of office display // Initial update of office display
updateOfficeDisplay(); 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() { function getDateRange() {
@@ -183,8 +255,8 @@ async function loadClosingData() {
const promises = officeIdsToLoad.map(async (oid) => { const promises = officeIdsToLoad.map(async (oid) => {
try { try {
const [weeklyRes, specificRes] = await Promise.all([ const [weeklyRes, specificRes] = await Promise.all([
api.get(`/api/offices/${oid}/weekly-closing-days`), api.getCached(`/api/offices/${oid}/weekly-closing-days`, 60),
api.get(`/api/offices/${oid}/closing-days`) api.getCached(`/api/offices/${oid}/closing-days`, 60)
]); ]);
officeClosingRules[oid] = { weekly: [], specific: [] }; officeClosingRules[oid] = { weekly: [], specific: [] };
@@ -242,7 +314,9 @@ function renderCalendar() {
// Build header row // Build header row
const dayNames = ['Dom', 'Lun', 'Mar', 'Mer', 'Gio', 'Ven', 'Sab']; const dayNames = ['Dom', 'Lun', 'Mar', 'Mer', 'Gio', 'Ven', 'Sab'];
let headerHtml = '<th>Nome</th><th>Ufficio</th>'; const showGroup = currentUser.role === 'admin';
let headerHtml = '<th>Nome</th>';
if (showGroup) headerHtml += '<th>Gruppo</th>';
for (let i = 0; i < dayCount; i++) { for (let i = 0; i < dayCount; i++) {
const date = new Date(startDate); const date = new Date(startDate);
@@ -272,15 +346,30 @@ function renderCalendar() {
// Build body rows // Build body rows
if (teamData.length === 0) { if (teamData.length === 0) {
body.innerHTML = `<tr><td colspan="${dayCount + 2}" class="text-center">Nessun membro del team trovato</td></tr>`; body.innerHTML = `<tr><td colspan="${dayCount + (showGroup ? 2 : 1)}" class="text-center">Nessun membro del team trovato</td></tr>`;
return; return;
} }
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>`;
if (showGroup) {
bodyHtml += `<td class="member-manager">${member.office_name || '-'}</td>`;
}
for (let i = 0; i < dayCount; i++) { for (let i = 0; i < dayCount; i++) {
const date = new Date(startDate); const date = new Date(startDate);
@@ -314,7 +403,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

View File

@@ -7,6 +7,7 @@
let currentUser = null; let currentUser = null;
let offices = []; let offices = [];
let currentOfficeId = null; let currentOfficeId = null;
let currentOffice = null;
let officeUsers = []; let officeUsers = [];
let currentWeeklyClosingDays = []; let currentWeeklyClosingDays = [];
@@ -20,6 +21,7 @@ document.addEventListener('DOMContentLoaded', async () => {
return; return;
} }
populateHourSelect();
await loadOffices(); await loadOffices();
setupEventListeners(); setupEventListeners();
}); });
@@ -73,6 +75,27 @@ async function loadOfficeRules(officeId) {
document.getElementById('rulesContent').style.display = 'block'; document.getElementById('rulesContent').style.display = 'block';
document.getElementById('noOfficeMessage').style.display = 'none'; document.getElementById('noOfficeMessage').style.display = 'none';
// Load full office object for algorithm settings
try {
const response = await api.get(`/api/offices/${officeId}`);
if (response && response.ok) {
currentOffice = await response.json();
// Populate algorithm form
const modeSelect = document.getElementById('assignmentModeSelect');
if (currentOffice.booking_window_enabled === false) {
modeSelect.value = 'realtime';
} else {
modeSelect.value = currentOffice.assignment_mode || 'random';
}
document.getElementById('bookingWindowHour').value = currentOffice.booking_window_end_hour ?? 18;
document.getElementById('bookingWindowMinute').value = currentOffice.booking_window_end_minute ?? 0;
updateAlgorithmVisibility();
}
} catch (e) {
console.error("Error loading office details:", e);
}
// Load users for this office (for dropdowns) // Load users for this office (for dropdowns)
await loadOfficeUsers(officeId); await loadOfficeUsers(officeId);
@@ -91,6 +114,67 @@ async function loadOfficeUsers(officeId) {
} }
} }
function populateHourSelect() {
const hourSelect = document.getElementById('bookingWindowHour');
const minuteSelect = document.getElementById('bookingWindowMinute');
if (!hourSelect || !minuteSelect) return;
hourSelect.innerHTML = '';
for (let h = 0; h < 24; h++) {
const option = document.createElement('option');
option.value = h;
option.textContent = h.toString().padStart(2, '0');
hourSelect.appendChild(option);
}
minuteSelect.innerHTML = '';
for (let m = 0; m < 60; m++) {
const option = document.createElement('option');
option.value = m;
option.textContent = m.toString().padStart(2, '0');
minuteSelect.appendChild(option);
}
}
function updateAlgorithmVisibility() {
const mode = document.getElementById('assignmentModeSelect').value;
const group = document.getElementById('cutoffTimeGroup');
if (group) group.style.display = (mode === 'realtime') ? 'none' : 'block';
}
async function saveAlgorithmSettings(e) {
e.preventDefault();
if (!currentOfficeId) return;
const btn = e.target.querySelector('button[type="submit"]');
const originalText = btn.textContent;
btn.disabled = true;
btn.textContent = 'Salvataggio...';
const mode = document.getElementById('assignmentModeSelect').value;
const data = {
assignment_mode: mode === 'realtime' ? 'random' : mode,
booking_window_enabled: mode !== 'realtime',
booking_window_end_hour: parseInt(document.getElementById('bookingWindowHour').value),
booking_window_end_minute: parseInt(document.getElementById('bookingWindowMinute').value)
};
try {
const res = await api.put(`/api/offices/${currentOfficeId}`, data);
if (res) {
utils.showMessage('Impostazioni algoritmo salvate', 'success');
currentOffice = res;
}
} catch (e) {
console.error(e);
utils.showMessage('Errore nel salvataggio impostazioni', 'error');
} finally {
btn.disabled = false;
btn.textContent = originalText;
}
}
// Weekly Closing Days // Weekly Closing Days
async function loadWeeklyClosingDays(officeId) { async function loadWeeklyClosingDays(officeId) {
const response = await api.get(`/api/offices/${officeId}/weekly-closing-days`); const response = await api.get(`/api/offices/${officeId}/weekly-closing-days`);
@@ -134,6 +218,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 +261,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 +275,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);
} }
} }
@@ -267,26 +354,70 @@ async function loadExclusions(officeId) {
</span> </span>
${e.notes ? `<span class="rule-note">${e.notes}</span>` : ''} ${e.notes ? `<span class="rule-note">${e.notes}</span>` : ''}
</div> </div>
<button class="btn-icon btn-danger" onclick="deleteExclusion('${e.id}')"> <div class="rule-actions" style="display: flex; gap: 0.5rem; align-items: center;">
&times; <button class="btn-icon" onclick='openEditExclusion("${e.id}", ${JSON.stringify(e).replace(/'/g, "&#39;")})' title="Modifica">
</button> <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M12 20h9"></path><path d="M16.5 3.5a2.121 2.121 0 0 1 3 3L7 19l-4 1 1-4L16.5 3.5z"></path>
</svg>
</button>
<button class="btn-icon btn-danger" onclick="deleteExclusion('${e.id}')" title="Elimina">
&times;
</button>
</div>
</div> </div>
`).join(''); `).join('');
} }
} }
async function addExclusion(data) { // Global variable to track edit mode
const response = await api.post(`/api/offices/${currentOfficeId}/exclusions`, data); 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) { if (response && response.ok) {
await loadExclusions(currentOfficeId); await loadExclusions(currentOfficeId);
document.getElementById('exclusionModal').style.display = 'none'; document.getElementById('exclusionModal').style.display = 'none';
document.getElementById('exclusionForm').reset(); resetExclusionForm();
} else { } else {
const error = await response.json(); 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) { async function deleteExclusion(id) {
if (!confirm('Eliminare questa esclusione?')) return; if (!confirm('Eliminare questa esclusione?')) return;
const response = await api.delete(`/api/offices/${currentOfficeId}/exclusions/${id}`); const response = await api.delete(`/api/offices/${currentOfficeId}/exclusions/${id}`);
@@ -335,6 +466,10 @@ function setupEventListeners() {
modals.forEach(m => { modals.forEach(m => {
document.getElementById(m.btn).addEventListener('click', () => { document.getElementById(m.btn).addEventListener('click', () => {
if (m.id !== 'closingDayModal') populateUserSelects(); 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.id).style.display = 'flex';
}); });
document.getElementById(m.close).addEventListener('click', () => { document.getElementById(m.close).addEventListener('click', () => {
@@ -368,16 +503,243 @@ function setupEventListeners() {
document.getElementById('exclusionForm').addEventListener('submit', (e) => { document.getElementById('exclusionForm').addEventListener('submit', (e) => {
e.preventDefault(); e.preventDefault();
addExclusion({ saveExclusion({
user_id: document.getElementById('exclusionUser').value, user_id: document.getElementById('exclusionUser').value,
start_date: document.getElementById('exclusionStartDate').value || null, start_date: document.getElementById('exclusionStartDate').value || null,
end_date: document.getElementById('exclusionEndDate').value || null, end_date: document.getElementById('exclusionEndDate').value || null,
notes: document.getElementById('exclusionNotes').value || null notes: document.getElementById('exclusionNotes').value || null
}); });
}); });
// Algorithm settings events
document.getElementById('assignmentModeSelect').addEventListener('change', updateAlgorithmVisibility);
document.getElementById('algorithmForm').addEventListener('submit', saveAlgorithmSettings);
// Test Tools Logic
// Set default date to tomorrow
const tomorrow = new Date();
tomorrow.setDate(tomorrow.getDate() + 1);
const testDateStart = document.getElementById('testDateStart');
if (testDateStart) testDateStart.valueAsDate = tomorrow;
document.getElementById('runAllocationBtn').addEventListener('click', async () => {
if (!confirm('Sei sicuro di voler avviare l\'assegnazione ORA? Questo potrebbe sovrascrivere le assegnazioni esistenti per la data selezionata.')) return;
const dateStart = document.getElementById('testDateStart').value;
const dateEnd = document.getElementById('testDateEnd').value;
if (!dateStart) return utils.showMessage('Seleziona una data di inizio', 'error');
let start = new Date(dateStart);
let end = dateEnd ? new Date(dateEnd) : new Date(dateStart);
if (end < start) {
return utils.showMessage('La data di fine deve essere successiva alla data di inizio', 'error');
}
let current = new Date(start);
let successCount = 0;
let errorCount = 0;
utils.showMessage('Avvio assegnazione...', 'success');
while (current <= end) {
const dateStr = utils.formatDate(current);
try {
await api.post('/api/parking/run-allocation', {
date: dateStr,
office_id: currentOfficeId
});
successCount++;
} catch (e) {
console.error(`Error for ${dateStr}`, e);
errorCount++;
}
current.setDate(current.getDate() + 1);
}
if (errorCount === 0) {
utils.showMessage(`Assegnazione completata per ${successCount} giorni.`, 'success');
} else {
utils.showMessage(`Completato con errori: ${successCount} successi, ${errorCount} errori.`, 'warning');
}
});
document.getElementById('clearAssignmentsBtn').addEventListener('click', async () => {
if (!confirm('ATTENZIONE: Stai per eliminare TUTTE le assegnazioni per il periodo selezionato. Procedere?')) return;
const dateStart = document.getElementById('testDateStart').value;
const dateEnd = document.getElementById('testDateEnd').value;
if (!dateStart) return utils.showMessage('Seleziona una data di inizio', 'error');
let start = new Date(dateStart);
let end = dateEnd ? new Date(dateEnd) : new Date(dateStart);
if (end < start) {
return utils.showMessage('La data di fine deve essere successiva alla data di inizio', 'error');
}
let current = new Date(start);
let totalRemoved = 0;
utils.showMessage('Rimozione in corso...', 'warning');
while (current <= end) {
const dateStr = utils.formatDate(current);
try {
const res = await api.post('/api/parking/clear-assignments', {
date: dateStr,
office_id: currentOfficeId
});
if (res && res.ok) {
const data = await res.json();
totalRemoved += (data.count || 0);
}
} catch (e) {
console.error(`Error clearing ${dateStr}`, e);
}
current.setDate(current.getDate() + 1);
}
utils.showMessage(`Operazione eseguita.`, 'warning');
});
const clearPresenceBtn = document.getElementById('clearPresenceBtn');
if (clearPresenceBtn) {
clearPresenceBtn.addEventListener('click', async () => {
if (!confirm('ATTENZIONE: Stai per eliminare TUTTI GLI STATI (Presente/Assente/ecc) e relative assegnazioni per tutti gli utenti del gruppo nel periodo selezionato. \n\nQuesta azione è irreversibile. Procedere?')) return;
const dateStart = document.getElementById('testDateStart').value;
const dateEnd = document.getElementById('testDateEnd').value;
if (!dateStart) return utils.showMessage('Seleziona una data di inizio', 'error');
// Validate office
if (!currentOfficeId) {
return utils.showMessage('Errore: Nessun gruppo selezionato', 'error');
}
const endDateVal = dateEnd || dateStart;
utils.showMessage('Rimozione stati in corso...', 'warning');
try {
const res = await api.post('/api/presence/admin/clear-office-presence', {
start_date: dateStart,
end_date: endDateVal,
office_id: currentOfficeId
});
if (res && res.ok) {
const data = await res.json();
utils.showMessage(`Operazione completata. Rimossi ${data.count_presence} stati e ${data.count_parking} assegnazioni.`, 'success');
} else {
const err = await res.json();
utils.showMessage('Errore: ' + (err.detail || 'Operazione fallita'), 'error');
}
} catch (e) {
console.error(e);
utils.showMessage('Errore di comunicazione col server', 'error');
}
});
}
const testEmailBtn = document.getElementById('testEmailBtn');
if (testEmailBtn) {
testEmailBtn.addEventListener('click', async () => {
const dateVal = document.getElementById('testEmailDate').value;
// Validate office
if (!currentOfficeId) {
return utils.showMessage('Errore: Nessun gruppo selezionato', 'error');
}
utils.showMessage('Invio mail di test in corso...', 'warning');
try {
const res = await api.post('/api/parking/test-email', {
date: dateVal || null,
office_id: currentOfficeId
});
if (res && res.status >= 200 && res.status < 300) {
const data = await res.json();
if (data.success) {
let msg = `Email inviata con successo per la data: ${data.date}.`;
if (data.mode === 'FILE') {
msg += ' (SMTP disabilitato: Loggato su file)';
}
utils.showMessage(msg, 'success');
} else {
utils.showMessage('Invio fallito. Controlla i log del server.', 'error');
}
} else {
const err = res ? await res.json() : {};
console.error("Test Email Error:", err);
const errMsg = err.detail ? (typeof err.detail === 'object' ? JSON.stringify(err.detail) : err.detail) : 'Invio fallito';
utils.showMessage('Errore: ' + errMsg, 'error');
}
} catch (e) {
console.error(e);
utils.showMessage('Errore di comunicazione col server', 'error');
}
});
}
const bulkEmailBtn = document.getElementById('bulkEmailBtn');
if (bulkEmailBtn) {
bulkEmailBtn.addEventListener('click', async () => {
const dateVal = document.getElementById('testEmailDate').value;
// Validate office
if (!currentOfficeId) {
return utils.showMessage('Errore: Nessun gruppo selezionato', 'error');
}
if (!dateVal) {
return utils.showMessage('Per il test a TUTTI è obbligatorio selezionare una data specifica.', 'error');
}
if (!confirm(`Sei sicuro di voler inviare una mail di promemoria a TUTTI gli utenti con parcheggio assegnato per il giorno ${dateVal}?\n\nQuesta azione invierà vere email.`)) return;
utils.showMessage('Invio mail massive in corso...', 'warning');
try {
const res = await api.post('/api/parking/test-email', {
date: dateVal,
office_id: currentOfficeId,
bulk_send: true
});
if (res && res.status >= 200 && res.status < 300) {
const data = await res.json();
if (data.success) {
let msg = `Processo completato per il ${data.date}. Inviate: ${data.count || 0}, Fallite: ${data.failed || 0}.`;
if (data.mode === 'BULK' && (data.count || 0) === 0) msg += " (Nessun assegnatario trovato)";
utils.showMessage(msg, 'success');
} else {
utils.showMessage('Errore durante l\'invio.', 'error');
}
} else {
const err = res ? await res.json() : {};
console.error("Bulk Test Email Error:", err);
const errMsg = err.detail ? (typeof err.detail === 'object' ? JSON.stringify(err.detail) : err.detail) : 'Invio fallito';
utils.showMessage('Errore: ' + errMsg, 'error');
}
} catch (e) {
console.error(e);
utils.showMessage('Errore di comunicazione col server: ' + e.message, 'error');
}
});
}
} }
// Global functions // Global functions
window.deleteClosingDay = deleteClosingDay; window.deleteClosingDay = deleteClosingDay;
window.deleteGuarantee = deleteGuarantee; window.deleteGuarantee = deleteGuarantee;
window.deleteClosingDay = deleteClosingDay;
window.deleteGuarantee = deleteGuarantee;
window.deleteExclusion = deleteExclusion; window.deleteExclusion = deleteExclusion;
window.openEditExclusion = openEditExclusion;

View File

@@ -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">&times;</button> <button class="modal-close" id="closeOfficeModal">&times;</button>
</div> </div>
<div class="modal-body"> <div class="modal-body">
@@ -83,14 +83,41 @@
<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 class="form-group">
<label for="assignmentModeSelect">Metodo di Assegnazione</label>
<select id="assignmentModeSelect" class="form-control">
<option value="realtime">In Tempo Reale (FIFO)</option>
<option value="random">Automatico - Casuale (Batch)</option>
<option value="fairness">Automatico - Punteggio (Batch)</option>
</select>
<small class="text-muted">FIFO: primo che arriva prende il posto. Automatico: assegnazione collettiva dopo il cut-off.</small>
</div>
<div class="form-group" id="cutoffGroup">
<label>Orario di Cut-off (Giorno Precedente)</label>
<div style="display: flex; gap: 10px; align-items: center;">
<select id="officeCutoffHour" class="form-control" style="width: 80px;">
<!-- Hours 0-23 generated by JS -->
</select>
<span>:</span>
<select id="officeCutoffMinute" class="form-control" style="width: 80px;">
<option value="0">00</option>
<option value="15">15</option>
<option value="30">30</option>
<option value="45">45</option>
</select>
</div>
<small class="text-muted">Orario limite per la prenotazione del giorno successivo (solo per modalità Automatica)</small>
</div> </div>

View File

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

View File

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

View File

@@ -87,7 +87,11 @@
</div> </div>
<div class="legend-item"> <div class="legend-item">
<div class="legend-color status-absent"></div> <div class="legend-color status-absent"></div>
<span>Assente</span> <span>Ferie</span>
</div>
<div class="legend-item">
<div class="legend-color status-business_trip"></div>
<span>Trasferta</span>
</div> </div>
</div> </div>
</div> </div>
@@ -104,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"
@@ -113,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"
@@ -135,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;">
@@ -155,11 +159,35 @@
</div> </div>
<!-- Exclusion Card -->
<div class="card" id="exclusionCard" style="margin-top: 2rem;">
<div class="card-header">
<h3>Esclusione Assegnazione</h3>
</div>
<div class="card-body">
<p class="text-muted" style="margin-bottom: 1rem;">
Puoi decidere di escluderti automaticamente dalla logica di assegnazione dei posti auto.
Le richieste di esclusione sono visibili agli amministratori.
</p>
<div id="exclusionStatusDisplay"
style="display: none; padding: 1rem; border-radius: 6px; margin-bottom: 1rem; background-color: #f3f4f6; border: 1px solid #e5e7eb;">
<!-- Filled by JS -->
</div>
<div style="display: flex; gap: 0.5rem;">
<button class="btn btn-dark" id="manageExclusionBtn">Gestisci Esclusione</button>
</div>
</div>
</div>
<div class="card parking-map-card" style="margin-top: 2rem;"> <div class="card parking-map-card" style="margin-top: 2rem;">
<h3>Mappa Parcheggio</h3> <h3>Mappa Parcheggio</h3>
<img src="/assets/parking-map.png" alt="Mappa Parcheggio" <img src="/assets/parking-map.png" alt="Mappa Parcheggio"
style="width: 100%; height: auto; border-radius: 4px; border: 1px solid var(--border);"> style="width: 100%; height: auto; border-radius: 4px; border: 1px solid var(--border);">
</div> </div> <!-- End parking-map-card -->
</div> </div>
</main> </main>
@@ -199,7 +227,11 @@
</button> </button>
<button type="button" class="status-btn qe-status-btn" data-status="absent"> <button type="button" class="status-btn qe-status-btn" data-status="absent">
<div class="status-icon status-absent"></div> <div class="status-icon status-absent"></div>
<span>Assente</span> <span>Ferie</span>
</button>
<button type="button" class="status-btn qe-status-btn" data-status="business_trip">
<div class="status-icon status-business_trip"></div>
<span>Trasferta</span>
</button> </button>
<button type="button" class="status-btn qe-status-btn" data-status="clear"> <button type="button" class="status-btn qe-status-btn" data-status="clear">
<div class="status-icon" <div class="status-icon"
@@ -241,7 +273,11 @@
</button> </button>
<button class="status-btn" data-status="absent"> <button class="status-btn" data-status="absent">
<div class="status-icon status-absent"></div> <div class="status-icon status-absent"></div>
<span>Assente</span> <span>Ferie</span>
</button>
<button class="status-btn" data-status="business_trip">
<div class="status-icon status-business_trip"></div>
<span>Trasferta</span>
</button> </button>
</div> </div>
@@ -276,9 +312,85 @@
</div> </div>
</div> </div>
</div> </div>
</div>
<!-- User Exclusion Modal -->
<div class="modal" id="userExclusionModal" style="display: none;">
<div class="modal-content modal-small">
<div class="modal-header">
<h3>Gestisci Esclusione</h3>
<button class="modal-close" id="closeUserExclusionModal">&times;</button>
</div>
<div class="modal-body">
<form id="userExclusionForm">
<div class="form-group">
<label style="font-weight: 500; margin-bottom: 0.5rem; display: block;">Durata
Esclusione</label>
<div style="display: flex; gap: 1rem; margin-bottom: 1rem;">
<label style="display: flex; align-items: center; gap: 0.5rem; cursor: pointer;">
<input type="radio" name="exclusionType" value="forever" checked>
<span>Tempo Indeterminato</span>
</label>
<label style="display: flex; align-items: center; gap: 0.5rem; cursor: pointer;">
<input type="radio" name="exclusionType" value="range">
<span>Periodo Specifico</span>
</label>
</div>
</div>
<div id="exclusionDateRange" style="display: none;">
<div class="form-group">
<label for="ueStartDate">Data Inizio</label>
<input type="date" id="ueStartDate" class="form-control">
</div>
<div class="form-group">
<label for="ueEndDate">Data Fine</label>
<input type="date" id="ueEndDate" class="form-control">
</div>
</div>
<div class="form-group">
<label for="ueNotes">Motivo (opzionale)</label>
<textarea id="ueNotes" class="form-control" rows="2"
placeholder="Es. Lavoro da remoto per un mese..."></textarea>
</div>
<div class="form-actions">
<button type="button" class="btn btn-danger" id="deleteExclusionBtn"
style="display: none; margin-right: auto;">Rimuovi</button>
<button type="button" class="btn btn-secondary" id="cancelUserExclusion">Annulla</button>
<button type="submit" class="btn btn-dark">Salva</button>
</div>
</form>
</div>
</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">&times;</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>

View File

@@ -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">Manager</label> <label for="manager">Gruppo</label>
<input type="text" id="manager" disabled> <input type="text" id="manager" disabled>
<small class="text-muted">Il tuo manager è 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>
@@ -130,7 +130,7 @@
}); });
async function loadProfile() { async function loadProfile() {
const response = await api.get('/api/users/me/profile'); const response = await api.getCached('/api/users/me/profile', 60);
if (response && response.ok) { if (response && response.ok) {
const profile = await response.json(); const profile = await response.json();
isLdapUser = profile.is_ldap_user; isLdapUser = profile.is_ldap_user;
@@ -139,7 +139,7 @@
document.getElementById('name').value = profile.name || ''; document.getElementById('name').value = profile.name || '';
document.getElementById('email').value = profile.email; document.getElementById('email').value = profile.email;
document.getElementById('role').value = profile.role; document.getElementById('role').value = profile.role;
document.getElementById('manager').value = profile.manager_name || 'Nessuno'; document.getElementById('manager').value = profile.office_name || 'Nessuno';
// LDAP mode adjustments // LDAP mode adjustments
if (isLdapUser) { if (isLdapUser) {
@@ -169,6 +169,8 @@
const response = await api.put('/api/users/me/profile', data); const response = await api.put('/api/users/me/profile', data);
if (response && response.ok) { if (response && response.ok) {
utils.showMessage('Profilo aggiornato con successo', 'success'); utils.showMessage('Profilo aggiornato con successo', 'success');
api.invalidateCache('/api/users/me/profile');
api.invalidateCache('/api/auth/me'); // Update nav bar name too
// Update nav display // Update nav display
const nameEl = document.getElementById('userName'); const nameEl = document.getElementById('userName');
if (nameEl) nameEl.textContent = data.name; if (nameEl) nameEl.textContent = data.name;

View File

@@ -47,24 +47,30 @@
<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>
</div> </div>
<div class="card-body"> <div class="card-body">
<form id="notificationForm"> <form id="notificationForm">
<div class="form-group">
<label class="toggle-label">
<span>Riepilogo Settimanale</span>
<label class="toggle-switch">
<input type="checkbox" id="notifyWeeklyParking">
<span class="toggle-slider"></span>
</label>
</label>
<small class="text-muted">Ricevi il riepilogo settimanale delle assegnazioni parcheggio ogni
Venerdì alle 12:00</small>
</div>
<div class="form-group"> <div class="form-group">
<label class="toggle-label"> <label class="toggle-label">
<span>Promemoria Giornaliero</span> <span>Promemoria Giornaliero</span>
@@ -141,13 +147,17 @@
// Notification settings // Notification settings
// Notification settings // Notification settings
document.getElementById('notifyWeeklyParking').checked = currentUser.notify_weekly_parking !== 0;
document.getElementById('notifyDailyParking').checked = currentUser.notify_daily_parking !== 0; document.getElementById('notifyDailyParking').checked = currentUser.notify_daily_parking !== 0;
document.getElementById('notifyDailyHour').value = currentUser.notify_daily_parking_hour || 8; document.getElementById('notifyDailyHour').value = currentUser.notify_daily_parking_hour || 8;
document.getElementById('notifyDailyMinute').value = currentUser.notify_daily_parking_minute || 0; document.getElementById('notifyDailyMinute').value = currentUser.notify_daily_parking_minute || 0;
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() {
@@ -164,7 +174,7 @@
e.preventDefault(); e.preventDefault();
const data = { const data = {
notify_weekly_parking: document.getElementById('notifyWeeklyParking').checked ? 1 : 0, notify_weekly_parking: 0,
notify_daily_parking: document.getElementById('notifyDailyParking').checked ? 1 : 0, notify_daily_parking: document.getElementById('notifyDailyParking').checked ? 1 : 0,
notify_daily_parking_hour: parseInt(document.getElementById('notifyDailyHour').value), notify_daily_parking_hour: parseInt(document.getElementById('notifyDailyHour').value),
notify_daily_parking_minute: parseInt(document.getElementById('notifyDailyMinute').value), notify_daily_parking_minute: parseInt(document.getElementById('notifyDailyMinute').value),
@@ -183,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>

View File

@@ -55,13 +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">
@@ -100,8 +101,36 @@
</div> </div>
<div class="legend-item"> <div class="legend-item">
<div class="legend-color status-absent"></div> <div class="legend-color status-absent"></div>
<span>Assente</span> <span>Ferie</span>
</div> </div>
<div class="legend-item">
<div class="legend-color status-business_trip"></div>
<span>Trasferta</span>
</div>
</div>
</div>
<!-- Export Card (Admin/Manager only) -->
<div id="exportCard" class="card" style="display: none;">
<h3 style="margin-top: 0; margin-bottom: 1rem; font-size: 1.1rem;">Esporta Report</h3>
<div style="display: flex; gap: 1rem; align-items: flex-end;">
<div>
<small style="display: block; margin-bottom: 0.25rem;">Da:</small>
<input type="date" id="exportStartDate" class="form-control">
</div>
<div>
<small style="display: block; margin-bottom: 0.25rem;">A:</small>
<input type="date" id="exportEndDate" class="form-control">
</div>
<button id="exportBtn" class="btn btn-dark" style="height: 38px;">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor"
stroke-width="2" style="margin-right: 0.5rem;">
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"></path>
<polyline points="7 10 12 15 17 10"></polyline>
<line x1="12" y1="15" x2="12" y2="3"></line>
</svg>
Esporta Excel
</button>
</div> </div>
</div> </div>
</div> </div>
@@ -127,7 +156,11 @@
</button> </button>
<button class="status-btn" data-status="absent"> <button class="status-btn" data-status="absent">
<div class="status-icon status-absent"></div> <div class="status-icon status-absent"></div>
<span>Assente</span> <span>Ferie</span>
</button>
<button class="status-btn" data-status="business_trip">
<div class="status-icon status-business_trip"></div>
<span>Trasferta</span>
</button> </button>
</div> </div>
<button class="btn btn-secondary btn-full" id="clearDayBtn" style="margin-top: 1rem;">Cancella <button class="btn btn-secondary btn-full" id="clearDayBtn" style="margin-top: 1rem;">Cancella

View File

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

31
main.py
View File

@@ -21,12 +21,31 @@ from app.routes.users import router as users_router
from app.routes.offices import router as offices_router from app.routes.offices import router as offices_router
from app.routes.presence import router as presence_router from app.routes.presence import router as presence_router
from app.routes.parking import router as parking_router from app.routes.parking import router as parking_router
from database.connection import init_db from app.routes.parking import router as parking_router
from database.connection import init_db, get_db_session
from services.notifications import run_scheduled_notifications
from services.parking import process_daily_allocations
import asyncio
# Rate limiter setup # Rate limiter setup
limiter = Limiter(key_func=get_remote_address) limiter = Limiter(key_func=get_remote_address)
async def scheduler_task():
"""Background task to run scheduled notifications every minute"""
config.logger.info("Scheduler task started")
while True:
try:
with get_db_session() as db:
run_scheduled_notifications(db)
process_daily_allocations(db)
except Exception as e:
config.logger.error(f"Scheduler error: {e}")
# Check every 60 seconds
await asyncio.sleep(60)
@asynccontextmanager @asynccontextmanager
async def lifespan(app: FastAPI): async def lifespan(app: FastAPI):
"""Initialize database on startup""" """Initialize database on startup"""
@@ -72,6 +91,9 @@ async def lifespan(app: FastAPI):
log(f"feedback: App reachable via Caddy at {reachable_url}") log(f"feedback: App reachable via Caddy at {reachable_url}")
# Start scheduler
asyncio.create_task(scheduler_task())
yield yield
log("Shutting down Parking Manager application") log("Shutting down Parking Manager application")
@@ -97,6 +119,8 @@ app.include_router(users_router)
app.include_router(offices_router) app.include_router(offices_router)
app.include_router(presence_router) app.include_router(presence_router)
app.include_router(parking_router) app.include_router(parking_router)
from app.routes.reports import router as reports_router
app.include_router(reports_router)
# Static Files # Static Files
app.mount("/css", StaticFiles(directory=str(config.FRONTEND_DIR / "css")), name="css") app.mount("/css", StaticFiles(directory=str(config.FRONTEND_DIR / "css")), name="css")
@@ -171,10 +195,7 @@ async def settings_page():
return FileResponse(config.FRONTEND_DIR / "pages" / "settings.html") return FileResponse(config.FRONTEND_DIR / "pages" / "settings.html")
@app.get("/parking-settings")
async def parking_settings_page():
"""Parking Settings page"""
return FileResponse(config.FRONTEND_DIR / "pages" / "parking-settings.html")
@app.get("/favicon.svg") @app.get("/favicon.svg")

25
migrate_db.py Normal file
View 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
View 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}")

View File

@@ -10,3 +10,4 @@ slowapi==0.1.9
python-multipart==0.0.9 python-multipart==0.0.9
idna<4,>=2.5 idna<4,>=2.5
email-validator>=2.1.0.post1 email-validator>=2.1.0.post1
openpyxl>=3.1.2

View File

@@ -100,17 +100,17 @@ def notify_parking_assigned(user: "User", assignment_date: date, spot_name: str)
if not user.notify_parking_changes: if not user.notify_parking_changes:
return return
day_name = assignment_date.strftime("%A, %B %d") day_name = assignment_date.strftime("%d/%m/%Y")
subject = f"Parking spot assigned for {day_name}" subject = f"Assegnazione Posto Auto - {day_name}"
body_html = f""" body_html = f"""
<html> <html>
<body> <body>
<h2>Parking Spot Assigned</h2> <h2>Posto Auto Assegnato</h2>
<p>Hi {user.name},</p> <p>Ciao {user.name},</p>
<p>You have been assigned a parking spot for {day_name}:</p> <p>Ti è stato assegnato un posto auto per il giorno {day_name}:</p>
<p style="font-size: 18px; font-weight: bold;">Spot {spot_name}</p> <p style="font-size: 18px; font-weight: bold;">Posto {spot_name}</p>
<p>Best regards,<br>Parking Manager</p> <p>Cordiali saluti,<br>Team Parking Manager</p>
</body> </body>
</html> </html>
""" """
@@ -122,37 +122,64 @@ def notify_parking_released(user: "User", assignment_date: date, spot_name: str)
if not user.notify_parking_changes: if not user.notify_parking_changes:
return return
day_name = assignment_date.strftime("%A, %B %d") day_name = assignment_date.strftime("%d/%m/%Y")
subject = f"Parking spot released for {day_name}" subject = f"Rilascio Posto Auto - {day_name}"
body_html = f""" body_html = f"""
<html> <html>
<body> <body>
<h2>Parking Spot Released</h2> <h2>Posto Auto Rilasciato</h2>
<p>Hi {user.name},</p> <p>Ciao {user.name},</p>
<p>Your parking spot (Spot {spot_name}) for {day_name} has been released.</p> <p>Il tuo posto auto (Posto {spot_name}) per il giorno {day_name} è stato rilasciato.</p>
<p>Best regards,<br>Parking Manager</p> <p>Cordiali saluti,<br>Team Parking Manager</p>
</body> </body>
</html> </html>
""" """
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:
return return
day_name = assignment_date.strftime("%A, %B %d") day_name = assignment_date.strftime("%d/%m/%Y")
subject = f"Parking spot reassigned for {day_name}" subject = f"Riassegnazione Posto Auto - {day_name}"
body_html = f""" body_html = f"""
<html> <html>
<body> <body>
<h2>Parking Spot Reassigned</h2> <h2>Posto Auto Riassegnato</h2>
<p>Hi {user.name},</p> <p>Ciao {user.name},</p>
<p>Your parking spot (Spot {spot_name}) for {day_name} has been reassigned to {new_user_name}.</p> <p>Il tuo posto auto (Posto {spot_name}) per il giorno {day_name} è stato riassegnato a {new_user_name}.</p>
<p>Best regards,<br>Parking Manager</p> <p>Cordiali saluti,<br>Team Parking Manager</p>
</body> </body>
</html> </html>
""" """
@@ -188,19 +215,19 @@ def send_presence_reminder(user: "User", next_week_dates: List[date], db: "Sessi
return False return False
# Send reminder # Send reminder
start_date = next_week_dates[0].strftime("%B %d") start_date = next_week_dates[0].strftime("%d/%m/%Y")
end_date = next_week_dates[-1].strftime("%B %d, %Y") end_date = next_week_dates[-1].strftime("%d/%m/%Y")
subject = f"Reminder: Please fill your presence for {start_date} - {end_date}" subject = f"Promemoria Presenze - Settimana {start_date} - {end_date}"
body_html = f""" body_html = f"""
<html> <html>
<body> <body>
<h2>Presence Reminder</h2> <h2>Promemoria Compilazione Presenze</h2>
<p>Hi {user.name},</p> <p>Ciao {user.name},</p>
<p>This is a friendly reminder to fill your presence for the upcoming week <p>Ti ricordiamo di compilare le tue presenze per la prossima settimana
({start_date} - {end_date}).</p> ({start_date} - {end_date}).</p>
<p>Please log in to the Parking Manager to mark your presence.</p> <p>Accedi al Parking Manager per segnare le tue presenze.</p>
<p>Best regards,<br>Parking Manager</p> <p>Cordiali saluti,<br>Team Parking Manager</p>
</body> </body>
</html> </html>
""" """
@@ -220,98 +247,22 @@ def send_presence_reminder(user: "User", next_week_dates: List[date], db: "Sessi
return False return False
def send_weekly_parking_summary(user: "User", next_week_dates: List[date], db: "Session") -> bool:
"""Send weekly parking assignment summary for next week (Friday at 12)"""
from database.models import DailyParkingAssignment, NotificationLog
from services.parking import get_spot_display_name
if not user.notify_weekly_parking:
return False
week_ref = get_week_reference(next_week_dates[0])
# Check if already sent for this week
existing = db.query(NotificationLog).filter(
NotificationLog.user_id == user.id,
NotificationLog.notification_type == NotificationType.WEEKLY_PARKING,
NotificationLog.reference_date == week_ref
).first()
if existing:
return False
# Get parking assignments for next week
assignments = db.query(DailyParkingAssignment).filter(
DailyParkingAssignment.user_id == user.id,
DailyParkingAssignment.date.in_(next_week_dates)
).all()
if not assignments:
return False
# Build assignment list
assignment_lines = []
# a.date is now a date object
for a in sorted(assignments, key=lambda x: x.date):
day_name = a.date.strftime("%A")
spot_name = get_spot_display_name(a.spot_id, a.office_id, db)
assignment_lines.append(f"<li>{day_name}, {a.date.strftime('%B %d')}: Spot {spot_name}</li>")
start_date = next_week_dates[0].strftime("%B %d")
end_date = next_week_dates[-1].strftime("%B %d, %Y")
subject = f"Your parking spots for {start_date} - {end_date}"
body_html = f"""
<html>
<body>
<h2>Weekly Parking Summary</h2>
<p>Hi {user.name},</p>
<p>Here are your parking spot assignments for the upcoming week:</p>
<ul>
{''.join(assignment_lines)}
</ul>
<p>Parking assignments are now frozen. You can still release or reassign your spots if needed.</p>
<p>Best regards,<br>Parking Manager</p>
</body>
</html>
"""
if send_email(user.email, subject, body_html):
log = NotificationLog(
id=generate_uuid(),
user_id=user.id,
notification_type=NotificationType.WEEKLY_PARKING,
reference_date=week_ref,
sent_at=datetime.utcnow()
)
db.add(log)
db.commit()
return True
return False
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}")
if not user.notify_daily_parking: if not user.notify_daily_parking:
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,
@@ -322,31 +273,22 @@ def send_daily_parking_reminder(user: "User", date_obj: datetime, db: "Session")
return False return False
spot_name = get_spot_display_name(assignment.spot_id, assignment.office_id, db) spot_name = get_spot_display_name(assignment.spot_id, assignment.office_id, db)
day_name = date_obj.strftime("%A, %B %d") day_name = date_obj.strftime("%d/%m/%Y")
subject = f"Parking reminder for {day_name}" subject = f"Promemoria Parcheggio - {day_name}"
body_html = f""" body_html = f"""
<html> <html>
<body> <body>
<h2>Daily Parking Reminder</h2> <h2>Promemoria Parcheggio Giornaliero</h2>
<p>Hi {user.name},</p> <p>Ciao {user.name},</p>
<p>You have a parking spot assigned for today ({day_name}):</p> <p>Hai un posto auto assegnato per oggi ({day_name}):</p>
<p style="font-size: 18px; font-weight: bold;">Spot {spot_name}</p> <p style="font-size: 18px; font-weight: bold;">Posto {spot_name}</p>
<p>Best regards,<br>Parking Manager</p> <p>Cordiali saluti,<br>Team Parking Manager</p>
</body> </body>
</html> </html>
""" """
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
@@ -359,11 +301,15 @@ def run_scheduled_notifications(db: "Session"):
Schedule: Schedule:
- Thursday at 12:00: Presence reminder for next week - Thursday at 12:00: Presence reminder for next week
- Friday at 12:00: Weekly parking summary - Friday at 12:00: Weekly parking summary
- Daily at user's preferred time: Daily parking reminder (Mon-Fri) - Daily at user's preferred time: Daily parking reminder (Only on open days)
""" """
from database.models import User from database.models import User, OfficeWeeklyClosingDay, OfficeClosingDay
from zoneinfo import ZoneInfo
# Use configured timezone
tz = ZoneInfo(config.TIMEZONE)
now = datetime.now(tz)
now = datetime.now()
current_hour = now.hour current_hour = now.hour
current_minute = now.minute current_minute = now.minute
current_weekday = now.weekday() # 0=Monday, 6=Sunday current_weekday = now.weekday() # 0=Monday, 6=Sunday
@@ -372,19 +318,45 @@ def run_scheduled_notifications(db: "Session"):
users = db.query(User).all() users = db.query(User).all()
for user in users: for user in users:
# Thursday at 12: Presence reminder # Thursday Reminder: DISABLED as per user request
if current_weekday == 3 and current_hour == 12 and current_minute < 5: # if current_weekday == 3 and current_hour == 12 and current_minute < 5:
next_week = get_next_week_dates(today_date) # next_week = get_next_week_dates(today_date)
send_presence_reminder(user, next_week, db) # send_presence_reminder(user, next_week, db)
# Friday at 12: Weekly parking summary # Daily parking reminder at user's preferred time
if current_weekday == 4 and current_hour == 12 and current_minute < 5: user_hour = user.notify_daily_parking_hour or 8
next_week = get_next_week_dates(today_date) user_minute = user.notify_daily_parking_minute or 0
send_weekly_parking_summary(user, next_week, db)
# Daily parking reminder at user's preferred time (working days only) # Check if it's the right time for this user
if current_weekday < 5: # Monday to Friday if current_hour == user_hour and current_minute == user_minute:
user_hour = user.notify_daily_parking_hour or 8 config.logger.info(f"[SCHEDULER] Triggering Daily Parking Reminder check for user {user.email} (Scheduled: {user_hour}:{user_minute})")
user_minute = user.notify_daily_parking_minute or 0 # Check if Office is OPEN today
if current_hour == user_hour and abs(current_minute - user_minute) < 5: is_office_open = True
if user.office:
# Check weekly closing days (e.g. Sat/Sun)
# Note: WeekDay enum matches python weekday (0=Mon)
weekly_closed = db.query(OfficeWeeklyClosingDay).filter(
OfficeWeeklyClosingDay.office_id == user.office_id,
OfficeWeeklyClosingDay.weekday == current_weekday
).first()
if weekly_closed:
is_office_open = False
# Check specific closing days (Holidays)
if is_office_open:
specific_closed = db.query(OfficeClosingDay).filter(
OfficeClosingDay.office_id == user.office_id,
OfficeClosingDay.date == today_date
).first()
if specific_closed:
is_office_open = False
else:
# Fallback if no office assigned: default to Mon-Fri open
if current_weekday >= 5:
is_office_open = False
if is_office_open:
send_daily_parking_reminder(user, now, db) send_daily_parking_reminder(user, now, db)

52
services/offices.py Normal file
View File

@@ -0,0 +1,52 @@
from sqlalchemy.orm import Session
from database.models import OfficeSpot
from utils.helpers import generate_uuid
def sync_office_spots(office_id: str, quota: int, prefix: str, db: Session):
"""
Synchronize OfficeSpot records with the office quota.
- If active spots < quota: Create new spots
- If active spots > quota: Remove highest numbered spots (Cascade handles assignments)
- If prefix changes: Rename all spots
"""
# Get all current spots sorted by number
current_spots = db.query(OfficeSpot).filter(
OfficeSpot.office_id == office_id
).order_by(OfficeSpot.spot_number).all()
# 1. Handle Prefix Change
# If prefix changed, we need to update names of ALL existing spots
# We do this first to ensure names are correct even if we don't add/remove
if current_spots:
first_spot = current_spots[0]
# Check simple heuristic: does name start with prefix?
# Better: we can't easily know old prefix from here without querying Office,
# but we can just re-generate names for all valid spots.
for spot in current_spots:
expected_name = f"{prefix}{spot.spot_number}"
if spot.name != expected_name:
spot.name = expected_name
current_count = len(current_spots)
# 2. Add Spots
if current_count < quota:
for i in range(current_count + 1, quota + 1):
new_spot = OfficeSpot(
id=generate_uuid(),
office_id=office_id,
spot_number=i,
name=f"{prefix}{i}",
is_unavailable=False
)
db.add(new_spot)
# 3. Remove Spots
elif current_count > quota:
# Identify spots to remove (highest numbers)
spots_to_remove = current_spots[quota:]
for spot in spots_to_remove:
db.delete(spot)
db.commit()

View File

@@ -13,55 +13,31 @@ from sqlalchemy.orm import Session
from sqlalchemy import or_ from sqlalchemy import or_
from database.models import ( from database.models import (
DailyParkingAssignment, User, UserPresence, Office, DailyParkingAssignment, User, UserPresence, Office, OfficeSpot,
ParkingGuarantee, ParkingExclusion, OfficeClosingDay, OfficeWeeklyClosingDay, ParkingGuarantee, ParkingExclusion, OfficeClosingDay, OfficeWeeklyClosingDay,
UserRole, PresenceStatus UserRole, PresenceStatus
) )
from utils.helpers import generate_uuid from utils.helpers import generate_uuid
from app import config from app import config
from services.notifications import notify_parking_assigned
def get_spot_prefix(office: Office, db: Session) -> str: def get_spot_prefix(office: Office, db: Session) -> str:
"""Get the spot prefix for an office (from office.spot_prefix or auto-assign)""" """Get the spot prefix for an office (from office.spot_prefix or auto-assign)"""
# Logic moved to Office creation/update mostly, but keeping helper if needed
if office.spot_prefix: if office.spot_prefix:
return office.spot_prefix return office.spot_prefix
return "A" # Fallback
# Auto-assign based on alphabetical order of offices without prefix
offices = db.query(Office).filter(
Office.spot_prefix == None
).order_by(Office.name).all()
# Find existing prefixes
existing_prefixes = set(
o.spot_prefix for o in db.query(Office).filter(
Office.spot_prefix != None
).all()
)
# Find first available letter
office_index = next((i for i, o in enumerate(offices) if o.id == office.id), 0)
letter = 'A'
count = 0
while letter in existing_prefixes or count < office_index:
if letter not in existing_prefixes:
count += 1
letter = chr(ord(letter) + 1)
if ord(letter) > ord('Z'):
letter = 'A'
break
return letter
def get_spot_display_name(spot_id: str, office_id: str, db: Session) -> str: def get_spot_display_name(spot_id: str, office_id: str, db: Session) -> str:
"""Get display name for a spot (e.g., 'A3' instead of 'spot-3')""" """Get display name for a spot"""
office = db.query(Office).filter(Office.id == office_id).first() # Now easy: fetch from OfficeSpot
if not office: # But wait: spot_id in assignment IS the OfficeSpot.id
return spot_id spot = db.query(OfficeSpot).filter(OfficeSpot.id == spot_id).first()
if spot:
prefix = get_spot_prefix(office, db) return spot.name
spot_number = spot_id.replace("spot-", "") return "Unknown"
return f"{prefix}{spot_number}"
def is_closing_day(office_id: str, check_date: date, db: Session) -> bool: def is_closing_day(office_id: str, check_date: date, db: Session) -> bool:
@@ -95,35 +71,36 @@ def is_closing_day(office_id: str, check_date: date, db: Session) -> bool:
def initialize_parking_pool(office_id: str, quota: int, pool_date: date, db: Session) -> int: def initialize_parking_pool(office_id: str, quota: int, pool_date: date, db: Session) -> int:
"""Initialize empty parking spots for an office's pool on a given date.
Returns 0 if it's a closing day (no parking available).
""" """
# Don't create pool on closing days Get total capacity for the date.
(Legacy name kept for compatibility, but now it just returns count).
"""
if is_closing_day(office_id, pool_date, db): if is_closing_day(office_id, pool_date, db):
return 0 return 0
existing = db.query(DailyParkingAssignment).filter( return db.query(OfficeSpot).filter(
DailyParkingAssignment.office_id == office_id, OfficeSpot.office_id == office_id,
DailyParkingAssignment.date == pool_date OfficeSpot.is_unavailable == False
).count() ).count()
if existing > 0:
return existing
for i in range(1, quota + 1): def get_available_spots(office_id: str, pool_date: date, db: Session) -> list[OfficeSpot]:
spot = DailyParkingAssignment( """Get list of unassigned OfficeSpots for a date"""
id=generate_uuid(), # 1. Get all active spots
date=pool_date, all_spots = db.query(OfficeSpot).filter(
spot_id=f"spot-{i}", OfficeSpot.office_id == office_id,
user_id=None, OfficeSpot.is_unavailable == False
office_id=office_id, ).all()
created_at=datetime.now(timezone.utc)
)
db.add(spot)
db.commit() # 2. Get assigned spot IDs
config.logger.debug(f"Initialized {quota} parking spots for office {office_id} on {pool_date}") assigned_ids = db.query(DailyParkingAssignment.spot_id).filter(
return quota DailyParkingAssignment.office_id == office_id,
DailyParkingAssignment.date == pool_date
).all()
assigned_set = {a[0] for a in assigned_ids}
# 3. Filter
return [s for s in all_spots if s.id not in assigned_set]
def get_user_parking_ratio(user_id: str, office_id: str, db: Session) -> float: def get_user_parking_ratio(user_id: str, office_id: str, db: Session) -> float:
@@ -151,21 +128,31 @@ def get_user_parking_ratio(user_id: str, office_id: str, db: Session) -> float:
def is_user_excluded(user_id: str, office_id: str, check_date: date, db: Session) -> bool: def is_user_excluded(user_id: str, office_id: str, check_date: date, db: Session) -> bool:
"""Check if user is excluded from parking for this date""" """Check if user is excluded from parking for this date"""
exclusion = db.query(ParkingExclusion).filter( exclusions = db.query(ParkingExclusion).filter(
ParkingExclusion.office_id == office_id, ParkingExclusion.office_id == office_id,
ParkingExclusion.user_id == user_id ParkingExclusion.user_id == user_id
).first() ).all()
if not exclusion: if not exclusions:
return False return False
# Check date range # Check against all exclusions
if exclusion.start_date and check_date < exclusion.start_date: for exclusion in exclusions:
return False # If any exclusion covers this date, user is excluded
if exclusion.end_date and check_date > exclusion.end_date:
return False
return True # Check date range
start_ok = True
if exclusion.start_date and check_date < exclusion.start_date:
start_ok = False
end_ok = True
if exclusion.end_date and check_date > exclusion.end_date:
end_ok = False
if start_ok and end_ok:
return True
return False
def has_guarantee(user_id: str, office_id: str, check_date: date, db: Session) -> bool: def has_guarantee(user_id: str, office_id: str, check_date: date, db: Session) -> bool:
@@ -227,47 +214,73 @@ def get_users_wanting_parking(office_id: str, pool_date: date, db: Session) -> l
return candidates return candidates
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).
Called after presence is set for a date. Creates new DailyParkingAssignment rows only for assigned users.
Returns {assigned: [...], waitlist: [...]}
""" """
office = db.query(Office).filter(Office.id == office_id).first()
if not office or not office.parking_quota:
return {"assigned": [], "waitlist": []}
# No parking on closing days
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}
# Initialize pool # Retrieve office to check assignment mode
initialize_parking_pool(office_id, office.parking_quota, pool_date, db) 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 # 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)
# Get available spots if mode == "random":
free_spots = db.query(DailyParkingAssignment).filter( import random
DailyParkingAssignment.office_id == office_id, guaranteed = [c for c in candidates if c["has_guarantee"]]
DailyParkingAssignment.date == pool_date, non_guaranteed = [c for c in candidates if not c["has_guarantee"]]
DailyParkingAssignment.user_id == None
).all() # Shuffle non-guaranteed users to pick randomly
random.shuffle(non_guaranteed)
candidates = guaranteed + non_guaranteed
# Get available spots (OfficeSpots not yet in assignments table)
free_spots = get_available_spots(office_id, pool_date, db)
assigned = [] assigned = []
waitlist = [] waitlist = []
for candidate in candidates: for candidate in candidates:
if free_spots: if free_spots:
# Sort spots by number to fill A1, A2... order
free_spots.sort(key=lambda s: s.spot_number)
spot = free_spots.pop(0) spot = free_spots.pop(0)
spot.user_id = candidate["user_id"]
# Create assignment
assignment = DailyParkingAssignment(
id=generate_uuid(),
date=pool_date,
spot_id=spot.id,
user_id=candidate["user_id"],
office_id=office_id,
created_at=datetime.now(timezone.utc)
)
db.add(assignment)
assigned.append(candidate["user_id"]) assigned.append(candidate["user_id"])
else: else:
waitlist.append(candidate["user_id"]) waitlist.append(candidate["user_id"])
db.commit() db.commit()
# Send notifications to successful assignees
for user_id in assigned:
user = db.query(User).filter(User.id == user_id).first()
if user:
# Re-fetch the assignment to get the spot details
assignment = db.query(DailyParkingAssignment).filter(
DailyParkingAssignment.user_id == user_id,
DailyParkingAssignment.date == pool_date
).first()
if assignment:
spot_name = get_spot_display_name(assignment.spot_id, office_id, db)
notify_parking_assigned(user, pool_date, spot_name)
return {"assigned": assigned, "waitlist": waitlist} return {"assigned": assigned, "waitlist": waitlist}
@@ -282,16 +295,41 @@ 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
# Release the spot # Get old user name for notification
assignment.user_id = None 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
spot_id = assignment.spot_id
# Release the spot (Delete the row)
db.delete(assignment)
db.commit() db.commit()
# Try to assign to next user in fairness queue # Try to assign to next user in fairness queue
candidates = get_users_wanting_parking(office_id, pool_date, db) candidates = get_users_wanting_parking(office_id, pool_date, db)
if candidates: if candidates:
assignment.user_id = candidates[0]["user_id"] top_candidate = candidates[0]
# Create new assignment for top candidate
new_assignment = DailyParkingAssignment(
id=generate_uuid(),
date=pool_date,
spot_id=spot_id,
user_id=top_candidate["user_id"],
office_id=office_id,
created_at=datetime.now(timezone.utc)
)
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
@@ -309,33 +347,12 @@ def handle_presence_change(user_id: str, change_date: date, old_status: Presence
if not office or not office.parking_quota: if not office or not office.parking_quota:
return return
# Initialize pool if needed # No initialization needed for sparse model
initialize_parking_pool(office.id, office.parking_quota, change_date, db)
if old_status == PresenceStatus.PRESENT and new_status in [PresenceStatus.REMOTE, PresenceStatus.ABSENT]: if old_status == PresenceStatus.PRESENT and new_status in [PresenceStatus.REMOTE, PresenceStatus.ABSENT]:
# User no longer coming - release their spot (will auto-reassign) # User no longer coming - release their spot (will auto-reassign)
release_user_spot(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:
# Allocation time is Day-1 at cutoff hour
cutoff_dt = datetime.combine(change_date - timedelta(days=1), datetime.min.time())
cutoff_dt = cutoff_dt.replace(
hour=office.booking_window_end_hour,
minute=office.booking_window_end_minute
)
# If now is before cutoff, do not assign yet (wait for batch job)
if datetime.utcnow() < cutoff_dt:
should_assign = False
config.logger.debug(f"Queuing parking request for user {user_id} on {change_date} (Window open until {cutoff_dt})")
if should_assign:
# User coming in - run fair assignment for this date
assign_parking_fairly(office.id, change_date, db)
def clear_assignments_for_office_date(office_id: str, pool_date: date, db: Session) -> int: def clear_assignments_for_office_date(office_id: str, pool_date: date, db: Session) -> int:
""" """
@@ -344,13 +361,12 @@ def clear_assignments_for_office_date(office_id: str, pool_date: date, db: Sessi
""" """
assignments = db.query(DailyParkingAssignment).filter( assignments = db.query(DailyParkingAssignment).filter(
DailyParkingAssignment.office_id == office_id, DailyParkingAssignment.office_id == office_id,
DailyParkingAssignment.date == pool_date, DailyParkingAssignment.date == pool_date
DailyParkingAssignment.user_id != None
).all() ).all()
count = len(assignments) count = len(assignments)
for a in assignments: for a in assignments:
a.user_id = None db.delete(a)
db.commit() db.commit()
return count return count
@@ -366,3 +382,52 @@ def run_batch_allocation(office_id: str, pool_date: date, db: Session) -> dict:
# 2. Run fair allocation # 2. Run fair allocation
return assign_parking_fairly(office_id, pool_date, db) return assign_parking_fairly(office_id, pool_date, db)
def process_daily_allocations(db: Session):
"""
Check if any office's booking window has just closed and run batch allocation.
Run by scheduler every minute.
HALT: Checks if the cutoff time for TOMORROW has been reached.
"""
from zoneinfo import ZoneInfo
# Use configured timezone
tz = ZoneInfo(config.TIMEZONE)
now = datetime.now(tz)
# Check all offices with window enabled
offices = db.query(Office).filter(Office.booking_window_enabled == True).all()
config.logger.debug(f"[SCHEDULER] Checking booking windows for {len(offices)} offices - Current Time: {now.strftime('%H:%M')}")
for office in offices:
# Cutoff is defined as "Previous Day" (today) at Booking End Hour
# 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:
# 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)
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}")
try:
run_batch_allocation(office.id, target_date, db)
config.logger.info(f"[SCHEDULER] Assegnazione Giornaliera parcheggi completed for {office.name} on {target_date}")
except Exception as e:
config.logger.error(f"[SCHEDULER] Failed Assegnazione Giornaliera parcheggi for {office.name}: {e}")

View File

@@ -18,7 +18,14 @@ security = HTTPBearer(auto_error=False)
def is_admin_from_groups(groups: list[str]) -> bool: def is_admin_from_groups(groups: list[str]) -> bool:
"""Check if user is admin based on Authelia groups""" """Check if user is admin based on Authelia groups"""
return config.AUTHELIA_ADMIN_GROUP in groups admin_group = config.AUTHELIA_ADMIN_GROUP
is_admin = admin_group in groups
# Case-insensitive check fallback (just in case)
if not is_admin:
is_admin = admin_group.lower() in [g.lower() for g in groups]
return is_admin
def get_or_create_authelia_user( def get_or_create_authelia_user(
@@ -43,19 +50,19 @@ def get_or_create_authelia_user(
# Only sync admin status from LLDAP, other roles managed by app admin # Only sync admin status from LLDAP, other roles managed by app admin
if is_admin and user.role != "admin": if is_admin and user.role != "admin":
user.role = "admin" user.role = "admin"
user.updated_at = datetime.utcnow().isoformat() user.updated_at = datetime.utcnow()
db.commit() db.commit()
db.refresh(user) db.refresh(user)
elif not is_admin and user.role == "admin": elif not is_admin and user.role == "admin":
# Removed from parking_admins group -> demote to employee # Removed from parking_admins group -> demote to employee
user.role = "employee" user.role = "employee"
user.updated_at = datetime.utcnow().isoformat() user.updated_at = datetime.utcnow()
db.commit() db.commit()
db.refresh(user) db.refresh(user)
# Update name if changed # Update name if changed
if user.name != name and name: if user.name != name and name:
user.name = name user.name = name
user.updated_at = datetime.utcnow().isoformat() user.updated_at = datetime.utcnow()
db.commit() db.commit()
db.refresh(user) db.refresh(user)
return user return user
@@ -67,8 +74,8 @@ def get_or_create_authelia_user(
name=name or email.split("@")[0], name=name or email.split("@")[0],
role="admin" if is_admin else "employee", role="admin" if is_admin else "employee",
password_hash=None, # No password for Authelia users password_hash=None, # No password for Authelia users
created_at=datetime.utcnow().isoformat(), created_at=datetime.utcnow(),
updated_at=datetime.utcnow().isoformat() updated_at=datetime.utcnow()
) )
db.add(user) db.add(user)
db.commit() db.commit()
@@ -92,7 +99,7 @@ def get_current_user(
remote_name = request.headers.get(config.AUTHELIA_HEADER_NAME, "") remote_name = request.headers.get(config.AUTHELIA_HEADER_NAME, "")
remote_groups = request.headers.get(config.AUTHELIA_HEADER_GROUPS, "") remote_groups = request.headers.get(config.AUTHELIA_HEADER_GROUPS, "")
print(f"[Authelia] Headers: user={remote_user}, email={remote_email}, name={remote_name}, groups={remote_groups}") remote_groups = request.headers.get(config.AUTHELIA_HEADER_GROUPS, "")
if not remote_user: if not remote_user:
raise HTTPException( raise HTTPException(