Commit graph

11 commits

Author SHA1 Message Date
DJP
e1db93ad4a backend + frontend: leave hours, server-side bookings filter, dynamic meta, defensive charts
Backend (33/33 tests, +5 new):

- Split Zoho parser's canonical "billable" into "billable" (bool column)
  and "billingType" (string column with values like "Client Related" /
  "Leave Hours" / "Idle Time"). Each parsed row now carries both, and
  billable is cross-filled from billingType when only the latter is
  present.
- Merge service computes leaveHours separately from non_billable_h: any
  row with billingType "leave hours"/"leave" lands in the leave bucket
  and is no longer double-counted as non-billable.
- UtilisationSummaryRow gains leaveHours: float; TimelogRow gains
  billingType: str | None.
- /api/airtable/bookings accepts ?department=&name= (comma-separated
  multi-value), folded into the filterByFormula alongside the date
  overlap. Apostrophes in names are escaped. Cache key now includes
  the filter values so different selections don't collide.
- /api/airtable/meta computes departments + employmentTypes from a
  live fetch_resources call (sorted distinct), falls back to the
  hardcoded lists on any exception. billingTypes/bookingStatuses
  stay static.
- Logout cookie now mirrors the login cookie's HttpOnly / Secure /
  SameSite / Path attributes with max_age=0 and empty value, for
  consistency.

Frontend (typecheck/lint/build clean):

- types.ts: UtilisationSummaryRow.leaveHours: number.
- BillabilityBreakdown uses r.leaveHours directly; idle becomes
  max(0, available - billable - nonBillable - leave). Capped to top
  20 employees by (available + billable) with "Other (N)" rollup;
  Legend replaced with compact inline swatches.
- BookingVsActual and FTEvsFreelancer: same top-20 + Other treatment
  to prevent the ProjectLoad-style x-axis explosion at scale.
- Defensive sweep on WeeklyUtilisation, MonthlyUtilisation,
  BookingVsActual, FTEvsFreelancer: null-coerce sort keys, Number()-
  guard arithmetic, skip rows with no usable period/employee.
- getBookings signature gains department + name; Resourcing passes
  them through. Client-side visibleBookings filter retained as
  belt-and-braces since linked-lookup filterByFormula on Airtable
  can be flaky.
- Tutorial steps.ts restructured to cover the new chart and CSV
  export tags; existing TutorialOverlay defensive selector check
  preserved.
- ErrorBoundary: removed dead eslint-disable directive flagged by
  --report-unused-disable-directives.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 20:48:12 -04:00
DJP
d9860d7beb backend: raise multipart upload cap from 20 MB to 100 MB
A full-year Zoho time-log export can run 30-60 MB and was getting
rejected with {"detail":"Payload too large"} on the Department tab
upload. 20 MB was an under-cautious default; 100 MB matches what
Apache will pass through without a LimitRequestBody override.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 20:39:45 -04:00
DJP
c485757dc3 frontend: cap Project Load chart to top 10 projects, drop legend, honour filters
Real Airtable bookings have 100+ unique project names in a single
month. The previous chart rendered one <Bar>+legend entry per project,
which flooded the legend off-screen and broke the page layout entirely
(screenshot 2026-05-16).

- Aggregate sorts projects by total hours and shows top 10 only; the
  remainder are collapsed into a single greyed "Other (N)" stack so
  the totals still add up but the visual stays sane.
- Remove the <Legend> entirely. With long L'Oréal-style project names
  even 10 entries dominate. The tooltip (capped to 360px wide, with
  filterNull) handles per-segment lookup on hover.
- Wire FilterBar's department/name selections through to the chart on
  the Resourcing page. The backend's /api/airtable/bookings endpoint
  doesn't accept those params yet, so filter client-side for v1.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 13:57:11 -04:00
DJP
a1a7729a0e frontend: contain chart crashes with ErrorBoundary + null-safe Project Load aggregate
Resourcing page went blank in prod the first time a booking arrived
with a null resourceName (Airtable placeholder rows, or any booking
whose resource lookup didn't resolve). The Project Load per Person
chart's aggregate then crashed on `null.localeCompare(...)` during
sort, and with no error boundary in the tree, React unmounted the
entire route — visible to the user as "loads, then goes blank".

- ProjectLoadPerPerson.aggregate: coerce nullable lookup values to
  scalars before keying/sorting; treat empty resourceName as
  "Placeholder", missing project name as "Unknown", and NaN totals
  as 0.
- New ErrorBoundary component (class — React still requires class
  for boundaries) that renders a card with the error message instead
  of unmounting the parent.
- Wrap each chart on both Resourcing and Department in its own
  ErrorBoundary so a future chart-specific bug only blanks that one
  card, not the whole page.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 13:52:56 -04:00
DJP
8e28464bdf deploy.sh: fix self-collision in slug check when clone path != slug
The collision check filtered self-matches by /opt/${SLUG}/, which only
works when the on-disk directory matches the URL slug. When the repo is
cloned to a different directory (e.g. /opt/loreal-utilisation-dept/ to
match the Bitbucket repo name while keeping URL slug "utilisation-dept"),
the script flagged its own apache-*.conf as a foreign collision and
refused to redeploy. Filter against \$REPO_ROOT instead, which holds the
actual on-disk path and matches what grep -l emits.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 13:47:03 -04:00
DJP
ac9743c696 backend: flatten Airtable lookup fields at the boundary
`Resource Name`, `Project Number (from Master)`, `Project Name (from
Master)`, `Department (from Resource Name)` on Booking Resource are
linked-record / lookup fields — Airtable always returns them as lists,
even when cardinality is 1. The previous booking normaliser only
handled one specific list case (`Resource Name (from Resource)`) and
let the other field aliases pass through as raw lists. The merge
service then crashed with `AttributeError: 'list' object has no
attribute 'strip'` on first call to /api/utilisation/summary in prod.

Introduce a `_flatten()` helper and apply it to every linked/lookup
field in `normalise_booking`. Also make `_name_key` in the merge
service defensive against raw lists so a missed flatten elsewhere
degrades gracefully instead of 500ing.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 13:20:50 -04:00
DJP
bdfaa5b27f deploy.sh: print Include line using actual repo path, not hardcoded /opt/<slug>
The repo can be cloned to any directory (e.g. /opt/loreal-utilisation-dept
to match the Bitbucket repo name) — the slug we use for the URL path and
compose project is independent of the on-disk path. The previously printed
Apache Include line assumed `/opt/<slug>/`, which broke when the clone
location didn't match the slug. Use $APACHE_CONF (already absolute and
correct, derived from $SCRIPT_DIR) instead.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 13:04:11 -04:00
DJP
287f0b1b01 deploy.sh: pull first, fail fast, re-exec if deploy.sh itself changed
Move `git pull --ff-only origin main` to the very top of the script so
every deploy reflects the latest committed code. Pull failure is now
fatal (was warn-and-continue). After the pull, hash-compare deploy.sh
against its pre-pull copy; if it changed, exec the new version with a
DEPLOY_RECURSE_GUARD env var to prevent any chance of infinite re-exec.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 12:57:18 -04:00
DJP
4bf53124a8 deploy.sh: detect slug/port collisions against the live vhost
Fail fast if another app on optical-dev already claims /utilisation-dept/
in the shared Apache vhost or any sibling /opt/*/deploy/apache-*.conf,
and surface which 8200-8299 ports are already taken by sibling apps so
the port picker's choice is visible. Silently skips on dev laptops where
the vhost file isn't present.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 12:55:19 -04:00
DJP
04edbfdd2c Initial commit: dockerised FastAPI backend + React/Vite frontend rewrite
Replaces a static SPA that shipped an Airtable PAT in the JS bundle.
The new architecture holds all secrets server-side, fronts the app
behind Apache on optical-dev with the shared-vhost split-build pattern,
and is designed for a later Azure AD/MSAL swap-in.

- backend/   FastAPI + uvicorn, local auth (Azure AD stub), Airtable
             proxy with TTL cache, Zoho .xlsx/.csv parser, merge
             service for utilisation summaries. 28 pytest tests.
- frontend/  React + Vite + TS + Tailwind + Recharts SPA. Login entry
             chunk 12.83 KB gzipped; Recharts lazy-loaded. No tokens
             or Airtable URLs in the built bundle.
- deploy/    Idempotent deploy.sh (port auto-pick 8200-8299,
             .env-persisted) + split-build Apache include template.
- docker-compose.yml pins name: utilisation-dept and binds 127.0.0.1.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 12:37:04 -04:00
Dave Porter
c9f9c5cced Initial commit 2026-05-16 16:32:31 +00:00