From c74a0ed350f8fa74e3aece6d1b61f6020beccd7d Mon Sep 17 00:00:00 2001 From: Stefano Manfredi <56640837+stemanfredi@users.noreply.github.com> Date: Wed, 26 Nov 2025 23:37:50 +0000 Subject: [PATCH] Initial commit: Parking Manager Features: - Manager-centric parking spot management - Fair assignment algorithm (parking/presence ratio) - Presence tracking calendar - Closing days (specific & weekly recurring) - Guarantees and exclusions - Authelia/LLDAP integration for SSO Stack: - FastAPI backend - SQLite database - Vanilla JS frontend - Docker deployment --- .env.example | 26 + .gitignore | 51 + Dockerfile | 23 + README.md | 179 +++ app/__init__.py | 0 app/config.py | 44 + app/routes/__init__.py | 0 app/routes/auth.py | 137 +++ app/routes/managers.py | 372 ++++++ app/routes/offices.py | 197 ++++ app/routes/parking.py | 358 ++++++ app/routes/presence.py | 437 +++++++ app/routes/users.py | 317 ++++++ compose.yml | 34 + create_test_db.py | 183 +++ database/__init__.py | 0 database/connection.py | 39 + database/models.py | 232 ++++ deploy/Caddyfile.snippet | 8 + deploy/DEPLOY.md | 137 +++ deploy/compose.production.yml | 32 + frontend/css/styles.css | 1749 +++++++++++++++++++++++++++++ frontend/favicon.svg | 4 + frontend/js/admin-users.js | 247 ++++ frontend/js/api.js | 174 +++ frontend/js/nav.js | 183 +++ frontend/js/office-rules.js | 376 +++++++ frontend/js/presence.js | 370 ++++++ frontend/js/team-calendar.js | 408 +++++++ frontend/js/utils.js | 222 ++++ frontend/pages/admin-users.html | 132 +++ frontend/pages/landing.html | 32 + frontend/pages/login.html | 64 ++ frontend/pages/office-rules.html | 215 ++++ frontend/pages/presence.html | 186 +++ frontend/pages/profile.html | 188 ++++ frontend/pages/register.html | 98 ++ frontend/pages/settings.html | 217 ++++ frontend/pages/team-calendar.html | 157 +++ main.py | 127 +++ requirements.txt | 6 + run_notifications.py | 32 + services/__init__.py | 0 services/auth.py | 79 ++ services/holidays.py | 116 ++ services/notifications.py | 393 +++++++ services/parking.py | 343 ++++++ utils/__init__.py | 0 utils/auth_middleware.py | 170 +++ 49 files changed, 9094 insertions(+) create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 Dockerfile create mode 100644 README.md create mode 100644 app/__init__.py create mode 100644 app/config.py create mode 100644 app/routes/__init__.py create mode 100644 app/routes/auth.py create mode 100644 app/routes/managers.py create mode 100644 app/routes/offices.py create mode 100644 app/routes/parking.py create mode 100644 app/routes/presence.py create mode 100644 app/routes/users.py create mode 100644 compose.yml create mode 100644 create_test_db.py create mode 100644 database/__init__.py create mode 100644 database/connection.py create mode 100644 database/models.py create mode 100644 deploy/Caddyfile.snippet create mode 100644 deploy/DEPLOY.md create mode 100644 deploy/compose.production.yml create mode 100644 frontend/css/styles.css create mode 100644 frontend/favicon.svg create mode 100644 frontend/js/admin-users.js create mode 100644 frontend/js/api.js create mode 100644 frontend/js/nav.js create mode 100644 frontend/js/office-rules.js create mode 100644 frontend/js/presence.js create mode 100644 frontend/js/team-calendar.js create mode 100644 frontend/js/utils.js create mode 100644 frontend/pages/admin-users.html create mode 100644 frontend/pages/landing.html create mode 100644 frontend/pages/login.html create mode 100644 frontend/pages/office-rules.html create mode 100644 frontend/pages/presence.html create mode 100644 frontend/pages/profile.html create mode 100644 frontend/pages/register.html create mode 100644 frontend/pages/settings.html create mode 100644 frontend/pages/team-calendar.html create mode 100644 main.py create mode 100644 requirements.txt create mode 100644 run_notifications.py create mode 100644 services/__init__.py create mode 100644 services/auth.py create mode 100644 services/holidays.py create mode 100644 services/notifications.py create mode 100644 services/parking.py create mode 100644 utils/__init__.py create mode 100644 utils/auth_middleware.py diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..35030cc --- /dev/null +++ b/.env.example @@ -0,0 +1,26 @@ +# Parking Manager Configuration + +# Security - REQUIRED: Change in production! +SECRET_KEY=change-me-to-a-random-string-at-least-32-chars + +# Server +HOST=0.0.0.0 +PORT=8000 + +# Database (SQLite path) +DATABASE_PATH=/app/data/parking.db + +# CORS (comma-separated origins, or * for all) +ALLOWED_ORIGINS=https://parking.rocketscale.it + +# Authentication +# Set to true when behind Authelia reverse proxy +AUTHELIA_ENABLED=false + +# SMTP - Email Notifications (optional) +SMTP_HOST= +SMTP_PORT=587 +SMTP_USER= +SMTP_PASSWORD= +SMTP_FROM=noreply@rocketscale.it +SMTP_USE_TLS=true diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..aa6ad52 --- /dev/null +++ b/.gitignore @@ -0,0 +1,51 @@ +# Python +__pycache__/ +*.py[cod] +*$py.class +*.so +.Python +*.egg +*.egg-info/ +.eggs/ +dist/ +build/ +*.manifest +*.spec + +# Virtual environment +.venv/ +venv/ +ENV/ + +# Database +*.db +*.sqlite +*.sqlite3 + +# Environment variables +.env +.env.local +.env.production + +# IDE +.idea/ +.vscode/ +*.swp +*.swo +*~ + +# OS +.DS_Store +Thumbs.db + +# Logs +*.log +logs/ + +# Testing +.pytest_cache/ +.coverage +htmlcov/ + +# Docker (local overrides) +docker-compose.override.yml diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..399d5da --- /dev/null +++ b/Dockerfile @@ -0,0 +1,23 @@ +FROM python:3.12-slim + +WORKDIR /app + +# Install dependencies +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +# Copy application code +COPY . . + +# Create data directory for SQLite +RUN mkdir -p /app/data + +# Expose port +EXPOSE 8000 + +# Health check +HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \ + CMD python -c "import urllib.request; urllib.request.urlopen('http://localhost:8000/health')" || exit 1 + +# Run with uvicorn +CMD ["python", "-m", "uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..31e6670 --- /dev/null +++ b/README.md @@ -0,0 +1,179 @@ +# Parking Manager + +A manager-centric parking spot management application with fair assignment algorithm. + +## Features + +- **Manager-centric model**: Managers own parking spots, not offices +- **Fair assignment algorithm**: Users with lowest parking/presence ratio get priority +- **Presence tracking**: Calendar-based presence marking (present/remote/absent) +- **Closing days**: Support for specific dates and weekly recurring closures +- **Guarantees & exclusions**: Per-user parking rules +- **Authelia/LLDAP integration**: SSO authentication with group-based roles + +## Architecture + +``` +├── app/ +│ ├── routes/ # API endpoints +│ │ ├── auth.py # Authentication + holidays +│ │ ├── users.py # User management +│ │ ├── offices.py # Office CRUD +│ │ ├── managers.py # Manager rules (closing days, guarantees) +│ │ ├── presence.py # Presence marking +│ │ └── parking.py # Parking assignments +│ └── config.py # Application configuration +├── database/ +│ ├── models.py # SQLAlchemy ORM models +│ └── connection.py # Database setup +├── services/ +│ ├── auth.py # JWT + password handling +│ ├── parking.py # Fair assignment algorithm +│ ├── holidays.py # Public holiday calculation +│ └── notifications.py # Email notifications (TODO: scheduler) +├── frontend/ +│ ├── pages/ # HTML pages +│ ├── js/ # JavaScript modules +│ └── css/ # Stylesheets +└── main.py # FastAPI application entry +``` + +## Quick Start (Development) + +```bash +# Create virtual environment +python3 -m venv .venv +source .venv/bin/activate + +# Install dependencies +pip install -r requirements.txt + +# Run development server +python main.py +``` + +Access at http://localhost:8000 + +## Docker Deployment + +```bash +# Build image +docker build -t parking-manager . + +# Run with environment variables +docker run -d \ + -p 8000:8000 \ + -v ./data:/app/data \ + -e SECRET_KEY=your-secret-key \ + -e AUTHELIA_ENABLED=true \ + parking-manager +``` + +Or use Docker Compose: + +```bash +docker compose up -d +``` + +## Environment Variables + +| Variable | Description | Default | +|----------|-------------|---------| +| `SECRET_KEY` | JWT signing key | Random (dev only) | +| `HOST` | Bind address | `0.0.0.0` | +| `PORT` | Server port | `8000` | +| `DATABASE_URL` | SQLite path | `sqlite:///data/parking.db` | +| `AUTHELIA_ENABLED` | Enable Authelia SSO | `false` | +| `ALLOWED_ORIGINS` | CORS origins | `*` | + +### SMTP (Notifications - Optional) + +| Variable | Description | +|----------|-------------| +| `SMTP_HOST` | SMTP server hostname | +| `SMTP_PORT` | SMTP port (default: 587) | +| `SMTP_USER` | SMTP username | +| `SMTP_PASSWORD` | SMTP password | +| `SMTP_FROM` | From email address | + +## Authentication + +### Standalone Mode +Built-in JWT authentication with bcrypt password hashing. Users register/login via `/login` and `/register`. + +### Authelia Mode +When `AUTHELIA_ENABLED=true`, the app trusts Authelia headers: +- `Remote-User`: User email/username +- `Remote-Name`: Display name +- `Remote-Groups`: Comma-separated group list + +Group mapping: +- `parking-admins` → admin role +- `parking-managers` → manager role +- Others → employee role + +## User Roles + +| Role | Permissions | +|------|-------------| +| **admin** | Full access, manage users/offices | +| **manager** | Manage assigned offices, set rules | +| **employee** | Mark own presence, view calendar | + +## API Endpoints + +### Authentication +- `POST /api/auth/login` - Login +- `POST /api/auth/register` - Register (standalone mode) +- `POST /api/auth/logout` - Logout +- `GET /api/auth/me` - Current user info +- `GET /api/auth/holidays/{year}` - Public holidays + +### Users +- `GET /api/users` - List users (admin) +- `POST /api/users` - Create user (admin) +- `PUT /api/users/{id}` - Update user (admin) +- `DELETE /api/users/{id}` - Delete user (admin) +- `GET /api/users/me/profile` - Own profile +- `PUT /api/users/me/settings` - Own settings + +### Offices +- `GET /api/offices` - List offices +- `POST /api/offices` - Create office (admin) +- `PUT /api/offices/{id}` - Update office (admin) +- `DELETE /api/offices/{id}` - Delete office (admin) + +### Managers +- `GET /api/managers` - List managers +- `GET /api/managers/{id}` - Manager details +- `PUT /api/managers/{id}/settings` - Update parking quota (admin) +- `GET/POST/DELETE /api/managers/{id}/closing-days` - Specific closures +- `GET/POST/DELETE /api/managers/{id}/weekly-closing-days` - Recurring closures +- `GET/POST/DELETE /api/managers/{id}/guarantees` - Parking guarantees +- `GET/POST/DELETE /api/managers/{id}/exclusions` - Parking exclusions + +### Presence +- `POST /api/presence/mark` - Mark presence +- `POST /api/presence/mark-bulk` - Bulk mark +- `GET /api/presence/my-presences` - Own presences +- `GET /api/presence/team` - Team calendar (manager/admin) + +### Parking +- `GET /api/parking/assignments/{date}` - Day's assignments +- `GET /api/parking/my-assignments` - Own assignments +- `POST /api/parking/manual-assign` - Manual assignment +- `POST /api/parking/reassign-spot` - Reassign spot + +## Fairness Algorithm + +Parking spots are assigned based on a fairness ratio: + +``` +ratio = parking_days / office_days +``` + +Users with the lowest ratio get priority. Guaranteed users are always assigned first. + +## License + +MIT diff --git a/app/__init__.py b/app/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/config.py b/app/config.py new file mode 100644 index 0000000..ab661d1 --- /dev/null +++ b/app/config.py @@ -0,0 +1,44 @@ +""" +Application Configuration +Environment-based settings with sensible defaults +""" +import os +from pathlib import Path + +# Database +DATABASE_PATH = os.getenv("DATABASE_PATH", "parking.db") +DATABASE_URL = os.getenv("DATABASE_URL", f"sqlite:///{DATABASE_PATH}") + +# JWT Authentication +SECRET_KEY = os.getenv("SECRET_KEY", "change-me-in-production") +ALGORITHM = "HS256" +ACCESS_TOKEN_EXPIRE_MINUTES = 1440 # 24 hours + +# Server +HOST = os.getenv("HOST", "0.0.0.0") +PORT = int(os.getenv("PORT", "8000")) + +# CORS +ALLOWED_ORIGINS = os.getenv("ALLOWED_ORIGINS", "http://localhost:8000,http://127.0.0.1:8000").split(",") + +# Authelia Integration +AUTHELIA_ENABLED = os.getenv("AUTHELIA_ENABLED", "false").lower() == "true" +# Header names sent by Authelia +AUTHELIA_HEADER_USER = os.getenv("AUTHELIA_HEADER_USER", "Remote-User") +AUTHELIA_HEADER_NAME = os.getenv("AUTHELIA_HEADER_NAME", "Remote-Name") +AUTHELIA_HEADER_EMAIL = os.getenv("AUTHELIA_HEADER_EMAIL", "Remote-Email") +AUTHELIA_HEADER_GROUPS = os.getenv("AUTHELIA_HEADER_GROUPS", "Remote-Groups") +# Group to role mapping +AUTHELIA_ADMIN_GROUP = os.getenv("AUTHELIA_ADMIN_GROUP", "parking-admins") +AUTHELIA_MANAGER_GROUP = os.getenv("AUTHELIA_MANAGER_GROUP", "parking-managers") + +# Email (optional) +SMTP_HOST = os.getenv("SMTP_HOST", "") +SMTP_PORT = int(os.getenv("SMTP_PORT", "587")) +SMTP_USER = os.getenv("SMTP_USER", "") +SMTP_PASSWORD = os.getenv("SMTP_PASSWORD", "") +SMTP_FROM_EMAIL = os.getenv("SMTP_FROM_EMAIL", SMTP_USER) + +# Paths +BASE_DIR = Path(__file__).resolve().parent.parent +FRONTEND_DIR = BASE_DIR / "frontend" diff --git a/app/routes/__init__.py b/app/routes/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/routes/auth.py b/app/routes/auth.py new file mode 100644 index 0000000..0e1d8e9 --- /dev/null +++ b/app/routes/auth.py @@ -0,0 +1,137 @@ +""" +Authentication Routes +Login, register, logout, and user info +""" +from fastapi import APIRouter, Depends, HTTPException, status, Response +from pydantic import BaseModel, EmailStr +from sqlalchemy.orm import Session + +from database.connection import get_db +from services.auth import ( + create_user, authenticate_user, create_access_token, + get_user_by_email, hash_password, verify_password +) +from utils.auth_middleware import get_current_user +from app import config +import re + +router = APIRouter(prefix="/api/auth", tags=["auth"]) + + +class RegisterRequest(BaseModel): + email: EmailStr + password: str + name: str + office_id: str | None = None + + +class LoginRequest(BaseModel): + email: EmailStr + password: str + + +class TokenResponse(BaseModel): + access_token: str + token_type: str = "bearer" + + +class UserResponse(BaseModel): + id: str + email: str + name: str | None + office_id: str | None + role: str + manager_parking_quota: int | None = None + week_start_day: int = 0 + # Notification preferences + notify_weekly_parking: int = 1 + notify_daily_parking: int = 1 + notify_daily_parking_hour: int = 8 + notify_daily_parking_minute: int = 0 + notify_parking_changes: int = 1 + + +@router.post("/register", response_model=TokenResponse) +def register(data: RegisterRequest, db: Session = Depends(get_db)): + """Register a new user""" + if get_user_by_email(db, data.email): + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Email already registered" + ) + + if len(data.password) < 8: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Password must be at least 8 characters" + ) + + user = create_user( + db=db, + email=data.email, + password=data.password, + name=data.name, + office_id=data.office_id + ) + + token = create_access_token(user.id, user.email) + return TokenResponse(access_token=token) + + +@router.post("/login", response_model=TokenResponse) +def login(data: LoginRequest, response: Response, db: Session = Depends(get_db)): + """Login with email and password""" + user = authenticate_user(db, data.email, data.password) + + if not user: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Invalid credentials" + ) + + token = create_access_token(user.id, user.email) + + response.set_cookie( + key="session_token", + value=token, + httponly=True, + max_age=config.ACCESS_TOKEN_EXPIRE_MINUTES * 60, + samesite="lax" + ) + + return TokenResponse(access_token=token) + + +@router.post("/logout") +def logout(response: Response): + """Logout and clear session""" + response.delete_cookie("session_token") + return {"message": "Logged out"} + + +@router.get("/me", response_model=UserResponse) +def get_me(user=Depends(get_current_user)): + """Get current user info""" + return UserResponse( + id=user.id, + email=user.email, + name=user.name, + office_id=user.office_id, + role=user.role, + manager_parking_quota=user.manager_parking_quota, + week_start_day=user.week_start_day or 0, + notify_weekly_parking=user.notify_weekly_parking if user.notify_weekly_parking is not None else 1, + notify_daily_parking=user.notify_daily_parking if user.notify_daily_parking is not None else 1, + notify_daily_parking_hour=user.notify_daily_parking_hour if user.notify_daily_parking_hour is not None else 8, + notify_daily_parking_minute=user.notify_daily_parking_minute if user.notify_daily_parking_minute is not None else 0, + notify_parking_changes=user.notify_parking_changes if user.notify_parking_changes is not None else 1 + ) + + +@router.get("/holidays/{year}") +def get_holidays(year: int): + """Get public holidays for a given year""" + from services.holidays import get_holidays_for_year + if year < 2000 or year > 2100: + raise HTTPException(status_code=400, detail="Year must be between 2000 and 2100") + return get_holidays_for_year(year) diff --git a/app/routes/managers.py b/app/routes/managers.py new file mode 100644 index 0000000..c3c379f --- /dev/null +++ b/app/routes/managers.py @@ -0,0 +1,372 @@ +""" +Manager Rules Routes +Manager settings, closing days, guarantees, and exclusions + +Key concept: Managers own parking spots and set rules for all their managed offices. +Rules are set at manager level, not office level. +""" +from datetime import datetime +from fastapi import APIRouter, Depends, HTTPException +from pydantic import BaseModel +from sqlalchemy.orm import Session +import uuid + +from database.connection import get_db +from database.models import ( + Office, User, OfficeMembership, + ManagerClosingDay, ManagerWeeklyClosingDay, + ParkingGuarantee, ParkingExclusion +) +from utils.auth_middleware import require_admin, require_manager_or_admin, get_current_user + +router = APIRouter(prefix="/api/managers", tags=["managers"]) + + +# Request/Response Models +class ClosingDayCreate(BaseModel): + date: str # YYYY-MM-DD + reason: str | None = None + + +class WeeklyClosingDayCreate(BaseModel): + weekday: int # 0=Sunday, 1=Monday, ..., 6=Saturday + + +class GuaranteeCreate(BaseModel): + user_id: str + start_date: str | None = None + end_date: str | None = None + + +class ExclusionCreate(BaseModel): + user_id: str + start_date: str | None = None + end_date: str | None = None + + +class ManagerSettingsUpdate(BaseModel): + parking_quota: int | None = None + spot_prefix: str | None = None + + +# Manager listing and details +@router.get("") +def list_managers(db: Session = Depends(get_db), user=Depends(require_manager_or_admin)): + """Get all managers with their managed offices and parking quota""" + managers = db.query(User).filter(User.role == "manager").all() + result = [] + + for manager in managers: + memberships = db.query(OfficeMembership).filter(OfficeMembership.user_id == manager.id).all() + office_ids = [m.office_id for m in memberships] + offices = db.query(Office).filter(Office.id.in_(office_ids)).all() if office_ids else [] + + result.append({ + "id": manager.id, + "name": manager.name, + "email": manager.email, + "parking_quota": manager.manager_parking_quota or 0, + "spot_prefix": manager.manager_spot_prefix, + "offices": [{"id": o.id, "name": o.name} for o in offices] + }) + + return result + + +@router.get("/{manager_id}") +def get_manager_details(manager_id: str, db: Session = Depends(get_db), user=Depends(require_manager_or_admin)): + """Get manager details including offices and parking settings""" + manager = db.query(User).filter(User.id == manager_id, User.role == "manager").first() + if not manager: + raise HTTPException(status_code=404, detail="Manager not found") + + memberships = db.query(OfficeMembership).filter(OfficeMembership.user_id == manager_id).all() + office_ids = [m.office_id for m in memberships] + offices = db.query(Office).filter(Office.id.in_(office_ids)).all() if office_ids else [] + + return { + "id": manager.id, + "name": manager.name, + "email": manager.email, + "parking_quota": manager.manager_parking_quota or 0, + "spot_prefix": manager.manager_spot_prefix, + "offices": [{"id": o.id, "name": o.name} for o in offices] + } + + +@router.put("/{manager_id}/settings") +def update_manager_settings(manager_id: str, data: ManagerSettingsUpdate, db: Session = Depends(get_db), user=Depends(require_admin)): + """Update manager parking settings (admin only)""" + manager = db.query(User).filter(User.id == manager_id, User.role == "manager").first() + if not manager: + raise HTTPException(status_code=404, detail="Manager not found") + + if data.parking_quota is not None: + if data.parking_quota < 0: + raise HTTPException(status_code=400, detail="Parking quota must be non-negative") + manager.manager_parking_quota = data.parking_quota + + if data.spot_prefix is not None: + if data.spot_prefix and not data.spot_prefix.isalpha(): + raise HTTPException(status_code=400, detail="Spot prefix must be a letter") + if data.spot_prefix: + data.spot_prefix = data.spot_prefix.upper() + existing = db.query(User).filter( + User.manager_spot_prefix == data.spot_prefix, + User.id != manager_id + ).first() + if existing: + raise HTTPException(status_code=400, detail=f"Spot prefix '{data.spot_prefix}' is already used") + manager.manager_spot_prefix = data.spot_prefix + + manager.updated_at = datetime.utcnow().isoformat() + db.commit() + + return { + "id": manager.id, + "parking_quota": manager.manager_parking_quota, + "spot_prefix": manager.manager_spot_prefix + } + + +@router.get("/{manager_id}/users") +def get_manager_users(manager_id: str, db: Session = Depends(get_db), user=Depends(require_manager_or_admin)): + """Get all users from offices managed by this manager""" + manager = db.query(User).filter(User.id == manager_id, User.role == "manager").first() + if not manager: + raise HTTPException(status_code=404, detail="Manager not found") + + memberships = db.query(OfficeMembership).filter(OfficeMembership.user_id == manager_id).all() + managed_office_ids = [m.office_id for m in memberships] + + if not managed_office_ids: + return [] + + users = db.query(User).filter(User.office_id.in_(managed_office_ids)).all() + return [{"id": u.id, "name": u.name, "email": u.email, "role": u.role, "office_id": u.office_id} for u in users] + + +# Closing days +@router.get("/{manager_id}/closing-days") +def get_manager_closing_days(manager_id: str, db: Session = Depends(get_db), user=Depends(get_current_user)): + """Get closing days for a manager""" + days = db.query(ManagerClosingDay).filter( + ManagerClosingDay.manager_id == manager_id + ).order_by(ManagerClosingDay.date).all() + return [{"id": d.id, "date": d.date, "reason": d.reason} for d in days] + + +@router.post("/{manager_id}/closing-days") +def add_manager_closing_day(manager_id: str, data: ClosingDayCreate, db: Session = Depends(get_db), user=Depends(require_manager_or_admin)): + """Add a closing day for a manager""" + manager = db.query(User).filter(User.id == manager_id, User.role == "manager").first() + if not manager: + raise HTTPException(status_code=404, detail="Manager not found") + + existing = db.query(ManagerClosingDay).filter( + ManagerClosingDay.manager_id == manager_id, + ManagerClosingDay.date == data.date + ).first() + if existing: + raise HTTPException(status_code=400, detail="Closing day already exists for this date") + + closing_day = ManagerClosingDay( + id=str(uuid.uuid4()), + manager_id=manager_id, + date=data.date, + reason=data.reason + ) + db.add(closing_day) + db.commit() + return {"id": closing_day.id, "message": "Closing day added"} + + +@router.delete("/{manager_id}/closing-days/{closing_day_id}") +def remove_manager_closing_day(manager_id: str, closing_day_id: str, db: Session = Depends(get_db), user=Depends(require_manager_or_admin)): + """Remove a closing day for a manager""" + closing_day = db.query(ManagerClosingDay).filter( + ManagerClosingDay.id == closing_day_id, + ManagerClosingDay.manager_id == manager_id + ).first() + if not closing_day: + raise HTTPException(status_code=404, detail="Closing day not found") + + db.delete(closing_day) + db.commit() + return {"message": "Closing day removed"} + + +# Weekly closing days +@router.get("/{manager_id}/weekly-closing-days") +def get_manager_weekly_closing_days(manager_id: str, db: Session = Depends(get_db), user=Depends(get_current_user)): + """Get weekly closing days for a manager""" + days = db.query(ManagerWeeklyClosingDay).filter( + ManagerWeeklyClosingDay.manager_id == manager_id + ).all() + return [{"id": d.id, "weekday": d.weekday} for d in days] + + +@router.post("/{manager_id}/weekly-closing-days") +def add_manager_weekly_closing_day(manager_id: str, data: WeeklyClosingDayCreate, db: Session = Depends(get_db), user=Depends(require_manager_or_admin)): + """Add a weekly closing day for a manager""" + manager = db.query(User).filter(User.id == manager_id, User.role == "manager").first() + if not manager: + raise HTTPException(status_code=404, detail="Manager not found") + + if data.weekday < 0 or data.weekday > 6: + raise HTTPException(status_code=400, detail="Weekday must be 0-6 (0=Sunday, 6=Saturday)") + + existing = db.query(ManagerWeeklyClosingDay).filter( + ManagerWeeklyClosingDay.manager_id == manager_id, + ManagerWeeklyClosingDay.weekday == data.weekday + ).first() + if existing: + raise HTTPException(status_code=400, detail="Weekly closing day already exists for this weekday") + + weekly_closing = ManagerWeeklyClosingDay( + id=str(uuid.uuid4()), + manager_id=manager_id, + weekday=data.weekday + ) + db.add(weekly_closing) + db.commit() + return {"id": weekly_closing.id, "message": "Weekly closing day added"} + + +@router.delete("/{manager_id}/weekly-closing-days/{weekly_id}") +def remove_manager_weekly_closing_day(manager_id: str, weekly_id: str, db: Session = Depends(get_db), user=Depends(require_manager_or_admin)): + """Remove a weekly closing day for a manager""" + weekly_closing = db.query(ManagerWeeklyClosingDay).filter( + ManagerWeeklyClosingDay.id == weekly_id, + ManagerWeeklyClosingDay.manager_id == manager_id + ).first() + if not weekly_closing: + raise HTTPException(status_code=404, detail="Weekly closing day not found") + + db.delete(weekly_closing) + db.commit() + return {"message": "Weekly closing day removed"} + + +# Guarantees +@router.get("/{manager_id}/guarantees") +def get_manager_guarantees(manager_id: str, db: Session = Depends(get_db), user=Depends(require_manager_or_admin)): + """Get parking guarantees for a manager""" + guarantees = db.query(ParkingGuarantee).filter(ParkingGuarantee.manager_id == manager_id).all() + result = [] + for g in guarantees: + target_user = db.query(User).filter(User.id == g.user_id).first() + result.append({ + "id": g.id, + "user_id": g.user_id, + "user_name": target_user.name if target_user else None, + "start_date": g.start_date, + "end_date": g.end_date + }) + return result + + +@router.post("/{manager_id}/guarantees") +def add_manager_guarantee(manager_id: str, data: GuaranteeCreate, db: Session = Depends(get_db), user=Depends(require_manager_or_admin)): + """Add parking guarantee for a manager""" + manager = db.query(User).filter(User.id == manager_id, User.role == "manager").first() + if not manager: + raise HTTPException(status_code=404, detail="Manager not found") + if not db.query(User).filter(User.id == data.user_id).first(): + raise HTTPException(status_code=404, detail="User not found") + + existing = db.query(ParkingGuarantee).filter( + ParkingGuarantee.manager_id == manager_id, + ParkingGuarantee.user_id == data.user_id + ).first() + if existing: + raise HTTPException(status_code=400, detail="User already has a parking guarantee") + + guarantee = ParkingGuarantee( + id=str(uuid.uuid4()), + manager_id=manager_id, + user_id=data.user_id, + start_date=data.start_date, + end_date=data.end_date, + created_at=datetime.utcnow().isoformat() + ) + db.add(guarantee) + db.commit() + return {"id": guarantee.id, "message": "Guarantee added"} + + +@router.delete("/{manager_id}/guarantees/{guarantee_id}") +def remove_manager_guarantee(manager_id: str, guarantee_id: str, db: Session = Depends(get_db), user=Depends(require_manager_or_admin)): + """Remove parking guarantee for a manager""" + guarantee = db.query(ParkingGuarantee).filter( + ParkingGuarantee.id == guarantee_id, + ParkingGuarantee.manager_id == manager_id + ).first() + if not guarantee: + raise HTTPException(status_code=404, detail="Guarantee not found") + + db.delete(guarantee) + db.commit() + return {"message": "Guarantee removed"} + + +# Exclusions +@router.get("/{manager_id}/exclusions") +def get_manager_exclusions(manager_id: str, db: Session = Depends(get_db), user=Depends(require_manager_or_admin)): + """Get parking exclusions for a manager""" + exclusions = db.query(ParkingExclusion).filter(ParkingExclusion.manager_id == manager_id).all() + result = [] + for e in exclusions: + target_user = db.query(User).filter(User.id == e.user_id).first() + result.append({ + "id": e.id, + "user_id": e.user_id, + "user_name": target_user.name if target_user else None, + "start_date": e.start_date, + "end_date": e.end_date + }) + return result + + +@router.post("/{manager_id}/exclusions") +def add_manager_exclusion(manager_id: str, data: ExclusionCreate, db: Session = Depends(get_db), user=Depends(require_manager_or_admin)): + """Add parking exclusion for a manager""" + manager = db.query(User).filter(User.id == manager_id, User.role == "manager").first() + if not manager: + raise HTTPException(status_code=404, detail="Manager not found") + if not db.query(User).filter(User.id == data.user_id).first(): + raise HTTPException(status_code=404, detail="User not found") + + existing = db.query(ParkingExclusion).filter( + ParkingExclusion.manager_id == manager_id, + ParkingExclusion.user_id == data.user_id + ).first() + if existing: + raise HTTPException(status_code=400, detail="User already has a parking exclusion") + + exclusion = ParkingExclusion( + id=str(uuid.uuid4()), + manager_id=manager_id, + user_id=data.user_id, + start_date=data.start_date, + end_date=data.end_date, + created_at=datetime.utcnow().isoformat() + ) + db.add(exclusion) + db.commit() + return {"id": exclusion.id, "message": "Exclusion added"} + + +@router.delete("/{manager_id}/exclusions/{exclusion_id}") +def remove_manager_exclusion(manager_id: str, exclusion_id: str, db: Session = Depends(get_db), user=Depends(require_manager_or_admin)): + """Remove parking exclusion for a manager""" + exclusion = db.query(ParkingExclusion).filter( + ParkingExclusion.id == exclusion_id, + ParkingExclusion.manager_id == manager_id + ).first() + if not exclusion: + raise HTTPException(status_code=404, detail="Exclusion not found") + + db.delete(exclusion) + db.commit() + return {"message": "Exclusion removed"} diff --git a/app/routes/offices.py b/app/routes/offices.py new file mode 100644 index 0000000..420ff01 --- /dev/null +++ b/app/routes/offices.py @@ -0,0 +1,197 @@ +""" +Office Management Routes +Admin CRUD for offices and manager-office memberships +""" +from datetime import datetime +from fastapi import APIRouter, Depends, HTTPException +from pydantic import BaseModel +from sqlalchemy.orm import Session +import uuid + +from database.connection import get_db +from database.models import Office, User, OfficeMembership +from utils.auth_middleware import require_admin, require_manager_or_admin, get_current_user + +router = APIRouter(prefix="/api/offices", tags=["offices"]) + + +# Request/Response Models +class OfficeCreate(BaseModel): + name: str + location: str | None = None + + +class OfficeUpdate(BaseModel): + name: str | None = None + location: str | None = None + + +class OfficeResponse(BaseModel): + id: str + name: str + location: str | None = None + created_at: str | None + + class Config: + from_attributes = True + + +class AddManagerRequest(BaseModel): + user_id: str + + +# Office CRUD Routes +@router.get("") +def list_offices(db: Session = Depends(get_db), user=Depends(get_current_user)): + """List all offices with counts""" + offices = db.query(Office).all() + result = [] + for office in offices: + manager_count = db.query(OfficeMembership).filter(OfficeMembership.office_id == office.id).count() + employee_count = db.query(User).filter(User.office_id == office.id).count() + result.append({ + "id": office.id, + "name": office.name, + "location": office.location, + "created_at": office.created_at, + "manager_count": manager_count, + "employee_count": employee_count + }) + return result + + +@router.get("/{office_id}", response_model=OfficeResponse) +def get_office(office_id: str, db: Session = Depends(get_db), user=Depends(get_current_user)): + """Get office by ID""" + office = db.query(Office).filter(Office.id == office_id).first() + if not office: + raise HTTPException(status_code=404, detail="Office not found") + return office + + +@router.post("", response_model=OfficeResponse) +def create_office(data: OfficeCreate, db: Session = Depends(get_db), user=Depends(require_admin)): + """Create new office (admin only)""" + office = Office( + id=str(uuid.uuid4()), + name=data.name, + location=data.location, + created_at=datetime.utcnow().isoformat() + ) + + db.add(office) + db.commit() + db.refresh(office) + return office + + +@router.put("/{office_id}", response_model=OfficeResponse) +def update_office(office_id: str, data: OfficeUpdate, db: Session = Depends(get_db), user=Depends(require_admin)): + """Update office (admin only)""" + office = db.query(Office).filter(Office.id == office_id).first() + if not office: + raise HTTPException(status_code=404, detail="Office not found") + + if data.name is not None: + office.name = data.name + if data.location is not None: + office.location = data.location + + office.updated_at = datetime.utcnow().isoformat() + db.commit() + db.refresh(office) + return office + + +@router.delete("/{office_id}") +def delete_office(office_id: str, db: Session = Depends(get_db), user=Depends(require_admin)): + """Delete office (admin only)""" + office = db.query(Office).filter(Office.id == office_id).first() + if not office: + raise HTTPException(status_code=404, detail="Office not found") + + if db.query(User).filter(User.office_id == office_id).count() > 0: + raise HTTPException(status_code=400, detail="Cannot delete office with assigned users") + + db.delete(office) + db.commit() + return {"message": "Office deleted"} + + +# Office membership routes (linking managers to offices) +@router.get("/{office_id}/managers") +def get_office_managers(office_id: str, db: Session = Depends(get_db), user=Depends(require_manager_or_admin)): + """Get managers for an office""" + memberships = db.query(OfficeMembership).filter(OfficeMembership.office_id == office_id).all() + manager_ids = [m.user_id for m in memberships] + managers = db.query(User).filter(User.id.in_(manager_ids)).all() + return [{"id": m.id, "name": m.name, "email": m.email} for m in managers] + + +@router.post("/{office_id}/managers") +def add_office_manager(office_id: str, data: AddManagerRequest, db: Session = Depends(get_db), user=Depends(require_admin)): + """Add manager to office (admin only)""" + if not db.query(Office).filter(Office.id == office_id).first(): + raise HTTPException(status_code=404, detail="Office not found") + + manager = db.query(User).filter(User.id == data.user_id).first() + if not manager: + raise HTTPException(status_code=404, detail="User not found") + if manager.role != "manager": + raise HTTPException(status_code=400, detail="User must have manager role") + + existing = db.query(OfficeMembership).filter( + OfficeMembership.office_id == office_id, + OfficeMembership.user_id == data.user_id + ).first() + if existing: + raise HTTPException(status_code=400, detail="Manager already assigned to office") + + membership = OfficeMembership( + id=str(uuid.uuid4()), + office_id=office_id, + user_id=data.user_id, + created_at=datetime.utcnow().isoformat() + ) + db.add(membership) + db.commit() + return {"message": "Manager added to office"} + + +@router.delete("/{office_id}/managers/{manager_id}") +def remove_office_manager(office_id: str, manager_id: str, db: Session = Depends(get_db), user=Depends(require_admin)): + """Remove manager from office (admin only)""" + membership = db.query(OfficeMembership).filter( + OfficeMembership.office_id == office_id, + OfficeMembership.user_id == manager_id + ).first() + if not membership: + raise HTTPException(status_code=404, detail="Manager not assigned to office") + + db.delete(membership) + db.commit() + return {"message": "Manager removed from office"} + + +# Legacy redirect for /api/offices/managers/list -> /api/managers +@router.get("/managers/list") +def list_managers_legacy(db: Session = Depends(get_db), user=Depends(require_manager_or_admin)): + """Get all managers with their managed offices and parking quota (legacy endpoint)""" + managers = db.query(User).filter(User.role == "manager").all() + result = [] + + for manager in managers: + memberships = db.query(OfficeMembership).filter(OfficeMembership.user_id == manager.id).all() + office_ids = [m.office_id for m in memberships] + offices = db.query(Office).filter(Office.id.in_(office_ids)).all() if office_ids else [] + + result.append({ + "id": manager.id, + "name": manager.name, + "email": manager.email, + "parking_quota": manager.manager_parking_quota or 0, + "spot_prefix": manager.manager_spot_prefix, + "offices": [{"id": o.id, "name": o.name} for o in offices] + }) + + return result diff --git a/app/routes/parking.py b/app/routes/parking.py new file mode 100644 index 0000000..728907f --- /dev/null +++ b/app/routes/parking.py @@ -0,0 +1,358 @@ +""" +Parking Management Routes +Parking assignments, spot management, and pool initialization + +Manager-centric model: +- Managers own parking spots (defined by manager_parking_quota) +- Spots are named with manager's letter prefix (A1, A2, B1, B2...) +- Assignments reference manager_id directly +""" +from typing import List +from datetime import datetime +from fastapi import APIRouter, Depends, HTTPException +from pydantic import BaseModel +from sqlalchemy.orm import Session +import uuid + +from database.connection import get_db +from database.models import DailyParkingAssignment, User, OfficeMembership +from utils.auth_middleware import get_current_user, require_manager_or_admin +from services.parking import initialize_parking_pool, get_spot_display_name +from services.notifications import queue_parking_change_notification + +router = APIRouter(prefix="/api/parking", tags=["parking"]) + + +# Request/Response Models +class InitPoolRequest(BaseModel): + date: str # YYYY-MM-DD + + +class ManualAssignRequest(BaseModel): + manager_id: str + user_id: str + spot_id: str + date: str + + +class ReassignSpotRequest(BaseModel): + assignment_id: str + new_user_id: str | None # None = release spot + + +class AssignmentResponse(BaseModel): + id: str + date: str + spot_id: str + spot_display_name: str | None = None + user_id: str | None + manager_id: str + user_name: str | None = None + user_email: str | None = None + user_office_id: str | None = None + + +# Routes +@router.post("/init-manager-pool") +def init_manager_pool(request: InitPoolRequest, db: Session = Depends(get_db), current_user=Depends(require_manager_or_admin)): + """Initialize parking pool for a manager on a given date""" + try: + datetime.strptime(request.date, "%Y-%m-%d") + except ValueError: + raise HTTPException(status_code=400, detail="Invalid date format") + + quota = current_user.manager_parking_quota or 0 + if quota == 0: + return {"success": True, "message": "No parking quota configured", "spots": 0} + + spots = initialize_parking_pool(current_user.id, quota, request.date, db) + return {"success": True, "spots": spots} + + +@router.get("/assignments/{date}", response_model=List[AssignmentResponse]) +def get_assignments(date: str, manager_id: str = None, db: Session = Depends(get_db), current_user=Depends(get_current_user)): + """Get parking assignments for a date, optionally filtered by manager""" + try: + datetime.strptime(date, "%Y-%m-%d") + except ValueError: + raise HTTPException(status_code=400, detail="Invalid date format") + + query = db.query(DailyParkingAssignment).filter(DailyParkingAssignment.date == date) + if manager_id: + query = query.filter(DailyParkingAssignment.manager_id == manager_id) + + assignments = query.all() + results = [] + + for assignment in assignments: + # Get display name using manager's spot prefix + spot_display_name = get_spot_display_name(assignment.spot_id, assignment.manager_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, + manager_id=assignment.manager_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 + result.user_office_id = user.office_id + + results.append(result) + + return results + + +@router.get("/my-assignments", response_model=List[AssignmentResponse]) +def get_my_assignments(start_date: str = None, end_date: str = None, db: Session = Depends(get_db), current_user=Depends(get_current_user)): + """Get current user's parking assignments""" + query = db.query(DailyParkingAssignment).filter( + DailyParkingAssignment.user_id == current_user.id + ) + + if start_date: + query = query.filter(DailyParkingAssignment.date >= start_date) + if end_date: + query = query.filter(DailyParkingAssignment.date <= end_date) + + assignments = query.order_by(DailyParkingAssignment.date.desc()).all() + results = [] + + for assignment in assignments: + spot_display_name = get_spot_display_name(assignment.spot_id, assignment.manager_id, db) + + results.append(AssignmentResponse( + id=assignment.id, + date=assignment.date, + spot_id=assignment.spot_id, + spot_display_name=spot_display_name, + user_id=assignment.user_id, + manager_id=assignment.manager_id, + user_name=current_user.name, + user_email=current_user.email + )) + + return results + + +@router.post("/manual-assign") +def manual_assign(data: ManualAssignRequest, db: Session = Depends(get_db), current_user=Depends(require_manager_or_admin)): + """Manually assign a spot to a user""" + # Verify user exists + user = db.query(User).filter(User.id == data.user_id).first() + if not user: + raise HTTPException(status_code=404, detail="User not found") + + # Verify manager exists and check permission + manager = db.query(User).filter(User.id == data.manager_id, User.role == "manager").first() + if not manager: + raise HTTPException(status_code=404, detail="Manager not found") + + # Only admin or the manager themselves can assign spots + if current_user.role != "admin" and current_user.id != data.manager_id: + raise HTTPException(status_code=403, detail="Not authorized to assign spots for this manager") + + # Check if spot exists and is free + spot = db.query(DailyParkingAssignment).filter( + DailyParkingAssignment.manager_id == data.manager_id, + DailyParkingAssignment.date == data.date, + DailyParkingAssignment.spot_id == data.spot_id + ).first() + + if not spot: + raise HTTPException(status_code=404, detail="Spot not found") + if spot.user_id: + raise HTTPException(status_code=400, detail="Spot already assigned") + + # Check if user already has a spot for this date (from any manager) + existing = db.query(DailyParkingAssignment).filter( + DailyParkingAssignment.date == data.date, + DailyParkingAssignment.user_id == data.user_id + ).first() + + if existing: + raise HTTPException(status_code=400, detail="User already has a spot for this date") + + spot.user_id = data.user_id + db.commit() + + spot_display_name = get_spot_display_name(data.spot_id, data.manager_id, db) + return {"message": "Spot assigned", "spot_id": data.spot_id, "spot_display_name": spot_display_name} + + +@router.post("/release-my-spot/{assignment_id}") +def release_my_spot(assignment_id: str, db: Session = Depends(get_db), current_user=Depends(get_current_user)): + """Release a parking spot assigned to the current user""" + assignment = db.query(DailyParkingAssignment).filter( + DailyParkingAssignment.id == assignment_id + ).first() + + if not assignment: + raise HTTPException(status_code=404, detail="Assignment not found") + + if assignment.user_id != current_user.id: + raise HTTPException(status_code=403, detail="You can only release your own parking spot") + + # Get spot display name for notification + spot_display_name = get_spot_display_name(assignment.spot_id, assignment.manager_id, db) + + assignment.user_id = None + db.commit() + + # Queue notification (self-release, so just confirmation) + queue_parking_change_notification( + current_user, assignment.date, "released", + spot_display_name, db=db + ) + + return {"message": "Parking spot released"} + + +@router.post("/reassign-spot") +def reassign_spot(data: ReassignSpotRequest, db: Session = Depends(get_db), current_user=Depends(get_current_user)): + """Reassign a spot to another user or release it. + Allowed by: spot owner, their manager, or admin. + """ + assignment = db.query(DailyParkingAssignment).filter( + DailyParkingAssignment.id == data.assignment_id + ).first() + + if not assignment: + raise HTTPException(status_code=404, detail="Assignment not found") + + # Check permission: admin, manager who owns the spot, or current spot holder + is_admin = current_user.role == 'admin' + is_spot_owner = assignment.user_id == current_user.id + is_manager = current_user.id == assignment.manager_id + + if not (is_admin or is_manager or is_spot_owner): + raise HTTPException(status_code=403, detail="Not authorized to reassign this spot") + + # Store old user for notification + old_user_id = assignment.user_id + old_user = db.query(User).filter(User.id == old_user_id).first() if old_user_id else None + + # Get spot display name for notifications + spot_display_name = get_spot_display_name(assignment.spot_id, assignment.manager_id, db) + + if data.new_user_id: + # Check new user exists + new_user = db.query(User).filter(User.id == data.new_user_id).first() + if not new_user: + raise HTTPException(status_code=404, detail="User not found") + + # Check user doesn't already have a spot for this date + existing = db.query(DailyParkingAssignment).filter( + DailyParkingAssignment.user_id == data.new_user_id, + DailyParkingAssignment.date == assignment.date, + DailyParkingAssignment.id != assignment.id + ).first() + + if existing: + raise HTTPException(status_code=400, detail="User already has a spot for this date") + + assignment.user_id = data.new_user_id + + # Queue notifications + # Notify old user that spot was reassigned + if old_user and old_user.id != new_user.id: + queue_parking_change_notification( + old_user, assignment.date, "reassigned", + spot_display_name, new_user.name, db + ) + # Notify new user that spot was assigned + queue_parking_change_notification( + new_user, assignment.date, "assigned", + spot_display_name, db=db + ) + else: + assignment.user_id = None + # Notify old user that spot was released + if old_user: + queue_parking_change_notification( + old_user, assignment.date, "released", + spot_display_name, db=db + ) + + db.commit() + db.refresh(assignment) + + # Build response + spot_display_name = get_spot_display_name(assignment.spot_id, assignment.manager_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, + manager_id=assignment.manager_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}") +def get_eligible_users(assignment_id: str, db: Session = Depends(get_db), current_user=Depends(get_current_user)): + """Get users eligible for reassignment of a parking spot. + Returns users in the same manager's offices. + """ + assignment = db.query(DailyParkingAssignment).filter( + DailyParkingAssignment.id == assignment_id + ).first() + + if not assignment: + raise HTTPException(status_code=404, detail="Assignment not found") + + # Check permission: admin, manager who owns the spot, or current spot holder + is_admin = current_user.role == 'admin' + is_spot_owner = assignment.user_id == current_user.id + is_manager = current_user.id == assignment.manager_id + + if not (is_admin or is_manager or is_spot_owner): + raise HTTPException(status_code=403, detail="Not authorized") + + # Get all users belonging to offices managed by this manager + # Get offices managed by this manager + managed_office_ids = db.query(OfficeMembership.office_id).filter( + OfficeMembership.user_id == assignment.manager_id + ).all() + managed_office_ids = [o[0] for o in managed_office_ids] + + # Get users in those offices + users = db.query(User).filter( + User.office_id.in_(managed_office_ids), + User.id != assignment.user_id # Exclude current holder + ).all() + + # Filter out users who already have a spot for this date + existing_assignments = db.query(DailyParkingAssignment.user_id).filter( + DailyParkingAssignment.date == assignment.date, + DailyParkingAssignment.user_id.isnot(None), + DailyParkingAssignment.id != assignment.id + ).all() + users_with_spots = {a[0] for a in existing_assignments} + + result = [] + for user in users: + if user.id not in users_with_spots: + result.append({ + "id": user.id, + "name": user.name, + "email": user.email, + "office_id": user.office_id + }) + + return result diff --git a/app/routes/presence.py b/app/routes/presence.py new file mode 100644 index 0000000..9116354 --- /dev/null +++ b/app/routes/presence.py @@ -0,0 +1,437 @@ +""" +Presence Management Routes +User presence marking and admin management +""" +from typing import List +from datetime import datetime, timedelta +from fastapi import APIRouter, Depends, HTTPException +from pydantic import BaseModel +from sqlalchemy.orm import Session +import uuid + +from database.connection import get_db +from database.models import UserPresence, User, DailyParkingAssignment, OfficeMembership, Office +from utils.auth_middleware import get_current_user, require_manager_or_admin, check_manager_access_to_user +from services.parking import handle_presence_change, get_spot_display_name + +router = APIRouter(prefix="/api/presence", tags=["presence"]) + + +# Request/Response Models +class PresenceMarkRequest(BaseModel): + date: str # YYYY-MM-DD + status: str # present, remote, absent + + +class AdminPresenceMarkRequest(BaseModel): + user_id: str + date: str + status: str + + +class BulkPresenceRequest(BaseModel): + start_date: str + end_date: str + status: str + days: List[int] | None = None # Optional: [0,1,2,3,4] for Mon-Fri + + +class AdminBulkPresenceRequest(BaseModel): + user_id: str + start_date: str + end_date: str + status: str + days: List[int] | None = None + + +class PresenceResponse(BaseModel): + id: str + user_id: str + date: str + status: str + created_at: str | None + updated_at: str | None + parking_spot_number: str | None = None + + class Config: + from_attributes = True + + +# Helper functions +def validate_status(status: str): + if status not in ["present", "remote", "absent"]: + raise HTTPException(status_code=400, detail="Status must be: present, remote, or absent") + + +def parse_date(date_str: str) -> datetime: + try: + return datetime.strptime(date_str, "%Y-%m-%d") + except ValueError: + raise HTTPException(status_code=400, detail="Invalid date format. Use YYYY-MM-DD") + + +def _mark_presence_for_user( + user_id: str, + date: str, + status: str, + db: Session, + target_user: User +) -> UserPresence: + """ + Core presence marking logic - shared by user and admin routes. + """ + validate_status(status) + parse_date(date) + + existing = db.query(UserPresence).filter( + UserPresence.user_id == user_id, + UserPresence.date == date + ).first() + + now = datetime.utcnow().isoformat() + old_status = existing.status if existing else None + + if existing: + existing.status = status + existing.updated_at = now + db.commit() + db.refresh(existing) + presence = existing + else: + presence = UserPresence( + id=str(uuid.uuid4()), + user_id=user_id, + date=date, + status=status, + created_at=now, + updated_at=now + ) + db.add(presence) + db.commit() + db.refresh(presence) + + # Handle parking assignment + if old_status != status and target_user.office_id: + try: + handle_presence_change( + user_id, date, + old_status or "absent", status, + target_user.office_id, db + ) + except Exception as e: + print(f"Warning: Parking handler failed: {e}") + + return presence + + +def _bulk_mark_presence( + user_id: str, + start_date: str, + end_date: str, + status: str, + days: List[int] | None, + db: Session, + target_user: User +) -> List[UserPresence]: + """ + Core bulk presence marking logic - shared by user and admin routes. + """ + validate_status(status) + start = parse_date(start_date) + end = parse_date(end_date) + + if end < start: + raise HTTPException(status_code=400, detail="End date must be after start date") + if (end - start).days > 90: + raise HTTPException(status_code=400, detail="Range cannot exceed 90 days") + + results = [] + current_date = start + now = datetime.utcnow().isoformat() + + while current_date <= end: + if days is None or current_date.weekday() in days: + date_str = current_date.strftime("%Y-%m-%d") + + existing = db.query(UserPresence).filter( + UserPresence.user_id == user_id, + UserPresence.date == date_str + ).first() + + old_status = existing.status if existing else None + + if existing: + existing.status = status + existing.updated_at = now + results.append(existing) + else: + presence = UserPresence( + id=str(uuid.uuid4()), + user_id=user_id, + date=date_str, + status=status, + created_at=now, + updated_at=now + ) + db.add(presence) + results.append(presence) + + # Handle parking for each date + if old_status != status and target_user.office_id: + try: + handle_presence_change( + user_id, date_str, + old_status or "absent", status, + target_user.office_id, db + ) + except Exception: + pass + + current_date += timedelta(days=1) + + db.commit() + return results + + +def _delete_presence( + user_id: str, + date: str, + db: Session, + target_user: User +) -> dict: + """ + Core presence deletion logic - shared by user and admin routes. + """ + parse_date(date) + + presence = db.query(UserPresence).filter( + UserPresence.user_id == user_id, + UserPresence.date == date + ).first() + + if not presence: + raise HTTPException(status_code=404, detail="Presence not found") + + old_status = presence.status + db.delete(presence) + db.commit() + + if target_user.office_id: + try: + handle_presence_change( + user_id, date, + old_status, "absent", + target_user.office_id, db + ) + except Exception: + pass + + return {"message": "Presence deleted"} + + +# User Routes +@router.post("/mark", response_model=PresenceResponse) +def mark_presence(data: PresenceMarkRequest, db: Session = Depends(get_db), current_user=Depends(get_current_user)): + """Mark presence for a date""" + return _mark_presence_for_user(current_user.id, data.date, data.status, db, current_user) + + +@router.post("/mark-bulk", response_model=List[PresenceResponse]) +def mark_bulk_presence(data: BulkPresenceRequest, db: Session = Depends(get_db), current_user=Depends(get_current_user)): + """Mark presence for a date range""" + return _bulk_mark_presence( + current_user.id, data.start_date, data.end_date, + data.status, data.days, db, current_user + ) + + +@router.get("/my-presences", response_model=List[PresenceResponse]) +def get_my_presences(start_date: str = None, end_date: str = None, db: Session = Depends(get_db), current_user=Depends(get_current_user)): + """Get current user's presences""" + query = db.query(UserPresence).filter(UserPresence.user_id == current_user.id) + + if start_date: + parse_date(start_date) + query = query.filter(UserPresence.date >= start_date) + if end_date: + parse_date(end_date) + query = query.filter(UserPresence.date <= end_date) + + return query.order_by(UserPresence.date.desc()).all() + + +@router.delete("/{date}") +def delete_presence(date: str, db: Session = Depends(get_db), current_user=Depends(get_current_user)): + """Delete presence for a date""" + return _delete_presence(current_user.id, date, db, current_user) + + +# Admin/Manager Routes +@router.post("/admin/mark", response_model=PresenceResponse) +def admin_mark_presence(data: AdminPresenceMarkRequest, db: Session = Depends(get_db), current_user=Depends(require_manager_or_admin)): + """Mark presence for any user (manager/admin)""" + target_user = db.query(User).filter(User.id == data.user_id).first() + if not target_user: + raise HTTPException(status_code=404, detail="User not found") + + check_manager_access_to_user(current_user, target_user, db) + return _mark_presence_for_user(data.user_id, data.date, data.status, db, target_user) + + +@router.post("/admin/mark-bulk", response_model=List[PresenceResponse]) +def admin_mark_bulk_presence(data: AdminBulkPresenceRequest, db: Session = Depends(get_db), current_user=Depends(require_manager_or_admin)): + """Bulk mark presence for any user (manager/admin)""" + target_user = db.query(User).filter(User.id == data.user_id).first() + if not target_user: + raise HTTPException(status_code=404, detail="User not found") + + check_manager_access_to_user(current_user, target_user, db) + return _bulk_mark_presence( + data.user_id, data.start_date, data.end_date, + data.status, data.days, db, target_user + ) + + +@router.delete("/admin/{user_id}/{date}") +def admin_delete_presence(user_id: str, date: str, db: Session = Depends(get_db), current_user=Depends(require_manager_or_admin)): + """Delete presence for any user (manager/admin)""" + target_user = db.query(User).filter(User.id == user_id).first() + if not target_user: + raise HTTPException(status_code=404, detail="User not found") + + check_manager_access_to_user(current_user, target_user, db) + return _delete_presence(user_id, date, db, target_user) + + +@router.get("/team") +def get_team_presences(start_date: str, end_date: str, manager_id: str = None, db: Session = Depends(get_db), current_user=Depends(require_manager_or_admin)): + """Get team presences with parking info for managers/admins, filtered by manager""" + parse_date(start_date) + parse_date(end_date) + + # Get users based on permissions and manager filter + if manager_id: + # Filter by specific manager's offices + managed_office_ids = [m.office_id for m in db.query(OfficeMembership).filter( + OfficeMembership.user_id == manager_id + ).all()] + if not managed_office_ids: + return [] + users = db.query(User).filter(User.office_id.in_(managed_office_ids)).all() + elif current_user.role == "admin": + # Admin sees all users + users = db.query(User).all() + else: + # Manager sees only users in their managed offices + managed_ids = [m.office_id for m in current_user.managed_offices] + if not managed_ids: + return [] + users = db.query(User).filter(User.office_id.in_(managed_ids)).all() + + # Batch query presences and parking for all users + user_ids = [u.id for u in users] + presences = db.query(UserPresence).filter( + UserPresence.user_id.in_(user_ids), + UserPresence.date >= start_date, + UserPresence.date <= end_date + ).all() + + parking_assignments = db.query(DailyParkingAssignment).filter( + DailyParkingAssignment.user_id.in_(user_ids), + DailyParkingAssignment.date >= start_date, + DailyParkingAssignment.date <= end_date + ).all() + + # Build lookups + parking_lookup = {} + parking_info_lookup = {} + for p in parking_assignments: + if p.user_id not in parking_lookup: + parking_lookup[p.user_id] = [] + parking_info_lookup[p.user_id] = [] + parking_lookup[p.user_id].append(p.date) + spot_display_name = get_spot_display_name(p.spot_id, p.manager_id, db) + parking_info_lookup[p.user_id].append({ + "id": p.id, + "date": p.date, + "spot_id": p.spot_id, + "spot_display_name": spot_display_name + }) + + # Build office and managed offices lookups + offices_lookup = {o.id: o.name for o in db.query(Office).all()} + managed_offices_lookup = {} + for m in db.query(OfficeMembership).all(): + if m.user_id not in managed_offices_lookup: + managed_offices_lookup[m.user_id] = [] + managed_offices_lookup[m.user_id].append(offices_lookup.get(m.office_id, m.office_id)) + + # Build response + result = [] + for user in users: + user_presences = [p for p in presences if p.user_id == user.id] + + # For managers, show managed offices; for others, show their office + if user.role == "manager" and user.id in managed_offices_lookup: + office_display = ", ".join(managed_offices_lookup[user.id]) + else: + office_display = offices_lookup.get(user.office_id) + + result.append({ + "id": user.id, + "name": user.name, + "office_id": user.office_id, + "office_name": office_display, + "presences": [{"date": p.date, "status": p.status} for p in user_presences], + "parking_dates": parking_lookup.get(user.id, []), + "parking_info": parking_info_lookup.get(user.id, []) + }) + + return result + + +@router.get("/admin/{user_id}") +def get_user_presences(user_id: str, start_date: str = None, end_date: str = None, db: Session = Depends(get_db), current_user=Depends(require_manager_or_admin)): + """Get any user's presences with parking info (manager/admin)""" + target_user = db.query(User).filter(User.id == user_id).first() + if not target_user: + raise HTTPException(status_code=404, detail="User not found") + + check_manager_access_to_user(current_user, target_user, db) + + query = db.query(UserPresence).filter(UserPresence.user_id == user_id) + + if start_date: + parse_date(start_date) + query = query.filter(UserPresence.date >= start_date) + if end_date: + parse_date(end_date) + query = query.filter(UserPresence.date <= end_date) + + presences = query.order_by(UserPresence.date.desc()).all() + + # Batch query parking assignments + date_strs = [p.date for p in presences] + parking_map = {} + if date_strs: + assignments = db.query(DailyParkingAssignment).filter( + DailyParkingAssignment.user_id == user_id, + DailyParkingAssignment.date.in_(date_strs) + ).all() + for a in assignments: + parking_map[a.date] = get_spot_display_name(a.spot_id, a.manager_id, db) + + # Build response + result = [] + for presence in presences: + result.append({ + "id": presence.id, + "user_id": presence.user_id, + "date": presence.date, + "status": presence.status, + "created_at": presence.created_at, + "updated_at": presence.updated_at, + "parking_spot_number": parking_map.get(presence.date) + }) + + return result diff --git a/app/routes/users.py b/app/routes/users.py new file mode 100644 index 0000000..d9bef3d --- /dev/null +++ b/app/routes/users.py @@ -0,0 +1,317 @@ +""" +User Management Routes +Admin user CRUD and user self-service (profile, settings, password) +""" +from typing import List +from datetime import datetime +from fastapi import APIRouter, Depends, HTTPException +from pydantic import BaseModel, EmailStr +from sqlalchemy.orm import Session +import uuid +import re + +from database.connection import get_db +from database.models import User, Office, OfficeMembership +from utils.auth_middleware import get_current_user, require_admin +from services.auth import hash_password, verify_password + +router = APIRouter(prefix="/api/users", tags=["users"]) + + +# Request/Response Models +class UserCreate(BaseModel): + email: EmailStr + password: str + name: str | None = None + role: str = "employee" + office_id: str | None = None + + +class UserUpdate(BaseModel): + email: EmailStr | None = None + name: str | None = None + role: str | None = None + office_id: str | None = None + manager_parking_quota: int | None = None + manager_spot_prefix: str | None = None + + +class ProfileUpdate(BaseModel): + name: str | None = None + office_id: str | None = None + + +class SettingsUpdate(BaseModel): + week_start_day: int | None = None + # Notification preferences + notify_weekly_parking: int | None = None + notify_daily_parking: int | None = None + notify_daily_parking_hour: int | None = None + notify_daily_parking_minute: int | None = None + notify_parking_changes: int | None = None + + +class ChangePasswordRequest(BaseModel): + current_password: str + new_password: str + + +class UserResponse(BaseModel): + id: str + email: str + name: str | None + role: str + office_id: str | None + manager_parking_quota: int | None = None + manager_spot_prefix: str | None = None + created_at: str | None + + class Config: + from_attributes = True + + +# Admin Routes +@router.get("", response_model=List[UserResponse]) +def list_users(db: Session = Depends(get_db), user=Depends(require_admin)): + """List all users (admin only)""" + users = db.query(User).all() + return users + + +@router.get("/{user_id}", response_model=UserResponse) +def get_user(user_id: str, db: Session = Depends(get_db), user=Depends(require_admin)): + """Get user by ID (admin only)""" + target = db.query(User).filter(User.id == user_id).first() + if not target: + raise HTTPException(status_code=404, detail="User not found") + return target + + +@router.post("", response_model=UserResponse) +def create_user(data: UserCreate, db: Session = Depends(get_db), user=Depends(require_admin)): + """Create new user (admin only)""" + if db.query(User).filter(User.email == data.email).first(): + raise HTTPException(status_code=400, detail="Email already registered") + + if data.role not in ["admin", "manager", "employee"]: + raise HTTPException(status_code=400, detail="Invalid role") + + if data.office_id: + if not db.query(Office).filter(Office.id == data.office_id).first(): + raise HTTPException(status_code=404, detail="Office not found") + + new_user = User( + id=str(uuid.uuid4()), + email=data.email, + password_hash=hash_password(data.password), + name=data.name, + role=data.role, + office_id=data.office_id, + created_at=datetime.utcnow().isoformat() + ) + + db.add(new_user) + db.commit() + db.refresh(new_user) + return new_user + + +@router.put("/{user_id}", response_model=UserResponse) +def update_user(user_id: str, data: UserUpdate, db: Session = Depends(get_db), user=Depends(require_admin)): + """Update user (admin only)""" + target = db.query(User).filter(User.id == user_id).first() + if not target: + raise HTTPException(status_code=404, detail="User not found") + + if data.email is not None: + existing = db.query(User).filter(User.email == data.email, User.id != user_id).first() + if existing: + raise HTTPException(status_code=400, detail="Email already in use") + target.email = data.email + + if data.name is not None: + target.name = data.name + + if data.role is not None: + if data.role not in ["admin", "manager", "employee"]: + raise HTTPException(status_code=400, detail="Invalid role") + target.role = data.role + + if data.office_id is not None: + if data.office_id and not db.query(Office).filter(Office.id == data.office_id).first(): + raise HTTPException(status_code=404, detail="Office not found") + target.office_id = data.office_id + + if data.manager_parking_quota is not None: + if target.role != "manager": + raise HTTPException(status_code=400, detail="Parking quota only for managers") + target.manager_parking_quota = data.manager_parking_quota + + if data.manager_spot_prefix is not None: + if target.role != "manager": + raise HTTPException(status_code=400, detail="Spot prefix only for managers") + prefix = data.manager_spot_prefix.upper() if data.manager_spot_prefix else None + if prefix and not prefix.isalpha(): + raise HTTPException(status_code=400, detail="Spot prefix must be a letter") + # Check for duplicate prefix + if prefix: + existing = db.query(User).filter( + User.manager_spot_prefix == prefix, + User.id != user_id + ).first() + if existing: + raise HTTPException(status_code=400, detail=f"Spot prefix '{prefix}' is already used by another manager") + target.manager_spot_prefix = prefix + + target.updated_at = datetime.utcnow().isoformat() + db.commit() + db.refresh(target) + return target + + +@router.delete("/{user_id}") +def delete_user(user_id: str, db: Session = Depends(get_db), current_user=Depends(require_admin)): + """Delete user (admin only)""" + if user_id == current_user.id: + raise HTTPException(status_code=400, detail="Cannot delete yourself") + + target = db.query(User).filter(User.id == user_id).first() + if not target: + raise HTTPException(status_code=404, detail="User not found") + + db.delete(target) + db.commit() + return {"message": "User deleted"} + + +# Self-service Routes +@router.get("/me/managed-offices") +def get_managed_offices(db: Session = Depends(get_db), current_user=Depends(get_current_user)): + """Get offices the current user manages""" + if current_user.role == "admin": + offices = db.query(Office).all() + return {"role": "admin", "offices": [{"id": o.id, "name": o.name} for o in offices]} + + if current_user.role == "manager": + memberships = db.query(OfficeMembership).filter( + OfficeMembership.user_id == current_user.id + ).all() + office_ids = [m.office_id for m in memberships] + offices = db.query(Office).filter(Office.id.in_(office_ids)).all() + return {"role": "manager", "offices": [{"id": o.id, "name": o.name} for o in offices]} + + if current_user.office_id: + office = db.query(Office).filter(Office.id == current_user.office_id).first() + if office: + return {"role": "employee", "offices": [{"id": office.id, "name": office.name}]} + + return {"role": current_user.role, "offices": []} + + +@router.get("/me/profile") +def get_profile(current_user=Depends(get_current_user)): + """Get current user's profile""" + return { + "id": current_user.id, + "email": current_user.email, + "name": current_user.name, + "role": current_user.role, + "office_id": current_user.office_id + } + + +@router.put("/me/profile") +def update_profile(data: ProfileUpdate, db: Session = Depends(get_db), current_user=Depends(get_current_user)): + """Update current user's profile""" + if data.name is not None: + current_user.name = data.name + + if data.office_id is not None: + if data.office_id and not db.query(Office).filter(Office.id == data.office_id).first(): + raise HTTPException(status_code=404, detail="Office not found") + current_user.office_id = data.office_id if data.office_id else None + + current_user.updated_at = datetime.utcnow().isoformat() + db.commit() + return {"message": "Profile updated"} + + +@router.get("/me/settings") +def get_settings(current_user=Depends(get_current_user)): + """Get current user's settings""" + return { + "week_start_day": current_user.week_start_day or 0, + "notify_weekly_parking": current_user.notify_weekly_parking if current_user.notify_weekly_parking is not None else 1, + "notify_daily_parking": current_user.notify_daily_parking if current_user.notify_daily_parking is not None else 1, + "notify_daily_parking_hour": current_user.notify_daily_parking_hour if current_user.notify_daily_parking_hour is not None else 8, + "notify_daily_parking_minute": current_user.notify_daily_parking_minute if current_user.notify_daily_parking_minute is not None else 0, + "notify_parking_changes": current_user.notify_parking_changes if current_user.notify_parking_changes is not None else 1 + } + + +@router.put("/me/settings") +def update_settings(data: SettingsUpdate, db: Session = Depends(get_db), current_user=Depends(get_current_user)): + """Update current user's settings""" + if data.week_start_day is not None: + if data.week_start_day not in [0, 1]: + raise HTTPException(status_code=400, detail="Week start must be 0 (Sunday) or 1 (Monday)") + current_user.week_start_day = data.week_start_day + + # Notification preferences + if data.notify_weekly_parking is not None: + current_user.notify_weekly_parking = data.notify_weekly_parking + + if data.notify_daily_parking is not None: + current_user.notify_daily_parking = data.notify_daily_parking + + if data.notify_daily_parking_hour is not None: + if data.notify_daily_parking_hour < 0 or data.notify_daily_parking_hour > 23: + raise HTTPException(status_code=400, detail="Hour must be 0-23") + current_user.notify_daily_parking_hour = data.notify_daily_parking_hour + + if data.notify_daily_parking_minute is not None: + if data.notify_daily_parking_minute < 0 or data.notify_daily_parking_minute > 59: + raise HTTPException(status_code=400, detail="Minute must be 0-59") + current_user.notify_daily_parking_minute = data.notify_daily_parking_minute + + if data.notify_parking_changes is not None: + current_user.notify_parking_changes = data.notify_parking_changes + + current_user.updated_at = datetime.utcnow().isoformat() + db.commit() + return { + "message": "Settings updated", + "week_start_day": current_user.week_start_day, + "notify_weekly_parking": current_user.notify_weekly_parking, + "notify_daily_parking": current_user.notify_daily_parking, + "notify_daily_parking_hour": current_user.notify_daily_parking_hour, + "notify_daily_parking_minute": current_user.notify_daily_parking_minute, + "notify_parking_changes": current_user.notify_parking_changes + } + + +@router.post("/me/change-password") +def change_password(data: ChangePasswordRequest, db: Session = Depends(get_db), current_user=Depends(get_current_user)): + """Change current user's password""" + if not verify_password(data.current_password, current_user.password_hash): + raise HTTPException(status_code=400, detail="Current password is incorrect") + + # Validate new password + password = data.new_password + errors = [] + if len(password) < 8: + errors.append("at least 8 characters") + if not re.search(r'[A-Z]', password): + errors.append("one uppercase letter") + if not re.search(r'[a-z]', password): + errors.append("one lowercase letter") + if not re.search(r'[0-9]', password): + errors.append("one number") + + if errors: + raise HTTPException(status_code=400, detail=f"Password must contain: {', '.join(errors)}") + + current_user.password_hash = hash_password(password) + current_user.updated_at = datetime.utcnow().isoformat() + db.commit() + return {"message": "Password changed"} diff --git a/compose.yml b/compose.yml new file mode 100644 index 0000000..f91f8b6 --- /dev/null +++ b/compose.yml @@ -0,0 +1,34 @@ +services: + parking: + build: . + container_name: parking-manager + restart: unless-stopped + ports: + - "8000:8000" + volumes: + - ./data:/app/data + environment: + - SECRET_KEY=${SECRET_KEY:-change-me-in-production} + - HOST=0.0.0.0 + - PORT=8000 + - DATABASE_PATH=/app/data/parking.db + - AUTHELIA_ENABLED=${AUTHELIA_ENABLED:-false} + - ALLOWED_ORIGINS=${ALLOWED_ORIGINS:-*} + # SMTP (optional) + - SMTP_HOST=${SMTP_HOST:-} + - SMTP_PORT=${SMTP_PORT:-587} + - SMTP_USER=${SMTP_USER:-} + - SMTP_PASSWORD=${SMTP_PASSWORD:-} + - SMTP_FROM=${SMTP_FROM:-} + healthcheck: + test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8000/health')"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 10s + +# For production with external network (Caddy/Authelia): +# networks: +# default: +# external: true +# name: proxy diff --git a/create_test_db.py b/create_test_db.py new file mode 100644 index 0000000..2193f54 --- /dev/null +++ b/create_test_db.py @@ -0,0 +1,183 @@ +""" +Create test database with sample data +Run: .venv/bin/python create_test_db.py + +Manager-centric model: +- Managers own parking spots (manager_parking_quota) +- Each manager has a spot prefix (A, B, C...) for display names +- Offices are containers for employees (like LDAP groups) + +LDAP Group Simulation: +- parking_admin: admin role +- parking_manager: manager role +- office groups (presales, design, operations): employee office assignment +""" +import uuid +from datetime import datetime, timezone +from database.connection import engine, SessionLocal +from database.models import Base, User, Office, OfficeMembership +from services.auth import hash_password + +# Drop and recreate all tables for clean slate +Base.metadata.drop_all(bind=engine) +Base.metadata.create_all(bind=engine) + +db = SessionLocal() + +now = datetime.now(timezone.utc).isoformat() +password_hash = hash_password("password123") + +# Create offices (representing LDAP groups) +offices = [ + Office(id="presales", name="Presales", location="Building A", created_at=now), + Office(id="design", name="Design", location="Building B", created_at=now), + Office(id="operations", name="Operations", location="Building C", created_at=now), +] + +for office in offices: + db.add(office) + print(f"Created office: {office.name}") + +db.commit() + +# Create users +# LDAP groups simulation: +# admin: parking_admin, presales +# manager1: parking_manager, presales, design +# manager2: parking_manager, operations +# user1: presales +# user2: design +# user3: design +# user4: operations +# user5: operations + +users_data = [ + { + "id": "admin", + "email": "admin@example.com", + "name": "Admin User", + "role": "admin", + "office_id": "presales", # Primary office from LDAP groups + }, + { + "id": "manager1", + "email": "manager1@example.com", + "name": "Alice Manager", + "role": "manager", + "office_id": "presales", + "manager_parking_quota": 3, # For 5 users: admin, alice, user1, user2, user3 + "manager_spot_prefix": "A", + }, + { + "id": "manager2", + "email": "manager2@example.com", + "name": "Bob Manager", + "role": "manager", + "office_id": "operations", + "manager_parking_quota": 2, # For 3 users: bob, user4, user5 + "manager_spot_prefix": "B", + }, + { + "id": "user1", + "email": "user1@example.com", + "name": "User One", + "role": "employee", + "office_id": "presales", + }, + { + "id": "user2", + "email": "user2@example.com", + "name": "User Two", + "role": "employee", + "office_id": "design", + }, + { + "id": "user3", + "email": "user3@example.com", + "name": "User Three", + "role": "employee", + "office_id": "design", + }, + { + "id": "user4", + "email": "user4@example.com", + "name": "User Four", + "role": "employee", + "office_id": "operations", + }, + { + "id": "user5", + "email": "user5@example.com", + "name": "User Five", + "role": "employee", + "office_id": "operations", + }, +] + +for data in users_data: + user = User( + id=data["id"], + email=data["email"], + password_hash=password_hash, + name=data["name"], + role=data["role"], + office_id=data.get("office_id"), + manager_parking_quota=data.get("manager_parking_quota", 0), + manager_spot_prefix=data.get("manager_spot_prefix"), + created_at=now + ) + db.add(user) + print(f"Created user: {user.email} ({user.role})") + +db.commit() + +# Create office memberships for managers +# Manager 1 (Alice) manages Presales and Design +for office_id in ["presales", "design"]: + membership = OfficeMembership( + id=str(uuid.uuid4()), + user_id="manager1", + office_id=office_id, + created_at=now + ) + db.add(membership) + print(f"Created membership: Alice -> {office_id}") + +# Manager 2 (Bob) manages Operations +membership = OfficeMembership( + id=str(uuid.uuid4()), + user_id="manager2", + office_id="operations", + created_at=now +) +db.add(membership) +print(f"Created membership: Bob -> operations") + +db.commit() +db.close() + +print("\n" + "="*60) +print("Test database created successfully!") +print("="*60) +print("\nTest accounts (all use password: password123):") +print("-"*60) +print(f"{'Email':<25} {'Role':<10} {'Office':<12} {'Spots'}") +print("-"*60) +print(f"{'admin@example.com':<25} {'admin':<10} {'presales':<12}") +print(f"{'manager1@example.com':<25} {'manager':<10} {'presales':<12}") +print(f"{'manager2@example.com':<25} {'manager':<10} {'operations':<12}") +print(f"{'user1@example.com':<25} {'employee':<10} {'presales':<12}") +print(f"{'user2@example.com':<25} {'employee':<10} {'design':<12}") +print(f"{'user3@example.com':<25} {'employee':<10} {'design':<12}") +print(f"{'user4@example.com':<25} {'employee':<10} {'operations':<12}") +print(f"{'user5@example.com':<25} {'employee':<10} {'operations':<12}") +print("-"*60) +print("\nParking pools:") +print(" Alice (manager1): 3 spots (A1,A2,A3)") +print(" -> presales: admin, alice, user1") +print(" -> design: user2, user3") +print(" -> 5 users, 3 spots = 60% ratio target") +print() +print(" Bob (manager2): 2 spots (B1,B2)") +print(" -> operations: bob, user4, user5") +print(" -> 3 users, 2 spots = 67% ratio target") diff --git a/database/__init__.py b/database/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/database/connection.py b/database/connection.py new file mode 100644 index 0000000..a9520bd --- /dev/null +++ b/database/connection.py @@ -0,0 +1,39 @@ +""" +Database Connection Management +""" +from contextlib import contextmanager +from sqlalchemy import create_engine +from sqlalchemy.orm import sessionmaker +from app import config + +engine = create_engine( + config.DATABASE_URL, + connect_args={"check_same_thread": False} if "sqlite" in config.DATABASE_URL else {} +) + +SessionLocal = sessionmaker(bind=engine) + + +def get_db(): + """Database session for FastAPI dependency injection""" + db = SessionLocal() + try: + yield db + finally: + db.close() + + +@contextmanager +def get_db_session(): + """Database session for regular Python code""" + db = SessionLocal() + try: + yield db + finally: + db.close() + + +def init_db(): + """Create all tables""" + from database.models import Base + Base.metadata.create_all(bind=engine) diff --git a/database/models.py b/database/models.py new file mode 100644 index 0000000..f03196e --- /dev/null +++ b/database/models.py @@ -0,0 +1,232 @@ +""" +SQLAlchemy ORM Models +Clean, focused data models for parking management +""" +from sqlalchemy import Column, String, Integer, Text, ForeignKey, Index +from sqlalchemy.orm import relationship, declarative_base + +Base = declarative_base() + + +class User(Base): + """Application users""" + __tablename__ = "users" + + id = Column(Text, primary_key=True) + email = Column(Text, unique=True, nullable=False) + password_hash = Column(Text) + name = Column(Text) + role = Column(Text, nullable=False, default="employee") # admin, manager, employee + office_id = Column(Text, ForeignKey("offices.id")) + + # Manager-specific fields (only relevant for role='manager') + manager_parking_quota = Column(Integer, default=0) # Number of spots this manager controls + manager_spot_prefix = Column(Text) # Letter prefix for spots: A, B, C, etc. + + # User preferences + week_start_day = Column(Integer, default=0) # 0=Sunday, 1=Monday, ..., 6=Saturday + + # Notification preferences + notify_weekly_parking = Column(Integer, default=1) # Weekly parking summary (Friday at 12) + notify_daily_parking = Column(Integer, default=1) # Daily parking reminder + notify_daily_parking_hour = Column(Integer, default=8) # Hour for daily reminder (0-23) + notify_daily_parking_minute = Column(Integer, default=0) # Minute for daily reminder (0-59) + notify_parking_changes = Column(Integer, default=1) # Immediate notification on assignment changes + + created_at = Column(Text) + updated_at = Column(Text) + + # Relationships + office = relationship("Office", back_populates="users") + presences = relationship("UserPresence", back_populates="user", cascade="all, delete-orphan") + assignments = relationship("DailyParkingAssignment", back_populates="user", foreign_keys="DailyParkingAssignment.user_id") + managed_offices = relationship("OfficeMembership", back_populates="user", cascade="all, delete-orphan") + + __table_args__ = ( + Index('idx_user_email', 'email'), + Index('idx_user_office', 'office_id'), + ) + + +class Office(Base): + """Office locations - containers for grouping employees""" + __tablename__ = "offices" + + id = Column(Text, primary_key=True) + name = Column(Text, nullable=False) + location = Column(Text) + # Note: parking_spots removed - spots are now managed at manager level + + created_at = Column(Text) + updated_at = Column(Text) + + # Relationships + users = relationship("User", back_populates="office") + managers = relationship("OfficeMembership", back_populates="office", cascade="all, delete-orphan") + + +class OfficeMembership(Base): + """Manager-Office relationship (which managers manage which offices)""" + __tablename__ = "office_memberships" + + 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) + created_at = Column(Text) + + # Relationships + user = relationship("User", back_populates="managed_offices") + office = relationship("Office", back_populates="managers") + + __table_args__ = ( + Index('idx_membership_user', 'user_id'), + Index('idx_membership_office', 'office_id'), + Index('idx_membership_unique', 'user_id', 'office_id', unique=True), + ) + + +class UserPresence(Base): + """Daily presence records""" + __tablename__ = "user_presences" + + id = Column(Text, primary_key=True) + user_id = Column(Text, ForeignKey("users.id", ondelete="CASCADE"), nullable=False) + date = Column(Text, nullable=False) # YYYY-MM-DD + status = Column(Text, nullable=False) # present, remote, absent + created_at = Column(Text) + updated_at = Column(Text) + + # Relationships + user = relationship("User", back_populates="presences") + + __table_args__ = ( + Index('idx_presence_user_date', 'user_id', 'date', unique=True), + Index('idx_presence_date', 'date'), + ) + + +class DailyParkingAssignment(Base): + """Parking spot assignments per day - spots belong to managers""" + __tablename__ = "daily_parking_assignments" + + id = Column(Text, primary_key=True) + date = Column(Text, nullable=False) # YYYY-MM-DD + spot_id = Column(Text, nullable=False) # A1, A2, B1, B2, etc. (prefix from manager) + user_id = Column(Text, ForeignKey("users.id", ondelete="SET NULL")) + manager_id = Column(Text, ForeignKey("users.id", ondelete="CASCADE"), nullable=False) # Manager who owns the spot + created_at = Column(Text) + + # Relationships + user = relationship("User", back_populates="assignments", foreign_keys=[user_id]) + manager = relationship("User", foreign_keys=[manager_id]) + + __table_args__ = ( + Index('idx_assignment_manager_date', 'manager_id', 'date'), + Index('idx_assignment_user', 'user_id'), + Index('idx_assignment_date_spot', 'date', 'spot_id'), + ) + + +class ManagerClosingDay(Base): + """Specific date closing days for a manager's offices (holidays, special closures)""" + __tablename__ = "manager_closing_days" + + id = Column(Text, primary_key=True) + manager_id = Column(Text, ForeignKey("users.id", ondelete="CASCADE"), nullable=False) + date = Column(Text, nullable=False) # YYYY-MM-DD + reason = Column(Text) + + # Relationships + manager = relationship("User") + + __table_args__ = ( + Index('idx_closing_manager_date', 'manager_id', 'date', unique=True), + ) + + +class ManagerWeeklyClosingDay(Base): + """Weekly recurring closing days for a manager's offices (e.g., Saturday and Sunday)""" + __tablename__ = "manager_weekly_closing_days" + + id = Column(Text, primary_key=True) + manager_id = Column(Text, ForeignKey("users.id", ondelete="CASCADE"), nullable=False) + weekday = Column(Integer, nullable=False) # 0=Sunday, 1=Monday, ..., 6=Saturday + + # Relationships + manager = relationship("User") + + __table_args__ = ( + Index('idx_weekly_closing_manager_day', 'manager_id', 'weekday', unique=True), + ) + + +class ParkingGuarantee(Base): + """Users guaranteed a parking spot when present (set by manager)""" + __tablename__ = "parking_guarantees" + + id = Column(Text, primary_key=True) + manager_id = Column(Text, ForeignKey("users.id", ondelete="CASCADE"), nullable=False) + user_id = Column(Text, ForeignKey("users.id", ondelete="CASCADE"), nullable=False) + start_date = Column(Text) # Optional: YYYY-MM-DD (null = no start limit) + end_date = Column(Text) # Optional: YYYY-MM-DD (null = no end limit) + created_at = Column(Text) + + # Relationships + manager = relationship("User", foreign_keys=[manager_id]) + user = relationship("User", foreign_keys=[user_id]) + + __table_args__ = ( + Index('idx_guarantee_manager_user', 'manager_id', 'user_id', unique=True), + ) + + +class ParkingExclusion(Base): + """Users excluded from parking assignment (set by manager)""" + __tablename__ = "parking_exclusions" + + id = Column(Text, primary_key=True) + manager_id = Column(Text, ForeignKey("users.id", ondelete="CASCADE"), nullable=False) + user_id = Column(Text, ForeignKey("users.id", ondelete="CASCADE"), nullable=False) + start_date = Column(Text) # Optional: YYYY-MM-DD (null = no start limit) + end_date = Column(Text) # Optional: YYYY-MM-DD (null = no end limit) + created_at = Column(Text) + + # Relationships + manager = relationship("User", foreign_keys=[manager_id]) + user = relationship("User", foreign_keys=[user_id]) + + __table_args__ = ( + Index('idx_exclusion_manager_user', 'manager_id', 'user_id', unique=True), + ) + + +class NotificationLog(Base): + """Log of sent notifications to prevent duplicates""" + __tablename__ = "notification_logs" + + id = Column(Text, primary_key=True) + user_id = Column(Text, ForeignKey("users.id", ondelete="CASCADE"), nullable=False) + notification_type = Column(Text, nullable=False) # presence_reminder, weekly_parking, daily_parking, parking_change + reference_date = Column(Text) # The date/week this notification refers to (YYYY-MM-DD or YYYY-Www) + sent_at = Column(Text, nullable=False) + + __table_args__ = ( + Index('idx_notification_user_type_date', 'user_id', 'notification_type', 'reference_date'), + ) + + +class NotificationQueue(Base): + """Queue for pending notifications (for immediate parking change notifications)""" + __tablename__ = "notification_queue" + + id = Column(Text, primary_key=True) + user_id = Column(Text, ForeignKey("users.id", ondelete="CASCADE"), nullable=False) + notification_type = Column(Text, nullable=False) # parking_change + subject = Column(Text, nullable=False) + body = Column(Text, nullable=False) + created_at = Column(Text, nullable=False) + sent_at = Column(Text) # null = not sent yet + + __table_args__ = ( + Index('idx_queue_pending', 'sent_at'), + ) diff --git a/deploy/Caddyfile.snippet b/deploy/Caddyfile.snippet new file mode 100644 index 0000000..0f589ca --- /dev/null +++ b/deploy/Caddyfile.snippet @@ -0,0 +1,8 @@ +# Caddy configuration snippet for parking.rocketscale.it +# Add this block to org-stack/Caddyfile after the (auth) snippet definition + +# Parking Manager - Protected by Authelia +parking.rocketscale.it { + import auth + reverse_proxy parking:8000 +} diff --git a/deploy/DEPLOY.md b/deploy/DEPLOY.md new file mode 100644 index 0000000..d2eb6e4 --- /dev/null +++ b/deploy/DEPLOY.md @@ -0,0 +1,137 @@ +# Deployment Guide for parking.rocketscale.it + +## Prerequisites + +- org-stack running on rocky@rocketscale.it +- Git repository on git.rocketscale.it + +## Step 1: Push to Git + +```bash +# On development machine +cd /mnt/code/boilerplate/org-parking +git init +git add . +git commit -m "Initial commit: Parking Manager" +git remote add origin git@git.rocketscale.it:rocky/parking-manager.git +git push -u origin main +``` + +## Step 2: Clone on Server + +```bash +# SSH to server +ssh rocky@rocketscale.it + +# Clone into org-stack +cd ~/org-stack +git clone git@git.rocketscale.it:rocky/parking-manager.git parking +``` + +## Step 3: Add to .env + +Add to `~/org-stack/.env`: + +```bash +# Parking Manager +PARKING_SECRET_KEY=your-random-secret-key-here +``` + +Generate a secret key: +```bash +python3 -c "import secrets; print(secrets.token_hex(32))" +``` + +## Step 4: Add to compose.yml + +Add the parking service to `~/org-stack/compose.yml`: + +```yaml + # =========================================================================== + # Parking Manager - Parking Spot Management + # =========================================================================== + parking: + build: ./parking + container_name: parking + restart: unless-stopped + volumes: + - parking_data:/app/data + environment: + - SECRET_KEY=${PARKING_SECRET_KEY} + - DATABASE_PATH=/app/data/parking.db + - AUTHELIA_ENABLED=true + - ALLOWED_ORIGINS=https://parking.rocketscale.it + - SMTP_HOST=${SMTP_HOST:-} + - SMTP_PORT=${SMTP_PORT:-587} + - SMTP_USER=${SMTP_USER:-} + - SMTP_PASSWORD=${SMTP_PASSWORD:-} + - SMTP_FROM=${SMTP_FROM:-} + networks: + - org-network + depends_on: + - authelia +``` + +Add to volumes section: +```yaml + parking_data: # Parking SQLite database +``` + +Add `parking` to Caddy's depends_on list. + +## Step 5: Add to Caddyfile + +Add to `~/org-stack/Caddyfile`: + +``` +# Parking Manager - Protected by Authelia +parking.rocketscale.it { + import auth + reverse_proxy parking:8000 +} +``` + +## Step 6: Create LLDAP Groups + +In lldap (https://ldap.rocketscale.it): + +1. Create group: `parking-admins` +2. Create group: `parking-managers` +3. Add yourself to `parking-admins` + +## Step 7: Deploy + +```bash +cd ~/org-stack + +# Build and start parking service +docker compose build parking +docker compose up -d parking + +# Reload Caddy to pick up new domain +docker compose exec caddy caddy reload --config /etc/caddy/Caddyfile + +# Check logs +docker compose logs -f parking +``` + +## Step 8: Verify + +1. Go to https://parking.rocketscale.it +2. You should be redirected to Authelia for login +3. After login, you should see the parking app +4. Your user should be auto-created with `admin` role (if in parking-admins group) + +## Troubleshooting + +### 401 Unauthorized +- Check Authelia headers are being passed +- Check `docker compose logs authelia` + +### User has wrong role +- Verify LLDAP group membership +- Roles sync on each login + +### Database errors +- Check volume mount: `docker compose exec parking ls -la /app/data` +- Check permissions: `docker compose exec parking id` diff --git a/deploy/compose.production.yml b/deploy/compose.production.yml new file mode 100644 index 0000000..6c3a5fc --- /dev/null +++ b/deploy/compose.production.yml @@ -0,0 +1,32 @@ +# Production compose file for org-stack integration +# This will be added to ~/org-stack/compose.yml on the server + +services: + parking: + build: ./parking + container_name: parking + restart: unless-stopped + volumes: + - parking_data:/app/data + environment: + - SECRET_KEY=${PARKING_SECRET_KEY} + - DATABASE_PATH=/app/data/parking.db + - AUTHELIA_ENABLED=true + - ALLOWED_ORIGINS=https://parking.rocketscale.it + # SMTP (shared with other services) + - SMTP_HOST=${SMTP_HOST:-} + - SMTP_PORT=${SMTP_PORT:-587} + - SMTP_USER=${SMTP_USER:-} + - SMTP_PASSWORD=${SMTP_PASSWORD:-} + - SMTP_FROM=${SMTP_FROM:-} + networks: + - org-network + depends_on: + - authelia + +volumes: + parking_data: + +networks: + org-network: + external: true diff --git a/frontend/css/styles.css b/frontend/css/styles.css new file mode 100644 index 0000000..f1c1ea1 --- /dev/null +++ b/frontend/css/styles.css @@ -0,0 +1,1749 @@ +/** + * Parking Manager - Consolidated Styles + * Single CSS file for all pages + */ + +/* ============================================================================ + CSS Variables + ============================================================================ */ +:root { + --primary: #1f1f1f; + --primary-hover: #333; + --secondary: #6b7280; + --success: #16a34a; + --success-bg: #dcfce7; + --warning: #f59e0b; + --warning-bg: #fef3c7; + --danger: #dc2626; + --danger-bg: #fee2e2; + --text: #1f1f1f; + --text-secondary: #666; + --text-muted: #999; + --border: #e5e5e5; + --border-dark: #d0d0d0; + --bg: #f9fafb; + --bg-white: #fff; + --sidebar-width: 260px; + --header-height: 64px; +} + +/* ============================================================================ + Reset & Base + ============================================================================ */ +*, *::before, *::after { + box-sizing: border-box; + margin: 0; + padding: 0; +} + +body { + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; + background: var(--bg); + color: var(--text); + line-height: 1.5; + min-height: 100vh; +} + +a { + color: inherit; + text-decoration: none; +} + +button { + font-family: inherit; + cursor: pointer; +} + +input, select, textarea { + font-family: inherit; + font-size: inherit; +} + +/* ============================================================================ + Layout - Sidebar + ============================================================================ */ +.sidebar { + position: fixed; + left: 0; + top: 0; + bottom: 0; + width: var(--sidebar-width); + background: white; + color: var(--text); + border-right: 1px solid var(--border); + display: flex; + flex-direction: column; + z-index: 100; +} + +.sidebar-header { + padding: 0 1.5rem; + min-height: 53px; + display: flex; + align-items: center; +} + +.sidebar-header h1 { + font-size: 1.25rem; + font-weight: 600; + color: var(--text); + margin: 0; +} + +.sidebar-nav { + flex: 1; + padding: 1rem 0; + overflow-y: auto; +} + +.nav-item { + display: flex; + align-items: center; + gap: 0.75rem; + padding: 0.75rem 1.5rem; + color: var(--text-secondary); + transition: all 0.2s; +} + +.nav-item:hover { + background: var(--bg); + color: var(--text); +} + +.nav-item.active { + background: var(--bg); + color: var(--text); + font-weight: 500; +} + +.sidebar-footer { + padding: 1rem; +} + +/* User Menu */ +.user-menu { + position: relative; +} + +.user-button { + display: flex; + align-items: center; + gap: 0.75rem; + width: 100%; + padding: 0.75rem; + background: var(--bg); + border: 1px solid var(--border); + border-radius: 8px; + color: var(--text); + text-align: left; +} + +.user-button:hover { + background: #eee; +} + +.user-avatar { + width: 36px; + height: 36px; + background: var(--primary); + color: white; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; +} + +.user-info { + flex: 1; + min-width: 0; +} + +.user-name { + font-size: 0.9rem; + font-weight: 500; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + color: var(--text); +} + +.user-role { + font-size: 0.75rem; + color: var(--text-secondary); + text-transform: capitalize; +} + +.user-dropdown { + position: absolute; + bottom: 100%; + left: 0; + right: 0; + margin-bottom: 0.5rem; + background: white; + border-radius: 8px; + box-shadow: 0 4px 20px rgba(0, 0, 0, 0.15); + overflow: hidden; +} + +.dropdown-item { + display: block; + width: 100%; + padding: 0.75rem 1rem; + color: var(--text); + background: none; + border: none; + text-align: left; + font-size: 0.9rem; +} + +.dropdown-item:hover { + background: var(--bg); +} + +.dropdown-divider { + border: none; + border-top: 1px solid var(--border); + margin: 0.25rem 0; +} + +/* ============================================================================ + Layout - Main Content + ============================================================================ */ +.main-content { + margin-left: var(--sidebar-width); + min-height: 100vh; + display: flex; + flex-direction: column; +} + +.page-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 0 1.5rem; + min-height: 53px; + background: white; + border-bottom: 1px solid var(--border); + gap: 0.75rem; +} + +.page-header h2 { + font-size: 1.25rem; + font-weight: 600; + flex: 1; + min-width: 0; +} + +.header-actions { + display: flex; + gap: 0.75rem; + align-items: center; +} + +.content-wrapper { + flex: 1; + padding: 1rem 1.5rem; +} + +/* ============================================================================ + Cards + ============================================================================ */ +.card { + background: white; + border: 1px solid var(--border); + border-radius: 8px; + padding: 1rem; + margin-bottom: 1rem; +} + +.card h3 { + font-size: 1.1rem; + font-weight: 600; + margin-bottom: 1rem; +} + +/* ============================================================================ + Buttons + ============================================================================ */ +.btn { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 0.5rem; + padding: 0.625rem 1.25rem; + font-size: 0.9rem; + font-weight: 500; + border: none; + border-radius: 6px; + cursor: pointer; + transition: all 0.2s; +} + +.btn-primary { + background: #2563eb; + color: white; +} + +.btn-primary:hover { + background: #1d4ed8; +} + +.btn-dark { + background: var(--primary); + color: white; +} + +.btn-dark:hover { + background: var(--primary-hover); +} + +.btn-secondary { + background: white; + color: var(--text); + border: 1px solid var(--border-dark); +} + +.btn-secondary:hover { + background: var(--bg); +} + +.btn-danger { + background: var(--danger); + color: white; +} + +.btn-danger:hover { + background: #b91c1c; +} + +.btn-icon { + width: 36px; + height: 36px; + padding: 0; + background: transparent; + border: 1px solid var(--border); + border-radius: 6px; + display: flex; + align-items: center; + justify-content: center; +} + +.btn-icon:hover { + background: var(--bg); +} + +.btn-full { + width: 100%; +} + +/* ============================================================================ + Forms + ============================================================================ */ +.form-group { + margin-bottom: 1rem; +} + +.form-group label { + display: block; + font-size: 0.875rem; + font-weight: 500; + margin-bottom: 0.5rem; + color: var(--text); +} + +.form-input, +.form-group input, +.form-group select, +.form-group textarea { + width: 100%; + padding: 0.625rem 0.75rem; + font-size: 0.9rem; + border: 1px solid var(--border-dark); + border-radius: 6px; + background: white; +} + +.form-input:focus, +.form-group input:focus, +.form-group select:focus, +.form-group textarea:focus { + outline: none; + border-color: #2563eb; + box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.1); +} + +.form-group textarea { + min-height: 80px; + resize: vertical; +} + +.form-group small { + display: block; + margin-top: 0.25rem; + font-size: 0.8rem; + color: var(--text-secondary); +} + +.form-actions { + display: flex; + gap: 0.75rem; + justify-content: flex-end; + margin-top: 1.5rem; +} + +/* Checkbox alignment */ +.checkbox-group { + margin-bottom: 0; +} + +.checkbox-label { + display: flex; + align-items: center; + gap: 0.5rem; + cursor: pointer; + font-size: 0.9rem; +} + +.checkbox-label input[type="checkbox"] { + width: 16px; + height: 16px; + margin: 0; + cursor: pointer; +} + +/* ============================================================================ + Modals + ============================================================================ */ +.modal { + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.5); + display: flex; + align-items: center; + justify-content: center; + z-index: 1000; + padding: 1rem; +} + +.modal-content { + background: white; + border-radius: 12px; + width: 100%; + max-width: 480px; + max-height: 90vh; + overflow: auto; +} + +.modal-small { + max-width: 360px; +} + +.modal-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 1.25rem 1.5rem; + border-bottom: 1px solid var(--border); +} + +.modal-header h3 { + font-size: 1.1rem; + font-weight: 600; + margin: 0; +} + +.modal-close { + width: 32px; + height: 32px; + background: none; + border: none; + font-size: 1.5rem; + color: var(--text-secondary); + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; +} + +.modal-close:hover { + color: var(--text); +} + +.modal-body { + padding: 1.5rem; +} + +/* ============================================================================ + Messages & Badges + ============================================================================ */ +.message { + padding: 0.75rem 1rem; + border-radius: 6px; + font-size: 0.9rem; + margin-bottom: 1rem; +} + +.message.success { + background: var(--success-bg); + color: #166534; +} + +.message.error { + background: var(--danger-bg); + color: #991b1b; +} + +.badge { + display: inline-block; + padding: 0.25rem 0.5rem; + font-size: 0.75rem; + font-weight: 500; + border-radius: 4px; +} + +.badge-success { + background: var(--success-bg); + color: #166534; +} + +.badge-warning { + background: var(--warning-bg); + color: #92400e; +} + +.badge-danger { + background: var(--danger-bg); + color: #991b1b; +} + +/* ============================================================================ + Tables + ============================================================================ */ +.table { + width: 100%; + border-collapse: collapse; +} + +.table th, +.table td { + padding: 0.75rem 1rem; + text-align: left; + border-bottom: 1px solid var(--border); +} + +.table th { + font-weight: 600; + font-size: 0.875rem; + color: var(--text-secondary); +} + +.table tbody tr:hover { + background: var(--bg); +} + +/* ============================================================================ + Calendar Components + ============================================================================ */ +.calendar-header { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 0.75rem; + max-width: 100%; +} + +.calendar-header h3 { + margin: 0; + font-size: 1rem; +} + +.calendar-grid { + display: grid; + grid-template-columns: repeat(7, 1fr); + grid-template-rows: auto repeat(6, 1fr); + gap: 4px; + margin-bottom: 1rem; + height: calc(100vh - 220px); +} + +.calendar-day { + display: flex; + flex-direction: column; + align-items: center; + justify-content: flex-start; + border: 1px solid var(--border); + border-radius: 6px; + cursor: pointer; + transition: all 0.2s; + font-size: 0.85rem; + padding: 0.5rem 0.25rem; + position: relative; + min-height: 0; +} + +.calendar-day:hover { + border-color: var(--primary); +} + +.calendar-day.weekend { + background: #f5f5f5; +} + +.calendar-day.holiday { + background: #fff7ed; +} + +.calendar-day.today { + border-color: var(--primary); + border-width: 2px; +} + +.calendar-day .day-number { + font-weight: 600; + font-size: 0.9rem; + line-height: 1.2; +} + +.calendar-day .day-name { + font-size: 0.65rem; + color: var(--text-secondary); + line-height: 1; +} + +.calendar-day .parking-badge { + position: absolute; + bottom: 0.25rem; + left: 50%; + transform: translateX(-50%); + background: #dbeafe; + color: #1e40af; + font-size: 0.6rem; + font-weight: 600; + padding: 0.1rem 0.3rem; + border-radius: 3px; + line-height: 1; +} + +/* Status colors */ +.status-present { + background: var(--success-bg) !important; + border-color: var(--success) !important; +} + +.status-remote { + background: #dbeafe !important; + border-color: #3b82f6 !important; +} + +.status-absent { + background: var(--danger-bg) !important; + border-color: var(--danger) !important; +} + +.status-nodata { + background: white; +} + +/* Legend */ +.legend { + display: flex; + gap: 1rem; + flex-wrap: wrap; + padding-top: 0.75rem; + border-top: 1px solid var(--border); +} + +.legend-item { + display: flex; + align-items: center; + gap: 0.35rem; + font-size: 0.75rem; + color: var(--text-secondary); +} + +.legend-color { + width: 14px; + height: 14px; + border-radius: 3px; + border: 1px solid var(--border); +} + +/* ============================================================================ + Status Buttons (for modals) + ============================================================================ */ +.status-buttons { + display: flex; + gap: 0.75rem; +} + +.status-btn, +.status-change-btn { + flex: 1; + display: flex; + flex-direction: column; + align-items: center; + gap: 0.5rem; + padding: 1rem; + background: white; + border: 2px solid var(--border); + border-radius: 8px; + cursor: pointer; + transition: all 0.2s; +} + +.status-btn:hover, +.status-change-btn:hover { + border-color: var(--primary); +} + +.status-btn.active, +.status-change-btn.active { + border-color: var(--primary); + background: var(--bg); +} + +.status-icon { + width: 24px; + height: 24px; + border-radius: 50%; +} + +/* ============================================================================ + Team Calendar Table + ============================================================================ */ +.team-calendar-wrapper { + overflow-x: auto; + margin-bottom: 1.5rem; +} + +.team-calendar { + width: 100%; + border-collapse: collapse; + font-size: 0.85rem; +} + +.team-calendar th, +.team-calendar td { + padding: 0.5rem; + text-align: center; + border: 1px solid var(--border); + min-width: 36px; +} + +.team-calendar th { + background: var(--bg); + font-weight: 500; +} + +.team-calendar th.day-column { + cursor: pointer; +} + +.team-calendar th.day-column:hover { + background: #eee; +} + +.team-calendar th.day-column.selected { + background: var(--primary); + color: white; +} + +.team-calendar th.weekend, +.team-calendar td.weekend { + background: #f5f5f5; +} + +.team-calendar th.holiday, +.team-calendar td.holiday { + background: #fff7ed; +} + +.team-calendar th.today { + font-weight: 700; +} + +.team-calendar td.today { + border: 2px solid var(--primary); +} + +.team-calendar .calendar-cell { + cursor: pointer; + transition: all 0.15s ease; +} + +.team-calendar .calendar-cell:hover { + background: rgba(0, 0, 0, 0.08); + border-color: var(--primary); +} + +.member-column { + text-align: left !important; + min-width: 150px; + position: sticky; + left: 0; + background: white; + z-index: 1; +} + +.member-cell { + text-align: left !important; + position: sticky; + left: 0; + background: white; + z-index: 1; +} + +.member-name { + font-weight: 500; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.day-cell { + cursor: pointer; +} + +.day-cell:hover { + opacity: 0.8; +} + +/* Summary footer */ +.summary-row { + background: var(--bg); +} + +.summary-label-cell { + text-align: left !important; + font-weight: 500; + position: sticky; + left: 0; + background: var(--bg); +} + +.summary-cell { + font-size: 0.8rem; + color: var(--text-secondary); +} + +.summary-cell.has-value { + color: var(--text); + font-weight: 500; +} + +/* ============================================================================ + Parking Grid + ============================================================================ */ +.parking-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(140px, 1fr)); + gap: 1rem; +} + +.parking-spot { + background: white; + border: 2px solid var(--border); + border-radius: 8px; + padding: 1rem; + cursor: pointer; + transition: all 0.2s; +} + +.parking-spot:hover { + border-color: var(--primary); +} + +.parking-spot.available { + border-style: dashed; +} + +.parking-spot.assigned { + border-color: var(--success); +} + +.parking-spot.my-spot { + border-color: var(--primary); + background: #f0f9ff; +} + +.spot-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 0.75rem; +} + +.spot-id { + font-weight: 600; + font-size: 1.1rem; +} + +.spot-badge { + font-size: 0.7rem; + padding: 0.2rem 0.5rem; + border-radius: 4px; +} + +.spot-badge.available { + background: var(--bg); + color: var(--text-secondary); +} + +.spot-badge.assigned { + background: var(--success-bg); + color: #166534; +} + +.spot-badge.mine { + background: #dbeafe; + color: #1d4ed8; +} + +.spot-user { + display: flex; + align-items: center; + gap: 0.75rem; +} + +.spot-avatar { + width: 32px; + height: 32px; + background: var(--primary); + color: white; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + font-size: 0.75rem; + font-weight: 600; +} + +.spot-user-name { + font-weight: 500; + font-size: 0.85rem; +} + +.spot-user-office { + font-size: 0.75rem; + color: var(--text-secondary); +} + +.spot-empty { + color: var(--text-muted); + font-size: 0.85rem; +} + +/* ============================================================================ + Toggle Switch + ============================================================================ */ +.toggle-switch { + position: relative; + display: inline-block; + width: 48px; + height: 26px; + cursor: pointer; +} + +.toggle-switch input { + opacity: 0; + width: 0; + height: 0; + position: absolute; +} + +.toggle-slider { + position: absolute; + cursor: pointer; + top: 0; + left: 0; + right: 0; + bottom: 0; + background-color: #ccc; + transition: 0.3s; + border-radius: 26px; +} + +.toggle-slider:before { + position: absolute; + content: ""; + height: 20px; + width: 20px; + left: 3px; + bottom: 3px; + background-color: white; + transition: 0.3s; + border-radius: 50%; +} + +.toggle-switch input:checked + .toggle-slider { + background-color: var(--primary); +} + +.toggle-switch input:checked + .toggle-slider:before { + transform: translateX(22px); +} + +/* ============================================================================ + Auth Pages + ============================================================================ */ +.auth-container { + min-height: 100vh; + display: flex; + align-items: center; + justify-content: center; + padding: 2rem; +} + +.auth-card { + background: white; + border-radius: 12px; + box-shadow: 0 4px 20px rgba(0, 0, 0, 0.1); + padding: 2.5rem; + width: 100%; + max-width: 400px; +} + +.auth-header { + text-align: center; + margin-bottom: 2rem; +} + +.auth-header h1 { + font-size: 1.5rem; + margin-bottom: 0.5rem; +} + +.auth-header p { + color: var(--text-secondary); +} + +.auth-footer { + text-align: center; + margin-top: 1.5rem; + font-size: 0.9rem; + color: var(--text-secondary); +} + +.auth-footer a { + color: var(--primary); + font-weight: 500; +} + +/* ============================================================================ + Settings & Profile Specific + ============================================================================ */ +.settings-container, +.profile-container { + max-width: 600px; +} + +.settings-section, +.profile-section { + background: white; + border: 1px solid var(--border); + border-radius: 8px; + padding: 1.5rem; + margin-bottom: 1.5rem; +} + +.settings-section h3, +.profile-section h3 { + margin: 0 0 0.5rem 0; + font-size: 1.1rem; +} + +.settings-section p { + margin: 0 0 1rem 0; + font-size: 0.875rem; + color: var(--text-secondary); +} + +.setting-item { + display: flex; + justify-content: space-between; + align-items: center; + padding: 1rem 0; + border-bottom: 1px solid var(--border); +} + +.setting-item:last-child { + border-bottom: none; + padding-bottom: 0; +} + +.setting-item:first-child { + padding-top: 0; +} + +.setting-info label { + display: block; + font-size: 0.9rem; + font-weight: 500; + margin-bottom: 0.25rem; +} + +.setting-info small { + font-size: 0.8rem; + color: var(--text-secondary); +} + +.setting-control { + margin-left: 1rem; +} + +.setting-control select { + padding: 0.5rem 0.75rem; + border: 1px solid var(--border-dark); + border-radius: 4px; + font-size: 0.9rem; + min-width: 140px; +} + +.profile-field { + margin-bottom: 1rem; +} + +.profile-field:last-child { + margin-bottom: 0; +} + +.profile-field label { + display: block; + font-size: 0.875rem; + font-weight: 500; + color: var(--text-secondary); + margin-bottom: 0.25rem; +} + +.profile-field .value { + font-size: 1rem; + color: var(--text); +} + +.password-requirements { + background: var(--bg); + border-radius: 4px; + padding: 0.75rem; + margin-bottom: 1rem; + font-size: 0.8rem; +} + +.password-requirements ul { + margin: 0.5rem 0 0 1.25rem; + padding: 0; +} + +.password-requirements li { + margin-bottom: 0.25rem; +} + +.password-requirements li.valid { + color: var(--success); +} + +.password-requirements li.invalid { + color: var(--danger); +} + +/* ============================================================================ + Office Rules Specific + ============================================================================ */ +.rules-container { + display: grid; + gap: 2rem; + max-width: 1200px; +} + +.rule-section { + background: white; + border: 1px solid var(--border); + border-radius: 8px; + padding: 1.5rem; +} + +.rule-section h3 { + margin: 0 0 0.5rem 0; + font-size: 1.1rem; +} + +.rule-section p { + color: var(--text-secondary); + margin-bottom: 1.5rem; + font-size: 0.9rem; +} + +.weekday-list, +.weekday-checkboxes { + display: flex; + flex-wrap: wrap; + gap: 1rem 2rem; + margin-bottom: 1rem; +} + +.weekday-list label, +.weekday-checkboxes label { + display: flex; + align-items: center; + gap: 0.5rem; + cursor: pointer; + font-size: 0.9rem; +} + +.weekday-list input[type="checkbox"], +.weekday-checkboxes input[type="checkbox"] { + width: 16px; + height: 16px; + cursor: pointer; +} + +.add-rule-form { + display: grid; + gap: 1rem; + margin-bottom: 1.5rem; + padding: 1rem; + background: var(--bg); + border-radius: 6px; +} + +.add-rule-form .btn { + justify-self: end; +} + +.form-row { + display: grid; + gap: 1rem; + grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); +} + +.rule-list { + display: flex; + flex-direction: column; + gap: 0.5rem; +} + +.rule-item { + display: flex; + justify-content: space-between; + align-items: center; + padding: 0.75rem; + background: white; + border: 1px solid var(--border); + border-radius: 4px; +} + +.rule-info strong { + color: var(--text); +} + +.rule-info small { + display: block; + color: var(--text-secondary); + margin-top: 0.25rem; +} + +.empty-state { + text-align: center; + padding: 2rem; + color: var(--text-muted); +} + +.loading { + text-align: center; + padding: 2rem; + color: var(--text-secondary); +} + +.header-select { + min-width: 200px; + padding: 0.5rem 0.75rem; + border: 1px solid var(--border-dark); + border-radius: 4px; + font-size: 0.9rem; + background: white; +} + +/* ============================================================================ + Admin Tables + ============================================================================ */ +.admin-table { + background: white; + border: 1px solid var(--border); + border-radius: 8px; + overflow: hidden; +} + +.admin-table table { + width: 100%; + border-collapse: collapse; +} + +.admin-table th, +.admin-table td { + padding: 1rem; + text-align: left; + border-bottom: 1px solid var(--border); +} + +.admin-table th { + background: var(--bg); + font-weight: 600; + font-size: 0.875rem; +} + +.admin-table tr:last-child td { + border-bottom: none; +} + +.admin-table tr:hover td { + background: var(--bg); +} + +.actions-cell { + display: flex; + gap: 0.5rem; +} + +/* ============================================================================ + Card Headers + ============================================================================ */ +.card-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 1rem; +} + +.card-header h3 { + margin: 0; +} + +.card-body { + padding: 0; +} + +/* ============================================================================ + Data Tables + ============================================================================ */ +.data-table-container { + overflow-x: auto; + -webkit-overflow-scrolling: touch; +} + +.data-table { + width: 100%; + border-collapse: collapse; + min-width: 600px; +} + +.data-table th, +.data-table td { + padding: 0.75rem 1rem; + text-align: left; + border-bottom: 1px solid var(--border); + white-space: nowrap; +} + +.data-table th { + background: var(--bg); + font-weight: 600; + font-size: 0.875rem; + color: var(--text-secondary); +} + +.data-table tbody tr:hover { + background: var(--bg); +} + +/* ============================================================================ + Team Calendar Table + ============================================================================ */ +.team-calendar-container { + overflow-x: auto; + margin-bottom: 1.5rem; +} + +.team-calendar-table { + width: 100%; + border-collapse: separate; + border-spacing: 0; + font-size: 0.85rem; +} + +.team-calendar-table th, +.team-calendar-table td { + padding: 0.5rem; + text-align: center; + border: 1px solid var(--border); + border-left: none; + border-top: none; +} + +.team-calendar-table th:first-child, +.team-calendar-table td:first-child { + border-left: 1px solid var(--border); +} + +.team-calendar-table thead tr:first-child th { + border-top: 1px solid var(--border); +} + +.team-calendar-table th { + background: var(--bg); + font-weight: 500; +} + +.team-calendar-table th.weekend, +.team-calendar-table td.weekend { + background: #f5f5f5; +} + +.team-calendar-table th.holiday, +.team-calendar-table td.holiday { + background: #fff7ed; +} + +.team-calendar-table .member-name { + text-align: left; + min-width: 160px; + font-weight: 500; +} + +.team-calendar-table .member-name { + text-align: left !important; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + max-width: 120px; +} + +.team-calendar-table .member-office { + text-align: left !important; + font-size: 0.75rem; + color: var(--text-secondary); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + max-width: 100px; +} + +.team-calendar-table .calendar-cell { + min-width: 40px; + position: relative; + cursor: pointer; + transition: box-shadow 0.2s; +} + +.team-calendar-table .calendar-cell:hover { + box-shadow: inset 0 0 0 1px var(--primary); +} + +.team-calendar-table .calendar-cell.status-present { + background: var(--success-bg) !important; + border-color: var(--border) !important; +} + +.team-calendar-table .calendar-cell.status-remote { + background: #dbeafe !important; + border-color: var(--border) !important; +} + +.team-calendar-table .calendar-cell.status-absent { + background: var(--danger-bg) !important; + border-color: var(--border) !important; +} + +.team-calendar-table th.today, +.team-calendar-table td.today { + box-shadow: inset 1px 0 0 var(--primary), inset -1px 0 0 var(--primary); +} + +.team-calendar-table th.today { + font-weight: 700; + box-shadow: inset 1px 0 0 var(--primary), inset -1px 0 0 var(--primary), inset 0 1px 0 var(--primary); +} + +.team-calendar-table tbody tr:last-child td.today { + box-shadow: inset 1px 0 0 var(--primary), inset -1px 0 0 var(--primary), inset 0 -1px 0 var(--primary); +} + +.team-calendar-table .calendar-cell.has-parking::after { + content: ''; + position: absolute; + top: 2px; + right: 2px; + width: 8px; + height: 8px; + background: var(--primary); + border-radius: 50%; +} + +.team-calendar-table .parking-indicator { + display: inline-block; + width: 20px; + height: 20px; + background: var(--primary); + color: white; + border-radius: 50%; + font-size: 0.7rem; + font-weight: 600; + line-height: 20px; +} + +.parking-badge-sm { + display: inline-block; + background: #dbeafe; + color: #1e40af; + font-size: 0.55rem; + font-weight: 600; + padding: 0.1rem 0.25rem; + border-radius: 2px; + line-height: 1; +} + +/* ============================================================================ + Role Badges + ============================================================================ */ +.badge-employee { + background: #e5e7eb; + color: #374151; +} + +.badge-manager { + background: #dbeafe; + color: #1e40af; +} + +.badge-admin { + background: #fae8ff; + color: #86198f; +} + +/* ============================================================================ + Toggle Label + ============================================================================ */ +.toggle-label { + display: flex; + justify-content: space-between; + align-items: center; +} + +/* ============================================================================ + Rules List + ============================================================================ */ +.rules-list { + display: flex; + flex-direction: column; + gap: 0.5rem; +} + +.rules-list .rule-item { + display: flex; + justify-content: space-between; + align-items: center; + padding: 0.75rem; + background: var(--bg); + border-radius: 6px; +} + +.rules-list .rule-info { + display: flex; + flex-direction: column; + gap: 0.125rem; +} + +.rules-list .rule-date, +.rules-list .rule-name { + font-weight: 500; +} + +.rules-list .rule-reason, +.rules-list .rule-email, +.rules-list .rule-date-range { + font-size: 0.8rem; + color: var(--text-secondary); +} + +.btn-icon.btn-danger { + color: var(--danger); + border-color: var(--danger); +} + +.btn-icon.btn-danger:hover { + background: var(--danger-bg); +} + +/* ============================================================================ + Input Group + ============================================================================ */ +.input-group { + display: flex; + gap: 0.5rem; +} + +.input-group select { + flex: 1; +} + +/* ============================================================================ + Button Sizes + ============================================================================ */ +.btn-sm { + padding: 0.375rem 0.75rem; + font-size: 0.8rem; +} + +/* ============================================================================ + Utility Classes + ============================================================================ */ +.text-center { + text-align: center; +} + +.text-muted { + color: var(--text-muted); +} + +.form-select { + padding: 0.5rem 0.75rem; + border: 1px solid var(--border-dark); + border-radius: 6px; + font-size: 0.9rem; + background: white; + min-width: 150px; +} + +/* ============================================================================ + Presence Calendar Card + ============================================================================ */ +.card.presence-card { + margin-bottom: 0; +} + +/* ============================================================================ + Mobile Menu Toggle + ============================================================================ */ +.menu-toggle { + display: none; + width: 40px; + height: 40px; + background: none; + border: 1px solid var(--border); + border-radius: 6px; + cursor: pointer; + align-items: center; + justify-content: center; + flex-shrink: 0; +} + +.menu-toggle:hover { + background: var(--bg); +} + +.sidebar-overlay { + display: none; + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.5); + z-index: 99; +} + +/* ============================================================================ + Responsive + ============================================================================ */ +@media (max-width: 768px) { + .menu-toggle { + display: flex; + } + + .sidebar { + transform: translateX(-100%); + transition: transform 0.3s ease; + } + + .sidebar.open { + transform: translateX(0); + } + + .sidebar-overlay.open { + display: block; + } + + .main-content { + margin-left: 0; + } + + .page-header { + padding: 0.75rem 1rem; + } + + .page-header h2 { + font-size: 1rem; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + + .header-actions { + gap: 0.5rem; + } + + .header-actions .form-select { + min-width: 100px; + font-size: 0.8rem; + padding: 0.4rem 0.5rem; + } + + .content-wrapper { + padding: 0.75rem; + } + + .calendar-grid { + gap: 2px; + /* On mobile, calculate height based on width to keep cells square-ish */ + /* 7 columns means each cell is ~14vw wide, so 7 rows = 7 * 14vw = 98vw */ + height: auto; + max-height: none; + } + + .calendar-day { + font-size: 0.7rem; + padding: 0.125rem; + border-radius: 4px; + /* Make cells square based on available width */ + aspect-ratio: 1; + } + + .calendar-day .day-number { + font-size: 0.75rem; + } + + .calendar-day .day-name { + font-size: 0.55rem; + } + + .calendar-day .parking-badge { + font-size: 0.5rem; + padding: 0.05rem 0.2rem; + } + + .form-row { + grid-template-columns: 1fr; + } + + /* Data tables responsive */ + .data-table { + font-size: 0.8rem; + } + + .data-table th, + .data-table td { + padding: 0.5rem; + } + + /* Team calendar table responsive */ + .team-calendar-table .member-name, + .team-calendar-table .member-office { + max-width: 80px; + font-size: 0.75rem; + } +} diff --git a/frontend/favicon.svg b/frontend/favicon.svg new file mode 100644 index 0000000..719701f --- /dev/null +++ b/frontend/favicon.svg @@ -0,0 +1,4 @@ + + + P + diff --git a/frontend/js/admin-users.js b/frontend/js/admin-users.js new file mode 100644 index 0000000..e609e8f --- /dev/null +++ b/frontend/js/admin-users.js @@ -0,0 +1,247 @@ +/** + * Admin Users Page + * Manage all users in the system + */ + +let currentUser = null; +let users = []; +let offices = []; +let managedOfficesMap = {}; // user_id -> [office_ids] + +document.addEventListener('DOMContentLoaded', async () => { + if (!api.requireAuth()) return; + + currentUser = await api.getCurrentUser(); + if (!currentUser) return; + + // Only admins can access + if (currentUser.role !== 'admin') { + window.location.href = '/presence'; + return; + } + + await Promise.all([loadUsers(), loadOffices()]); + await loadManagedOffices(); + renderUsers(); + setupEventListeners(); +}); + +async function loadUsers() { + const response = await api.get('/api/users'); + if (response && response.ok) { + users = await response.json(); + } +} + +async function loadOffices() { + const response = await api.get('/api/offices'); + if (response && response.ok) { + offices = await response.json(); + const select = document.getElementById('editOffice'); + offices.forEach(office => { + const option = document.createElement('option'); + option.value = office.id; + option.textContent = office.name; + select.appendChild(option); + }); + } +} + +async function loadManagedOffices() { + // Load managed offices for all managers + const response = await api.get('/api/offices/managers/list'); + if (response && response.ok) { + const managers = await response.json(); + managedOfficesMap = {}; + managers.forEach(m => { + managedOfficesMap[m.id] = m.offices.map(o => o.id); + }); + } +} + +function getManagedOfficeNames(userId) { + const officeIds = managedOfficesMap[userId] || []; + if (officeIds.length === 0) return '-'; + return officeIds.map(id => { + const office = offices.find(o => o.id === id); + return office ? office.name : id; + }).join(', '); +} + +function renderUsers(filter = '') { + const tbody = document.getElementById('usersBody'); + const filtered = filter + ? users.filter(u => + u.name.toLowerCase().includes(filter.toLowerCase()) || + u.email.toLowerCase().includes(filter.toLowerCase()) + ) + : users; + + if (filtered.length === 0) { + tbody.innerHTML = 'No users found'; + return; + } + + tbody.innerHTML = filtered.map(user => { + const office = offices.find(o => o.id === user.office_id); + const isManager = user.role === 'manager'; + const managedOffices = isManager ? getManagedOfficeNames(user.id) : '-'; + return ` + + ${user.name} + ${user.email} + ${user.role} + ${office ? office.name : '-'} + ${managedOffices} + ${isManager ? (user.manager_parking_quota || 0) : '-'} + ${isManager ? (user.manager_spot_prefix || '-') : '-'} + + + ${user.id !== currentUser.id ? ` + + ` : ''} + + + `; + }).join(''); +} + +function editUser(userId) { + const user = users.find(u => u.id === userId); + if (!user) return; + + document.getElementById('userId').value = user.id; + document.getElementById('editName').value = user.name; + document.getElementById('editEmail').value = user.email; + document.getElementById('editRole').value = user.role; + document.getElementById('editOffice').value = user.office_id || ''; + document.getElementById('editQuota').value = user.manager_parking_quota || 0; + document.getElementById('editPrefix').value = user.manager_spot_prefix || ''; + + const isManager = user.role === 'manager'; + + // Show/hide manager fields based on role + document.getElementById('quotaGroup').style.display = isManager ? 'block' : 'none'; + document.getElementById('prefixGroup').style.display = isManager ? 'block' : 'none'; + document.getElementById('managedOfficesGroup').style.display = isManager ? 'block' : 'none'; + + // Build managed offices checkboxes + const checkboxContainer = document.getElementById('managedOfficesCheckboxes'); + const userManagedOffices = managedOfficesMap[userId] || []; + checkboxContainer.innerHTML = offices.map(office => ` + + `).join(''); + + document.getElementById('userModal').style.display = 'flex'; +} + +async function deleteUser(userId) { + const user = users.find(u => u.id === userId); + if (!user) return; + + if (!confirm(`Delete user "${user.name}"? This cannot be undone.`)) return; + + const response = await api.delete(`/api/users/${userId}`); + if (response && response.ok) { + await loadUsers(); + await loadManagedOffices(); + renderUsers(); + utils.showMessage('User deleted', 'success'); + } else { + const error = await response.json(); + utils.showMessage(error.detail || 'Failed to delete user', 'error'); + } +} + +function setupEventListeners() { + // Search + document.getElementById('searchInput').addEventListener('input', (e) => { + renderUsers(e.target.value); + }); + + // Modal + document.getElementById('closeUserModal').addEventListener('click', () => { + document.getElementById('userModal').style.display = 'none'; + }); + + document.getElementById('cancelUser').addEventListener('click', () => { + document.getElementById('userModal').style.display = 'none'; + }); + + // Role change shows/hides manager fields + document.getElementById('editRole').addEventListener('change', (e) => { + const isManager = e.target.value === 'manager'; + document.getElementById('quotaGroup').style.display = isManager ? 'block' : 'none'; + document.getElementById('prefixGroup').style.display = isManager ? 'block' : 'none'; + document.getElementById('managedOfficesGroup').style.display = isManager ? 'block' : 'none'; + }); + + // Form submit + document.getElementById('userForm').addEventListener('submit', async (e) => { + e.preventDefault(); + + const userId = document.getElementById('userId').value; + const role = document.getElementById('editRole').value; + + const data = { + name: document.getElementById('editName').value, + role: role, + office_id: document.getElementById('editOffice').value || null + }; + + if (role === 'manager') { + data.manager_parking_quota = parseInt(document.getElementById('editQuota').value) || 0; + data.manager_spot_prefix = document.getElementById('editPrefix').value.toUpperCase() || null; + } + + const response = await api.put(`/api/users/${userId}`, data); + if (response && response.ok) { + // Update managed offices if manager + if (role === 'manager') { + await updateManagedOffices(userId); + } + + document.getElementById('userModal').style.display = 'none'; + await loadUsers(); + await loadManagedOffices(); + renderUsers(); + utils.showMessage('User updated', 'success'); + } else { + const error = await response.json(); + utils.showMessage(error.detail || 'Failed to update user', 'error'); + } + }); + + utils.setupModalClose('userModal'); +} + +async function updateManagedOffices(userId) { + // Get currently selected offices + const checkboxes = document.querySelectorAll('input[name="managedOffice"]:checked'); + const selectedOfficeIds = Array.from(checkboxes).map(cb => cb.value); + + // Get current managed offices + const currentOfficeIds = managedOfficesMap[userId] || []; + + // Find offices to add and remove + const toAdd = selectedOfficeIds.filter(id => !currentOfficeIds.includes(id)); + const toRemove = currentOfficeIds.filter(id => !selectedOfficeIds.includes(id)); + + // Add new memberships + for (const officeId of toAdd) { + await api.post(`/api/offices/${officeId}/managers`, { user_id: userId }); + } + + // Remove old memberships + for (const officeId of toRemove) { + await api.delete(`/api/offices/${officeId}/managers/${userId}`); + } +} + +// Make functions globally accessible +window.editUser = editUser; +window.deleteUser = deleteUser; diff --git a/frontend/js/api.js b/frontend/js/api.js new file mode 100644 index 0000000..fe29646 --- /dev/null +++ b/frontend/js/api.js @@ -0,0 +1,174 @@ +/** + * API Client Wrapper + * Centralized API communication with auth handling + */ + +const api = { + /** + * Get the auth token from localStorage + */ + getToken() { + return localStorage.getItem('access_token'); + }, + + /** + * Set the auth token + */ + setToken(token) { + localStorage.setItem('access_token', token); + }, + + /** + * Clear the auth token + */ + clearToken() { + localStorage.removeItem('access_token'); + }, + + /** + * Check if user is authenticated + */ + isAuthenticated() { + return !!this.getToken(); + }, + + /** + * Redirect to login if not authenticated + */ + requireAuth() { + if (!this.isAuthenticated()) { + window.location.href = '/login'; + return false; + } + return true; + }, + + /** + * Make an API request + */ + async request(method, url, data = null) { + const options = { + method, + headers: {} + }; + + const token = this.getToken(); + if (token) { + options.headers['Authorization'] = `Bearer ${token}`; + } + + if (data) { + options.headers['Content-Type'] = 'application/json'; + options.body = JSON.stringify(data); + } + + const response = await fetch(url, options); + + // Handle 401 - redirect to login + if (response.status === 401) { + this.clearToken(); + window.location.href = '/login'; + return null; + } + + return response; + }, + + /** + * GET request + */ + async get(url) { + return this.request('GET', url); + }, + + /** + * POST request + */ + async post(url, data) { + return this.request('POST', url, data); + }, + + /** + * PUT request + */ + async put(url, data) { + return this.request('PUT', url, data); + }, + + /** + * PATCH request + */ + async patch(url, data) { + return this.request('PATCH', url, data); + }, + + /** + * DELETE request + */ + async delete(url) { + return this.request('DELETE', url); + }, + + /** + * Get current user info + */ + async getCurrentUser() { + const response = await this.get('/api/auth/me'); + if (response && response.ok) { + return await response.json(); + } + return null; + }, + + /** + * Login + */ + async login(email, password) { + const response = await fetch('/api/auth/login', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ email, password }) + }); + + if (response.ok) { + const data = await response.json(); + this.setToken(data.access_token); + return { success: true }; + } + + const error = await response.json(); + return { success: false, error: error.detail || 'Login failed' }; + }, + + /** + * Register + */ + async register(email, password, name, officeId = null) { + const response = await fetch('/api/auth/register', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ email, password, name, office_id: officeId }) + }); + + if (response.ok) { + const data = await response.json(); + this.setToken(data.access_token); + return { success: true }; + } + + const error = await response.json(); + return { success: false, error: error.detail || 'Registration failed' }; + }, + + /** + * Logout + */ + async logout() { + await this.post('/api/auth/logout', {}); + this.clearToken(); + window.location.href = '/login'; + } +}; + +// Make globally available +window.api = api; diff --git a/frontend/js/nav.js b/frontend/js/nav.js new file mode 100644 index 0000000..f9de41d --- /dev/null +++ b/frontend/js/nav.js @@ -0,0 +1,183 @@ +/** + * Navigation Component + * Sidebar navigation and user menu + */ + +const MENU_ICON = ` + + + +`; + +const ICONS = { + calendar: ` + + + + + `, + users: ` + + + + + `, + rules: ` + + + `, + user: ` + + + `, + building: ` + + + + + + + + ` +}; + +const NAV_ITEMS = [ + { href: '/presence', icon: 'calendar', label: 'My Presence' }, + { href: '/team-calendar', icon: 'users', label: 'Team Calendar', roles: ['admin', 'manager'] }, + { href: '/office-rules', icon: 'rules', label: 'Office Rules', roles: ['admin', 'manager'] }, + { href: '/admin/users', icon: 'user', label: 'Manage Users', roles: ['admin'] } +]; + +function getIcon(name) { + return ICONS[name] || ''; +} + +function canAccessNavItem(item, userRole) { + if (!item.roles || item.roles.length === 0) return true; + return userRole && item.roles.includes(userRole); +} + +function renderNav(currentPath, userRole) { + return NAV_ITEMS + .filter(item => canAccessNavItem(item, userRole)) + .map(item => { + const isActive = item.href === currentPath || + (currentPath !== '/' && item.href !== '/' && currentPath.startsWith(item.href)); + const activeClass = isActive ? ' active' : ''; + + return ` + ${getIcon(item.icon)} + ${item.label} + `; + }).join('\n'); +} + +async function initNav() { + const navContainer = document.querySelector('.sidebar-nav'); + if (!navContainer) return; + + const currentPath = window.location.pathname; + let userRole = null; + let currentUser = null; + + // Get user info + if (api.isAuthenticated()) { + currentUser = await api.getCurrentUser(); + if (currentUser) { + userRole = currentUser.role; + } + } + + // Render navigation + navContainer.innerHTML = renderNav(currentPath, userRole); + + // Update user info in sidebar + if (currentUser) { + const userName = document.getElementById('userName'); + const userRole = document.getElementById('userRole'); + if (userName) userName.textContent = currentUser.name || 'User'; + if (userRole) userRole.textContent = currentUser.role || '-'; + } + + // Setup user menu + setupUserMenu(); + + // Setup mobile menu + setupMobileMenu(); +} + +function setupMobileMenu() { + const sidebar = document.querySelector('.sidebar'); + const pageHeader = document.querySelector('.page-header'); + if (!sidebar || !pageHeader) return; + + // Add menu toggle button to page header (at the start) + const menuToggle = document.createElement('button'); + menuToggle.className = 'menu-toggle'; + menuToggle.innerHTML = MENU_ICON; + menuToggle.setAttribute('aria-label', 'Toggle menu'); + pageHeader.insertBefore(menuToggle, pageHeader.firstChild); + + // Add overlay + const overlay = document.createElement('div'); + overlay.className = 'sidebar-overlay'; + document.body.appendChild(overlay); + + // Toggle sidebar + menuToggle.addEventListener('click', () => { + sidebar.classList.toggle('open'); + overlay.classList.toggle('open'); + }); + + // Close sidebar when clicking overlay + overlay.addEventListener('click', () => { + sidebar.classList.remove('open'); + overlay.classList.remove('open'); + }); + + // Close sidebar when clicking a nav item (on mobile) + sidebar.querySelectorAll('.nav-item').forEach(item => { + item.addEventListener('click', () => { + if (window.innerWidth <= 768) { + sidebar.classList.remove('open'); + overlay.classList.remove('open'); + } + }); + }); +} + +function setupUserMenu() { + const userMenuButton = document.getElementById('userMenuButton'); + const userDropdown = document.getElementById('userDropdown'); + const logoutButton = document.getElementById('logoutButton'); + + if (userMenuButton && userDropdown) { + userMenuButton.addEventListener('click', (e) => { + e.stopPropagation(); + const isOpen = userDropdown.style.display === 'block'; + userDropdown.style.display = isOpen ? 'none' : 'block'; + }); + + document.addEventListener('click', () => { + userDropdown.style.display = 'none'; + }); + + userDropdown.addEventListener('click', (e) => e.stopPropagation()); + } + + if (logoutButton) { + logoutButton.addEventListener('click', () => { + api.logout(); + }); + } +} + +// Export for use in other scripts +window.getIcon = getIcon; + +// Auto-initialize +if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', initNav); +} else { + initNav(); +} diff --git a/frontend/js/office-rules.js b/frontend/js/office-rules.js new file mode 100644 index 0000000..c500489 --- /dev/null +++ b/frontend/js/office-rules.js @@ -0,0 +1,376 @@ +/** + * Office Rules Page + * Manage closing days, parking guarantees, and exclusions + * + * Rules are set at manager level and apply to all offices managed by that manager. + */ + +let currentUser = null; +let selectedManagerId = null; +let managerUsers = []; + +document.addEventListener('DOMContentLoaded', async () => { + if (!api.requireAuth()) return; + + currentUser = await api.getCurrentUser(); + if (!currentUser) return; + + // Only managers and admins can access + if (currentUser.role === 'employee') { + window.location.href = '/presence'; + return; + } + + await loadManagers(); + setupEventListeners(); +}); + +async function loadManagers() { + const response = await api.get('/api/offices/managers/list'); + if (response && response.ok) { + const managers = await response.json(); + const select = document.getElementById('officeSelect'); + + // Filter to managers this user can see + let filteredManagers = managers; + if (currentUser.role === 'manager') { + // Manager only sees themselves + filteredManagers = managers.filter(m => m.id === currentUser.id); + } + + // Show managers in dropdown + let totalManagers = 0; + let firstManagerId = null; + + filteredManagers.forEach(manager => { + const option = document.createElement('option'); + option.value = manager.id; + // Show manager name and count of offices + const officeCount = manager.offices.length; + const officeNames = manager.offices.map(o => o.name).join(', '); + if (officeCount > 0) { + option.textContent = `${manager.name} (${officeNames})`; + } else { + option.textContent = `${manager.name} (no offices)`; + } + select.appendChild(option); + totalManagers++; + if (!firstManagerId) firstManagerId = manager.id; + }); + + // Auto-select if only one manager + if (totalManagers === 1 && firstManagerId) { + select.value = firstManagerId; + await selectManager(firstManagerId); + } + } +} + +async function selectManager(managerId) { + selectedManagerId = managerId; + + if (!managerId) { + document.getElementById('rulesContent').style.display = 'none'; + document.getElementById('noOfficeMessage').style.display = 'block'; + return; + } + + document.getElementById('rulesContent').style.display = 'block'; + document.getElementById('noOfficeMessage').style.display = 'none'; + + await Promise.all([ + loadWeeklyClosingDays(), + loadClosingDays(), + loadGuarantees(), + loadExclusions(), + loadManagerUsers() + ]); +} + +async function loadWeeklyClosingDays() { + const response = await api.get(`/api/offices/managers/${selectedManagerId}/weekly-closing-days`); + if (response && response.ok) { + const days = await response.json(); + const weekdays = days.map(d => d.weekday); + + // Update checkboxes + document.querySelectorAll('#weeklyClosingDays input[type="checkbox"]').forEach(cb => { + const weekday = parseInt(cb.dataset.weekday); + cb.checked = weekdays.includes(weekday); + }); + } +} + +async function loadManagerUsers() { + const response = await api.get(`/api/offices/managers/${selectedManagerId}/users`); + if (response && response.ok) { + managerUsers = await response.json(); + updateUserSelects(); + } +} + +function updateUserSelects() { + ['guaranteeUser', 'exclusionUser'].forEach(selectId => { + const select = document.getElementById(selectId); + select.innerHTML = ''; + managerUsers.forEach(user => { + const option = document.createElement('option'); + option.value = user.id; + option.textContent = user.name; + select.appendChild(option); + }); + }); +} + +async function loadClosingDays() { + const response = await api.get(`/api/offices/managers/${selectedManagerId}/closing-days`); + const container = document.getElementById('closingDaysList'); + + if (response && response.ok) { + const days = await response.json(); + if (days.length === 0) { + container.innerHTML = ''; + return; + } + + container.innerHTML = days.map(day => ` +
+
+ ${utils.formatDateDisplay(day.date)} + ${day.reason ? `${day.reason}` : ''} +
+ +
+ `).join(''); + } +} + +function formatDateRange(startDate, endDate) { + if (!startDate && !endDate) return ''; + if (startDate && !endDate) return `From ${utils.formatDateDisplay(startDate)}`; + if (!startDate && endDate) return `Until ${utils.formatDateDisplay(endDate)}`; + return `${utils.formatDateDisplay(startDate)} - ${utils.formatDateDisplay(endDate)}`; +} + +async function loadGuarantees() { + const response = await api.get(`/api/offices/managers/${selectedManagerId}/guarantees`); + const container = document.getElementById('guaranteesList'); + + if (response && response.ok) { + const guarantees = await response.json(); + if (guarantees.length === 0) { + container.innerHTML = ''; + return; + } + + container.innerHTML = guarantees.map(g => { + const dateRange = formatDateRange(g.start_date, g.end_date); + return ` +
+
+ ${g.user_name} + ${dateRange ? `${dateRange}` : ''} +
+ +
+ `}).join(''); + } +} + +async function loadExclusions() { + const response = await api.get(`/api/offices/managers/${selectedManagerId}/exclusions`); + const container = document.getElementById('exclusionsList'); + + if (response && response.ok) { + const exclusions = await response.json(); + if (exclusions.length === 0) { + container.innerHTML = ''; + return; + } + + container.innerHTML = exclusions.map(e => { + const dateRange = formatDateRange(e.start_date, e.end_date); + return ` +
+
+ ${e.user_name} + ${dateRange ? `${dateRange}` : ''} +
+ +
+ `}).join(''); + } +} + +// Delete functions +async function deleteClosingDay(id) { + if (!confirm('Delete this closing day?')) return; + const response = await api.delete(`/api/offices/managers/${selectedManagerId}/closing-days/${id}`); + if (response && response.ok) { + await loadClosingDays(); + } +} + +async function deleteGuarantee(id) { + if (!confirm('Remove this parking guarantee?')) return; + const response = await api.delete(`/api/offices/managers/${selectedManagerId}/guarantees/${id}`); + if (response && response.ok) { + await loadGuarantees(); + } +} + +async function deleteExclusion(id) { + if (!confirm('Remove this parking exclusion?')) return; + const response = await api.delete(`/api/offices/managers/${selectedManagerId}/exclusions/${id}`); + if (response && response.ok) { + await loadExclusions(); + } +} + +function setupEventListeners() { + // Manager selection + document.getElementById('officeSelect').addEventListener('change', (e) => { + selectManager(e.target.value); + }); + + // Weekly closing day checkboxes + document.querySelectorAll('#weeklyClosingDays input[type="checkbox"]').forEach(cb => { + cb.addEventListener('change', async (e) => { + const weekday = parseInt(e.target.dataset.weekday); + + if (e.target.checked) { + // Add weekly closing day + const response = await api.post(`/api/offices/managers/${selectedManagerId}/weekly-closing-days`, { weekday }); + if (!response || !response.ok) { + e.target.checked = false; + const error = await response.json(); + alert(error.detail || 'Failed to add weekly closing day'); + } + } else { + // Remove weekly closing day - need to find the ID first + const getResponse = await api.get(`/api/offices/managers/${selectedManagerId}/weekly-closing-days`); + if (getResponse && getResponse.ok) { + const days = await getResponse.json(); + const day = days.find(d => d.weekday === weekday); + if (day) { + const deleteResponse = await api.delete(`/api/offices/managers/${selectedManagerId}/weekly-closing-days/${day.id}`); + if (!deleteResponse || !deleteResponse.ok) { + e.target.checked = true; + } + } + } + } + }); + }); + + // Modal openers + document.getElementById('addClosingDayBtn').addEventListener('click', () => { + document.getElementById('closingDayForm').reset(); + document.getElementById('closingDayModal').style.display = 'flex'; + }); + + document.getElementById('addGuaranteeBtn').addEventListener('click', () => { + document.getElementById('guaranteeForm').reset(); + document.getElementById('guaranteeModal').style.display = 'flex'; + }); + + document.getElementById('addExclusionBtn').addEventListener('click', () => { + document.getElementById('exclusionForm').reset(); + document.getElementById('exclusionModal').style.display = 'flex'; + }); + + // Modal closers + ['closeClosingDayModal', 'cancelClosingDay'].forEach(id => { + document.getElementById(id).addEventListener('click', () => { + document.getElementById('closingDayModal').style.display = 'none'; + }); + }); + + ['closeGuaranteeModal', 'cancelGuarantee'].forEach(id => { + document.getElementById(id).addEventListener('click', () => { + document.getElementById('guaranteeModal').style.display = 'none'; + }); + }); + + ['closeExclusionModal', 'cancelExclusion'].forEach(id => { + document.getElementById(id).addEventListener('click', () => { + document.getElementById('exclusionModal').style.display = 'none'; + }); + }); + + // Form submissions + document.getElementById('closingDayForm').addEventListener('submit', async (e) => { + e.preventDefault(); + const data = { + date: document.getElementById('closingDate').value, + reason: document.getElementById('closingReason').value || null + }; + const response = await api.post(`/api/offices/managers/${selectedManagerId}/closing-days`, data); + if (response && response.ok) { + document.getElementById('closingDayModal').style.display = 'none'; + await loadClosingDays(); + } else { + const error = await response.json(); + alert(error.detail || 'Failed to add closing day'); + } + }); + + document.getElementById('guaranteeForm').addEventListener('submit', async (e) => { + e.preventDefault(); + const data = { + user_id: document.getElementById('guaranteeUser').value, + start_date: document.getElementById('guaranteeStartDate').value || null, + end_date: document.getElementById('guaranteeEndDate').value || null + }; + const response = await api.post(`/api/offices/managers/${selectedManagerId}/guarantees`, data); + if (response && response.ok) { + document.getElementById('guaranteeModal').style.display = 'none'; + await loadGuarantees(); + } else { + const error = await response.json(); + alert(error.detail || 'Failed to add guarantee'); + } + }); + + document.getElementById('exclusionForm').addEventListener('submit', async (e) => { + e.preventDefault(); + const data = { + user_id: document.getElementById('exclusionUser').value, + start_date: document.getElementById('exclusionStartDate').value || null, + end_date: document.getElementById('exclusionEndDate').value || null + }; + const response = await api.post(`/api/offices/managers/${selectedManagerId}/exclusions`, data); + if (response && response.ok) { + document.getElementById('exclusionModal').style.display = 'none'; + await loadExclusions(); + } else { + const error = await response.json(); + alert(error.detail || 'Failed to add exclusion'); + } + }); + + // Modal background clicks + utils.setupModalClose('closingDayModal'); + utils.setupModalClose('guaranteeModal'); + utils.setupModalClose('exclusionModal'); +} + +// Make delete functions globally accessible +window.deleteClosingDay = deleteClosingDay; +window.deleteGuarantee = deleteGuarantee; +window.deleteExclusion = deleteExclusion; diff --git a/frontend/js/presence.js b/frontend/js/presence.js new file mode 100644 index 0000000..500820f --- /dev/null +++ b/frontend/js/presence.js @@ -0,0 +1,370 @@ +/** + * My Presence Page + * Personal calendar for marking daily presence + */ + +let currentUser = null; +let currentDate = new Date(); +let presenceData = {}; +let parkingData = {}; +let currentAssignmentId = null; + +document.addEventListener('DOMContentLoaded', async () => { + if (!api.requireAuth()) return; + + currentUser = await api.getCurrentUser(); + if (!currentUser) return; + + await Promise.all([loadPresences(), loadParkingAssignments()]); + renderCalendar(); + setupEventListeners(); +}); + +async function loadPresences() { + const year = currentDate.getFullYear(); + const month = currentDate.getMonth(); + const firstDay = new Date(year, month, 1); + const lastDay = new Date(year, month + 1, 0); + + const startDate = utils.formatDate(firstDay); + const endDate = utils.formatDate(lastDay); + + const response = await api.get(`/api/presence/my-presences?start_date=${startDate}&end_date=${endDate}`); + if (response && response.ok) { + const presences = await response.json(); + presenceData = {}; + presences.forEach(p => { + presenceData[p.date] = p; + }); + } +} + +async function loadParkingAssignments() { + const year = currentDate.getFullYear(); + const month = currentDate.getMonth(); + const firstDay = new Date(year, month, 1); + const lastDay = new Date(year, month + 1, 0); + + const startDate = utils.formatDate(firstDay); + const endDate = utils.formatDate(lastDay); + + const response = await api.get(`/api/parking/my-assignments?start_date=${startDate}&end_date=${endDate}`); + if (response && response.ok) { + const assignments = await response.json(); + parkingData = {}; + assignments.forEach(a => { + parkingData[a.date] = a; + }); + } +} + +function renderCalendar() { + const year = currentDate.getFullYear(); + const month = currentDate.getMonth(); + const weekStartDay = currentUser.week_start_day || 0; // 0=Sunday, 1=Monday + + // Update month header + document.getElementById('currentMonth').textContent = `${utils.getMonthName(month)} ${year}`; + + // Get calendar info + const daysInMonth = utils.getDaysInMonth(year, month); + const firstDayOfMonth = new Date(year, month, 1).getDay(); // 0=Sunday + const today = new Date(); + + // Calculate offset based on week start preference + let firstDayOffset = firstDayOfMonth - weekStartDay; + if (firstDayOffset < 0) firstDayOffset += 7; + + // Build calendar grid + const grid = document.getElementById('calendarGrid'); + grid.innerHTML = ''; + + // Day headers - reorder based on week start day + const allDayNames = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']; + const dayNames = []; + for (let i = 0; i < 7; i++) { + dayNames.push(allDayNames[(weekStartDay + i) % 7]); + } + dayNames.forEach(name => { + const header = document.createElement('div'); + header.className = 'calendar-day'; + header.style.cursor = 'default'; + header.style.fontWeight = '600'; + header.style.fontSize = '0.75rem'; + header.textContent = name; + grid.appendChild(header); + }); + + // Empty cells before first day + for (let i = 0; i < firstDayOffset; i++) { + const empty = document.createElement('div'); + empty.className = 'calendar-day'; + empty.style.visibility = 'hidden'; + grid.appendChild(empty); + } + + // Day cells + for (let day = 1; day <= daysInMonth; day++) { + const date = new Date(year, month, day); + const dateStr = utils.formatDate(date); + const dayOfWeek = date.getDay(); + const isWeekend = dayOfWeek === 0 || dayOfWeek === 6; + const isHoliday = utils.isItalianHoliday(date); + const isToday = date.toDateString() === today.toDateString(); + const presence = presenceData[dateStr]; + const parking = parkingData[dateStr]; + + const cell = document.createElement('div'); + cell.className = 'calendar-day'; + cell.dataset.date = dateStr; + + if (isWeekend) cell.classList.add('weekend'); + if (isHoliday) cell.classList.add('holiday'); + if (isToday) cell.classList.add('today'); + + if (presence) { + cell.classList.add(`status-${presence.status}`); + } + + // Show parking badge if assigned + const parkingBadge = parking + ? `${parking.spot_display_name || parking.spot_id}` + : ''; + + cell.innerHTML = ` +
${day}
+ ${parkingBadge} + `; + + cell.addEventListener('click', () => openDayModal(dateStr, presence, parking)); + grid.appendChild(cell); + } +} + +function openDayModal(dateStr, presence, parking) { + const modal = document.getElementById('dayModal'); + const title = document.getElementById('dayModalTitle'); + + title.textContent = utils.formatDateDisplay(dateStr); + + // Highlight current status + document.querySelectorAll('.status-btn').forEach(btn => { + const status = btn.dataset.status; + if (presence && presence.status === status) { + btn.classList.add('active'); + } else { + btn.classList.remove('active'); + } + }); + + // Update parking section + const parkingSection = document.getElementById('parkingSection'); + const parkingInfo = document.getElementById('parkingInfo'); + const releaseBtn = document.getElementById('releaseParkingBtn'); + + if (parking) { + parkingSection.style.display = 'block'; + const spotName = parking.spot_display_name || parking.spot_id; + parkingInfo.innerHTML = `Parking: Spot ${spotName}`; + releaseBtn.dataset.assignmentId = parking.id; + document.getElementById('reassignParkingBtn').dataset.assignmentId = parking.id; + currentAssignmentId = parking.id; + } else { + parkingSection.style.display = 'none'; + } + + modal.dataset.date = dateStr; + modal.style.display = 'flex'; +} + +async function markPresence(status) { + const modal = document.getElementById('dayModal'); + const date = modal.dataset.date; + + const response = await api.post('/api/presence/mark', { date, status }); + if (response && response.ok) { + await Promise.all([loadPresences(), loadParkingAssignments()]); + renderCalendar(); + modal.style.display = 'none'; + } else { + const error = await response.json(); + alert(error.detail || 'Failed to mark presence'); + } +} + +async function clearPresence() { + const modal = document.getElementById('dayModal'); + const date = modal.dataset.date; + + if (!confirm('Clear presence for this date?')) return; + + const response = await api.delete(`/api/presence/${date}`); + if (response && response.ok) { + await Promise.all([loadPresences(), loadParkingAssignments()]); + renderCalendar(); + modal.style.display = 'none'; + } +} + +async function releaseParking() { + const modal = document.getElementById('dayModal'); + const releaseBtn = document.getElementById('releaseParkingBtn'); + const assignmentId = releaseBtn.dataset.assignmentId; + + if (!assignmentId) return; + if (!confirm('Release your parking spot for this date?')) return; + + const response = await api.post(`/api/parking/release-my-spot/${assignmentId}`); + + if (response && response.ok) { + await loadParkingAssignments(); + renderCalendar(); + modal.style.display = 'none'; + } else { + const error = await response.json(); + alert(error.detail || 'Failed to release parking spot'); + } +} + +async function openReassignModal() { + const assignmentId = currentAssignmentId; + if (!assignmentId) return; + + // Load eligible users + const response = await api.get(`/api/parking/eligible-users/${assignmentId}`); + if (!response || !response.ok) { + const error = await response.json(); + alert(error.detail || 'Failed to load eligible users'); + return; + } + + const users = await response.json(); + const select = document.getElementById('reassignUser'); + select.innerHTML = ''; + + if (users.length === 0) { + select.innerHTML = ''; + } else { + users.forEach(user => { + const option = document.createElement('option'); + option.value = user.id; + option.textContent = user.name; + select.appendChild(option); + }); + } + + // Get spot info from parking data + const parking = Object.values(parkingData).find(p => p.id === assignmentId); + if (parking) { + const spotName = parking.spot_display_name || parking.spot_id; + document.getElementById('reassignSpotInfo').textContent = `Spot ${spotName}`; + } + + document.getElementById('dayModal').style.display = 'none'; + document.getElementById('reassignModal').style.display = 'flex'; +} + +async function confirmReassign() { + const assignmentId = currentAssignmentId; + const newUserId = document.getElementById('reassignUser').value; + + if (!assignmentId || !newUserId) { + alert('Please select a user'); + return; + } + + const response = await api.post('/api/parking/reassign-spot', { + assignment_id: assignmentId, + new_user_id: newUserId + }); + + if (response && response.ok) { + await loadParkingAssignments(); + renderCalendar(); + document.getElementById('reassignModal').style.display = 'none'; + } else { + const error = await response.json(); + alert(error.detail || 'Failed to reassign parking spot'); + } +} + +function setupEventListeners() { + // Month navigation + document.getElementById('prevMonth').addEventListener('click', async () => { + currentDate.setMonth(currentDate.getMonth() - 1); + await Promise.all([loadPresences(), loadParkingAssignments()]); + renderCalendar(); + }); + + document.getElementById('nextMonth').addEventListener('click', async () => { + currentDate.setMonth(currentDate.getMonth() + 1); + await Promise.all([loadPresences(), loadParkingAssignments()]); + renderCalendar(); + }); + + // Day modal + document.getElementById('closeDayModal').addEventListener('click', () => { + document.getElementById('dayModal').style.display = 'none'; + }); + + document.querySelectorAll('.status-btn').forEach(btn => { + btn.addEventListener('click', () => markPresence(btn.dataset.status)); + }); + + document.getElementById('clearDayBtn').addEventListener('click', clearPresence); + document.getElementById('releaseParkingBtn').addEventListener('click', releaseParking); + document.getElementById('reassignParkingBtn').addEventListener('click', openReassignModal); + + utils.setupModalClose('dayModal'); + + // Reassign modal + document.getElementById('closeReassignModal').addEventListener('click', () => { + document.getElementById('reassignModal').style.display = 'none'; + }); + document.getElementById('cancelReassign').addEventListener('click', () => { + document.getElementById('reassignModal').style.display = 'none'; + }); + document.getElementById('confirmReassign').addEventListener('click', confirmReassign); + utils.setupModalClose('reassignModal'); + + // Bulk mark + document.getElementById('bulkMarkBtn').addEventListener('click', () => { + document.getElementById('bulkMarkModal').style.display = 'flex'; + }); + + document.getElementById('closeBulkModal').addEventListener('click', () => { + document.getElementById('bulkMarkModal').style.display = 'none'; + }); + + document.getElementById('cancelBulk').addEventListener('click', () => { + document.getElementById('bulkMarkModal').style.display = 'none'; + }); + + utils.setupModalClose('bulkMarkModal'); + + document.getElementById('bulkMarkForm').addEventListener('submit', async (e) => { + e.preventDefault(); + + const startDate = document.getElementById('startDate').value; + const endDate = document.getElementById('endDate').value; + const status = document.getElementById('bulkStatus').value; + const weekdaysOnly = document.getElementById('weekdaysOnly').checked; + + const data = { start_date: startDate, end_date: endDate, status }; + if (weekdaysOnly) { + data.days = [1, 2, 3, 4, 5]; // Mon-Fri (JS weekday) + } + + const response = await api.post('/api/presence/mark-bulk', data); + if (response && response.ok) { + const results = await response.json(); + alert(`Marked ${results.length} dates`); + document.getElementById('bulkMarkModal').style.display = 'none'; + await Promise.all([loadPresences(), loadParkingAssignments()]); + renderCalendar(); + } else { + const error = await response.json(); + alert(error.detail || 'Failed to bulk mark'); + } + }); +} diff --git a/frontend/js/team-calendar.js b/frontend/js/team-calendar.js new file mode 100644 index 0000000..2fd5e03 --- /dev/null +++ b/frontend/js/team-calendar.js @@ -0,0 +1,408 @@ +/** + * Team Calendar Page + * Shows presence and parking for all team members + * Filtered by manager (manager-centric model) + */ + +let currentUser = null; +let currentStartDate = null; +let viewMode = 'week'; // 'week' or 'month' +let managers = []; +let teamData = []; +let parkingDataLookup = {}; +let parkingAssignmentLookup = {}; +let selectedUserId = null; +let selectedDate = null; +let currentAssignmentId = null; + +document.addEventListener('DOMContentLoaded', async () => { + if (!api.requireAuth()) return; + + currentUser = await api.getCurrentUser(); + if (!currentUser) return; + + // Only managers and admins can view + if (currentUser.role === 'employee') { + window.location.href = '/presence'; + return; + } + + // Initialize start date based on week start preference + const weekStartDay = currentUser.week_start_day || 0; + currentStartDate = utils.getWeekStart(new Date(), weekStartDay); + + await loadManagers(); + await loadTeamData(); + renderCalendar(); + setupEventListeners(); +}); + +async function loadManagers() { + const response = await api.get('/api/offices/managers/list'); + if (response && response.ok) { + managers = await response.json(); + const select = document.getElementById('managerFilter'); + + // Filter managers based on user role + let filteredManagers = managers; + if (currentUser.role === 'manager') { + // Manager only sees themselves + filteredManagers = managers.filter(m => m.id === currentUser.id); + } + + filteredManagers.forEach(manager => { + const option = document.createElement('option'); + option.value = manager.id; + const officeNames = manager.offices.map(o => o.name).join(', '); + option.textContent = `${manager.name} (${officeNames})`; + select.appendChild(option); + }); + + // Auto-select if only one manager (for manager role) + if (filteredManagers.length === 1) { + select.value = filteredManagers[0].id; + } + } +} + +function getDateRange() { + let startDate = new Date(currentStartDate); + let endDate = new Date(currentStartDate); + + if (viewMode === 'week') { + endDate.setDate(endDate.getDate() + 6); + } else { + // Month view - start from first day of month + startDate = new Date(currentStartDate.getFullYear(), currentStartDate.getMonth(), 1); + endDate = new Date(currentStartDate.getFullYear(), currentStartDate.getMonth() + 1, 0); + } + + return { startDate, endDate }; +} + +async function loadTeamData() { + const { startDate, endDate } = getDateRange(); + const startStr = utils.formatDate(startDate); + const endStr = utils.formatDate(endDate); + + let url = `/api/presence/team?start_date=${startStr}&end_date=${endStr}`; + + const managerFilter = document.getElementById('managerFilter').value; + if (managerFilter) { + url += `&manager_id=${managerFilter}`; + } + + const response = await api.get(url); + if (response && response.ok) { + teamData = await response.json(); + // Build parking lookup with spot names and assignment IDs + parkingDataLookup = {}; + parkingAssignmentLookup = {}; + teamData.forEach(member => { + if (member.parking_info) { + member.parking_info.forEach(p => { + const key = `${member.id}_${p.date}`; + parkingDataLookup[key] = p.spot_display_name || p.spot_id; + parkingAssignmentLookup[key] = p.id; + }); + } + }); + } +} + +function renderCalendar() { + const header = document.getElementById('calendarHeader'); + const body = document.getElementById('calendarBody'); + const { startDate, endDate } = getDateRange(); + + // Update header text + if (viewMode === 'week') { + document.getElementById('currentWeek').textContent = + `${utils.formatDateShort(startDate)} - ${utils.formatDateShort(endDate)}`; + } else { + document.getElementById('currentWeek').textContent = + `${utils.getMonthName(startDate.getMonth())} ${startDate.getFullYear()}`; + } + + // Calculate number of days + const dayCount = Math.round((endDate - startDate) / (1000 * 60 * 60 * 24)) + 1; + + // Build header row + const dayNames = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']; + let headerHtml = 'NameOffice'; + + for (let i = 0; i < dayCount; i++) { + const date = new Date(startDate); + date.setDate(date.getDate() + i); + const dayOfWeek = date.getDay(); + const isWeekend = dayOfWeek === 0 || dayOfWeek === 6; + const isHoliday = utils.isItalianHoliday(date); + const isToday = date.toDateString() === new Date().toDateString(); + + let classes = []; + if (isWeekend) classes.push('weekend'); + if (isHoliday) classes.push('holiday'); + if (isToday) classes.push('today'); + + headerHtml += ` +
${dayNames[dayOfWeek].charAt(0)}
+
${date.getDate()}
+ `; + } + header.innerHTML = headerHtml; + + // Build body rows + if (teamData.length === 0) { + body.innerHTML = `No team members found`; + return; + } + + let bodyHtml = ''; + teamData.forEach(member => { + bodyHtml += ` + ${member.name || 'Unknown'} + ${member.office_name || '-'}`; + + for (let i = 0; i < dayCount; i++) { + const date = new Date(startDate); + date.setDate(date.getDate() + i); + const dateStr = utils.formatDate(date); + const dayOfWeek = date.getDay(); + const isWeekend = dayOfWeek === 0 || dayOfWeek === 6; + const isHoliday = utils.isItalianHoliday(date); + + const presence = member.presences.find(p => p.date === dateStr); + const parkingKey = `${member.id}_${dateStr}`; + const parkingSpot = parkingDataLookup[parkingKey]; + const hasParking = member.parking_dates && member.parking_dates.includes(dateStr); + const isToday = date.toDateString() === new Date().toDateString(); + + let cellClasses = ['calendar-cell']; + if (isWeekend) cellClasses.push('weekend'); + if (isHoliday) cellClasses.push('holiday'); + if (isToday) cellClasses.push('today'); + if (presence) cellClasses.push(`status-${presence.status}`); + + // Show parking badge instead of just 'P' + let parkingBadge = ''; + if (hasParking) { + const spotName = parkingSpot || 'P'; + parkingBadge = `${spotName}`; + } + + bodyHtml += `${parkingBadge}`; + } + + bodyHtml += ''; + }); + body.innerHTML = bodyHtml; + + // Add click handlers to cells + body.querySelectorAll('.calendar-cell').forEach(cell => { + cell.style.cursor = 'pointer'; + cell.addEventListener('click', () => { + const userId = cell.dataset.userId; + const date = cell.dataset.date; + const userName = cell.dataset.userName; + openDayModal(userId, date, userName); + }); + }); +} + +function openDayModal(userId, dateStr, userName) { + selectedUserId = userId; + selectedDate = dateStr; + + const modal = document.getElementById('dayModal'); + document.getElementById('dayModalTitle').textContent = utils.formatDateDisplay(dateStr); + document.getElementById('dayModalUser').textContent = userName; + + // Find current status and parking + const member = teamData.find(m => m.id === userId); + const presence = member?.presences.find(p => p.date === dateStr); + const parkingKey = `${userId}_${dateStr}`; + const parkingSpot = parkingDataLookup[parkingKey]; + const assignmentId = parkingAssignmentLookup[parkingKey]; + + // Highlight current 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'); + } + }); + + // Update parking section + const parkingSection = document.getElementById('parkingSection'); + const parkingInfo = document.getElementById('parkingInfo'); + + if (parkingSpot) { + parkingSection.style.display = 'block'; + parkingInfo.innerHTML = `Parking: Spot ${parkingSpot}`; + currentAssignmentId = assignmentId; + } else { + parkingSection.style.display = 'none'; + currentAssignmentId = null; + } + + modal.style.display = 'flex'; +} + +async function markPresence(status) { + if (!selectedUserId || !selectedDate) return; + + const response = await api.post('/api/presence/admin/mark', { + user_id: selectedUserId, + date: selectedDate, + status: status + }); + + if (response && response.ok) { + document.getElementById('dayModal').style.display = 'none'; + await loadTeamData(); + renderCalendar(); + } else { + const error = await response.json(); + alert(error.detail || 'Failed to mark presence'); + } +} + +async function clearPresence() { + if (!selectedUserId || !selectedDate) return; + if (!confirm('Clear presence for this date?')) return; + + const response = await api.delete(`/api/presence/admin/${selectedUserId}/${selectedDate}`); + if (response && response.ok) { + document.getElementById('dayModal').style.display = 'none'; + await loadTeamData(); + renderCalendar(); + } +} + +async function openReassignModal() { + if (!currentAssignmentId) return; + + // Load eligible users + const response = await api.get(`/api/parking/eligible-users/${currentAssignmentId}`); + if (!response || !response.ok) { + const error = await response.json(); + alert(error.detail || 'Failed to load eligible users'); + return; + } + + const users = await response.json(); + const select = document.getElementById('reassignUser'); + select.innerHTML = ''; + + if (users.length === 0) { + select.innerHTML = ''; + } else { + users.forEach(user => { + const option = document.createElement('option'); + option.value = user.id; + option.textContent = user.name; + select.appendChild(option); + }); + } + + // Get spot info + const parkingKey = `${selectedUserId}_${selectedDate}`; + const spotName = parkingDataLookup[parkingKey] || 'Unknown'; + document.getElementById('reassignSpotInfo').textContent = `Spot ${spotName}`; + + document.getElementById('dayModal').style.display = 'none'; + document.getElementById('reassignModal').style.display = 'flex'; +} + +async function confirmReassign() { + const newUserId = document.getElementById('reassignUser').value; + + if (!currentAssignmentId || !newUserId) { + alert('Please select a user'); + return; + } + + const response = await api.post('/api/parking/reassign-spot', { + assignment_id: currentAssignmentId, + new_user_id: newUserId + }); + + if (response && response.ok) { + await loadTeamData(); + renderCalendar(); + document.getElementById('reassignModal').style.display = 'none'; + } else { + const error = await response.json(); + alert(error.detail || 'Failed to reassign parking spot'); + } +} + +function setupEventListeners() { + // Navigation (prev/next) + document.getElementById('prevWeek').addEventListener('click', async () => { + if (viewMode === 'week') { + currentStartDate.setDate(currentStartDate.getDate() - 7); + } else { + currentStartDate.setMonth(currentStartDate.getMonth() - 1); + } + await loadTeamData(); + renderCalendar(); + }); + + document.getElementById('nextWeek').addEventListener('click', async () => { + if (viewMode === 'week') { + currentStartDate.setDate(currentStartDate.getDate() + 7); + } else { + currentStartDate.setMonth(currentStartDate.getMonth() + 1); + } + await loadTeamData(); + renderCalendar(); + }); + + // View toggle (week/month) + document.getElementById('viewToggle').addEventListener('change', async (e) => { + viewMode = e.target.value; + if (viewMode === 'month') { + // Set to first day of current month + currentStartDate = new Date(currentStartDate.getFullYear(), currentStartDate.getMonth(), 1); + } else { + // Set to current week start + const weekStartDay = currentUser.week_start_day || 0; + currentStartDate = utils.getWeekStart(new Date(), weekStartDay); + } + await loadTeamData(); + renderCalendar(); + }); + + // Manager filter + document.getElementById('managerFilter').addEventListener('change', async () => { + await loadTeamData(); + renderCalendar(); + }); + + // Day modal + document.getElementById('closeDayModal').addEventListener('click', () => { + document.getElementById('dayModal').style.display = 'none'; + }); + + document.querySelectorAll('#dayModal .status-btn').forEach(btn => { + btn.addEventListener('click', () => markPresence(btn.dataset.status)); + }); + + document.getElementById('clearDayBtn').addEventListener('click', clearPresence); + document.getElementById('reassignParkingBtn').addEventListener('click', openReassignModal); + + utils.setupModalClose('dayModal'); + + // Reassign modal + document.getElementById('closeReassignModal').addEventListener('click', () => { + document.getElementById('reassignModal').style.display = 'none'; + }); + document.getElementById('cancelReassign').addEventListener('click', () => { + document.getElementById('reassignModal').style.display = 'none'; + }); + document.getElementById('confirmReassign').addEventListener('click', confirmReassign); + utils.setupModalClose('reassignModal'); +} diff --git a/frontend/js/utils.js b/frontend/js/utils.js new file mode 100644 index 0000000..0ace644 --- /dev/null +++ b/frontend/js/utils.js @@ -0,0 +1,222 @@ +/** + * Utility Functions + * Date handling, holidays, and common helpers + */ + +// Holiday cache: { year: Set of date strings } +const holidayCache = {}; + +/** + * Load holidays for a year from API (called automatically) + */ +async function loadHolidaysForYear(year) { + if (holidayCache[year]) return; + + try { + const response = await fetch(`/api/auth/holidays/${year}`); + if (response.ok) { + const holidays = await response.json(); + holidayCache[year] = new Set(holidays.map(h => h.date)); + } + } catch (e) { + // Fall back to local calculation if API fails + console.warn('Holiday API failed, using local fallback'); + } +} + +/** + * Calculate Easter Sunday using Computus algorithm (fallback) + */ +function calculateEaster(year) { + const a = year % 19; + const b = Math.floor(year / 100); + const c = year % 100; + const d = Math.floor(b / 4); + const e = b % 4; + const f = Math.floor((b + 8) / 25); + const g = Math.floor((b - f + 1) / 3); + const h = (19 * a + b - d - g + 15) % 30; + const i = Math.floor(c / 4); + const k = c % 4; + const l = (32 + 2 * e + 2 * i - h - k) % 7; + const m = Math.floor((a + 11 * h + 22 * l) / 451); + const month = Math.floor((h + l - 7 * m + 114) / 31); + const day = ((h + l - 7 * m + 114) % 31) + 1; + return new Date(year, month - 1, day); +} + +/** + * Check if a date is an Italian holiday + * Uses cached API data if available, otherwise falls back to local calculation + */ +function isItalianHoliday(date) { + const year = date.getFullYear(); + const dateStr = formatDate(date); + + // Check cache first + if (holidayCache[year] && holidayCache[year].has(dateStr)) { + return true; + } + + // Fallback: local calculation + const month = date.getMonth() + 1; + const day = date.getDate(); + + const fixedHolidays = [ + [1, 1], [1, 6], [4, 25], [5, 1], [6, 2], + [8, 15], [11, 1], [12, 8], [12, 25], [12, 26] + ]; + + for (const [hm, hd] of fixedHolidays) { + if (month === hm && day === hd) return true; + } + + // Easter Monday + const easter = calculateEaster(year); + const easterMonday = new Date(easter); + easterMonday.setDate(easter.getDate() + 1); + if (date.getMonth() === easterMonday.getMonth() && date.getDate() === easterMonday.getDate()) { + return true; + } + + return false; +} + +/** + * Format date as YYYY-MM-DD + */ +function formatDate(date) { + const year = date.getFullYear(); + const month = String(date.getMonth() + 1).padStart(2, '0'); + const day = String(date.getDate()).padStart(2, '0'); + return `${year}-${month}-${day}`; +} + +/** + * Format date for display + */ +function formatDateDisplay(dateStr) { + const date = new Date(dateStr + 'T12:00:00'); + return date.toLocaleDateString('en-US', { + weekday: 'short', + month: 'short', + day: 'numeric' + }); +} + +/** + * Get month name + */ +function getMonthName(month) { + const months = [ + 'January', 'February', 'March', 'April', 'May', 'June', + 'July', 'August', 'September', 'October', 'November', 'December' + ]; + return months[month]; +} + +/** + * Get day name + */ +function getDayName(dayIndex) { + const days = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']; + return days[dayIndex]; +} + +/** + * Get days in month + */ +function getDaysInMonth(year, month) { + return new Date(year, month + 1, 0).getDate(); +} + +/** + * Get start of week for a date + */ +function getWeekStart(date, weekStartDay = 0) { + const d = new Date(date); + const day = d.getDay(); + const diff = (day - weekStartDay + 7) % 7; + d.setDate(d.getDate() - diff); + d.setHours(0, 0, 0, 0); + return d; +} + +/** + * Format date as short display (e.g., "Nov 26") + */ +function formatDateShort(date) { + return date.toLocaleDateString('en-US', { + month: 'short', + day: 'numeric' + }); +} + +/** + * Show a temporary message (creates toast if no container) + */ +function showMessage(message, type = 'success', duration = 3000) { + // Create toast container if it doesn't exist + let toastContainer = document.getElementById('toastContainer'); + if (!toastContainer) { + toastContainer = document.createElement('div'); + toastContainer.id = 'toastContainer'; + toastContainer.style.cssText = ` + position: fixed; + top: 1rem; + right: 1rem; + z-index: 9999; + display: flex; + flex-direction: column; + gap: 0.5rem; + `; + document.body.appendChild(toastContainer); + } + + const toast = document.createElement('div'); + toast.className = `message ${type}`; + toast.style.cssText = ` + padding: 0.75rem 1rem; + border-radius: 6px; + box-shadow: 0 2px 8px rgba(0,0,0,0.15); + animation: slideIn 0.2s ease; + `; + toast.textContent = message; + toastContainer.appendChild(toast); + + if (duration > 0) { + setTimeout(() => { + toast.style.animation = 'slideOut 0.2s ease'; + setTimeout(() => toast.remove(), 200); + }, duration); + } +} + +/** + * Close modal when clicking outside + */ +function setupModalClose(modalId) { + const modal = document.getElementById(modalId); + if (modal) { + modal.addEventListener('click', (e) => { + if (e.target.id === modalId) { + modal.style.display = 'none'; + } + }); + } +} + +// Export utilities +window.utils = { + loadHolidaysForYear, + isItalianHoliday, + formatDate, + formatDateDisplay, + formatDateShort, + getMonthName, + getDayName, + getDaysInMonth, + getWeekStart, + showMessage, + setupModalClose +}; diff --git a/frontend/pages/admin-users.html b/frontend/pages/admin-users.html new file mode 100644 index 0000000..0f0e931 --- /dev/null +++ b/frontend/pages/admin-users.html @@ -0,0 +1,132 @@ + + + + + + Manage Users - Parking Manager + + + + + + +
+ + +
+
+
+ + + + + + + + + + + + + + +
NameEmailRoleOfficeManaged OfficesParking QuotaSpot PrefixActions
+
+
+
+
+ + + + + + + + + + diff --git a/frontend/pages/landing.html b/frontend/pages/landing.html new file mode 100644 index 0000000..083d725 --- /dev/null +++ b/frontend/pages/landing.html @@ -0,0 +1,32 @@ + + + + + + Parking Manager + + + + +
+
+
+

Parking Manager

+

Manage office presence and parking assignments

+
+ +
+ Sign In + Create Account +
+
+
+ + + + diff --git a/frontend/pages/login.html b/frontend/pages/login.html new file mode 100644 index 0000000..50074ea --- /dev/null +++ b/frontend/pages/login.html @@ -0,0 +1,64 @@ + + + + + + Login - Parking Manager + + + + +
+
+
+

Welcome Back

+

Sign in to your account

+
+ +
+ +
+
+ + +
+
+ + +
+ +
+ + +
+
+ + + + + diff --git a/frontend/pages/office-rules.html b/frontend/pages/office-rules.html new file mode 100644 index 0000000..7b3fe18 --- /dev/null +++ b/frontend/pages/office-rules.html @@ -0,0 +1,215 @@ + + + + + + Office Rules - Parking Manager + + + + + + +
+ + + + +
+
+
+

Select a manager to manage their office rules

+
+
+
+
+ + + + + + + + + + + + + + + + diff --git a/frontend/pages/presence.html b/frontend/pages/presence.html new file mode 100644 index 0000000..5bc6c65 --- /dev/null +++ b/frontend/pages/presence.html @@ -0,0 +1,186 @@ + + + + + + My Presence - Parking Manager + + + + + + +
+ + +
+
+
+ +

Loading...

+ +
+ +
+ +
+
+
+ Present (Office) +
+
+
+ Remote +
+
+
+ Absent +
+
+
+
+
+ + + + + + + + + + + + + + + + diff --git a/frontend/pages/profile.html b/frontend/pages/profile.html new file mode 100644 index 0000000..d576312 --- /dev/null +++ b/frontend/pages/profile.html @@ -0,0 +1,188 @@ + + + + + + Profile - Parking Manager + + + + + + +
+ + +
+
+
+

Personal Information

+
+
+
+
+ + +
+
+ + + Email cannot be changed +
+
+ + +
+
+ +
+
+
+
+ +
+
+

Change Password

+
+
+
+
+ + +
+
+ + + Minimum 8 characters +
+
+ + +
+
+ +
+
+
+
+
+
+ + + + + + + diff --git a/frontend/pages/register.html b/frontend/pages/register.html new file mode 100644 index 0000000..8dc601c --- /dev/null +++ b/frontend/pages/register.html @@ -0,0 +1,98 @@ + + + + + + Register - Parking Manager + + + + +
+
+
+

Create Account

+

Sign up for a new account

+
+ +
+ +
+
+ + +
+
+ + +
+
+ + + Minimum 8 characters +
+
+ + +
+ +
+ + +
+
+ + + + + diff --git a/frontend/pages/settings.html b/frontend/pages/settings.html new file mode 100644 index 0000000..d2251d5 --- /dev/null +++ b/frontend/pages/settings.html @@ -0,0 +1,217 @@ + + + + + + Settings - Parking Manager + + + + + + +
+ + +
+
+
+

Preferences

+
+
+
+
+ + +
+
+ +
+
+
+
+ +
+
+

Parking Notifications

+
+
+
+
+ + Receive weekly parking assignments summary every Friday at 12:00 +
+
+ + Receive daily parking reminder on working days +
+
+ +
+ + : + +
+
+
+ + Receive immediate notifications when your parking assignment changes +
+
+ +
+
+
+
+
+
+ + + + + + + diff --git a/frontend/pages/team-calendar.html b/frontend/pages/team-calendar.html new file mode 100644 index 0000000..27cdb3a --- /dev/null +++ b/frontend/pages/team-calendar.html @@ -0,0 +1,157 @@ + + + + + + Team Calendar - Parking Manager + + + + + + +
+ + +
+
+
+ +

Loading...

+ +
+ +
+ + + + + +
+
+ +
+
+
+ Present +
+
+
+ Remote +
+
+
+ Absent +
+
+
+
+
+ + + + + + + + + + + + + diff --git a/main.py b/main.py new file mode 100644 index 0000000..6355ab3 --- /dev/null +++ b/main.py @@ -0,0 +1,127 @@ +""" +Parking Manager Application +FastAPI + SQLite + Vanilla JS +""" +from fastapi import FastAPI +from fastapi.staticfiles import StaticFiles +from fastapi.responses import FileResponse, RedirectResponse +from fastapi.middleware.cors import CORSMiddleware +from contextlib import asynccontextmanager + +from app import config +from app.routes.auth import router as auth_router +from app.routes.users import router as users_router +from app.routes.offices import router as offices_router +from app.routes.managers import router as managers_router +from app.routes.presence import router as presence_router +from app.routes.parking import router as parking_router +from database.connection import init_db + + +@asynccontextmanager +async def lifespan(app: FastAPI): + """Initialize database on startup""" + init_db() + yield + + +app = FastAPI(title="Parking Manager", version="1.0.0", lifespan=lifespan) + +# CORS +app.add_middleware( + CORSMiddleware, + allow_origins=config.ALLOWED_ORIGINS, + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], +) + +# API Routes +app.include_router(auth_router) +app.include_router(users_router) +app.include_router(offices_router) +app.include_router(managers_router) +app.include_router(presence_router) +app.include_router(parking_router) + +# Static Files +app.mount("/css", StaticFiles(directory=str(config.FRONTEND_DIR / "css")), name="css") +app.mount("/js", StaticFiles(directory=str(config.FRONTEND_DIR / "js")), name="js") + + +# Page Routes +@app.get("/") +async def index(): + """Landing page""" + return FileResponse(config.FRONTEND_DIR / "pages" / "landing.html") + + +@app.get("/login") +async def login_page(): + """Login page""" + return FileResponse(config.FRONTEND_DIR / "pages" / "login.html") + + +@app.get("/register") +async def register_page(): + """Register page""" + return FileResponse(config.FRONTEND_DIR / "pages" / "register.html") + + +@app.get("/dashboard") +async def dashboard(): + """Dashboard - redirect to team calendar""" + return RedirectResponse(url="/team-calendar", status_code=302) + + +@app.get("/presence") +async def presence_page(): + """My Presence page""" + return FileResponse(config.FRONTEND_DIR / "pages" / "presence.html") + + +@app.get("/team-calendar") +async def team_calendar_page(): + """Team Calendar page""" + return FileResponse(config.FRONTEND_DIR / "pages" / "team-calendar.html") + + +@app.get("/office-rules") +async def office_rules_page(): + """Office Rules page""" + return FileResponse(config.FRONTEND_DIR / "pages" / "office-rules.html") + + +@app.get("/admin/users") +async def admin_users_page(): + """Admin Users page""" + return FileResponse(config.FRONTEND_DIR / "pages" / "admin-users.html") + + +@app.get("/profile") +async def profile_page(): + """Profile page""" + return FileResponse(config.FRONTEND_DIR / "pages" / "profile.html") + + +@app.get("/settings") +async def settings_page(): + """Settings page""" + return FileResponse(config.FRONTEND_DIR / "pages" / "settings.html") + + +@app.get("/favicon.svg") +async def favicon(): + """Favicon""" + return FileResponse(config.FRONTEND_DIR / "favicon.svg", media_type="image/svg+xml") + + +@app.get("/health") +async def health(): + """Health check""" + return {"status": "ok"} + + +if __name__ == "__main__": + import uvicorn + uvicorn.run("main:app", host=config.HOST, port=config.PORT, reload=True) diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..6ebdd99 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,6 @@ +fastapi==0.115.5 +uvicorn[standard]==0.32.1 +pydantic[email]==2.10.3 +sqlalchemy==2.0.36 +python-jose[cryptography]==3.3.0 +bcrypt==4.2.1 diff --git a/run_notifications.py b/run_notifications.py new file mode 100644 index 0000000..444bdd1 --- /dev/null +++ b/run_notifications.py @@ -0,0 +1,32 @@ +#!/usr/bin/env python3 +""" +Notification Scheduler Script +Run this script via cron every 5 minutes: +*/5 * * * * cd /path/to/app && .venv/bin/python run_notifications.py + +This will: +- Send presence reminders on Thursday at 12:00 (repeat daily until compiled) +- Send weekly parking summaries on Friday at 12:00 +- Send daily parking reminders at user-configured times +- Process queued parking change notifications +""" +import sys +from database.connection import SessionLocal +from services.notifications import run_scheduled_notifications + + +def main(): + db = SessionLocal() + try: + print("Running scheduled notifications...") + run_scheduled_notifications(db) + print("Done.") + except Exception as e: + print(f"Error: {e}", file=sys.stderr) + sys.exit(1) + finally: + db.close() + + +if __name__ == "__main__": + main() diff --git a/services/__init__.py b/services/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/services/auth.py b/services/auth.py new file mode 100644 index 0000000..166699c --- /dev/null +++ b/services/auth.py @@ -0,0 +1,79 @@ +""" +Authentication Service +JWT token management and password hashing +""" +import uuid +import bcrypt +from datetime import datetime, timedelta +from jose import jwt +from sqlalchemy.orm import Session + +from app import config +from database.models import User + + +def hash_password(password: str) -> str: + """Hash a password using bcrypt""" + return bcrypt.hashpw(password.encode(), bcrypt.gensalt()).decode() + + +def verify_password(password: str, hashed: str) -> bool: + """Verify a password against its hash""" + return bcrypt.checkpw(password.encode(), hashed.encode()) + + +def create_access_token(user_id: str, email: str) -> str: + """Create a JWT access token""" + expire = datetime.utcnow() + timedelta(minutes=config.ACCESS_TOKEN_EXPIRE_MINUTES) + payload = { + "sub": user_id, + "email": email, + "exp": expire + } + return jwt.encode(payload, config.SECRET_KEY, algorithm=config.ALGORITHM) + + +def decode_token(token: str) -> dict | None: + """Decode and validate a JWT token""" + try: + return jwt.decode(token, config.SECRET_KEY, algorithms=[config.ALGORITHM]) + except Exception: + return None + + +def get_user_by_email(db: Session, email: str) -> User | None: + """Get user by email address""" + return db.query(User).filter(User.email == email).first() + + +def get_user_by_id(db: Session, user_id: str) -> User | None: + """Get user by ID""" + return db.query(User).filter(User.id == user_id).first() + + +def authenticate_user(db: Session, email: str, password: str) -> User | None: + """Authenticate user with email and password""" + user = get_user_by_email(db, email) + if not user or not user.password_hash: + return None + if not verify_password(password, user.password_hash): + return None + return user + + +def create_user(db: Session, email: str, password: str, name: str, office_id: str = None, role: str = "employee") -> User: + """Create a new user""" + user = User( + id=str(uuid.uuid4()), + email=email, + password_hash=hash_password(password), + name=name, + office_id=office_id, + role=role, + created_at=datetime.utcnow().isoformat(), + updated_at=datetime.utcnow().isoformat() + ) + db.add(user) + db.commit() + db.refresh(user) + return user diff --git a/services/holidays.py b/services/holidays.py new file mode 100644 index 0000000..7b9fdca --- /dev/null +++ b/services/holidays.py @@ -0,0 +1,116 @@ +""" +Holiday Service +Configurable holiday calculation for different regions + +Currently supports Italian holidays. Can be extended to support other regions +by adding new holiday sets and a configuration option. +""" +from datetime import datetime, date, timedelta + + +def calculate_easter(year: int) -> date: + """Calculate Easter Sunday using the Computus algorithm""" + a = year % 19 + b = year // 100 + c = year % 100 + d = b // 4 + e = b % 4 + f = (b + 8) // 25 + g = (b - f + 1) // 3 + h = (19 * a + b - d - g + 15) % 30 + i = c // 4 + k = c % 4 + l = (32 + 2 * e + 2 * i - h - k) % 7 + m = (a + 11 * h + 22 * l) // 451 + month = (h + l - 7 * m + 114) // 31 + day = ((h + l - 7 * m + 114) % 31) + 1 + return date(year, month, day) + + +def get_easter_monday(year: int) -> date: + """Get Easter Monday for a given year""" + easter = calculate_easter(year) + return easter + timedelta(days=1) + + +# Italian fixed holidays (month, day) +ITALIAN_FIXED_HOLIDAYS = [ + (1, 1), # New Year's Day + (1, 6), # Epiphany + (4, 25), # Liberation Day + (5, 1), # Labour Day + (6, 2), # Republic Day + (8, 15), # Assumption + (11, 1), # All Saints + (12, 8), # Immaculate Conception + (12, 25), # Christmas + (12, 26), # St. Stephen's +] + + +def is_italian_holiday(check_date: date | datetime) -> bool: + """Check if a date is an Italian public holiday""" + if isinstance(check_date, datetime): + check_date = check_date.date() + + year = check_date.year + month = check_date.month + day = check_date.day + + # Check fixed holidays + if (month, day) in ITALIAN_FIXED_HOLIDAYS: + return True + + # Check Easter Monday + easter_monday = get_easter_monday(year) + if check_date == easter_monday: + return True + + return False + + +def get_holidays_for_year(year: int) -> list[dict]: + """ + Get all holidays for a given year. + Returns list of {date: YYYY-MM-DD, name: string} + """ + holidays = [] + + # Fixed holidays + holiday_names = [ + "New Year's Day", "Epiphany", "Liberation Day", "Labour Day", + "Republic Day", "Assumption", "All Saints", "Immaculate Conception", + "Christmas", "St. Stephen's Day" + ] + + for (month, day), name in zip(ITALIAN_FIXED_HOLIDAYS, holiday_names): + holidays.append({ + "date": f"{year}-{month:02d}-{day:02d}", + "name": name + }) + + # Easter Monday + easter_monday = get_easter_monday(year) + holidays.append({ + "date": easter_monday.strftime("%Y-%m-%d"), + "name": "Easter Monday" + }) + + # Sort by date + holidays.sort(key=lambda h: h["date"]) + return holidays + + +def is_holiday(check_date: date | datetime | str, region: str = "IT") -> bool: + """ + Check if a date is a holiday for the given region. + Currently only supports IT (Italy). + """ + if isinstance(check_date, str): + check_date = datetime.strptime(check_date, "%Y-%m-%d").date() + + if region == "IT": + return is_italian_holiday(check_date) + + # Default: no holidays + return False diff --git a/services/notifications.py b/services/notifications.py new file mode 100644 index 0000000..fc3ce48 --- /dev/null +++ b/services/notifications.py @@ -0,0 +1,393 @@ +""" +Notification Service +Handles email notifications for presence reminders and parking assignments + +TODO: This service is NOT YET ACTIVE. To enable notifications: +1. Add APScheduler or similar to run run_scheduled_notifications() periodically +2. Configure SMTP environment variables (SMTP_HOST, SMTP_USER, SMTP_PASSWORD, SMTP_FROM) +3. Notifications will be sent for: + - Presence reminders (Thursday at 12:00) + - Weekly parking summary (Friday at 12:00) + - Daily parking reminders (at user's preferred time) + - Immediate parking change notifications (via queue) +""" +import smtplib +import os +from email.mime.text import MIMEText +from email.mime.multipart import MIMEMultipart +from datetime import datetime, timedelta +from sqlalchemy.orm import Session +import uuid + +from database.models import ( + User, UserPresence, DailyParkingAssignment, + NotificationLog, NotificationQueue, OfficeMembership +) +from services.parking import get_spot_display_name + + +# Email configuration (from environment variables) +SMTP_HOST = os.getenv("SMTP_HOST", "localhost") +SMTP_PORT = int(os.getenv("SMTP_PORT", "587")) +SMTP_USER = os.getenv("SMTP_USER", "") +SMTP_PASSWORD = os.getenv("SMTP_PASSWORD", "") +SMTP_FROM = os.getenv("SMTP_FROM", "noreply@parkingmanager.local") +SMTP_USE_TLS = os.getenv("SMTP_USE_TLS", "true").lower() == "true" + + +def send_email(to_email: str, subject: str, body_html: str, body_text: str = None): + """Send an email""" + if not SMTP_USER or not SMTP_PASSWORD: + print(f"[NOTIFICATION] Email not configured. Would send to {to_email}: {subject}") + return False + + try: + msg = MIMEMultipart("alternative") + msg["Subject"] = subject + msg["From"] = SMTP_FROM + msg["To"] = to_email + + if body_text: + msg.attach(MIMEText(body_text, "plain")) + msg.attach(MIMEText(body_html, "html")) + + with smtplib.SMTP(SMTP_HOST, SMTP_PORT) as server: + if SMTP_USE_TLS: + server.starttls() + server.login(SMTP_USER, SMTP_PASSWORD) + server.sendmail(SMTP_FROM, to_email, msg.as_string()) + + print(f"[NOTIFICATION] Email sent to {to_email}: {subject}") + return True + except Exception as e: + print(f"[NOTIFICATION] Failed to send email to {to_email}: {e}") + return False + + +def get_week_dates(reference_date: datetime): + """Get Monday-Sunday dates for the week containing reference_date""" + # Find Monday of this week + monday = reference_date - timedelta(days=reference_date.weekday()) + return [monday + timedelta(days=i) for i in range(7)] + + +def get_next_week_dates(reference_date: datetime): + """Get Monday-Sunday dates for the week after reference_date""" + # Find Monday of next week + days_until_next_monday = 7 - reference_date.weekday() + next_monday = reference_date + timedelta(days=days_until_next_monday) + return [next_monday + timedelta(days=i) for i in range(7)] + + +def check_week_presence_compiled(user_id: str, week_dates: list, db: Session) -> bool: + """Check if user has filled presence for all working days in a week""" + date_strs = [d.strftime("%Y-%m-%d") for d in week_dates] + + presences = db.query(UserPresence).filter( + UserPresence.user_id == user_id, + UserPresence.date.in_(date_strs) + ).all() + + # Consider week compiled if at least 5 days have presence marked + # (allowing for weekends or holidays) + return len(presences) >= 5 + + +def get_week_reference(date: datetime) -> str: + """Get ISO week reference string (e.g., 2024-W48)""" + return date.strftime("%Y-W%W") + + +def send_presence_reminder(user: User, next_week_dates: list, db: Session) -> bool: + """Send presence compilation reminder for next week""" + week_ref = get_week_reference(next_week_dates[0]) + + # Check if already sent today for this week + today = datetime.now().strftime("%Y-%m-%d") + existing = db.query(NotificationLog).filter( + NotificationLog.user_id == user.id, + NotificationLog.notification_type == "presence_reminder", + NotificationLog.reference_date == week_ref, + NotificationLog.sent_at >= today + ).first() + + if existing: + return False # Already sent today + + # Check if week is compiled + if check_week_presence_compiled(user.id, next_week_dates, db): + return False # Already compiled + + # Send reminder + start_date = next_week_dates[0].strftime("%B %d") + end_date = next_week_dates[-1].strftime("%B %d, %Y") + + subject = f"Reminder: Please fill your presence for {start_date} - {end_date}" + body_html = f""" + + +

Presence Reminder

+

Hi {user.name},

+

This is a friendly reminder to fill your presence for the upcoming week + ({start_date} - {end_date}).

+

Please log in to the Parking Manager to mark your presence.

+

Best regards,
Parking Manager

+ + + """ + + if send_email(user.email, subject, body_html): + # Log the notification + log = NotificationLog( + id=str(uuid.uuid4()), + user_id=user.id, + notification_type="presence_reminder", + reference_date=week_ref, + sent_at=datetime.now().isoformat() + ) + db.add(log) + db.commit() + return True + + return False + + +def send_weekly_parking_summary(user: User, next_week_dates: list, db: Session) -> bool: + """Send weekly parking assignment summary for next week (Friday at 12)""" + 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 == "weekly_parking", + NotificationLog.reference_date == week_ref + ).first() + + if existing: + return False + + # Get parking assignments for next week + date_strs = [d.strftime("%Y-%m-%d") for d in next_week_dates] + assignments = db.query(DailyParkingAssignment).filter( + DailyParkingAssignment.user_id == user.id, + DailyParkingAssignment.date.in_(date_strs) + ).all() + + if not assignments: + return False # No assignments, no need to notify + + # Build assignment list + assignment_lines = [] + for a in sorted(assignments, key=lambda x: x.date): + date_obj = datetime.strptime(a.date, "%Y-%m-%d") + day_name = date_obj.strftime("%A") + spot_name = get_spot_display_name(a.spot_id, a.manager_id, db) + assignment_lines.append(f"
  • {day_name}, {date_obj.strftime('%B %d')}: Spot {spot_name}
  • ") + + 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""" + + +

    Weekly Parking Summary

    +

    Hi {user.name},

    +

    Here are your parking spot assignments for the upcoming week:

    + +

    Parking assignments are now frozen. You can still release or reassign your spots if needed.

    +

    Best regards,
    Parking Manager

    + + + """ + + if send_email(user.email, subject, body_html): + log = NotificationLog( + id=str(uuid.uuid4()), + user_id=user.id, + notification_type="weekly_parking", + reference_date=week_ref, + sent_at=datetime.now().isoformat() + ) + db.add(log) + db.commit() + return True + + return False + + +def send_daily_parking_reminder(user: User, date: datetime, db: Session) -> bool: + """Send daily parking reminder for a specific date""" + if not user.notify_daily_parking: + return False + + date_str = date.strftime("%Y-%m-%d") + + # Check if already sent for this date + existing = db.query(NotificationLog).filter( + NotificationLog.user_id == user.id, + NotificationLog.notification_type == "daily_parking", + NotificationLog.reference_date == date_str + ).first() + + if existing: + return False + + # Get parking assignment for this date + assignment = db.query(DailyParkingAssignment).filter( + DailyParkingAssignment.user_id == user.id, + DailyParkingAssignment.date == date_str + ).first() + + if not assignment: + return False # No assignment today + + spot_name = get_spot_display_name(assignment.spot_id, assignment.manager_id, db) + + day_name = date.strftime("%A, %B %d") + + subject = f"Parking reminder for {day_name}" + body_html = f""" + + +

    Daily Parking Reminder

    +

    Hi {user.name},

    +

    You have a parking spot assigned for today ({day_name}):

    +

    Spot {spot_name}

    +

    Best regards,
    Parking Manager

    + + + """ + + if send_email(user.email, subject, body_html): + log = NotificationLog( + id=str(uuid.uuid4()), + user_id=user.id, + notification_type="daily_parking", + reference_date=date_str, + sent_at=datetime.now().isoformat() + ) + db.add(log) + db.commit() + return True + + return False + + +def queue_parking_change_notification( + user: User, + date: str, + change_type: str, # "assigned", "released", "reassigned" + spot_name: str, + new_user_name: str = None, + db: Session = None +): + """Queue an immediate notification for a parking assignment change""" + if not user.notify_parking_changes: + return + + date_obj = datetime.strptime(date, "%Y-%m-%d") + day_name = date_obj.strftime("%A, %B %d") + + if change_type == "assigned": + subject = f"Parking spot assigned for {day_name}" + body = f""" + + +

    Parking Spot Assigned

    +

    Hi {user.name},

    +

    You have been assigned a parking spot for {day_name}:

    +

    Spot {spot_name}

    +

    Best regards,
    Parking Manager

    + + + """ + elif change_type == "released": + subject = f"Parking spot released for {day_name}" + body = f""" + + +

    Parking Spot Released

    +

    Hi {user.name},

    +

    Your parking spot (Spot {spot_name}) for {day_name} has been released.

    +

    Best regards,
    Parking Manager

    + + + """ + elif change_type == "reassigned": + subject = f"Parking spot reassigned for {day_name}" + body = f""" + + +

    Parking Spot Reassigned

    +

    Hi {user.name},

    +

    Your parking spot (Spot {spot_name}) for {day_name} has been reassigned to {new_user_name}.

    +

    Best regards,
    Parking Manager

    + + + """ + else: + return + + # Add to queue + notification = NotificationQueue( + id=str(uuid.uuid4()), + user_id=user.id, + notification_type="parking_change", + subject=subject, + body=body, + created_at=datetime.now().isoformat() + ) + db.add(notification) + db.commit() + + +def process_notification_queue(db: Session): + """Process and send all pending notifications in the queue""" + pending = db.query(NotificationQueue).filter( + NotificationQueue.sent_at.is_(None) + ).all() + + for notification in pending: + user = db.query(User).filter(User.id == notification.user_id).first() + if user and send_email(user.email, notification.subject, notification.body): + notification.sent_at = datetime.now().isoformat() + + db.commit() + + +def run_scheduled_notifications(db: Session): + """Run all scheduled notifications - called by a scheduler/cron job""" + now = datetime.now() + today = now.date() + current_hour = now.hour + current_minute = now.minute + current_weekday = now.weekday() # 0=Monday, 6=Sunday + + users = db.query(User).all() + + for user in users: + # Thursday at 12: Presence reminder (unmanageable) + if current_weekday == 3 and current_hour == 12 and current_minute < 5: + next_week = get_next_week_dates(now) + send_presence_reminder(user, next_week, db) + + # Friday at 12: Weekly parking summary + if current_weekday == 4 and current_hour == 12 and current_minute < 5: + next_week = get_next_week_dates(now) + send_weekly_parking_summary(user, next_week, db) + + # Daily parking reminder at user's preferred time (working days only) + if current_weekday < 5: # Monday to Friday + user_hour = user.notify_daily_parking_hour or 8 + user_minute = user.notify_daily_parking_minute or 0 + if current_hour == user_hour and abs(current_minute - user_minute) < 5: + send_daily_parking_reminder(user, now, db) + + # Process queued notifications + process_notification_queue(db) diff --git a/services/parking.py b/services/parking.py new file mode 100644 index 0000000..4ea7420 --- /dev/null +++ b/services/parking.py @@ -0,0 +1,343 @@ +""" +Parking Assignment Service +Manager-centric parking spot management with fairness algorithm + +Key concepts: +- Managers own parking spots (defined by manager_parking_quota) +- Each manager has a spot prefix (A, B, C...) for display names +- Spots are named like A1, A2, B1, B2 based on manager prefix +- Fairness: users with lowest parking_days/office_days ratio get priority +""" +import uuid +from datetime import datetime, timezone +from sqlalchemy.orm import Session +from sqlalchemy import func + +from database.models import ( + DailyParkingAssignment, User, OfficeMembership, UserPresence, + ParkingGuarantee, ParkingExclusion, ManagerClosingDay, ManagerWeeklyClosingDay +) + + +def get_manager_for_office(office_id: str, db: Session) -> User | None: + """Find the manager responsible for an office""" + membership = db.query(OfficeMembership).filter( + OfficeMembership.office_id == office_id + ).first() + + if not membership: + return None + + return db.query(User).filter(User.id == membership.user_id).first() + + +def get_spot_prefix(manager: User, db: Session) -> str: + """Get the spot prefix for a manager (from manager_spot_prefix or auto-assign)""" + if manager.manager_spot_prefix: + return manager.manager_spot_prefix + + # Auto-assign based on alphabetical order of managers without prefix + managers = db.query(User).filter( + User.role == "manager", + User.manager_spot_prefix == None + ).order_by(User.name).all() + + # Find existing prefixes + existing_prefixes = set( + m.manager_spot_prefix for m in db.query(User).filter( + User.role == "manager", + User.manager_spot_prefix != None + ).all() + ) + + # Find first available letter + manager_index = next((i for i, m in enumerate(managers) if m.id == manager.id), 0) + letter = 'A' + count = 0 + while letter in existing_prefixes or count < manager_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, manager_id: str, db: Session) -> str: + """Get display name for a spot (e.g., 'A3' instead of 'spot-3')""" + manager = db.query(User).filter(User.id == manager_id).first() + if not manager: + return spot_id + + prefix = get_spot_prefix(manager, db) + spot_number = spot_id.replace("spot-", "") + return f"{prefix}{spot_number}" + + +def is_closing_day(manager_id: str, date: str, db: Session) -> bool: + """ + Check if date is a closing day for this manager. + Checks both specific closing days and weekly recurring closing days. + """ + # Check specific closing day + specific = db.query(ManagerClosingDay).filter( + ManagerClosingDay.manager_id == manager_id, + ManagerClosingDay.date == date + ).first() + if specific: + return True + + # Check weekly closing day + date_obj = datetime.strptime(date, "%Y-%m-%d") + weekday = date_obj.weekday() # 0=Monday in Python + # Convert to our format: 0=Sunday, 1=Monday, ..., 6=Saturday + weekday_sunday_start = (weekday + 1) % 7 + + weekly = db.query(ManagerWeeklyClosingDay).filter( + ManagerWeeklyClosingDay.manager_id == manager_id, + ManagerWeeklyClosingDay.weekday == weekday_sunday_start + ).first() + + return weekly is not None + + +def initialize_parking_pool(manager_id: str, quota: int, date: str, db: Session) -> int: + """Initialize empty parking spots for a manager's pool on a given date. + Returns 0 if it's a closing day (no parking available). + """ + # Don't create pool on closing days + if is_closing_day(manager_id, date, db): + return 0 + + existing = db.query(DailyParkingAssignment).filter( + DailyParkingAssignment.manager_id == manager_id, + DailyParkingAssignment.date == date + ).count() + + if existing > 0: + return existing + + for i in range(1, quota + 1): + spot = DailyParkingAssignment( + id=str(uuid.uuid4()), + date=date, + spot_id=f"spot-{i}", + user_id=None, + manager_id=manager_id, + created_at=datetime.now(timezone.utc).isoformat() + ) + db.add(spot) + + db.commit() + return quota + + +def get_user_parking_ratio(user_id: str, manager_id: str, db: Session) -> float: + """ + Calculate user's parking ratio: parking_days / office_days + Lower ratio = higher priority for next parking spot + """ + # Get offices managed by this manager + managed_office_ids = [ + m.office_id for m in db.query(OfficeMembership).filter( + OfficeMembership.user_id == manager_id + ).all() + ] + + # Count days user was present (office_days) + office_days = db.query(UserPresence).filter( + UserPresence.user_id == user_id, + UserPresence.status == "present" + ).count() + + if office_days == 0: + return 0.0 # New user, highest priority + + # Count days user got parking + parking_days = db.query(DailyParkingAssignment).filter( + DailyParkingAssignment.user_id == user_id, + DailyParkingAssignment.manager_id == manager_id + ).count() + + return parking_days / office_days + + +def is_user_excluded(user_id: str, manager_id: str, date: str, db: Session) -> bool: + """Check if user is excluded from parking for this date""" + exclusion = db.query(ParkingExclusion).filter( + ParkingExclusion.manager_id == manager_id, + ParkingExclusion.user_id == user_id + ).first() + + if not exclusion: + return False + + # Check date range + if exclusion.start_date and date < exclusion.start_date: + return False + if exclusion.end_date and date > exclusion.end_date: + return False + + return True + + +def has_guarantee(user_id: str, manager_id: str, date: str, db: Session) -> bool: + """Check if user has a parking guarantee for this date""" + guarantee = db.query(ParkingGuarantee).filter( + ParkingGuarantee.manager_id == manager_id, + ParkingGuarantee.user_id == user_id + ).first() + + if not guarantee: + return False + + # Check date range + if guarantee.start_date and date < guarantee.start_date: + return False + if guarantee.end_date and date > guarantee.end_date: + return False + + return True + + +def get_users_wanting_parking(manager_id: str, date: str, db: Session) -> list[dict]: + """ + Get all users who want parking for this date, sorted by fairness priority. + Returns list of {user_id, has_guarantee, ratio} + """ + # Get offices managed by this manager + managed_office_ids = [ + m.office_id for m in db.query(OfficeMembership).filter( + OfficeMembership.user_id == manager_id + ).all() + ] + + # Get users who marked "present" for this date and belong to managed offices + present_users = db.query(UserPresence).join(User).filter( + UserPresence.date == date, + UserPresence.status == "present", + User.office_id.in_(managed_office_ids) + ).all() + + candidates = [] + for presence in present_users: + user_id = presence.user_id + + # Skip excluded users + if is_user_excluded(user_id, manager_id, date, db): + continue + + # Skip users who already have a spot + existing = db.query(DailyParkingAssignment).filter( + DailyParkingAssignment.date == date, + DailyParkingAssignment.user_id == user_id + ).first() + if existing: + continue + + candidates.append({ + "user_id": user_id, + "has_guarantee": has_guarantee(user_id, manager_id, date, db), + "ratio": get_user_parking_ratio(user_id, manager_id, db) + }) + + # Sort: guaranteed users first, then by ratio (lowest first for fairness) + candidates.sort(key=lambda x: (not x["has_guarantee"], x["ratio"])) + + return candidates + + +def assign_parking_fairly(manager_id: str, date: str, db: Session) -> dict: + """ + Assign parking spots fairly based on parking ratio. + Called after presence is set for a date. + Returns {assigned: [...], waitlist: [...]} + """ + manager = db.query(User).filter(User.id == manager_id).first() + if not manager or not manager.manager_parking_quota: + return {"assigned": [], "waitlist": []} + + # No parking on closing days + if is_closing_day(manager_id, date, db): + return {"assigned": [], "waitlist": [], "closed": True} + + # Initialize pool + initialize_parking_pool(manager_id, manager.manager_parking_quota, date, db) + + # Get candidates sorted by fairness + candidates = get_users_wanting_parking(manager_id, date, db) + + # Get available spots + free_spots = db.query(DailyParkingAssignment).filter( + DailyParkingAssignment.manager_id == manager_id, + DailyParkingAssignment.date == date, + DailyParkingAssignment.user_id == None + ).all() + + assigned = [] + waitlist = [] + + for candidate in candidates: + if free_spots: + spot = free_spots.pop(0) + spot.user_id = candidate["user_id"] + assigned.append(candidate["user_id"]) + else: + waitlist.append(candidate["user_id"]) + + db.commit() + + return {"assigned": assigned, "waitlist": waitlist} + + +def release_user_spot(manager_id: str, user_id: str, date: str, db: Session) -> bool: + """Release a user's parking spot and reassign to next in fairness queue""" + assignment = db.query(DailyParkingAssignment).filter( + DailyParkingAssignment.manager_id == manager_id, + DailyParkingAssignment.date == date, + DailyParkingAssignment.user_id == user_id + ).first() + + if not assignment: + return False + + # Release the spot + assignment.user_id = None + db.commit() + + # Try to assign to next user in fairness queue + candidates = get_users_wanting_parking(manager_id, date, db) + if candidates: + assignment.user_id = candidates[0]["user_id"] + db.commit() + + return True + + +def handle_presence_change(user_id: str, date: str, old_status: str, new_status: str, office_id: str, db: Session): + """ + Handle presence status change and update parking accordingly. + Uses fairness algorithm for assignment. + """ + # Don't process past dates + target_date = datetime.strptime(date, "%Y-%m-%d").date() + if target_date < datetime.now().date(): + return + + # Find manager for this office + manager = get_manager_for_office(office_id, db) + if not manager or not manager.manager_parking_quota: + return + + # Initialize pool if needed + initialize_parking_pool(manager.id, manager.manager_parking_quota, date, db) + + if old_status == "present" and new_status in ["remote", "absent"]: + # User no longer coming - release their spot (will auto-reassign) + release_user_spot(manager.id, user_id, date, db) + + elif new_status == "present": + # User coming in - run fair assignment for this date + assign_parking_fairly(manager.id, date, db) diff --git a/utils/__init__.py b/utils/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/utils/auth_middleware.py b/utils/auth_middleware.py new file mode 100644 index 0000000..9321101 --- /dev/null +++ b/utils/auth_middleware.py @@ -0,0 +1,170 @@ +""" +Authentication Middleware +JWT token validation and Authelia header authentication for protected routes +""" +from fastapi import Depends, HTTPException, status, Request +from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials +from sqlalchemy.orm import Session +from datetime import datetime +import uuid + +from database.connection import get_db +from database.models import User +from services.auth import decode_token, get_user_by_id, get_user_by_email +from app import config + +security = HTTPBearer(auto_error=False) + + +def get_role_from_groups(groups: list[str]) -> str: + """Map Authelia groups to application roles""" + if config.AUTHELIA_ADMIN_GROUP in groups: + return "admin" + if config.AUTHELIA_MANAGER_GROUP in groups: + return "manager" + return "employee" + + +def get_or_create_authelia_user( + email: str, + name: str, + groups: list[str], + db: Session +) -> User: + """Get existing user or create from Authelia headers""" + user = get_user_by_email(db, email) + role = get_role_from_groups(groups) + + if user: + # Update role if changed in LLDAP + if user.role != role: + user.role = role + user.updated_at = datetime.utcnow().isoformat() + db.commit() + db.refresh(user) + # Update name if changed + if user.name != name and name: + user.name = name + user.updated_at = datetime.utcnow().isoformat() + db.commit() + db.refresh(user) + return user + + # Create new user from Authelia + user = User( + id=str(uuid.uuid4()), + email=email, + name=name or email.split("@")[0], + role=role, + password_hash=None, # No password for Authelia users + created_at=datetime.utcnow().isoformat(), + updated_at=datetime.utcnow().isoformat() + ) + db.add(user) + db.commit() + db.refresh(user) + return user + + +def get_current_user( + request: Request, + credentials: HTTPAuthorizationCredentials = Depends(security), + db: Session = Depends(get_db) +): + """ + Extract and validate user from JWT token or Authelia headers. + Authelia mode takes precedence when enabled. + """ + # Authelia mode: trust headers from reverse proxy + if config.AUTHELIA_ENABLED: + remote_user = request.headers.get(config.AUTHELIA_HEADER_USER) + remote_email = request.headers.get(config.AUTHELIA_HEADER_EMAIL, remote_user) + remote_name = request.headers.get(config.AUTHELIA_HEADER_NAME, "") + remote_groups = request.headers.get(config.AUTHELIA_HEADER_GROUPS, "") + + if not remote_user: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Not authenticated (Authelia headers missing)" + ) + + # Parse groups (comma-separated) + groups = [g.strip() for g in remote_groups.split(",") if g.strip()] + + # Get or create user + return get_or_create_authelia_user(remote_email, remote_name, groups, db) + + # JWT mode: validate Bearer token + if not credentials: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Not authenticated" + ) + + token = credentials.credentials + payload = decode_token(token) + if not payload: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Invalid or expired token" + ) + + user_id = payload.get("sub") + if not user_id: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Invalid token payload" + ) + + user = get_user_by_id(db, user_id) + if not user: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="User not found" + ) + + return user + + +def require_admin(user=Depends(get_current_user)): + """Require admin role""" + if user.role != "admin": + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Admin role required" + ) + return user + + +def require_manager_or_admin(user=Depends(get_current_user)): + """Require manager or admin role""" + if user.role not in ["admin", "manager"]: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Manager or admin role required" + ) + return user + + +def check_manager_access_to_user(current_user, target_user, db: Session) -> bool: + """ + Check if current_user (manager) has access to target_user. + Admins always have access. Managers can only access users in their managed offices. + Returns True if access granted, raises HTTPException if not. + """ + if current_user.role == "admin": + return True + + if current_user.role == "manager": + managed_office_ids = [m.office_id for m in current_user.managed_offices] + if target_user.office_id not in managed_office_ids: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="User not in your managed offices" + ) + return True + + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Access denied" + )