Compare commits
10 Commits
17453f5d13
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 5d30434dd5 | |||
|
|
104ad53a9a | ||
| a7ef46640d | |||
| 991569d9eb | |||
| 8f5c1e1f94 | |||
| a94ec11c80 | |||
| efa7533179 | |||
| e0b18fd3c3 | |||
| ae099f04cf | |||
| 5f4ef6faee |
81
.env.prod
Normal file
81
.env.prod
Normal 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
1
.gitignore
vendored
@@ -25,7 +25,6 @@ ENV/
|
|||||||
# Environment variables
|
# Environment variables
|
||||||
.env
|
.env
|
||||||
.env.local
|
.env.local
|
||||||
.env.production
|
|
||||||
|
|
||||||
# IDE
|
# IDE
|
||||||
.idea/
|
.idea/
|
||||||
|
|||||||
@@ -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
|
|
||||||
}
|
|
||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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"
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
195
app/routes/reports.py
Normal 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
|
||||||
|
)
|
||||||
@@ -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"}
|
||||||
|
|||||||
15
compose.yml
15
compose.yml
@@ -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
|
|
||||||
|
|||||||
@@ -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 |
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|
||||||
|
|||||||
@@ -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');
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@@ -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, "'")})' 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">
|
||||||
|
×
|
||||||
|
</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;
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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;">
|
||||||
×
|
<button class="btn-icon" onclick='openEditExclusion("${e.id}", ${JSON.stringify(e).replace(/'/g, "'")})' 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">
|
||||||
|
×
|
||||||
|
</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;
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>Gestione Uffici - Parking Manager</title>
|
<title>Gestione Gruppi - Parking Manager</title>
|
||||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg">
|
<link rel="icon" type="image/svg+xml" href="/favicon.svg">
|
||||||
<link rel="stylesheet" href="/css/styles.css">
|
<link rel="stylesheet" href="/css/styles.css">
|
||||||
</head>
|
</head>
|
||||||
@@ -42,7 +42,7 @@
|
|||||||
|
|
||||||
<main class="main-content">
|
<main class="main-content">
|
||||||
<header class="page-header">
|
<header class="page-header">
|
||||||
<h2>Gestione Uffici</h2>
|
<h2>Gestione Gruppi</h2>
|
||||||
<div class="header-actions">
|
<div class="header-actions">
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
@@ -50,8 +50,8 @@
|
|||||||
<div class="content-wrapper">
|
<div class="content-wrapper">
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<div style="padding-bottom: 1rem; display: flex; justify-content: space-between; align-items: center;">
|
<div style="padding-bottom: 1rem; display: flex; justify-content: space-between; align-items: center;">
|
||||||
<h3 style="margin: 0;">Lista Uffici</h3>
|
<h3 style="margin: 0;">Lista Gruppi</h3>
|
||||||
<button class="btn btn-dark" id="addOfficeBtn">Nuovo Ufficio</button>
|
<button class="btn btn-dark" id="addOfficeBtn">Nuovo Gruppo</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="data-table-container">
|
<div class="data-table-container">
|
||||||
<table class="data-table" id="officesTable">
|
<table class="data-table" id="officesTable">
|
||||||
@@ -75,7 +75,7 @@
|
|||||||
<div class="modal" id="officeModal" style="display: none;">
|
<div class="modal" id="officeModal" style="display: none;">
|
||||||
<div class="modal-content">
|
<div class="modal-content">
|
||||||
<div class="modal-header">
|
<div class="modal-header">
|
||||||
<h3 id="officeModalTitle">Nuovo Ufficio</h3>
|
<h3 id="officeModalTitle">Nuovo Gruppo</h3>
|
||||||
<button class="modal-close" id="closeOfficeModal">×</button>
|
<button class="modal-close" id="closeOfficeModal">×</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-body">
|
<div class="modal-body">
|
||||||
@@ -83,14 +83,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>
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
|
||||||
@@ -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">×</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">×</button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<p id="adminAssignSpotInfo" style="margin-bottom: 1rem;"></p>
|
||||||
|
<form id="adminAssignForm">
|
||||||
|
<div class="form-group" style="margin-bottom: 1rem;">
|
||||||
|
<label for="adminAssignUser">Seleziona Utente (Presente in Sede)</label>
|
||||||
|
<select id="adminAssignUser" class="form-control" required style="width: 100%;">
|
||||||
|
<option value="">Caricamento utenti...</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="form-actions">
|
||||||
|
<button type="button" class="btn btn-secondary" id="cancelAdminAssign">Annulla</button>
|
||||||
|
<button type="submit" class="btn btn-dark">Assegna</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<script src="/js/api.js"></script>
|
<script src="/js/api.js"></script>
|
||||||
<script src="/js/utils.js"></script>
|
<script src="/js/utils.js"></script>
|
||||||
<script src="/js/nav.js"></script>
|
<script src="/js/nav.js"></script>
|
||||||
|
|||||||
@@ -74,9 +74,9 @@
|
|||||||
<small class="text-muted">Il ruolo è assegnato dal tuo amministratore</small>
|
<small class="text-muted">Il ruolo è assegnato dal tuo amministratore</small>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="manager">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;
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
31
main.py
@@ -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
25
migrate_db.py
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
import sqlite3
|
||||||
|
|
||||||
|
def migrate():
|
||||||
|
db_path = "data/parking.db"
|
||||||
|
try:
|
||||||
|
conn = sqlite3.connect(db_path)
|
||||||
|
cursor = conn.cursor()
|
||||||
|
|
||||||
|
# Add the new column
|
||||||
|
cursor.execute("ALTER TABLE offices ADD COLUMN assignment_mode VARCHAR DEFAULT 'fairness'")
|
||||||
|
|
||||||
|
print("Success: Added assignment_mode to offices table.")
|
||||||
|
|
||||||
|
conn.commit()
|
||||||
|
except sqlite3.OperationalError as e:
|
||||||
|
if "duplicate column name" in str(e):
|
||||||
|
print("Info: Column assignment_mode already exists.")
|
||||||
|
else:
|
||||||
|
print(f"Error: {e}")
|
||||||
|
finally:
|
||||||
|
if conn:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
migrate()
|
||||||
26
rename_script.py
Normal file
26
rename_script.py
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
import os
|
||||||
|
|
||||||
|
frontend_dir = r"\\wsl.localhost\Ubuntu\home\ssalemi\Progetti\Rocketscale\org-parking\frontend"
|
||||||
|
|
||||||
|
targets = [
|
||||||
|
("Ufficio", "Gruppo"),
|
||||||
|
("Uffici", "Gruppi"),
|
||||||
|
("ufficio", "gruppo"),
|
||||||
|
("uffici", "gruppi")
|
||||||
|
]
|
||||||
|
|
||||||
|
for root, _, files in os.walk(frontend_dir):
|
||||||
|
for filename in files:
|
||||||
|
if filename.endswith(".html") or filename.endswith(".js"):
|
||||||
|
filepath = os.path.join(root, filename)
|
||||||
|
with open(filepath, "r", encoding="utf-8") as f:
|
||||||
|
content = f.read()
|
||||||
|
original_content = content
|
||||||
|
# Replacing case sensitive
|
||||||
|
for old, new in targets:
|
||||||
|
content = content.replace(old, new)
|
||||||
|
|
||||||
|
if content != original_content:
|
||||||
|
with open(filepath, "w", encoding="utf-8") as f:
|
||||||
|
f.write(content)
|
||||||
|
print(f"Updated {filepath}")
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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
52
services/offices.py
Normal 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()
|
||||||
@@ -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}")
|
||||||
|
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
Reference in New Issue
Block a user