diff --git a/backend/app/models/schemas.py b/backend/app/models/schemas.py index 7c9dc67..cfb1993 100644 --- a/backend/app/models/schemas.py +++ b/backend/app/models/schemas.py @@ -103,6 +103,8 @@ class UtilisationSummaryRow(BaseModel): employmentType: str | None = None availableHours: float = 0.0 bookedHours: float = 0.0 + activeBookedHours: float = 0.0 + softBookedHours: float = 0.0 loggedHours: float = 0.0 billableHours: float = 0.0 nonBillableHours: float = 0.0 @@ -118,15 +120,58 @@ class UtilisationFiltersApplied(BaseModel): department: str | None = None name: str | None = None billing_type: str | None = None + period: str | None = None model_config = ConfigDict(populate_by_name=True) +class UtilisationTotals(BaseModel): + totalBooked: float = 0.0 + activeBooked: float = 0.0 + softBooked: float = 0.0 + totalLogged: float = 0.0 + totalBillable: float = 0.0 + totalProjects: int = 0 + activePeople: int = 0 + activeBookingsCount: int = 0 + availableHours: float = 0.0 + allocatedNetOfLeave: float = 0.0 + periodFrom: str | None = None + periodTo: str | None = None + + class UtilisationSummaryResponse(BaseModel): rows: list[UtilisationSummaryRow] + totals: UtilisationTotals filters_applied: UtilisationFiltersApplied +class BreakdownProjectLogged(BaseModel): + project: str + hours: float = 0.0 + billable: float = 0.0 + nonBillable: float = 0.0 + + +class BreakdownProjectBooked(BaseModel): + project: str + hours: float = 0.0 + active: float = 0.0 + soft: float = 0.0 + + +class BreakdownResponse(BaseModel): + model_config = ConfigDict(populate_by_name=True) + + period: str + employee: str | None = None + from_: str | None = Field(default=None, alias="from") + to: str | None = None + logged: list[BreakdownProjectLogged] = Field(default_factory=list) + booked: list[BreakdownProjectBooked] = Field(default_factory=list) + hasLogged: bool = False + + # ---------- Health ---------- class HealthResponse(BaseModel): diff --git a/backend/app/routers/utilisation.py b/backend/app/routers/utilisation.py index c674ad2..1529185 100644 --- a/backend/app/routers/utilisation.py +++ b/backend/app/routers/utilisation.py @@ -1,4 +1,4 @@ -"""Utilisation summary endpoint.""" +"""Utilisation summary + drill-down endpoints.""" from __future__ import annotations @@ -11,7 +11,7 @@ from app.deps.auth import auth_required from app.routers.airtable import _bookings_cache, _resources_cache from app.routers.timelog import get_cached_parse from app.services.airtable_fetch import fetch_bookings, fetch_resources -from app.services.merge import summarise +from app.services.merge import breakdown_by_project, compute_totals, summarise router = APIRouter(prefix="/api/utilisation", tags=["utilisation"], dependencies=[Depends(auth_required)]) @@ -26,6 +26,26 @@ def _parse_date(s: str | None, default: date) -> date: 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"), @@ -47,23 +67,8 @@ async def summary( if period not in ("week", "month"): raise HTTPException(status_code=400, detail="period must be 'week' or 'month'") - # Resources — use the shared cache. - async def load_resources() -> dict[str, Any]: - rows = await fetch_resources(include_inactive=False) - return {"resources": rows} - - res_payload = await _resources_cache.get_or_set("resources:False", load_resources) - resources = res_payload.get("resources", []) - - # Bookings — share cache with /api/airtable/bookings. - bkey = f"bookings:{from_d}:{to_d}" - - async def load_bookings() -> dict[str, Any]: - rows = await fetch_bookings(from_=from_d, to=to_d) - return {"bookings": rows} - - bk_payload = await _bookings_cache.get_or_set(bkey, load_bookings) - bookings = bk_payload.get("bookings", []) + resources = await _load_resources() + bookings = await _load_bookings(from_d, to_d) logged_rows: list[dict[str, Any]] = [] if x_timelog_hash: @@ -71,6 +76,7 @@ async def summary( if cached: logged_rows = cached.get("rows", []) + filters = {"department": department, "name": name, "billing_type": billing_type} rows = summarise( logged_rows, bookings, @@ -78,16 +84,56 @@ async def summary( from_=from_d, to_=to_d, period=period, # type: ignore[arg-type] - filters={"department": department, "name": name, "billing_type": billing_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, }, } + + +@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. + + `period` must be either an ISO week (YYYY-Www) or a month (YYYY-MM). + The optional `from`/`to` query params clamp the breakdown window — useful + when the user has a custom date range that doesn't span the full 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_cached_parse(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, + ) diff --git a/backend/app/services/merge.py b/backend/app/services/merge.py index e1b647e..ea13f12 100644 --- a/backend/app/services/merge.py +++ b/backend/app/services/merge.py @@ -10,6 +10,10 @@ Decisions: - bookedHours pro-rate: if a booking spans multiple periods, distribute totalHoursBooked proportionally to the working-day overlap between the booking and each period. This is the simplest defensible split. +- Active vs Soft bookings: classified by `bookingStatus`. The split keeps + the original SPA's "Active Booked" / "Soft Booked" stacks. Unknown or + null statuses default to active (consistent with the original). +- bookedHours stays as the sum (= active + soft) for backwards compatibility. - forecastHours == bookedHours for v1. - All dates are inclusive on both ends. - The function is deliberately pure — no I/O, no globals — so it's easy @@ -26,6 +30,20 @@ from typing import Any, Iterable, Literal PeriodKind = Literal["week", "month"] +# Booking status buckets. Anything not in SOFT is treated as active. +_SOFT_STATUSES = {"soft booking", "soft booked", "soft-booked", "soft-booking"} +_ACTIVE_STATUSES = {"active", "active booked", "fully booked", "partially booked"} + + +def _is_soft(status: Any) -> bool: + if not status: + return False + s = str(status).strip().lower() + if not s: + return False + return s in _SOFT_STATUSES + + # ---------------------------------------------------------------------- # Period helpers # ---------------------------------------------------------------------- @@ -201,6 +219,8 @@ def summarise( "employmentType": emp_type, "availableHours": 0.0, "bookedHours": 0.0, + "activeBookedHours": 0.0, + "softBookedHours": 0.0, "loggedHours": 0.0, "billableHours": 0.0, "nonBillableHours": 0.0, @@ -216,7 +236,10 @@ def summarise( available_hours = (avail_per_week * weekdays_in_window) / 5.0 if avail_per_week else 0.0 # bookedHours: pro-rate by working-day overlap of booking within window. + # Split by bookingStatus: soft vs active. Unknown defaults to active. booked = 0.0 + active_booked = 0.0 + soft_booked = 0.0 for b in emp_bookings: b_start = b.get("startDate") b_end = b.get("endDate") or b_start @@ -232,7 +255,12 @@ def summarise( if overlap_weekdays == 0: continue total_hours = float(b.get("totalHoursBooked") or 0.0) - booked += total_hours * (overlap_weekdays / booking_total_weekdays) + slice_hours = total_hours * (overlap_weekdays / booking_total_weekdays) + booked += slice_hours + if _is_soft(b.get("bookingStatus")): + soft_booked += slice_hours + else: + active_booked += slice_hours # Logged hours filtered to this window. logged_h = 0.0 @@ -270,6 +298,8 @@ def summarise( "employmentType": emp_type, "availableHours": round(available_hours, 2), "bookedHours": round(booked, 2), + "activeBookedHours": round(active_booked, 2), + "softBookedHours": round(soft_booked, 2), "loggedHours": round(logged_h, 2), "billableHours": round(billable_h, 2), "nonBillableHours": round(non_billable_h, 2), @@ -281,3 +311,218 @@ def summarise( out.sort(key=lambda r: ((r["department"] or ""), r["employee"], r["period"])) return out + + +def compute_totals( + rows: Iterable[dict[str, Any]], + bookings: Iterable[dict[str, Any]], + *, + from_: date, + to_: date, + filters: dict[str, Any] | None = None, +) -> dict[str, Any]: + """Aggregate KPI rollups across the supplied summary rows + the bookings + in scope (used for distinct-project counts that aren't reflected in the + aggregated rows). + + `filters` is the same dict passed to `summarise` so we can honour the + department / name narrowing for the bookings dimension. The summary rows + have already been filtered, so we only need to filter bookings here. + """ + rows = list(rows) + bookings = list(bookings) + filters = filters or {} + dep_filter = (filters.get("department") or "").strip().lower() or None + name_filter = (filters.get("name") or "").strip().lower() or None + + total_booked = 0.0 + active_booked = 0.0 + soft_booked = 0.0 + total_logged = 0.0 + total_billable = 0.0 + available_total = 0.0 + leave_total = 0.0 + active_people: set[str] = set() + for r in rows: + total_booked += float(r.get("bookedHours") or 0.0) + active_booked += float(r.get("activeBookedHours") or 0.0) + soft_booked += float(r.get("softBookedHours") or 0.0) + logged = float(r.get("loggedHours") or 0.0) + total_logged += logged + total_billable += float(r.get("billableHours") or 0.0) + available_total += float(r.get("availableHours") or 0.0) + leave_total += float(r.get("leaveHours") or 0.0) + if logged > 0: + emp = (r.get("employee") or "").strip() + if emp: + active_people.add(emp) + + # Active bookings (count, projects) — narrow bookings to the same scope + # as the rows. The summarise() function already pre-filters out logged + # rows + booking slices that don't overlap the window, but we re-apply + # the date overlap and dept/name filters here for parity. + in_scope_bookings: list[dict[str, Any]] = [] + project_names: set[str] = set() + for b in bookings: + b_start = b.get("startDate") + b_end = b.get("endDate") or b_start + if not b_start: + continue + if not b_end: + b_end = b_start + if _overlap(b_start, b_end, from_, to_) is None: + continue + if dep_filter: + dep_val = (b.get("department") or "").strip().lower() + if dep_val != dep_filter: + continue + if name_filter: + name_val = _name_key(b.get("resourceName")) + if name_filter not in name_val: + continue + in_scope_bookings.append(b) + pn = b.get("projectName") or b.get("projectNumber") + if pn: + pn_str = str(pn).strip() + if pn_str: + project_names.add(pn_str) + + return { + "totalBooked": round(total_booked, 2), + "activeBooked": round(active_booked, 2), + "softBooked": round(soft_booked, 2), + "totalLogged": round(total_logged, 2), + "totalBillable": round(total_billable, 2), + "totalProjects": len(project_names), + "activePeople": len(active_people), + "activeBookingsCount": len(in_scope_bookings), + "availableHours": round(available_total, 2), + "allocatedNetOfLeave": round(max(available_total - leave_total, 0.0), 2), + "periodFrom": from_.isoformat(), + "periodTo": to_.isoformat(), + } + + +def breakdown_by_project( + logged: Iterable[dict[str, Any]], + bookings: Iterable[dict[str, Any]], + *, + period: str, + employee: str | None = None, + from_: date | None = None, + to_: date | None = None, +) -> dict[str, Any]: + """Per-project hour breakdown for a given period label. + + The period can be either an ISO-week ("2026-W19") or a month ("2026-05"). + If `employee` is supplied we narrow to that one resource; otherwise we + aggregate across all resources visible. + + Returns a structure suitable for the drill-down panel. + """ + p_start, p_end = _bounds_from_period_label(period) + if p_start is None or p_end is None: + return {"period": period, "employee": employee, "logged": [], "booked": [], "hasLogged": False} + + if from_: + p_start = max(p_start, from_) + if to_: + p_end = min(p_end, to_) + + emp_key = _name_key(employee) if employee else None + + # Booked breakdown — pro-rated by working-day overlap. + booked_totals: dict[str, dict[str, float]] = {} + for b in bookings: + if emp_key and _name_key(b.get("resourceName")) != emp_key: + continue + b_start = b.get("startDate") + b_end = b.get("endDate") or b_start + if not b_start: + continue + if not b_end: + b_end = b_start + ov = _overlap(b_start, b_end, p_start, p_end) + if ov is None: + continue + booking_total_weekdays = _weekdays_between(b_start, b_end) or 1 + overlap_weekdays = _weekdays_between(ov[0], ov[1]) + if overlap_weekdays == 0: + continue + total_hours = float(b.get("totalHoursBooked") or 0.0) + slice_hours = total_hours * (overlap_weekdays / booking_total_weekdays) + project = ( + (b.get("projectName") or b.get("projectNumber") or "Unknown") + ) + project_key = str(project).strip() or "Unknown" + bucket = booked_totals.setdefault(project_key, {"project": project_key, "hours": 0.0, "active": 0.0, "soft": 0.0}) + bucket["hours"] += slice_hours + if _is_soft(b.get("bookingStatus")): + bucket["soft"] += slice_hours + else: + bucket["active"] += slice_hours + + # Logged breakdown — straight per-project sum within window. + logged_totals: dict[str, dict[str, float]] = {} + has_logged_input = False + for r in logged: + has_logged_input = True + if emp_key and _name_key(r.get("employee")) != emp_key: + continue + d = r.get("date") + if not d: + continue + if d < p_start or d > p_end: + continue + project = (r.get("project") or "Unknown") + project_key = str(project).strip() or "Unknown" + bucket = logged_totals.setdefault(project_key, {"project": project_key, "hours": 0.0, "billable": 0.0, "nonBillable": 0.0}) + hrs = float(r.get("hours") or 0.0) + bucket["hours"] += hrs + if r.get("billable"): + bucket["billable"] += hrs + else: + bucket["nonBillable"] += hrs + + def _round(d: dict[str, Any]) -> dict[str, Any]: + return {k: (round(v, 2) if isinstance(v, float) else v) for k, v in d.items()} + + logged_rows = sorted((_round(v) for v in logged_totals.values()), key=lambda r: -r["hours"]) + booked_rows = sorted((_round(v) for v in booked_totals.values()), key=lambda r: -r["hours"]) + + return { + "period": period, + "employee": employee, + "from": p_start.isoformat(), + "to": p_end.isoformat(), + "logged": logged_rows, + "booked": booked_rows, + "hasLogged": has_logged_input, + } + + +def _bounds_from_period_label(label: str) -> tuple[date | None, date | None]: + """Parse a period label ("YYYY-Www" or "YYYY-MM") to [start, end] dates.""" + label = (label or "").strip() + if not label: + return None, None + # ISO week + if "W" in label.upper(): + try: + year_s, week_s = label.upper().split("-W", 1) + year = int(year_s) + week = int(week_s) + monday = date.fromisocalendar(year, week, 1) + sunday = monday + timedelta(days=6) + return monday, sunday + except (ValueError, IndexError): + return None, None + # Month + try: + year_s, month_s = label.split("-", 1) + year = int(year_s) + month = int(month_s) + first = date(year, month, 1) + return _month_bounds(first) + except (ValueError, IndexError): + return None, None diff --git a/backend/tests/test_merge.py b/backend/tests/test_merge.py index 8fc59db..33d7b9f 100644 --- a/backend/tests/test_merge.py +++ b/backend/tests/test_merge.py @@ -6,7 +6,12 @@ from datetime import date import pytest -from app.services.merge import iter_periods, summarise +from app.services.merge import ( + breakdown_by_project, + compute_totals, + iter_periods, + summarise, +) def test_iter_periods_week(): @@ -180,3 +185,132 @@ def test_leave_hours_split_out_from_non_billable(sample_resources): assert bhakti["billableHours"] == 32.0 assert bhakti["nonBillableHours"] == 0.0 assert bhakti["loggedHours"] == 40.0 + + +# --------------------------------------------------------------------------- +# Active vs Soft split + totals +# --------------------------------------------------------------------------- + + +def test_active_vs_soft_split(sample_resources): + """Bookings tagged 'Soft Booking' must land in softBookedHours; others active.""" + bookings = [ + { + "id": "bk-active", "task": "A", "startDate": date(2026, 5, 4), + "endDate": date(2026, 5, 8), "resourceName": "Bhakti Doshi", + "projectNumber": "P-A", "projectName": "Active proj", + "department": "Creative Team", "division": "Production", + "hoursSelection": [], "totalHoursBooked": 20.0, + "bookingStatus": "Active", "placeholder": False, + }, + { + "id": "bk-soft", "task": "S", "startDate": date(2026, 5, 4), + "endDate": date(2026, 5, 8), "resourceName": "Bhakti Doshi", + "projectNumber": "P-S", "projectName": "Soft proj", + "department": "Creative Team", "division": "Production", + "hoursSelection": [], "totalHoursBooked": 12.0, + "bookingStatus": "Soft Booking", "placeholder": False, + }, + ] + rows = summarise( + [], bookings, sample_resources, + from_=date(2026, 5, 4), to_=date(2026, 5, 8), period="week", + ) + bhakti = next(r for r in rows if r["employee"] == "Bhakti Doshi" and r["period"] == "2026-W19") + assert bhakti["activeBookedHours"] == pytest.approx(20.0) + assert bhakti["softBookedHours"] == pytest.approx(12.0) + assert bhakti["bookedHours"] == pytest.approx(32.0) + + +def test_unknown_status_defaults_to_active(sample_resources): + """Null/unknown statuses fall through to active (not silently dropped).""" + bookings = [ + { + "id": "bk-null", "task": "N", "startDate": date(2026, 5, 4), + "endDate": date(2026, 5, 8), "resourceName": "Bhakti Doshi", + "projectNumber": "P-N", "projectName": "Null proj", + "department": "Creative Team", "division": "Production", + "hoursSelection": [], "totalHoursBooked": 15.0, + "bookingStatus": None, "placeholder": False, + }, + { + "id": "bk-junk", "task": "J", "startDate": date(2026, 5, 4), + "endDate": date(2026, 5, 8), "resourceName": "Bhakti Doshi", + "projectNumber": "P-J", "projectName": "Junk status proj", + "department": "Creative Team", "division": "Production", + "hoursSelection": [], "totalHoursBooked": 5.0, + "bookingStatus": "Pencilled In Tentatively", "placeholder": False, + }, + ] + rows = summarise( + [], bookings, sample_resources, + from_=date(2026, 5, 4), to_=date(2026, 5, 8), period="week", + ) + bhakti = next(r for r in rows if r["employee"] == "Bhakti Doshi" and r["period"] == "2026-W19") + assert bhakti["activeBookedHours"] == pytest.approx(20.0) + assert bhakti["softBookedHours"] == 0.0 + assert bhakti["bookedHours"] == pytest.approx(20.0) + + +def test_compute_totals(sample_resources, sample_bookings, sample_logged): + rows = summarise( + sample_logged, sample_bookings, sample_resources, + from_=date(2026, 5, 4), to_=date(2026, 5, 8), period="week", + ) + totals = compute_totals( + rows, sample_bookings, + from_=date(2026, 5, 4), to_=date(2026, 5, 8), + ) + assert totals["totalBooked"] == pytest.approx(38.0) + assert totals["activeBooked"] == pytest.approx(38.0) + assert totals["softBooked"] == 0.0 + assert totals["totalLogged"] == pytest.approx(35.0) + assert totals["totalBillable"] == pytest.approx(28.0) + assert totals["totalProjects"] == 1 # one distinct projectName + assert totals["activePeople"] == 1 # only Bhakti has loggedHours > 0 + assert totals["activeBookingsCount"] == 1 + assert totals["periodFrom"] == "2026-05-04" + assert totals["periodTo"] == "2026-05-08" + # allocated net of leave: avail (40 + 40 for two FTEs in W19) - 0 leave = 80 + assert totals["allocatedNetOfLeave"] == pytest.approx(80.0) + + +def test_breakdown_by_project_logged_and_booked(sample_resources, sample_bookings, sample_logged): + out = breakdown_by_project( + sample_logged, sample_bookings, + period="2026-W19", employee="Bhakti Doshi", + ) + assert out["period"] == "2026-W19" + assert out["employee"] == "Bhakti Doshi" + assert out["hasLogged"] is True + # Logged side: Acme (28h billable across 4 days) + Internal (7h non-billable) + logged_by_project = {row["project"]: row for row in out["logged"]} + assert logged_by_project["Acme"]["hours"] == pytest.approx(28.0) + assert logged_by_project["Acme"]["billable"] == pytest.approx(28.0) + assert logged_by_project["Internal"]["hours"] == pytest.approx(7.0) + assert logged_by_project["Internal"]["nonBillable"] == pytest.approx(7.0) + # Booked side: one booking on Acme Spring Launch = 38h, all active + booked_by_project = {row["project"]: row for row in out["booked"]} + assert booked_by_project["Acme Spring Launch"]["hours"] == pytest.approx(38.0) + assert booked_by_project["Acme Spring Launch"]["active"] == pytest.approx(38.0) + + +def test_breakdown_no_timelog(sample_resources, sample_bookings): + """Empty timelog input: hasLogged should be False so the UI can prompt.""" + out = breakdown_by_project([], sample_bookings, period="2026-W19", employee="Bhakti Doshi") + assert out["hasLogged"] is False + assert out["logged"] == [] + # Booked side still works + assert any(row["project"] == "Acme Spring Launch" for row in out["booked"]) + + +def test_breakdown_month_period(sample_resources, sample_bookings): + """Month-level period label resolves to that month's day-range.""" + out = breakdown_by_project([], sample_bookings, period="2026-05") + # The single sample booking falls inside May 2026. + assert any(row["project"] == "Acme Spring Launch" for row in out["booked"]) + + +def test_breakdown_invalid_period_returns_empty(sample_bookings): + out = breakdown_by_project([], sample_bookings, period="not-a-period") + assert out["logged"] == [] and out["booked"] == [] diff --git a/frontend/src/api/endpoints.ts b/frontend/src/api/endpoints.ts index bcff9bb..6283036 100644 --- a/frontend/src/api/endpoints.ts +++ b/frontend/src/api/endpoints.ts @@ -2,6 +2,7 @@ import { apiFetch, buildQuery } from './client'; import type { AuthMeResponse, BookingsResponse, + BreakdownResponse, LoginResponse, MetaResponse, ParseResponse, @@ -68,6 +69,7 @@ export interface SummaryParams { department?: string; name?: string; billing_type?: string; + period?: 'week' | 'month'; timelogHash?: string; } @@ -77,3 +79,18 @@ export function getUtilisationSummary(params: SummaryParams = {}) { if (timelogHash) headers['X-Timelog-Hash'] = timelogHash; return apiFetch(`/utilisation/summary${buildQuery(query)}`, { headers }); } + +export interface BreakdownParams { + period: string; + employee?: string; + from?: string; + to?: string; + timelogHash?: string; +} + +export function getUtilisationBreakdown(params: BreakdownParams) { + const { timelogHash, ...query } = params; + const headers: Record = {}; + if (timelogHash) headers['X-Timelog-Hash'] = timelogHash; + return apiFetch(`/utilisation/breakdown${buildQuery(query)}`, { headers }); +} diff --git a/frontend/src/api/types.ts b/frontend/src/api/types.ts index f75243c..a492740 100644 --- a/frontend/src/api/types.ts +++ b/frontend/src/api/types.ts @@ -57,6 +57,8 @@ export interface UtilisationSummaryRow { employmentType: string; availableHours: number; bookedHours: number; + activeBookedHours: number; + softBookedHours: number; loggedHours: number; billableHours: number; nonBillableHours: number; @@ -66,11 +68,51 @@ export interface UtilisationSummaryRow { bookedUtilisationPct: number; } +export interface UtilisationTotals { + totalBooked: number; + activeBooked: number; + softBooked: number; + totalLogged: number; + totalBillable: number; + totalProjects: number; + activePeople: number; + activeBookingsCount: number; + availableHours: number; + allocatedNetOfLeave: number; + periodFrom: string | null; + periodTo: string | null; +} + export interface UtilisationSummaryResponse { rows: UtilisationSummaryRow[]; + totals: UtilisationTotals; filters_applied: Record; } +export interface BreakdownProjectLogged { + project: string; + hours: number; + billable: number; + nonBillable: number; +} + +export interface BreakdownProjectBooked { + project: string; + hours: number; + active: number; + soft: number; +} + +export interface BreakdownResponse { + period: string; + employee: string | null; + from: string | null; + to: string | null; + logged: BreakdownProjectLogged[]; + booked: BreakdownProjectBooked[]; + hasLogged: boolean; +} + export interface ResourcesResponse { resources: Resource[]; cached_at: string; diff --git a/frontend/src/components/HourBreakdown.tsx b/frontend/src/components/HourBreakdown.tsx new file mode 100644 index 0000000..6bf5d10 --- /dev/null +++ b/frontend/src/components/HourBreakdown.tsx @@ -0,0 +1,140 @@ +import { useEffect, useState } from 'react'; +import { X } from 'lucide-react'; +import * as api from '../api/endpoints'; +import { ApiError } from '../api/client'; +import type { BreakdownResponse } from '../api/types'; +import { formatHours, formatPeriod } from '../lib/dates'; + +interface Props { + period: string; + employee?: string | null; + from?: string; + to?: string; + timelogHash?: string | null; + onClose: () => void; +} + +/** + * Drill-down panel: per-project breakdown of logged + booked hours for the + * clicked bar's period. Mirrors the original SPA's "Hour Breakdown" overlay. + */ +export default function HourBreakdown({ period, employee, from, to, timelogHash, onClose }: Props) { + const [data, setData] = useState(null); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + + useEffect(() => { + let cancelled = false; + setLoading(true); + setError(null); + api + .getUtilisationBreakdown({ + period, + employee: employee ?? undefined, + from, + to, + timelogHash: timelogHash ?? undefined, + }) + .then((res) => { + if (!cancelled) setData(res); + }) + .catch((err) => { + if (cancelled) return; + setError(err instanceof ApiError ? err.detail : (err as Error).message); + }) + .finally(() => { + if (!cancelled) setLoading(false); + }); + return () => { + cancelled = true; + }; + }, [period, employee, from, to, timelogHash]); + + return ( +
+
+

+ Hour Breakdown — {formatPeriod(period)} + {employee && · {employee}} +

+ +
+ + {loading &&
Loading breakdown…
} + {error &&
{error}
} + + {!loading && !error && data && ( +
+
+
+ Logged by project +
+ {!data.hasLogged ? ( +
+ Upload a time log to see logged breakdown. +
+ ) : data.logged.length === 0 ? ( +
No logged hours in this period.
+ ) : ( + + + + + + + + + + {data.logged.map((r) => ( + + + + + + ))} + +
ProjectHoursBillable
{r.project}{formatHours(r.hours)}{formatHours(r.billable)}
+ )} +
+ +
+
+ Booked by project +
+ {data.booked.length === 0 ? ( +
No bookings in this period.
+ ) : ( + + + + + + + + + + + {data.booked.map((r) => ( + + + + + + + ))} + +
ProjectHoursActiveSoft
{r.project}{formatHours(r.hours)}{formatHours(r.active)}{formatHours(r.soft)}
+ )} +
+
+ )} +
+ ); +} diff --git a/frontend/src/components/KpiTiles.tsx b/frontend/src/components/KpiTiles.tsx new file mode 100644 index 0000000..c2a5c61 --- /dev/null +++ b/frontend/src/components/KpiTiles.tsx @@ -0,0 +1,67 @@ +import type { UtilisationTotals } from '../api/types'; +import { formatHours } from '../lib/dates'; + +interface Props { + totals: UtilisationTotals | null; + /** Per-period averaging: "day" / "week" / "month" toggle on the parent page. */ + period?: 'week' | 'month'; +} + +interface Tile { + label: string; + value: string; + hint?: string; +} + +/** + * KPI tiles displayed above the charts on Department + Resourcing. + * Mirrors the original SPA's "Total Booked / Logged / Billable / projects / + * Active People / Allocated (net of leave) / Avg per person per week" set. + */ +export default function KpiTiles({ totals, period = 'week' }: Props) { + if (!totals) return null; + + const avgPerPerson = (() => { + if (totals.activePeople <= 0) return 0; + return totals.totalLogged / totals.activePeople; + })(); + + // Crude per-week / per-month average so the user can compare against + // weekly availability. Day-level would require counting working days. + const avgLabel = period === 'month' ? 'Avg / person / month' : 'Avg / person / week'; + + const tiles: Tile[] = [ + { label: 'Total Booked', value: formatHours(totals.totalBooked), hint: `Active ${formatHours(totals.activeBooked)} · Soft ${formatHours(totals.softBooked)}` }, + { label: 'Total Logged', value: formatHours(totals.totalLogged) }, + { label: 'Total Billable', value: formatHours(totals.totalBillable) }, + { label: 'Total projects', value: String(totals.totalProjects) }, + { label: 'Active People (logged)', value: String(totals.activePeople) }, + { label: 'Active bookings pulled', value: String(totals.activeBookingsCount) }, + { label: avgLabel, value: formatHours(avgPerPerson) }, + { label: 'Allocated (net of leave)', value: formatHours(totals.allocatedNetOfLeave) }, + ]; + + const periodCovered = totals.periodFrom && totals.periodTo + ? `${totals.periodFrom} → ${totals.periodTo}` + : null; + + return ( +
+
+

Headline numbers

+ {periodCovered && ( + Period covered: {periodCovered} + )} +
+
+ {tiles.map((t) => ( +
+
{t.label}
+
{t.value}
+ {t.hint &&
{t.hint}
} +
+ ))} +
+
+ ); +} diff --git a/frontend/src/components/PeriodToggle.tsx b/frontend/src/components/PeriodToggle.tsx new file mode 100644 index 0000000..b8cbb51 --- /dev/null +++ b/frontend/src/components/PeriodToggle.tsx @@ -0,0 +1,58 @@ +import type { PeriodKind } from '../lib/filters'; + +interface Props { + value: PeriodKind; + onChange: (next: PeriodKind) => void; + /** Whether to show the "Per day" option. The backend does not aggregate + * by day (too noisy across 100+ employees) so we hide it by default. */ + includeDay?: boolean; +} + +const OPTIONS: { value: PeriodKind | 'day'; label: string }[] = [ + { value: 'day', label: 'Per day' }, + { value: 'week', label: 'Per week' }, + { value: 'month', label: 'Per month' }, +]; + +/** + * Three-state radio toggle for week/month aggregation. "Per day" is shown + * as disabled when includeDay=false (the default) so the user can still see + * the original SPA's labelling but understands day-level isn't supported. + */ +export default function PeriodToggle({ value, onChange, includeDay = false }: Props) { + return ( +
+ {OPTIONS.map((opt) => { + const isDay = opt.value === 'day'; + const disabled = isDay && !includeDay; + const active = !disabled && value === opt.value; + return ( + + ); + })} +
+ ); +} diff --git a/frontend/src/components/charts/BookingVsActual.tsx b/frontend/src/components/charts/BookingVsActual.tsx index a40ddde..150f099 100644 --- a/frontend/src/components/charts/BookingVsActual.tsx +++ b/frontend/src/components/charts/BookingVsActual.tsx @@ -21,7 +21,8 @@ const TOP_N = 20; interface Bucket { employee: string; - booked: number; + activeBooked: number; + softBooked: number; actual: number; } @@ -30,14 +31,17 @@ function aggregateByEmployee(rows: UtilisationSummaryRow[]): Bucket[] { for (const r of rows) { const employee = String(r.employee ?? '').trim(); if (!employee) continue; - const b = map.get(employee) ?? { employee, booked: 0, actual: 0 }; - b.booked += Number(r.bookedHours) || 0; + const b = map.get(employee) ?? { employee, activeBooked: 0, softBooked: 0, actual: 0 }; + b.activeBooked += Number(r.activeBookedHours) || 0; + b.softBooked += Number(r.softBookedHours) || 0; b.actual += Number(r.loggedHours) || 0; map.set(employee, b); } const all = [...map.values()].sort( - (a, b) => b.booked + b.actual - (a.booked + a.actual), + (a, b) => + b.activeBooked + b.softBooked + b.actual - + (a.activeBooked + a.softBooked + a.actual), ); if (all.length <= TOP_N) return all; @@ -45,11 +49,13 @@ function aggregateByEmployee(rows: UtilisationSummaryRow[]): Bucket[] { const rest = all.slice(TOP_N); const other: Bucket = { employee: `Other (${rest.length})`, - booked: 0, + activeBooked: 0, + softBooked: 0, actual: 0, }; for (const b of rest) { - other.booked += b.booked; + other.activeBooked += b.activeBooked; + other.softBooked += b.softBooked; other.actual += b.actual; } return [...top, other]; @@ -69,7 +75,8 @@ export default function BookingVsActual({ rows }: Props) { - + + diff --git a/frontend/src/components/charts/MonthlyUtilisation.tsx b/frontend/src/components/charts/MonthlyUtilisation.tsx index 87dea3c..7fdc181 100644 --- a/frontend/src/components/charts/MonthlyUtilisation.tsx +++ b/frontend/src/components/charts/MonthlyUtilisation.tsx @@ -15,12 +15,15 @@ import { formatPeriod, monthOf } from '../../lib/dates'; interface Props { rows: UtilisationSummaryRow[]; showForecast: boolean; + /** Optional: clicking a bar surfaces an Hour Breakdown for that month. */ + onPeriodClick?: (period: string) => void; } interface Bucket { period: string; available: number; - booked: number; + activeBooked: number; + softBooked: number; logged: number; forecast: number; } @@ -32,28 +35,45 @@ function aggregateByMonth(rows: UtilisationSummaryRow[]): Bucket[] { if (!rawPeriod) continue; const key = /^\d{4}-\d{2}/.test(rawPeriod) ? rawPeriod.slice(0, 7) : monthOf(rawPeriod); if (!key) continue; - const b = map.get(key) ?? { period: key, available: 0, booked: 0, logged: 0, forecast: 0 }; + const b = map.get(key) ?? { + period: key, + available: 0, + activeBooked: 0, + softBooked: 0, + logged: 0, + forecast: 0, + }; b.available += Number(r.availableHours) || 0; - b.booked += Number(r.bookedHours) || 0; + b.activeBooked += Number(r.activeBookedHours) || 0; + b.softBooked += Number(r.softBookedHours) || 0; b.logged += Number(r.loggedHours) || 0; b.forecast += Number(r.forecastHours) || 0; map.set(key, b); } - // Period-based: cardinality is bounded by the date range, no top-N cap needed. return [...map.values()].sort((a, b) => String(a.period ?? '').localeCompare(String(b.period ?? '')), ); } -export default function MonthlyUtilisation({ rows, showForecast }: Props) { +export default function MonthlyUtilisation({ rows, showForecast, onPeriodClick }: Props) { const data = aggregateByMonth(rows).map((b) => ({ ...b, label: formatPeriod(b.period), })); + const onBarClick = (payload: { period?: string } | undefined) => { + if (onPeriodClick && payload?.period) onPeriodClick(payload.period); + }; + return (
-

Monthly Utilisation

+
+

Monthly Utilisation

+ + {onPeriodClick ? 'Click a bar for Hour Breakdown · ' : ''} + {showForecast ? 'Forecast line visible' : 'Forecast line hidden'} + +
@@ -62,11 +82,33 @@ export default function MonthlyUtilisation({ rows, showForecast }: Props) { - + + {showForecast && ( - + )} diff --git a/frontend/src/components/charts/WeeklyUtilisation.tsx b/frontend/src/components/charts/WeeklyUtilisation.tsx index ac45eff..7b328bb 100644 --- a/frontend/src/components/charts/WeeklyUtilisation.tsx +++ b/frontend/src/components/charts/WeeklyUtilisation.tsx @@ -14,40 +14,59 @@ import { formatPeriod, weekOf } from '../../lib/dates'; interface Props { rows: UtilisationSummaryRow[]; onPeriodClick?: (period: string) => void; + /** Aggregation kind. The summary endpoint controls the row labels — + * this prop just affects the X-axis title + the period regex match. */ + period?: 'week' | 'month'; } interface Bucket { period: string; - booked: number; + activeBooked: number; + softBooked: number; logged: number; available: number; } -function aggregateByWeek(rows: UtilisationSummaryRow[]): Bucket[] { +function aggregate(rows: UtilisationSummaryRow[], period: 'week' | 'month'): Bucket[] { const map = new Map(); for (const r of rows) { const rawPeriod = String(r.period ?? ''); if (!rawPeriod) continue; - const key = /^\d{4}-W\d{2}$/.test(rawPeriod) ? rawPeriod : weekOf(rawPeriod); + let key: string; + if (period === 'month') { + key = /^\d{4}-\d{2}/.test(rawPeriod) ? rawPeriod.slice(0, 7) : ''; + } else { + key = /^\d{4}-W\d{2}$/.test(rawPeriod) ? rawPeriod : weekOf(rawPeriod); + } if (!key) continue; - const b = map.get(key) ?? { period: key, booked: 0, logged: 0, available: 0 }; - b.booked += Number(r.bookedHours) || 0; + const b = map.get(key) ?? { period: key, activeBooked: 0, softBooked: 0, logged: 0, available: 0 }; + b.activeBooked += Number(r.activeBookedHours) || 0; + b.softBooked += Number(r.softBookedHours) || 0; b.logged += Number(r.loggedHours) || 0; b.available += Number(r.availableHours) || 0; map.set(key, b); } - // Period-based: cardinality is bounded by the date range, no top-N cap needed. return [...map.values()].sort((a, b) => String(a.period ?? '').localeCompare(String(b.period ?? '')), ); } -export default function WeeklyUtilisation({ rows, onPeriodClick }: Props) { - const data = aggregateByWeek(rows).map((b) => ({ ...b, label: formatPeriod(b.period) })); +export default function WeeklyUtilisation({ rows, onPeriodClick, period = 'week' }: Props) { + const data = aggregate(rows, period).map((b) => ({ ...b, label: formatPeriod(b.period) })); + const title = period === 'month' ? 'Monthly Utilisation (per-week chart in month mode)' : 'Weekly Utilisation'; + + const onBarClick = (payload: { period?: string } | undefined) => { + if (onPeriodClick && payload?.period) onPeriodClick(payload.period); + }; return (
-

Weekly Utilisation

+
+

{title}

+ {onPeriodClick && ( + Click a bar for Hour Breakdown + )} +
@@ -57,12 +76,19 @@ export default function WeeklyUtilisation({ rows, onPeriodClick }: Props) { { - if (onPeriodClick && payload?.period) onPeriodClick(payload.period); - }} + onClick={onBarClick} + style={{ cursor: onPeriodClick ? 'pointer' : 'default' }} + /> + diff --git a/frontend/src/components/tutorial/steps.ts b/frontend/src/components/tutorial/steps.ts index b9cb2cd..9c74940 100644 --- a/frontend/src/components/tutorial/steps.ts +++ b/frontend/src/components/tutorial/steps.ts @@ -1,45 +1,64 @@ export interface TutorialStep { /** matches the `data-tutorial-id` attribute on the target element */ selector: string; + /** chapter label, mirrors the original SPA */ title: string; description: string; } +/** + * The original SPA exposed a chapter list ("Chart overview", + * "Reading the Utilisation Chart", "Forecast Line & Filters", "Drill-In", + * "Spotting Resource Issues", etc.). The selectors below map each chapter + * onto the matching data-tutorial-id on screen. Steps whose target isn't + * on the current page are filtered out by TutorialOverlay at runtime. + */ + export const departmentSteps: TutorialStep[] = [ { selector: 'navbar', - title: 'Top navigation', - description: 'Switch between Department, Resourcing, Bookings and Tutorial tabs from here.', + title: 'How to Use the Department Tab', + description: 'Navigate to the Department Tab from here. The rest of the tour walks through every chart on this page.', }, { selector: 'upload-zone', title: 'Upload your timelog', - description: 'Drag a Harvest/Toggl export here, or click to choose a file. .xlsx and .csv supported.', + description: 'Drag a Zoho / Harvest / Toggl export here, or click to choose a file. .xlsx and .csv supported. The file stays in this session only.', }, { selector: 'filter-bar', - title: 'Filter the view', - description: 'Pick a date range preset (or use a custom range), and narrow down by department, name or billing type.', + title: 'Forecast Line & Filters', + description: 'Pick a date range preset (or use a custom range), narrow by department, name or billing type, and toggle the forecast overlay.', }, { - selector: 'forecast-toggle', - title: 'Forecast line', - description: 'Toggle the forecast overlay on or off — useful for spotting upcoming over- or under-bookings.', + selector: 'kpi-tiles', + title: 'Hours & Utilisation', + description: 'Headline KPIs — Total Booked / Logged / Billable, distinct projects, active people and net-of-leave capacity for the chosen period.', + }, + { + selector: 'sync-airtable', + title: 'Airtable sync', + description: 'Force-refresh the bookings cache to pick up brand-new Airtable changes. Use sparingly — counts against API rate limits.', }, { selector: 'chart-monthly-utilisation', - title: 'Monthly utilisation', - description: 'Booked, logged and available hours grouped by calendar month.', + title: 'Reading the Utilisation Chart', + description: 'Booked, logged and available hours grouped by month. Active vs Soft bookings are stacked. Click bars & toggle forecast line.', + }, + { + selector: 'hour-breakdown', + title: 'Drill-In', + description: 'Click any Booked bar to open the Hour Breakdown — a per-project split of logged and booked hours for that period.', }, { selector: 'chart-booking-vs-actual', - title: 'Booking vs Actual', - description: 'See whose bookings match their logged hours and whose drift. Capped at the top 20 busiest people; the rest roll up into "Other".', + title: 'Chart overview — Booking vs Actual', + description: 'See whose bookings match their logged hours and whose drift. Active vs Soft are stacked; capped at the top 20 busiest people.', }, { selector: 'chart-billability-breakdown', - title: 'Billability breakdown', - description: 'Per-person split of billable, non-billable, leave and idle hours. Capped at the top 20 by available + billable.', + title: 'Spotting Resource Issues', + description: 'Per-person split of billable, non-billable, leave and idle hours. Spot errors like a wall of non-billable on a "busy" person.', }, { selector: 'export-csv', @@ -51,23 +70,43 @@ export const departmentSteps: TutorialStep[] = [ export const resourcingSteps: TutorialStep[] = [ { selector: 'filter-bar', - title: 'Filter the view', + title: 'Forecast Line & Filters', description: 'The same filter controls you used on Department — date range, department and name all flow through to the Airtable bookings query.', }, + { + selector: 'kpi-tiles', + title: 'Hours & Utilisation', + description: 'Headline KPIs across the people in scope: total booked (active + soft), logged, billable, distinct projects and net-of-leave capacity.', + }, + { + selector: 'period-toggle', + title: 'Chart overview — Per week / Per month', + description: 'Switch the chart aggregation between week and month. Day-level isn\'t supported (too noisy across 100+ employees).', + }, + { + selector: 'sync-airtable', + title: 'Sync Airtable Bookings', + description: 'Force-refresh the bookings cache so the charts reflect the latest Airtable state.', + }, { selector: 'chart-weekly-utilisation', - title: 'Weekly utilisation', - description: 'Click a Booked bar to drill into a specific week.', + title: 'Reading the Utilisation Chart', + description: 'Active Booked + Soft Booked stack together; Logged and Available sit alongside. Click bars & toggle forecast line.', + }, + { + selector: 'hour-breakdown', + title: 'Drill-In', + description: 'After clicking a bar, this panel shows the per-project split for the selected period (logged + booked).', }, { selector: 'chart-project-load', - title: 'Project load per person', + title: 'Chart overview — Project Load', description: 'Stacked bars show which projects are loading up each resource. Capped at the top 10 projects by hours; the rest roll up into "Other".', }, { selector: 'chart-fte-vs-freelancer', - title: 'FTE vs Freelancer', - description: 'Compare utilisation between salaried staff and contractors. Each side caps at the top 20.', + title: 'Spotting Resource Issues', + description: 'Compare utilisation between salaried staff and contractors side by side. Use this to spot under-used FTEs or over-loaded freelancers.', }, { selector: 'export-csv', @@ -79,17 +118,17 @@ export const resourcingSteps: TutorialStep[] = [ export const bookingsSteps: TutorialStep[] = [ { selector: 'filter-bar', - title: 'Filter the view', + title: 'Forecast Line & Filters', description: 'Date range, department and name filters narrow the table below.', }, { selector: 'bookings-table', - title: 'Bookings table', + title: 'Reading the bookings table', description: 'A virtualised view of every booking returned by Airtable for the active filters.', }, { selector: 'bookings-refresh', - title: 'Refresh from Airtable', + title: 'Sync Airtable Bookings', description: 'Bypass the cache and pull a fresh copy from Airtable. Use sparingly — counts against API rate limits.', }, ]; diff --git a/frontend/src/lib/filters.ts b/frontend/src/lib/filters.ts index b012493..c005b4a 100644 --- a/frontend/src/lib/filters.ts +++ b/frontend/src/lib/filters.ts @@ -1,6 +1,8 @@ import type { DatePreset, DateRange } from './dates'; import { dateRangePreset } from './dates'; +export type PeriodKind = 'week' | 'month'; + export interface FilterState { preset: DatePreset; range: DateRange | null; @@ -8,6 +10,7 @@ export interface FilterState { names: string[]; billingType: string | null; showForecast: boolean; + period: PeriodKind; } export const initialFilterState: FilterState = { @@ -17,6 +20,7 @@ export const initialFilterState: FilterState = { names: [], billingType: null, showForecast: true, + period: 'week', }; export type FilterAction = @@ -25,6 +29,7 @@ export type FilterAction = | { type: 'set-departments'; departments: string[] } | { type: 'set-names'; names: string[] } | { type: 'set-billing-type'; billingType: string | null } + | { type: 'set-period'; period: PeriodKind } | { type: 'toggle-forecast' } | { type: 'reset' }; @@ -42,6 +47,8 @@ export function filterReducer(state: FilterState, action: FilterAction): FilterS return { ...state, names: action.names }; case 'set-billing-type': return { ...state, billingType: action.billingType }; + case 'set-period': + return { ...state, period: action.period }; case 'toggle-forecast': return { ...state, showForecast: !state.showForecast }; case 'reset': @@ -59,5 +66,6 @@ export function filtersToQuery(state: FilterState): Record([]); + const [totals, setTotals] = useState(null); const [summaryLoading, setSummaryLoading] = useState(false); const [summaryError, setSummaryError] = useState(null); + const [selectedPeriod, setSelectedPeriod] = useState(null); + const [syncing, setSyncing] = useState(false); const names = useMemo( () => airtable.resources.map((r) => r.name).sort((a, b) => a.localeCompare(b)), [airtable.resources], ); - const loadSummary = useMemo( - () => async () => { - setSummaryLoading(true); - setSummaryError(null); - try { - const q = filtersToQuery(filters); - const res = await api.getUtilisationSummary({ - from: q.from, - to: q.to, - department: q.department, - name: q.name, - billing_type: q.billing_type, - timelogHash: tl.hash ?? undefined, - }); - setSummary(res.rows); - } catch (err) { - setSummaryError(err instanceof ApiError ? err.detail : (err as Error).message); - } finally { - setSummaryLoading(false); - } - }, - [filters, tl.hash], - ); + const loadSummary = useCallback(async () => { + setSummaryLoading(true); + setSummaryError(null); + try { + const q = filtersToQuery(filters); + const res = await api.getUtilisationSummary({ + from: q.from, + to: q.to, + department: q.department, + name: q.name, + billing_type: q.billing_type, + period: filters.period, + timelogHash: tl.hash ?? undefined, + }); + setSummary(res.rows); + setTotals(res.totals ?? null); + } catch (err) { + setSummaryError(err instanceof ApiError ? err.detail : (err as Error).message); + } finally { + setSummaryLoading(false); + } + }, [filters, tl.hash]); useEffect(() => { void loadSummary(); }, [loadSummary]); + const handleSync = useCallback(async () => { + setSyncing(true); + try { + // Force-refresh bookings cache, then re-derive summary. + const q = filtersToQuery(filters); + await api.getBookings({ from: q.from, to: q.to, refresh: true }); + await loadSummary(); + } catch (err) { + setSummaryError(err instanceof ApiError ? err.detail : (err as Error).message); + } finally { + setSyncing(false); + } + }, [filters, loadSummary]); + const handleExport = () => { if (summary.length === 0) return; const csv = rowsToCsv(summary as unknown as Record[]); @@ -71,6 +90,7 @@ export default function Department() {
  • Upload your timelog export (.xlsx or .csv). It stays in this session only.
  • Pick a date preset (or custom range) and narrow by department or name.
  • The charts below recompute automatically. Toggle the forecast line to compare scenarios.
  • +
  • Click a bar to drill into a per-project Hour Breakdown.
  • Use Export CSV to share the summary outside the app.
  • @@ -95,16 +115,35 @@ export default function Department() { />
    -
    - +
    + { + dispatch({ type: 'set-period', period: p }); + // Clear drill-down: period labels won't match across week/month. + setSelectedPeriod(null); + }} + /> +
    + + +
    {airtable.error && } @@ -112,17 +151,39 @@ export default function Department() { {(airtable.loading || summaryLoading) && } - {!summaryError && summary.length > 0 && ( + {!summaryError && ( <> - - - - - - - - + + + {summary.length > 0 && ( + <> + + + + {selectedPeriod && ( + + setSelectedPeriod(null)} + /> + + )} + + + + + + + + )} )}
    diff --git a/frontend/src/pages/Resourcing.tsx b/frontend/src/pages/Resourcing.tsx index ea789b2..272a802 100644 --- a/frontend/src/pages/Resourcing.tsx +++ b/frontend/src/pages/Resourcing.tsx @@ -1,6 +1,9 @@ import { useCallback, useEffect, useMemo, useReducer, useState } from 'react'; -import { Download } from 'lucide-react'; +import { Download, RefreshCw } from 'lucide-react'; import FilterBar from '../components/FilterBar'; +import KpiTiles from '../components/KpiTiles'; +import PeriodToggle from '../components/PeriodToggle'; +import HourBreakdown from '../components/HourBreakdown'; import WeeklyUtilisation from '../components/charts/WeeklyUtilisation'; import ProjectLoadPerPerson from '../components/charts/ProjectLoadPerPerson'; import FTEvsFreelancer from '../components/charts/FTEvsFreelancer'; @@ -8,18 +11,22 @@ import Loading from '../components/Loading'; import ErrorBox from '../components/ErrorBox'; import ErrorBoundary from '../components/ErrorBoundary'; import { useAirtableData } from '../hooks/useAirtableData'; +import { useTimelog } from '../hooks/useTimelog'; import { filterReducer, filtersToQuery, initialFilterState } from '../lib/filters'; import { downloadCsv, rowsToCsv } from '../lib/csv'; import * as api from '../api/endpoints'; import { ApiError } from '../api/client'; -import type { Booking, UtilisationSummaryRow } from '../api/types'; +import type { Booking, UtilisationSummaryRow, UtilisationTotals } from '../api/types'; export default function Resourcing() { const airtable = useAirtableData(false); + const tl = useTimelog(); const [filters, dispatch] = useReducer(filterReducer, initialFilterState); const [bookings, setBookings] = useState([]); const [summary, setSummary] = useState([]); + const [totals, setTotals] = useState(null); const [loading, setLoading] = useState(false); + const [syncing, setSyncing] = useState(false); const [error, setError] = useState(null); const [selectedPeriod, setSelectedPeriod] = useState(null); @@ -42,39 +49,55 @@ export default function Resourcing() { }); }, [bookings, filters.departments, filters.names]); - const load = useCallback(async () => { - setLoading(true); - setError(null); - try { - const q = filtersToQuery(filters); - const [bk, sm] = await Promise.all([ - api.getBookings({ - from: q.from, - to: q.to, - department: q.department, - name: q.name, - }), - api.getUtilisationSummary({ - from: q.from, - to: q.to, - department: q.department, - name: q.name, - billing_type: q.billing_type, - }), - ]); - setBookings(bk.bookings); - setSummary(sm.rows); - } catch (err) { - setError(err instanceof ApiError ? err.detail : (err as Error).message); - } finally { - setLoading(false); - } - }, [filters]); + const load = useCallback( + async (refresh = false) => { + setLoading(true); + setError(null); + try { + const q = filtersToQuery(filters); + const [bk, sm] = await Promise.all([ + api.getBookings({ + from: q.from, + to: q.to, + department: q.department, + name: q.name, + refresh, + }), + api.getUtilisationSummary({ + from: q.from, + to: q.to, + department: q.department, + name: q.name, + billing_type: q.billing_type, + period: filters.period, + timelogHash: tl.hash ?? undefined, + }), + ]); + setBookings(bk.bookings); + setSummary(sm.rows); + setTotals(sm.totals ?? null); + } catch (err) { + setError(err instanceof ApiError ? err.detail : (err as Error).message); + } finally { + setLoading(false); + } + }, + [filters, tl.hash], + ); useEffect(() => { void load(); }, [load]); + const handleSync = useCallback(async () => { + setSyncing(true); + try { + await load(true); + } finally { + setSyncing(false); + } + }, [load]); + const handleExport = () => { if (summary.length === 0) return; const csv = rowsToCsv(summary as unknown as Record[]); @@ -92,36 +115,70 @@ export default function Resourcing() { showForecastToggle={false} /> -
    - {selectedPeriod ? ( -
    - Drilled into period {selectedPeriod}{' '} - -
    - ) : ( - - )} - +
    + { + dispatch({ type: 'set-period', period: p }); + setSelectedPeriod(null); + }} + /> +
    + {selectedPeriod && ( +
    + Drilled into {selectedPeriod}{' '} + +
    + )} + + +
    - {error && } + {error && load(false)} />} {loading && } {!error && ( <> - - + + + + + + {selectedPeriod && ( + + setSelectedPeriod(null)} + /> + + )} diff --git a/frontend/src/pages/Tutorial.tsx b/frontend/src/pages/Tutorial.tsx index 1ab4f44..80c37dc 100644 --- a/frontend/src/pages/Tutorial.tsx +++ b/frontend/src/pages/Tutorial.tsx @@ -28,10 +28,14 @@ export default function Tutorial() { return (
    -

    Tutorial

    +

    Tutorial — Interactive Walkthrough

    - A walkthrough of every tab. Click Replay to relaunch the guided tour for that section — it only - highlights elements that are currently visible, so navigate to the matching tab first. + A walkthrough of every tab. Use the chapter buttons to replay any section — the overlay only highlights + elements that are currently visible, so navigate to the matching tab first, then click Replay. +

    +

    + Note: the original Video Walkthrough has been retired; the interactive tour below covers the same ground. + Jump to any step using the in-overlay arrow controls.

    @@ -50,7 +54,7 @@ export default function Tutorial() {
      {allSteps[s.key].map((step) => ( -
    • +
    • {step.title}: {step.description}
    • ))}