loreal-utilisation-dept/backend/app/routers/utilisation.py
DJP 9f3d3cabfd feat: rebuild Department / Forecast / Project Type pages to match original SPA
Three big pages rebuilt against the original's screen recording — addresses
the "the chart is not dynamic" + "see booking and timesheet together"
feedback. 72/72 backend tests (+12 new), frontend typecheck/lint/build
clean, main entry 16.43 KB gzipped.

Department page:
- Department pill subnav (Creative / PM Team / Syndication / Transcreation /
  Opera Upload / Operations / Adhoc People). Replaces dropdown.
- Last Week / This Week / Next Week pills with date-range labels +
  month-preset pills derived from the uploaded timelog.
- HOURS & UTILISATION strip: 6 tiles — Allocated (net of leave), Time
  Logged (net of leave), Active People, Actual %, Forecast % (amber
  highlight), Leave Hours.
- BILLABILITY BREAKDOWN strip: Fee Related, Client Related, Non-Billable,
  Total Billable %.
- FTE VS FREELANCER section: two large composite cards, each with nested
  Actual + Forecast utilisation sub-cards.
- DeptWeeklyChart (new): per-person stacked bars with billing-category
  colours and red Forecast % line overlay. Click a name → DailyBreakdownModal
  with per-weekday stacked bars + red avg-% line. Click a bar segment →
  project-logs panel for that segment.
- DeptBookingVsActual (new): grouped per-person bars (Active / Soft /
  Actual).
- ResourceAvailability (new): sortable two-section table (FTE / Freelancer)
  showing each person's Active Booked, Soft Booked, Booked %, Logged h,
  and Status — the "booking + timesheet together" view.

Forecast page:
- 12 KPI tiles in two rows (Weekly Team Capacity → Active Next Week).
- Last Week / This Week toggle + "Project Summary loaded" badge.
- Weekly Pipeline chart: active-count bars + red exit-rate line + dashed
  forecast-avg baseline. Click a bar → drill into that week.
- Right-hand "How this forecast is built" sidebar with prose explanations.

Project Type Summary page:
- "Project Type Benchmark" header + coverage callouts (months in timelog,
  warning about projects starting before coverage, recommended export
  range).
- Month-preset pills.
- Sortable Summary by Project Type table — 10 columns including avg
  assets/week and avg projects/month.
- Per-type detail panel below: monthly assets trend chart + project list.
- Bottom: Avg H/Asset and Avg Duration totals + Insights & Recommended
  Actions section with auto-generated outlier callouts.

Backend additions:
- /api/utilisation/department, /api/utilisation/daily-breakdown
- /api/forecast extended with thisWeek + nextWeek capacity blocks
- /api/project-types extended with monthly trends, project lists,
  longest-project tracking, coverage section, insights
- services/department.py + tests/test_department.py
- Booking model gains resourceRecordIds so daily breakdowns can match
  bookings by Airtable record ID, not just flattened name

Tutorial selectors preserved + new ones added: dept-pills,
resource-availability, forecast-sidebar, forecast-canvas.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 23:17:42 -04:00

231 lines
7.5 KiB
Python

"""Utilisation summary + drill-down endpoints."""
from __future__ import annotations
from datetime import date, timedelta
from typing import Any
from fastapi import APIRouter, Depends, Header, HTTPException, Query
from app.deps.auth import auth_required
from app.routers.airtable import _bookings_cache, _resources_cache
from app.services.airtable_fetch import fetch_bookings, fetch_resources
from app.services.department import build_daily_breakdown, build_department
from app.services.merge import breakdown_by_project, compute_totals, summarise
from app.services.parse_store import get_timelog
from app.services.timelog_filters import apply_filters
router = APIRouter(prefix="/api/utilisation", tags=["utilisation"], dependencies=[Depends(auth_required)])
def _parse_date(s: str | None, default: date) -> date:
if not s:
return default
try:
return date.fromisoformat(s)
except ValueError as e:
raise HTTPException(status_code=400, detail=f"Invalid date: {e}")
async def _load_resources() -> list[dict[str, Any]]:
async def loader() -> dict[str, Any]:
rows = await fetch_resources(include_inactive=False)
return {"resources": rows}
payload = await _resources_cache.get_or_set("resources:False", loader)
return payload.get("resources", [])
async def _load_bookings(from_d: date, to_d: date) -> list[dict[str, Any]]:
bkey = f"bookings:{from_d}:{to_d}"
async def loader() -> dict[str, Any]:
rows = await fetch_bookings(from_=from_d, to=to_d)
return {"bookings": rows}
payload = await _bookings_cache.get_or_set(bkey, loader)
return payload.get("bookings", [])
@router.get("/summary")
async def summary(
from_: str | None = Query(None, alias="from"),
to: str | None = Query(None),
department: str | None = Query(None),
name: str | None = Query(None),
billing_type: str | None = Query(None),
period: str = Query("week"),
# FilterBar v2: timelog-sourced dimensions.
brands: str | None = Query(None),
divisions: str | None = Query(None),
hubs: str | None = Query(None),
user_roles: str | None = Query(None, alias="userRoles"),
x_timelog_hash: str | None = Header(None, alias="X-Timelog-Hash"),
) -> dict[str, Any]:
today = date.today()
# Default window: current ISO week's Monday through 4 weeks out.
monday = today - timedelta(days=today.weekday())
default_from = monday
default_to = monday + timedelta(days=27)
from_d = _parse_date(from_, default_from)
to_d = _parse_date(to, default_to)
if period not in ("week", "month"):
raise HTTPException(status_code=400, detail="period must be 'week' or 'month'")
resources = await _load_resources()
bookings = await _load_bookings(from_d, to_d)
logged_rows: list[dict[str, Any]] = []
if x_timelog_hash:
cached = get_timelog(x_timelog_hash)
if cached:
logged_rows = cached.get("rows", [])
# Apply FilterBar v2 dimensions to logs before summarising.
logged_rows = apply_filters(
logged_rows,
brands=brands, divisions=divisions, hubs=hubs, user_roles=user_roles,
)
filters = {"department": department, "name": name, "billing_type": billing_type}
rows = summarise(
logged_rows,
bookings,
resources,
from_=from_d,
to_=to_d,
period=period, # type: ignore[arg-type]
filters=filters,
)
totals = compute_totals(rows, bookings, from_=from_d, to_=to_d, filters=filters)
return {
"rows": rows,
"totals": totals,
"filters_applied": {
"from": from_d.isoformat(),
"to": to_d.isoformat(),
"department": department,
"name": name,
"billing_type": billing_type,
"period": period,
"brands": brands,
"divisions": divisions,
"hubs": hubs,
"userRoles": user_roles,
},
}
@router.get("/department")
async def department(
from_: str | None = Query(None, alias="from"),
to: str | None = Query(None),
department: str | None = Query(None),
brands: str | None = Query(None),
divisions: str | None = Query(None),
hubs: str | None = Query(None),
user_roles: str | None = Query(None, alias="userRoles"),
names: str | None = Query(None),
x_timelog_hash: str | None = Header(None, alias="X-Timelog-Hash"),
) -> dict[str, Any]:
"""Department-page aggregate: bookings + logs merged per-person.
Powers the rebuilt Department.tsx page. Returns KPI tiles, per-dept
summary, per-person rows (with billing buckets + booked + logged),
FTE / Freelancer split, and the active-month list for the pill row.
"""
today = date.today()
monday = today - timedelta(days=today.weekday())
from_d = _parse_date(from_, monday)
to_d = _parse_date(to, monday + timedelta(days=6))
resources = await _load_resources()
bookings = await _load_bookings(from_d, to_d)
logged_rows: list[dict[str, Any]] = []
if x_timelog_hash:
cached = get_timelog(x_timelog_hash)
if cached:
logged_rows = cached.get("rows", [])
logged_rows = apply_filters(
logged_rows,
brands=brands, divisions=divisions, hubs=hubs, user_roles=user_roles,
names=names,
)
return build_department(
logs=logged_rows,
bookings=bookings,
resources=resources,
from_=from_d,
to_=to_d,
department=department,
)
@router.get("/daily-breakdown")
async def daily_breakdown(
employee: str = Query(..., description="Submitter email (or name fallback)"),
from_: str | None = Query(None, alias="from"),
to: str | None = Query(None),
x_timelog_hash: str | None = Header(None, alias="X-Timelog-Hash"),
) -> dict[str, Any]:
"""Per-weekday breakdown for one person — drives the Daily Breakdown modal."""
today = date.today()
monday = today - timedelta(days=today.weekday())
from_d = _parse_date(from_, monday)
to_d = _parse_date(to, monday + timedelta(days=4))
resources = await _load_resources()
bookings = await _load_bookings(from_d, to_d)
logged_rows: list[dict[str, Any]] = []
if x_timelog_hash:
cached = get_timelog(x_timelog_hash)
if cached:
logged_rows = cached.get("rows", [])
return build_daily_breakdown(
logs=logged_rows,
bookings=bookings,
resources=resources,
person_email=employee,
from_=from_d,
to_=to_d,
)
@router.get("/breakdown")
async def breakdown(
period: str = Query(..., description="Period label, e.g. 2026-W19 or 2026-05"),
employee: str | None = Query(None),
from_: str | None = Query(None, alias="from"),
to: str | None = Query(None),
x_timelog_hash: str | None = Header(None, alias="X-Timelog-Hash"),
) -> dict[str, Any]:
"""Drill-down: per-project hour breakdown (logged + booked) for a period."""
today = date.today()
monday = today - timedelta(days=today.weekday())
from_d = _parse_date(from_, monday - timedelta(days=180))
to_d = _parse_date(to, monday + timedelta(days=180))
bookings = await _load_bookings(from_d, to_d)
logged_rows: list[dict[str, Any]] = []
if x_timelog_hash:
cached = get_timelog(x_timelog_hash)
if cached:
logged_rows = cached.get("rows", [])
return breakdown_by_project(
logged_rows,
bookings,
period=period,
employee=employee,
from_=from_d,
to_=to_d,
)