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>
231 lines
7.5 KiB
Python
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,
|
|
)
|