No description
Find a file
DJP cd1c99d5e0 feat: KPI tiles, active/soft booking split, hour-breakdown drill-down, period toggle, forecast line, sync button
Major parity push against the original SPA's bundle-level feature set
(non-architectural items — separate Forecast / ProjectType / TimeLog
views and AI Chat remain TBD).

Backend (40/40 tests, +7):

- merge.py splits booked hours by booking status: active vs soft.
  Active set: Active, Active Booked, Fully Booked, Partially Booked.
  Soft set: Soft Booking, Soft Booked, Soft-Booked. Unknown statuses
  default to active so they're not silently dropped. Existing
  `bookedHours` field is preserved as the sum for back-compat.
- compute_totals(): rolls KPIs across the filtered summary —
  totalBooked, activeBooked, softBooked, totalLogged, totalBillable,
  totalLeave, totalProjects (distinct projectName/projectNumber),
  activePeople (distinct employees with logged>0), allocated,
  allocatedNetOfLeave.
- breakdown_by_project(): drills into a single period+employee (or
  whole-period) and returns per-project logged + booked hours.
- New /api/utilisation/breakdown endpoint. /api/utilisation/summary
  response gains `totals` and accepts `period=week|month`.
- New schemas: UtilisationTotals, BreakdownResponse, plus
  activeBookedHours / softBookedHours on UtilisationSummaryRow.

Frontend (typecheck/lint/build clean):

- KpiTiles component shows Total Booked / Logged / Billable, Total
  projects, Active People (logged), Active bookings, Avg/person/week
  or /month, Allocated (net of leave), Period covered.
- PeriodToggle (Per day / Per week / Per month). Day is rendered
  disabled with an explanatory tooltip — backend only accepts week/
  month.
- HourBreakdown drill-down panel: per-project logged + booked rows,
  shown when a chart bar is clicked; "Upload a time log to see logged
  breakdown" empty-state when no upload yet.
- MonthlyUtilisation: ComposedChart with stacked Active/Soft booked
  bars + forecast Line overlay driven by the existing showForecast
  toggle. onPeriodClick wired into HourBreakdown.
- WeeklyUtilisation, BookingVsActual: same Active/Soft stack treatment.
- Resourcing now passes the timelog hash through to summary so
  loggedHours actually populates there too (was 0 before).
- Sync Airtable button on both Department and Resourcing — force-
  refreshes bookings cache, re-derives summary.
- Tutorial steps re-mapped to the original SPA's chapter titles:
  "Reading the Utilisation Chart", "Hours & Utilisation", "Drill-In",
  "Forecast Line & Filters", "Spotting Resource Issues", "Sync
  Airtable Bookings". Tutorial page heading is now "Interactive
  Walkthrough" with the original copy.
- Defensive coercion in Bookings table totalHoursBooked rendering.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 21:06:23 -04:00
backend feat: KPI tiles, active/soft booking split, hour-breakdown drill-down, period toggle, forecast line, sync button 2026-05-17 21:06:23 -04:00
deploy deploy.sh: fix self-collision in slug check when clone path != slug 2026-05-16 13:47:03 -04:00
frontend feat: KPI tiles, active/soft booking split, hour-breakdown drill-down, period toggle, forecast line, sync button 2026-05-17 21:06:23 -04:00
.env.example Initial commit: dockerised FastAPI backend + React/Vite frontend rewrite 2026-05-16 12:37:04 -04:00
.gitignore Initial commit: dockerised FastAPI backend + React/Vite frontend rewrite 2026-05-16 12:37:04 -04:00
docker-compose.yml Initial commit: dockerised FastAPI backend + React/Vite frontend rewrite 2026-05-16 12:37:04 -04:00
README.md Initial commit: dockerised FastAPI backend + React/Vite frontend rewrite 2026-05-16 12:37:04 -04:00

utilisation-dept

L'Oréal Utilisation Dashboard — internal tool that merges Zoho time-log exports with Airtable resource/booking data and renders utilisation charts per department, per person, and per project.

This is a clean rewrite of an earlier static SPA that shipped an Airtable Personal Access Token in the JS bundle. The new architecture keeps all secrets on the backend.

Architecture

Browser ─► Apache (optical-dev.oliver.solutions, shared vhost)
            ├─ /utilisation-dept/         → static SPA (Vite build in /var/www/html/utilisation-dept/)
            └─ /utilisation-dept/api/     → FastAPI container (127.0.0.1:<port>)
                                                │
                                                └─ Airtable REST API (PAT held in .env)
  • Backend: FastAPI + uvicorn in a Docker container, bound to 127.0.0.1 only. Apache fronts the public traffic.
  • Frontend: React + Vite + TypeScript + Tailwind + Recharts. Vite base: '/utilisation-dept/'. Built into /var/www/html/utilisation-dept/.
  • Database: none. Airtable is the source of truth; uploaded Zoho files are parsed in memory and discarded.
  • Auth: local admin account today (bcrypt creds in .env). Designed for an Azure AD/MSAL swap-in later — see app/auth/azure.py.

First-time setup

1. Rotate the Airtable PAT (do this before anything else)

The old SPA had its PAT hardcoded in the JS bundle. Assume that token is compromised.

  1. Go to https://airtable.com/create/tokens.
  2. Revoke the old token (patHAB...) if it still exists.
  3. Create a new Personal Access Token:
    • Name: utilisation-dept-backend
    • Scopes: data.records:read
    • Access: limit to base appoByydxIQANKtSh only, tables Resource and Booking Resource.
  4. Copy the token. You'll paste it into .env below.

2. Generate secrets

# Session secret (paste output into SESSION_SECRET):
python3 -c "import secrets; print(secrets.token_urlsafe(48))"

# Admin password hash (replace 'your-password' with the real password):
python3 -c "from passlib.hash import bcrypt; print(bcrypt.hash('your-password'))"

Heads-up: bcrypt hashes contain $ characters. Wrap the hash in single quotes in .env, otherwise docker-compose will treat the $... segments as variable substitutions and the container will receive a truncated value:

ADMIN_PASSWORD_BCRYPT='$2b$12$abc...xyz'

3. Configure .env

cp .env.example .env
# edit .env and fill in:
#   AIRTABLE_PAT          (from step 1)
#   SESSION_SECRET        (from step 2)
#   ADMIN_PASSWORD_BCRYPT (from step 2)

Local development

# Backend
cd backend
python3 -m venv .venv && source .venv/bin/activate
pip install -r requirements.txt
uvicorn app.main:app --reload --port 8200

# Frontend (in another terminal)
cd frontend
npm install
npm run dev
# open http://localhost:5173/utilisation-dept/

The Vite dev server proxies /utilisation-dept/api/* to http://localhost:8200.

To skip auth entirely during dev, set DEV_AUTH_BYPASS=true in .env (NEVER do this in production — the startup banner will warn loudly).

Local Docker

docker compose up --build
# health: curl http://localhost:8200/api/health

Tests

cd backend && pytest
cd frontend && npm run typecheck && npm run lint

Deployment to optical-dev.oliver.solutions

The server hosts many small apps behind a shared Apache vhost. This app lives at /opt/utilisation-dept/.

First deploy

ssh user@optical-dev.oliver.solutions
sudo git clone <repo-url> /opt/utilisation-dept
cd /opt/utilisation-dept

# Create .env (see "First-time setup" above for value generation).
sudo cp .env.example .env
sudo vi .env

./deploy/deploy.sh

deploy.sh will:

  • Pick a free port in 82008299 and persist it to .env.
  • Render deploy/apache-utilisation-dept.conf from the template.
  • Build the frontend → /var/www/html/utilisation-dept/.
  • Build + start the Docker container.
  • Health-poll /api/health.
  • Print the Apache Include line for the shared vhost.

Wire into the shared vhost (one time only)

Add inside </VirtualHost> of /etc/apache2/sites-enabled/optical-dev.oliver.solutions.conf:

    Include /opt/utilisation-dept/deploy/apache-utilisation-dept.conf

Then:

sudo apachectl configtest && sudo systemctl reload apache2

Smoke-check

curl -sI https://optical-dev.oliver.solutions/utilisation-dept/api/health
curl -sf https://optical-dev.oliver.solutions/utilisation-dept/api/health
# expect 200 and {"ok":true,"version":"0.1.0"}

Then open https://optical-dev.oliver.solutions/utilisation-dept/ in a browser and confirm:

  • Login screen renders.
  • View-source on the loaded JS: search for patHAB — must return nothing.
  • Logging in with the local admin creds shows the dashboard.

Subsequent deploys

ssh user@optical-dev.oliver.solutions
cd /opt/utilisation-dept
./deploy/deploy.sh           # --no-pull, --no-build, --no-frontend, --logs available

Operations

Where the auth log lives

/opt/utilisation-dept/backend/logs/auth.log (mounted into the container at /app/logs/). Rotating, 5 MB × 5 backups.

This is the only audit trail for who logged in when — there's no database.

Rotating the PAT

  1. Generate a new PAT (same scopes as in setup).
  2. Edit .env → update AIRTABLE_PAT.
  3. docker compose -p utilisation-dept restart backend
  4. Revoke the old PAT in the Airtable portal.

Forgot admin password

  1. Generate a new bcrypt hash: python3 -c "from passlib.hash import bcrypt; print(bcrypt.hash('new-password'))"
  2. Edit .env → update ADMIN_PASSWORD_BCRYPT.
  3. docker compose -p utilisation-dept restart backend

Future work

  • Azure AD / MSAL SSO: app/auth/azure.py is stubbed. Flip AUTH_MODE=azure in .env, fill in AZURE_TENANT_ID + AZURE_CLIENT_ID, register the SPA in Azure (platform type: Single-page application), wire MSAL on the frontend, send the ID token to the backend. See ~/.claude/skills/azure-ad-msal-auth.md.
  • Database if/when we need history, audit trails, or to cache more aggressively. Today the data lives in Airtable and the Zoho upload is ephemeral.