No description
Find a file
DJP d4c6576a95 parity v3: two-bar charts, airtable link fallbacks, filter split, weekly comparison, project-type detail
Round 3 of parity fixes after the user shared side-by-side screen
recordings of our build vs. the original SPA. 72/72 tests, frontend
typecheck/lint/build clean, main entry 16.95 KB gz.

Airtable booking → person linkage (root cause of empty Resource
Availability + Daily Breakdown):
- normalise_booking now tries Resource / Booking Resource / Booked
  Resource / Resource (from Booking) / Resource Name (from Resource)
  as fallbacks for resourceRecordIds — first non-empty wins. Only
  values matching `rec` + 17 chars are kept.
- One-shot LOG_AIRTABLE_SCHEMA_ONCE info log on the first booking
  response so we can see what fields the live base actually returns.
  Flip to False once we've confirmed the field name.
- Name-based fallback in department.py already in place.

Charts:
- DeptWeeklyChart: two bars per entity. Bar 1 stacks Soft Booked + Active
  Booked. Bar 2 stacks Allocated + per-billing-category. Red Avg %
  utilisation line crosses both. Legend gains Active/Soft Booked +
  Avg %.
- DailyBreakdownModal: two bars per weekday (allocated + billing),
  Active Booked + Soft Booked pills at the top, full two-row legend
  matching frame f020.

Filters:
- New GlobalFilterBar (Division/Brand/Hub/Role/From/To/Reset) lives
  above the tab nav in ProtectedShell, visible on every page.
- New DepartmentFilterBar (Name/Division/Brand/Employment) lives inside
  Department.tsx only.
- Resourcing / Bookings lose their redundant inline FilterBar — global
  one covers them.

Forecast:
- Pipeline chart bars now stacked by project type (PIPELINE_TYPES
  palette). Legend below the chart includes the type colours +
  Exiting + Forecast avg.
- New WeeklyComparisonTable below the pipeline: This Week / Next Week
  / Week +2 / Week +3 × project type, Active / Exit counts per cell,
  totals row.
- "Last Week" subtitle now reads "Full week actual hours (Mon–Fri)" —
  matches the original SPA's semantic.
- Backend: ForecastWeek gains activeAssetsByType + exitingByType maps.

Project Type Summary:
- Selected-type detail panel below the table: avg h/asset + avg
  duration tiles (with min–max range), totals line, dept hours
  segment bar with colour legend, Insights & Recommended Actions
  panel, Panel 1 chart (avg h/asset by completion month, stacked by
  division).
- Backend: ProjectTypeStatExtended gains deptHoursBreakdown,
  monthlyAvgHoursPerAsset (with byDivision), minDurationDays.

Adhoc People:
- Department page now surfaces a small warning card next to the dept
  pills listing the top 6 unmatched Zoho submitter emails + a "+N
  more" count.

Header subtitle:
- Reads "<filename> · last updated <localised dd/mm/yyyy hh:mm:ss>"
  when parsed_at is present in the parse response. Backend's
  /api/timelog/parse now emits parsed_at (ISO 8601). Falls back to
  row count if missing.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 08:50:21 -04:00
backend parity v3: two-bar charts, airtable link fallbacks, filter split, weekly comparison, project-type detail 2026-05-18 08:50:21 -04:00
deploy deploy.sh: fix self-collision in slug check when clone path != slug 2026-05-16 13:47:03 -04:00
frontend parity v3: two-bar charts, airtable link fallbacks, filter split, weekly comparison, project-type detail 2026-05-18 08:50:21 -04:00
.env.example feat: Forecast, Project Type Summary, Time Log Detail, AI Chat, filters v2, stats bar, RBAC 2026-05-17 21:40:03 -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.