From d4c6576a9557f6887b06c8a0e944fe2348f7d365 Mon Sep 17 00:00:00 2001 From: DJP Date: Mon, 18 May 2026 08:50:21 -0400 Subject: [PATCH] parity v3: two-bar charts, airtable link fallbacks, filter split, weekly comparison, project-type detail MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 " · last updated " 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) --- backend/app/services/airtable_fetch.py | 46 ++- backend/app/services/forecast.py | 14 +- backend/app/services/project_types.py | 70 +++- backend/app/services/zoho_parse.py | 7 +- frontend/src/App.tsx | 18 + frontend/src/api/types.ts | 19 + .../src/components/DailyBreakdownModal.tsx | 148 +++++-- .../src/components/DepartmentFilterBar.tsx | 98 +++++ frontend/src/components/GlobalFilterBar.tsx | 149 +++++++ frontend/src/components/Navbar.tsx | 26 +- .../src/components/charts/DeptWeeklyChart.tsx | 190 +++++++-- frontend/src/hooks/useDataContext.tsx | 5 + frontend/src/pages/Bookings.tsx | 23 +- frontend/src/pages/Department.tsx | 90 ++-- frontend/src/pages/Forecast.tsx | 239 +++++++++-- frontend/src/pages/ProjectTypeSummary.tsx | 389 ++++++++++++++---- frontend/src/pages/Resourcing.tsx | 23 +- 17 files changed, 1258 insertions(+), 296 deletions(-) create mode 100644 frontend/src/components/DepartmentFilterBar.tsx create mode 100644 frontend/src/components/GlobalFilterBar.tsx diff --git a/backend/app/services/airtable_fetch.py b/backend/app/services/airtable_fetch.py index 7c740e5..7cf6a5b 100644 --- a/backend/app/services/airtable_fetch.py +++ b/backend/app/services/airtable_fetch.py @@ -25,6 +25,12 @@ from app.deps.airtable import airtable_client logger = logging.getLogger(__name__) +# One-shot schema logger — set to True to log the first raw booking record's +# field keys so we can verify which link-field name Airtable actually uses. +# Flips to False after the first booking is normalised (see normalise_booking). +LOG_AIRTABLE_SCHEMA_ONCE: bool = True + + # ---------------------------------------------------------------------- # Low-level pagination # ---------------------------------------------------------------------- @@ -151,7 +157,43 @@ def normalise_resource(rec: dict[str, Any]) -> dict[str, Any]: def normalise_booking(rec: dict[str, Any]) -> dict[str, Any]: + global LOG_AIRTABLE_SCHEMA_ONCE f = rec.get("fields", {}) + + # One-shot schema log so deployments can see the exact field names the + # live Airtable base exposes. Flips to False after the first booking is + # processed so we don't spam logs. + if LOG_AIRTABLE_SCHEMA_ONCE: + try: + logger.info( + "Airtable booking schema (first record) — id=%s · field keys=%s", + rec.get("id"), + sorted(list(f.keys())), + ) + finally: + LOG_AIRTABLE_SCHEMA_ONCE = False + + # Linked-record fields can live under several different names depending + # on how the base was set up. Try each in priority order and take the + # FIRST non-empty list — falling back to flattened name lookup later. + rec_id_candidates = ( + f.get("Resource"), + f.get("Booking Resource"), + f.get("Booked Resource"), + f.get("Resource (from Booking)"), + f.get("Resource Name (from Resource)"), + ) + resource_record_ids: list[str] = [] + for candidate in rec_id_candidates: + rids = _as_list(candidate or []) + # Only treat as record-ids if values look like Airtable recIDs + # (`rec` prefix, 17 chars). Otherwise this was probably a name lookup + # and we use the flattened name path instead. + rids = [r for r in rids if r.startswith("rec") and len(r) == 17] + if rids: + resource_record_ids = rids + break + # Every linked/lookup field on Booking Resource comes back as a list from # Airtable — flatten at the boundary so downstream consumers get scalars. return { @@ -163,11 +205,13 @@ def normalise_booking(rec: dict[str, Any]) -> dict[str, Any]: f.get("Resource Name (from Resource)") or f.get("Resource Name") or f.get("Resource") + or f.get("Booking Resource") + or f.get("Booked Resource") ), # Linked-record ids — used by the Department / Daily Breakdown # services to resolve bookings to people without relying on the # flattened name lookup (which can mis-match for renamed people). - "resourceRecordIds": _as_list(f.get("Resource") or []), + "resourceRecordIds": resource_record_ids, "projectNumber": _flatten( f.get("Project Number (from Master)") or f.get("Project Number") diff --git a/backend/app/services/forecast.py b/backend/app/services/forecast.py index 65984d2..facb996 100644 --- a/backend/app/services/forecast.py +++ b/backend/app/services/forecast.py @@ -235,14 +235,24 @@ def build_forecast( active_assets = 0.0 exiting_assets = 0.0 + # Per-project-type breakdowns — populates the stacked pipeline chart + # and the weekly comparison table on the Forecast page. + active_by_type: dict[str, float] = {} + exiting_by_type: dict[str, float] = {} for p in active_projects: overlap = _weekday_overlap(p["_start"], p["_end"], w_start, w_end) if overlap <= 0: continue share = overlap / p["_total_weekdays"] - active_assets += p["_assets"] * share + contribution = p["_assets"] * share + active_assets += contribution + ptype = (p.get("projectType") or "Other") or "Other" + active_by_type[ptype] = active_by_type.get(ptype, 0.0) + contribution if w_start <= p["_end"] <= w_end: exiting_assets += p["_assets"] + exiting_by_type[ptype] = ( + exiting_by_type.get(ptype, 0.0) + p["_assets"] + ) exit_rate = (exiting_assets / active_assets * 100.0) if active_assets > 0 else 0.0 can_take_on = max(dept_capacity - active_assets, 0.0) @@ -257,6 +267,8 @@ def build_forecast( "weeklyThroughput": round(weekly_throughput, 1), "deptCapacityAssetsPerWeek": round(dept_capacity, 1), "canTakeOn": round(can_take_on, 1), + "activeAssetsByType": {k: round(v, 1) for k, v in active_by_type.items()}, + "exitingByType": {k: round(v, 1) for k, v in exiting_by_type.items()}, }) decision = _capacity_decision(weeks, dept_capacity) diff --git a/backend/app/services/project_types.py b/backend/app/services/project_types.py index 3fce894..ed1991e 100644 --- a/backend/app/services/project_types.py +++ b/backend/app/services/project_types.py @@ -53,6 +53,10 @@ def build_project_types( proj_assets: dict[str, float] = {} proj_dates: dict[str, tuple[date | None, date | None]] = {} proj_status: dict[str, str | None] = {} + # Department breakdown per project — used for the dept-hours distribution + # bar on the Project Type Summary detail panel. + proj_dept_hours: dict[str, dict[str, float]] = {} + proj_division: dict[str, str] = {} for r in logs: title = (r.get("projectTitle") or r.get("projectNumber") or "").strip() @@ -72,6 +76,20 @@ def build_project_types( ) if title not in proj_status: proj_status[title] = r.get("projectStatus") + # Department of the time-logger. Used by the dept-hours panel below. + # Try a few field shapes — Zoho's "Department" lookup column varies. + dept = ( + r.get("department") + or r.get("submitterDepartment") + or r.get("Department") + or "Unknown" + ) + if hrs > 0 and dept: + d_str = str(dept).strip() or "Unknown" + proj_dept_hours.setdefault(title, {}) + proj_dept_hours[title][d_str] = proj_dept_hours[title].get(d_str, 0.0) + hrs + if title not in proj_division and r.get("division"): + proj_division[title] = str(r["division"]).strip() # If we have a project summary, use it as the authoritative type/asset/date list. if project_summary: @@ -99,7 +117,12 @@ def build_project_types( "hours": 0.0, "assets": 0.0, "duration_days": [], - "monthly_assets": {}, # YYYY-MM → asset count + "monthly_assets": {}, # YYYY-MM → asset count + "monthly_hours": {}, # YYYY-MM → total hours + "monthly_div_count": {}, # YYYY-MM → {division → projectCount} + "monthly_project_count": {}, # YYYY-MM → projectCount + "monthly_asset_count": {}, # YYYY-MM → total assets + "dept_hours": {}, # dept → total hours "project_titles": [], "longest_days": 0, "longest_title": None, @@ -119,6 +142,15 @@ def build_project_types( if e: ym = e.strftime("%Y-%m") bucket["monthly_assets"][ym] = bucket["monthly_assets"].get(ym, 0.0) + proj_assets.get(title, 0.0) + bucket["monthly_hours"][ym] = bucket["monthly_hours"].get(ym, 0.0) + proj_hours.get(title, 0.0) + bucket["monthly_asset_count"][ym] = bucket["monthly_asset_count"].get(ym, 0.0) + proj_assets.get(title, 0.0) + bucket["monthly_project_count"][ym] = bucket["monthly_project_count"].get(ym, 0) + 1 + div = proj_division.get(title) or "Unknown" + bucket["monthly_div_count"].setdefault(ym, {}) + bucket["monthly_div_count"][ym][div] = bucket["monthly_div_count"][ym].get(div, 0) + 1 + # Department hours roll-up — sum each contributing dept's hours. + for dept, h in proj_dept_hours.get(title, {}).items(): + bucket["dept_hours"][dept] = bucket["dept_hours"].get(dept, 0.0) + h bucket["project_titles"].append({ "title": title, "assets": proj_assets.get(title, 0.0), @@ -161,10 +193,43 @@ def build_project_types( hours = b["hours"] assets = b["assets"] durations = b["duration_days"] + min_duration = min(durations) if durations else 0 months_for_type = len(b["monthly_assets"]) or 1 assets_per_month = assets / months_for_type if months_for_type > 0 else 0.0 projects_per_month = projects / months_for_type if months_for_type > 0 else 0.0 avg_per_asset = (hours / assets) if assets > 0 else 0.0 + + # Department hours distribution — the horizontal segment bar. + total_dept_h = sum(b["dept_hours"].values()) + dept_breakdown = [ + { + "dept": dept, + "hours": round(h, 1), + "pct": round((h / total_dept_h) * 100.0, 1) if total_dept_h > 0 else 0.0, + } + for dept, h in sorted(b["dept_hours"].items(), key=lambda kv: -kv[1]) + ] + + # Monthly Avg h/asset breakdown by division — fills Panel 1 (stacked + # bar chart on the per-type detail panel). + monthly_avg_rows: list[dict[str, Any]] = [] + for ym in sorted(b["monthly_assets"].keys()): + month_assets = b["monthly_asset_count"].get(ym, 0.0) + month_hours = b["monthly_hours"].get(ym, 0.0) + overall = (month_hours / month_assets) if month_assets > 0 else 0.0 + divs = b["monthly_div_count"].get(ym, {}) + div_total = sum(divs.values()) or 1 + by_division = { + div: round(overall * (cnt / div_total), 2) + for div, cnt in divs.items() + } + monthly_avg_rows.append({ + "month": ym, + "overall": round(overall, 2), + "projectCount": b["monthly_project_count"].get(ym, 0), + "byDivision": by_division, + }) + stats.append({ "projectType": ptype, "projectCount": projects, @@ -172,6 +237,7 @@ def build_project_types( "totalAssets": round(assets, 1), "avgHoursPerAsset": round(avg_per_asset, 2), "avgDurationDays": round(sum(durations) / len(durations), 1) if durations else 0.0, + "minDurationDays": min_duration, "longestProjectDays": b["longest_days"], "longestProjectTitle": b["longest_title"], "concentrationPct": round(hours / hours_divisor * 100.0, 1), @@ -185,6 +251,8 @@ def build_project_types( {"month": k, "assets": round(v, 1)} for k, v in sorted(b["monthly_assets"].items()) ], + "deptHoursBreakdown": dept_breakdown, + "monthlyAvgHoursPerAsset": monthly_avg_rows, "projectList": sorted(b["project_titles"], key=lambda x: -x["hours"])[:50], }) stats.sort(key=lambda x: x["totalHours"], reverse=True) diff --git a/backend/app/services/zoho_parse.py b/backend/app/services/zoho_parse.py index 973960c..804c2fd 100644 --- a/backend/app/services/zoho_parse.py +++ b/backend/app/services/zoho_parse.py @@ -259,7 +259,7 @@ def _split_submitter(raw: Any) -> tuple[str | None, str | None]: # ---------------------------------------------------------------------- def parse(filename: str, content: bytes) -> dict[str, Any]: - """Parse uploaded file. Returns dict with rows, unrecognised_columns, content_hash.""" + """Parse uploaded file. Returns dict with rows, unrecognised_columns, content_hash, parsed_at.""" fn = (filename or "").lower() if fn.endswith(".xlsx") or fn.endswith(".xlsm"): rows, unknown = _parse_xlsx(content) @@ -273,10 +273,15 @@ def parse(filename: str, content: bytes) -> dict[str, Any]: rows, unknown = _parse_xlsx(content) digest = hashlib.sha256(content).hexdigest() + # ISO-8601 UTC timestamp — surfaced to the UI as "last updated" alongside + # the filename. Local-time formatting happens client-side. + from datetime import datetime, timezone + parsed_at = datetime.now(timezone.utc).isoformat() return { "rows": rows, "unrecognised_columns": unknown, "content_hash": f"sha256:{digest}", + "parsed_at": parsed_at, } diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index dcc18e8..1195ed0 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -3,10 +3,12 @@ import { Navigate, Route, Routes } from 'react-router-dom'; import AuthGate from './components/AuthGate'; import Navbar from './components/Navbar'; import StatsBar from './components/StatsBar'; +import GlobalFilterBar from './components/GlobalFilterBar'; import ChatToggle from './components/ChatToggle'; import Loading from './components/Loading'; import Login from './pages/Login'; import { canAccess, useAuth } from './hooks/useAuth'; +import { useDataContext } from './hooks/useDataContext'; const Department = lazy(() => import('./pages/Department')); const Resourcing = lazy(() => import('./pages/Resourcing')); @@ -24,6 +26,21 @@ function RoleGate({ slug, children }: { slug: string; children: React.ReactNode return <>{children}; } +function GlobalFilters() { + // Lives inside ProtectedShell so the context providers are mounted. + const { filters, dispatch, dimensions } = useDataContext(); + return ( + + ); +} + function ProtectedShell({ children, slug }: { children: React.ReactNode; slug: string }) { return ( @@ -31,6 +48,7 @@ function ProtectedShell({ children, slug }: { children: React.ReactNode; slug: s
+
}>{children}
diff --git a/frontend/src/api/types.ts b/frontend/src/api/types.ts index caf6851..d27234d 100644 --- a/frontend/src/api/types.ts +++ b/frontend/src/api/types.ts @@ -71,6 +71,7 @@ export interface ParseResponse { rows: TimelogRow[]; unrecognised_columns: string[]; content_hash: string; + parsed_at?: string; } export interface DeliverableRow { @@ -202,6 +203,8 @@ export interface ForecastWeek { weeklyThroughput: number; deptCapacityAssetsPerWeek: number; canTakeOn: number; + activeAssetsByType?: Record; + exitingByType?: Record; } export interface ForecastResponse { @@ -247,9 +250,23 @@ export interface ProjectTypeProject { duration: number; } +export interface ProjectTypeDeptHours { + dept: string; + hours: number; + pct: number; +} + +export interface ProjectTypeMonthlyAvgHpa { + month: string; + overall: number; + projectCount: number; + byDivision: Record; +} + export interface ProjectTypeStatExtended extends ProjectTypeStat { longestProjectDays: number; longestProjectTitle: string | null; + minDurationDays?: number; avgAssetsPerProject: number; avgAssetsPerMonth: number; avgAssetsPerWeek: number; @@ -257,6 +274,8 @@ export interface ProjectTypeStatExtended extends ProjectTypeStat { avgProjectsPerWeek: number; monthlyAssets: ProjectTypeMonthlyAsset[]; projectList: ProjectTypeProject[]; + deptHoursBreakdown?: ProjectTypeDeptHours[]; + monthlyAvgHoursPerAsset?: ProjectTypeMonthlyAvgHpa[]; } export interface ProjectTypesResponse { diff --git a/frontend/src/components/DailyBreakdownModal.tsx b/frontend/src/components/DailyBreakdownModal.tsx index 2fcbbbf..85c3a55 100644 --- a/frontend/src/components/DailyBreakdownModal.tsx +++ b/frontend/src/components/DailyBreakdownModal.tsx @@ -14,9 +14,11 @@ import * as api from '../api/endpoints'; import { ApiError } from '../api/client'; import type { DailyBreakdownResponse } from '../api/types'; import { + ACTIVE_BOOKED_COLOR, ALLOCATED_COLOR, BILLING_GROUPS, - FORECAST_LINE_COLOR, + SOFT_BOOKED_COLOR, + UTIL_LINE_COLOR, fmt1, } from '../lib/department'; @@ -32,8 +34,9 @@ interface Props { /** * Per-person, per-weekday breakdown modal — triggered by clicking a - * person's name on the Weekly Utilisation chart. Renders the dynamic - * chart the user was missing. + * person's name on the Weekly Utilisation chart. Renders TWO bars per + * weekday: an "allocated" stack and a "booked" stack, plus a red + * Avg % utilisation line traversing the days. */ export default function DailyBreakdownModal({ employee, @@ -84,6 +87,7 @@ export default function DailyBreakdownModal({ totalLogged: d.totalLogged, activeBooked: d.activeBooked, softBooked: d.softBooked, + totalBooked: +(d.activeBooked + d.softBooked).toFixed(1), actualPct: d.actualPct, forecastPct: d.forecastPct, }; @@ -96,7 +100,10 @@ export default function DailyBreakdownModal({ const weekdayCount = data?.weekdays?.length ?? 0; const dailyCap = data?.dailyCapacity ?? 8; - const chartWidth = Math.max(weekdayCount * 110 + 120, 760); + // Two bars per day → wider min width than the previous single-bar layout. + const chartWidth = Math.max(weekdayCount * 130 + 140, 800); + const activeBookedH = data?.summary.activeBookedH ?? 0; + const softBookedH = data?.summary.softBookedH ?? 0; return (
- {data.summary.activeBookedH > 0 && ( + {activeBookedH > 0 && ( - - Active Booked: {fmt1(data.summary.activeBookedH)}h + + Active Booked: {fmt1(activeBookedH)}h )} - {data.summary.softBookedH > 0 && ( + {softBookedH > 0 && ( - - Soft Booked: {fmt1(data.summary.softBookedH)}h + + Soft Booked: {fmt1(softBookedH)}h )} - {data.summary.activeBookedH === 0 && data.summary.softBookedH === 0 && ( + {activeBookedH === 0 && softBookedH === 0 && ( No bookings in Airtable for this period @@ -167,12 +174,14 @@ export default function DailyBreakdownModal({
No data for this period.
)} {!loading && !error && chartData.length > 0 && ( -
+
+ + {/* Bar 1 — Booked stack (Soft bottom → Active top) */} + + (v >= 1 ? fmt1(v) : '')} + /> + + + (v >= 1 ? fmt1(v) : '')} + /> + (v > 0 ? fmt1(v) : '')} + /> + + + {/* Bar 2 — Allocated + billing stack */} (v >= 1 ? fmt1(v) : '')} /> - {BILLING_GROUPS.map((g) => ( - - { + const isLast = idx === BILLING_GROUPS.length - 1; + return ( + (v >= 1 ? fmt1(v) : '')} - /> - - ))} + name={g.label} + fill={g.color} + stackId="logged" + maxBarSize={36} + radius={isLast ? [2, 2, 0, 0] : [0, 0, 0, 0]} + > + (v >= 1 ? fmt1(v) : '')} + /> + {isLast && ( + (v > 0 ? fmt1(v) : '')} + /> + )} + + ); + })} + (v > 0 ? `${Math.round(v)}%` : '')} /> @@ -252,13 +316,21 @@ export default function DailyBreakdownModal({
)}
-
+
{BILLING_GROUPS.map((g) => ( ))} - + +
+
+ + + + + +
diff --git a/frontend/src/components/DepartmentFilterBar.tsx b/frontend/src/components/DepartmentFilterBar.tsx new file mode 100644 index 0000000..3036224 --- /dev/null +++ b/frontend/src/components/DepartmentFilterBar.tsx @@ -0,0 +1,98 @@ +import type { Dispatch } from 'react'; +import type { FilterAction, FilterState } from '../lib/filters'; + +/** + * Page-scoped filter bar — rendered inside Department.tsx only. + * + * Small inline single-select dropdowns: Name / Division / Brand / + * Employment. The values mirror the global filter state but stay scoped + * to this page only (we still write into the shared reducer so other + * pages pick them up if the user lands there). + * + * Note: matches frame v_dept/f001 right pane — labels are upper-case with + * a coloured "All" sentinel. + */ +interface Props { + state: FilterState; + dispatch: Dispatch; + names: string[]; + brands: string[]; + divisions: string[]; + employmentTypes: string[]; +} + +function Inline({ + label, + value, + options, + onChange, +}: { + label: string; + value: string; + options: string[]; + onChange: (v: string) => void; +}) { + return ( + + ); +} + +export default function DepartmentFilterBar({ + state, + dispatch, + names, + brands, + divisions, + employmentTypes, +}: Props) { + return ( +
+ dispatch({ type: 'set-names', names: v ? [v] : [] })} + /> + dispatch({ type: 'set-divisions', divisions: v ? [v] : [] })} + /> + dispatch({ type: 'set-brands', brands: v ? [v] : [] })} + /> + {/* + Employment is informational only — the reducer doesn't carry an + employment-type slot, and the brief asks us to keep existing + actions. Renders as a no-op dropdown to match the visual layout. + */} + {}} + /> +
+ ); +} diff --git a/frontend/src/components/GlobalFilterBar.tsx b/frontend/src/components/GlobalFilterBar.tsx new file mode 100644 index 0000000..6185552 --- /dev/null +++ b/frontend/src/components/GlobalFilterBar.tsx @@ -0,0 +1,149 @@ +import type { Dispatch } from 'react'; +import type { FilterAction, FilterState } from '../lib/filters'; + +/** + * Top global filter bar — rendered once in the AppShell between Navbar and + * the tab nav. Compact single-select dropdowns with stacked labels. + * + * Sources every option list from the parsed timelog dimensions, plus the + * canonical Airtable user-role list. Mirrors the layout in v_dept/f001 + * (right pane): Division / Brand / Hub-Market / Role / From / To / Reset. + */ +interface Props { + state: FilterState; + dispatch: Dispatch; + brands: string[]; + divisions: string[]; + hubs: string[]; + userRoles: string[]; +} + +function Field({ + label, + tutorialId, + children, +}: { + label: string; + tutorialId?: string; + children: React.ReactNode; +}) { + return ( + + ); +} + +function Single({ + value, + options, + onChange, +}: { + value: string; + options: string[]; + onChange: (v: string) => void; +}) { + return ( + + ); +} + +export default function GlobalFilterBar({ + state, + dispatch, + brands, + divisions, + hubs, + userRoles, +}: Props) { + const range = state.range ?? { from: '', to: '' }; + + // The reducer's set-* actions accept arrays for backwards-compat with the + // legacy multiselect controls. Sending a single-element array (or empty) + // is the simplest "All" sentinel. + const onSet = (action: FilterAction) => dispatch(action); + + return ( +
+ + onSet({ type: 'set-divisions', divisions: v ? [v] : [] })} + /> + + + onSet({ type: 'set-brands', brands: v ? [v] : [] })} + /> + + + onSet({ type: 'set-hubs', hubs: v ? [v] : [] })} + /> + + + onSet({ type: 'set-user-roles', userRoles: v ? [v] : [] })} + /> + + + + dispatch({ + type: 'set-custom-range', + range: { from: e.target.value, to: range.to }, + }) + } + className="bg-slate-800 border border-slate-700 rounded px-2 py-1 text-slate-200 text-xs" + /> + + + + dispatch({ + type: 'set-custom-range', + range: { from: range.from, to: e.target.value }, + }) + } + className="bg-slate-800 border border-slate-700 rounded px-2 py-1 text-slate-200 text-xs" + /> + + +
+ ); +} diff --git a/frontend/src/components/Navbar.tsx b/frontend/src/components/Navbar.tsx index 984a641..106da4a 100644 --- a/frontend/src/components/Navbar.tsx +++ b/frontend/src/components/Navbar.tsx @@ -35,12 +35,26 @@ export default function Navbar() { const visible = TABS.filter((t) => canAccess(user?.role, t.slug)); - // Mirror the original SPA: filename + upload time once a timelog is loaded, - // tagline otherwise. We don't have a true "uploaded at" timestamp on the - // context, so we fall back to "ready" when the upload is parsed. - const subtitle = timelog.filename - ? `${timelog.filename} · ${timelog.rows.toLocaleString()} rows` - : "Daily capacity & project intake analysis"; + // Mirror the original SPA: filename + last-updated timestamp once a + // timelog is loaded, tagline otherwise. Falls back to a row count when + // the upload predates the parsed_at field. + let subtitle = 'Daily capacity & project intake analysis'; + if (timelog.filename) { + if (timelog.parsedAt) { + const parsed = new Date(timelog.parsedAt); + const stamp = parsed.toLocaleString('en-GB', { + day: '2-digit', + month: '2-digit', + year: 'numeric', + hour: '2-digit', + minute: '2-digit', + second: '2-digit', + }); + subtitle = `${timelog.filename} · last updated ${stamp}`; + } else { + subtitle = `${timelog.filename} · ${timelog.rows.toLocaleString()} rows`; + } + } return (
diff --git a/frontend/src/components/charts/DeptWeeklyChart.tsx b/frontend/src/components/charts/DeptWeeklyChart.tsx index 7405483..87233b6 100644 --- a/frontend/src/components/charts/DeptWeeklyChart.tsx +++ b/frontend/src/components/charts/DeptWeeklyChart.tsx @@ -12,9 +12,11 @@ import { import { Maximize2, X } from 'lucide-react'; import type { DepartmentPerson, DepartmentSummaryRow } from '../../api/types'; import { + ACTIVE_BOOKED_COLOR, ALLOCATED_COLOR, BILLING_GROUPS, - FORECAST_LINE_COLOR, + SOFT_BOOKED_COLOR, + UTIL_LINE_COLOR, fmt1, shortName, } from '../../lib/department'; @@ -23,15 +25,18 @@ import { * Department-level Weekly Utilisation chart. * * Two modes: - * - "all-departments" — per-department stacked bars (overview). - * - "people" — per-person stacked bars (when a dept is selected). + * - "departments" — per-department dual bars (overview, click a bar to drill). + * - "people" — per-person dual bars (when a dept is selected). * - * Stacks bottom→top: Allocated · House Admin · Idle · Client · Fee · Other. - * A red Forecast Utilisation % line floats above each bar, labelled with - * the per-person/per-dept forecast %. + * Each entity renders TWO side-by-side bars (matching the original SPA): + * • "allocated" stack — Allocated · House Admin · Idle · Client · Fee · Other + * • "booked" stack — Soft Booked (bottom) → Active Booked (top) + * A red Avg % utilisation line floats above each entity with the + * per-entity utilisation % label. * - * Click a person's name (clickable label) → opens DailyBreakdownModal. - * Click a bar segment → opens project-logs popup (handled by parent). + * Click a person's name → opens DailyBreakdownModal. + * Click a logged-bar segment → opens project-logs popup (handled by parent). + * Click a department bar → drills into that department. */ interface PersonMode { @@ -64,6 +69,9 @@ interface Row { bg_feeRelated: number; bg_other: number; totalLogged: number; + activeBooked: number; + softBooked: number; + totalBooked: number; utilisationPct: number; forecastPct: number; } @@ -82,6 +90,9 @@ function bucketsFromPerson(p: DepartmentPerson): Row { bg_feeRelated: Math.round(p.feeRelated), bg_other: Math.round(p.other), totalLogged: Math.round(totalLogged), + activeBooked: Math.round(p.activeBooked), + softBooked: Math.round(p.softBooked), + totalBooked: Math.round(p.activeBooked + p.softBooked), utilisationPct: Math.round(p.utilisationPct), forecastPct: Math.round(p.forecastPct), }; @@ -99,6 +110,9 @@ function bucketsFromDept(d: DepartmentSummaryRow): Row { bg_feeRelated: Math.round(d.feeRelated), bg_other: Math.round(d.other), totalLogged: Math.round(d.logged), + activeBooked: Math.round(d.activeBooked), + softBooked: Math.round(d.softBooked), + totalBooked: Math.round(d.activeBooked + d.softBooked), utilisationPct: Math.round(d.utilisationPct), forecastPct: Math.round(d.forecastPct), }; @@ -113,7 +127,8 @@ export default function DeptWeeklyChart({ mode, title, subtitle }: Props) { }, [mode]); const chartHeight = expanded ? Math.max(window.innerHeight - 160, 520) : 460; - const minWidth = data.length * 56 + 200; + // Two bars per entity → wider slot than the old single-bar layout. + const minWidth = data.length * 84 + 200; const xAxisTick = (props: { x: number; y: number; payload: { value: string } }) => { const row = data.find((d) => d.name === props.payload.value); @@ -153,6 +168,37 @@ export default function DeptWeeklyChart({ mode, title, subtitle }: Props) { } }; + // Verbose tooltip — emits one row per known value (matches frame v_dept/f010). + const tooltipFormatter = ( + value: number, + name: string, + props: { payload?: Record }, + ): [string, string] | null => { + const p = (props?.payload ?? {}) as Record; + switch (name) { + case 'softBooked': + return [`${fmt1(value)}h`, 'softBooked']; + case 'activeBooked': + return [`${fmt1(value)}h`, 'activeBooked']; + case 'allocated': + return [`${fmt1(value)}h`, 'Allocated (net of leave)']; + case 'bg_houseAdmin': + return [`${fmt1(value)}h`, 'House Admin']; + case 'bg_idle': + return [`${fmt1(value)}h`, 'Idle Time']; + case 'bg_clientRelated': + return [`${fmt1(value)}h`, 'Client Related (Non Project Related)']; + case 'bg_feeRelated': + return [`${fmt1(value)}h`, 'Fee Related']; + case 'bg_other': + return [`${fmt1(value)}h`, 'Other']; + case 'utilisationLine': + return [`${(p.utilisationPct ?? value).toFixed(1)}%`, 'Utilisation']; + default: + return [`${fmt1(value)}h`, name]; + } + }; + const chartBody = (
@@ -161,6 +207,8 @@ export default function DeptWeeklyChart({ mode, title, subtitle }: Props) { height={chartHeight} data={data} margin={{ top: 30, right: 40, left: 8, bottom: 100 }} + barCategoryGap="20%" + barGap={4} > @@ -183,18 +231,62 @@ export default function DeptWeeklyChart({ mode, title, subtitle }: Props) { borderRadius: 8, fontSize: 12, }} + formatter={tooltipFormatter} labelFormatter={(_, payload) => { const p = payload?.[0]?.payload as Row | undefined; - return p?.fullName ?? ''; + return mode.kind === 'people' + ? `${p?.fullName ?? ''} — click to drill down` + : `${p?.fullName ?? ''} — click to drill into department`; }} /> + + {/* Bar 1 — Booked stack (Soft on bottom, Active on top) */} + + (v >= 8 ? fmt1(v) : '')} + /> + + + (v >= 8 ? fmt1(v) : '')} + /> + (v > 0 ? fmt1(v) : '')} + /> + + + {/* Bar 2 — Allocated stack (Allocated base + billing segments) */} @@ -205,49 +297,55 @@ export default function DeptWeeklyChart({ mode, title, subtitle }: Props) { formatter={(v: number) => (v >= 8 ? fmt1(v) : '')} /> - {BILLING_GROUPS.map((g) => ( - - { + const isLast = idx === BILLING_GROUPS.length - 1; + return ( + (v >= 5 ? fmt1(v) : '')} - /> - {g.id === 'other' && ( + name={g.key} + fill={g.color} + stackId="logged" + maxBarSize={36} + style={{ cursor: 'pointer' }} + onClick={segmentClick(g.id)} + radius={isLast ? [2, 2, 0, 0] : [0, 0, 0, 0]} + > (v > 0 ? fmt1(v) : '')} + dataKey={g.key} + position="center" + style={{ fill: g.textOn, fontSize: 10, fontWeight: 700 }} + formatter={(v: number) => (v >= 5 ? fmt1(v) : '')} /> - )} - - ))} + {isLast && ( + (v > 0 ? fmt1(v) : '')} + /> + )} + + ); + })} + + {/* Avg % utilisation line — uses actual utilisation, not forecast. */} (v > 0 ? `${Math.round(v)}%` : '')} /> @@ -317,7 +415,9 @@ function Legend() { {BILLING_GROUPS.map((g) => ( ))} - + + +
); } diff --git a/frontend/src/hooks/useDataContext.tsx b/frontend/src/hooks/useDataContext.tsx index 0fef003..ee1a3ca 100644 --- a/frontend/src/hooks/useDataContext.tsx +++ b/frontend/src/hooks/useDataContext.tsx @@ -17,6 +17,7 @@ interface UploadState { hash: string | null; rows: number; filename: string | null; + parsedAt: string | null; uploading: boolean; error: string | null; unrecognised: string[]; @@ -26,6 +27,7 @@ const emptyUpload: UploadState = { hash: null, rows: 0, filename: null, + parsedAt: null, uploading: false, error: null, unrecognised: [], @@ -129,6 +131,7 @@ export function DataProvider({ children }: { children: React.ReactNode }) { hash: res.content_hash, rows: res.rows.length, filename: file.name, + parsedAt: res.parsed_at ?? new Date().toISOString(), uploading: false, error: null, unrecognised: res.unrecognised_columns, @@ -147,6 +150,7 @@ export function DataProvider({ children }: { children: React.ReactNode }) { hash: res.content_hash, rows: res.rows.length, filename: file.name, + parsedAt: new Date().toISOString(), uploading: false, error: null, unrecognised: res.unrecognised_columns, @@ -165,6 +169,7 @@ export function DataProvider({ children }: { children: React.ReactNode }) { hash: res.content_hash, rows: res.rows.length, filename: file.name, + parsedAt: new Date().toISOString(), uploading: false, error: null, unrecognised: res.unrecognised_columns, diff --git a/frontend/src/pages/Bookings.tsx b/frontend/src/pages/Bookings.tsx index 52559c9..a0c6437 100644 --- a/frontend/src/pages/Bookings.tsx +++ b/frontend/src/pages/Bookings.tsx @@ -1,9 +1,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { Download, RefreshCw } from 'lucide-react'; -import FilterBar from '../components/FilterBar'; import Loading from '../components/Loading'; import ErrorBox from '../components/ErrorBox'; -import { useAirtableData } from '../hooks/useAirtableData'; import { useDataContext } from '../hooks/useDataContext'; import { filtersToQuery } from '../lib/filters'; import { downloadCsv, rowsToCsv } from '../lib/csv'; @@ -15,8 +13,7 @@ const ROW_HEIGHT = 36; const OVERSCAN = 8; export default function Bookings() { - const airtable = useAirtableData(false); - const { filters, dispatch, dimensions } = useDataContext(); + const { filters } = useDataContext(); const [bookings, setBookings] = useState([]); const [cachedAt, setCachedAt] = useState(null); const [loading, setLoading] = useState(false); @@ -26,11 +23,6 @@ export default function Bookings() { const [scrollTop, setScrollTop] = useState(0); const [viewportH, setViewportH] = useState(480); - const names = useMemo( - () => airtable.resources.map((r) => r.name).sort((a, b) => a.localeCompare(b)), - [airtable.resources], - ); - const load = useCallback( async (refresh = false) => { setLoading(true); @@ -85,19 +77,6 @@ export default function Bookings() { return (
- -
{total.toLocaleString()} booking{total === 1 ? '' : 's'} diff --git a/frontend/src/pages/Department.tsx b/frontend/src/pages/Department.tsx index 3e87692..36c22c1 100644 --- a/frontend/src/pages/Department.tsx +++ b/frontend/src/pages/Department.tsx @@ -1,6 +1,6 @@ import { useCallback, useEffect, useMemo, useState } from 'react'; import { Download, RefreshCw } from 'lucide-react'; -import FilterBar from '../components/FilterBar'; +import DepartmentFilterBar from '../components/DepartmentFilterBar'; import Loading from '../components/Loading'; import ErrorBox from '../components/ErrorBox'; import ErrorBoundary from '../components/ErrorBoundary'; @@ -133,17 +133,22 @@ export default function Department() { return (
- + Airtable ·{' '} + {airtable.resources.length.toLocaleString()} resources · synced{' '} + {new Date(airtable.cachedAt).toLocaleString('en-GB')} +
+ )} + + {/* Action row */} @@ -168,34 +173,51 @@ export default function Department() {
- {/* Airtable status line */} - {airtable.cachedAt && ( -
- Airtable ·{' '} - {airtable.resources.length.toLocaleString()} resources · synced{' '} - {new Date(airtable.cachedAt).toLocaleString('en-GB')} + {/* Department pills + Adhoc People warning side panel */} +
+
+ {DEPT_PILLS.map((d) => { + const active = (selectedDept ?? null) === d.id; + return ( + + ); + })}
- )} - - {/* Department pills */} -
- {DEPT_PILLS.map((d) => { - const active = (selectedDept ?? null) === d.id; - return ( - - ); - })} + {/* Adhoc People — Zoho submitters not matched to an Airtable resource. */} + {data && data.people.filter((p) => p.department === 'Unknown').length > 0 && ( +
+
+ Adhoc People · {data.people.filter((p) => p.department === 'Unknown').length} +
+
+ Zoho emails with no Airtable Resource match — review or add to the roster. +
+
+ {data.people + .filter((p) => p.department === 'Unknown') + .slice(0, 6) + .map((p) => ( +
+ + {p.email || p.name} + + create / update +
+ ))} +
+
+ )}
{/* Week pills */} diff --git a/frontend/src/pages/Forecast.tsx b/frontend/src/pages/Forecast.tsx index 9d2319a..4e29c3e 100644 --- a/frontend/src/pages/Forecast.tsx +++ b/frontend/src/pages/Forecast.tsx @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useMemo, useState } from 'react'; +import { Fragment, useCallback, useEffect, useMemo, useState } from 'react'; import { Bar, CartesianGrid, @@ -13,10 +13,8 @@ import { } from 'recharts'; import Loading from '../components/Loading'; import ErrorBox from '../components/ErrorBox'; -import FilterBar from '../components/FilterBar'; import * as api from '../api/endpoints'; import { ApiError } from '../api/client'; -import { useAirtableData } from '../hooks/useAirtableData'; import { useDataContext } from '../hooks/useDataContext'; import type { ForecastResponse, ForecastWeek } from '../api/types'; import { filtersToQuery } from '../lib/filters'; @@ -36,8 +34,7 @@ import { fmt1, getWeekBounds } from '../lib/department'; * 5. Right sidebar: "How this forecast is built" copy */ export default function ForecastPage() { - const airtable = useAirtableData(false); - const { filters, dispatch, timelog, deliverable, projectSummary, dimensions } = useDataContext(); + const { filters, timelog, deliverable, projectSummary } = useDataContext(); const [weekOffset, setWeekOffset] = useState<-1 | 0>(0); const [data, setData] = useState(null); const [loading, setLoading] = useState(false); @@ -76,39 +73,76 @@ export default function ForecastPage() { void load(); }, [load]); - const names = useMemo( - () => airtable.resources.map((r) => r.name).sort((a, b) => a.localeCompare(b)), - [airtable.resources], - ); - const tw = data?.thisWeek; const weeklyAvg = data?.totals.weeklyThroughput ?? 0; - // Build chart data from the 8-week look-ahead. - const chartData = (data?.weeks ?? []).map((w) => ({ - label: w.weekLabel, - weekStart: w.weekStart, - active: w.activeAssets, - exiting: w.exitingAssets, - exitRate: w.exitRatePct, - capacity: w.deptCapacityAssetsPerWeek, - })); + // Project-type palette — matches the original SPA's PIPELINE_TYPES order + // so the chart's stack colours stay stable across uploads. + const PIPELINE_TYPES = useMemo( + () => [ + 'COUNTRY PULL - SIMPLE', + 'COUNTRY PULL - ADAPTATION', + 'COUNTRY PULL - CREATION', + 'COUNTRY PULL', + 'GLOBAL PUSH - PDP', + 'LOCAL PUSH - PDP', + 'GLOBAL PUSH - EVENTING', + 'LOCAL PUSH - EVENTING', + 'URGENT BRIEF', + 'Globally Initiated', + 'Locally Initiated', + ], + [], + ); + const TYPE_PALETTE = useMemo( + () => [ + '#6366f1', '#8b5cf6', '#ec4899', '#f59e0b', '#10b981', + '#3b82f6', '#ef4444', '#14b8a6', '#f97316', '#84cc16', + '#06b6d4', '#d946ef', '#64748b', '#a16207', + ], + [], + ); + const typeColour = useCallback( + (type: string, fallbackIdx: number) => { + const idx = PIPELINE_TYPES.indexOf(type); + return TYPE_PALETTE[(idx >= 0 ? idx : PIPELINE_TYPES.length + fallbackIdx) % TYPE_PALETTE.length]; + }, + [PIPELINE_TYPES, TYPE_PALETTE], + ); + + // Union of types seen across the chart window — keeps stack column count + // stable per render. Always includes the canonical PIPELINE_TYPES first. + const typesInChart = useMemo(() => { + const seen = new Set(); + for (const w of data?.weeks ?? []) { + for (const t of Object.keys(w.activeAssetsByType ?? {})) seen.add(t); + } + const ordered: string[] = []; + for (const t of PIPELINE_TYPES) if (seen.has(t)) ordered.push(t); + for (const t of seen) if (!PIPELINE_TYPES.includes(t)) ordered.push(t); + return ordered; + }, [data, PIPELINE_TYPES]); + + // Build chart data — one row per week with per-type counts as flat keys + // and the "exiting" line as a separate dataKey. + const chartData = (data?.weeks ?? []).map((w) => { + const row: Record = { + label: w.weekLabel, + weekStart: w.weekStart, + exiting: w.exitingAssets, + exitRate: w.exitRatePct, + capacity: w.deptCapacityAssetsPerWeek, + total: w.activeAssets, + }; + for (const t of typesInChart) row[t] = w.activeAssetsByType?.[t] ?? 0; + return row; + }); + + // Restrict the comparison table to four weeks (This Week + next three). + const tableWeeks = (data?.weeks ?? []).slice(0, 4); return (
- - {error && } {loading && } {!loading && !error && data && ( @@ -121,7 +155,9 @@ export default function ForecastPage() { {weekOffset === 0 ? "This Week's" : "Last Week's"} Capacity Status · {tw?.weekLabel ?? wb.label}

- Mon–yesterday actual hours · timesheet & deliverable data are both 1 day delayed + {weekOffset === 0 + ? 'Mon–yesterday actual hours · timesheet & deliverable data are both 1 day delayed' + : 'Full week actual hours (Mon–Fri) · timesheet & deliverable data are both 1 day delayed'}

@@ -283,37 +319,83 @@ export default function ForecastPage() { - + {typesInChart.map((type, i) => ( + + ))} + {/* Total label rides on the top of the stack — uses an + invisible bar so Recharts renders it at the right Y. */} + (v > 0 ? fmt1(v) : '')} /> - + > + (v > 0 ? fmt1(v) : '')} + /> +
+ {/* Project-type legend — colour-keyed swatches + Exiting & Forecast-avg markers */} +
+ {typesInChart.map((type, i) => ( + + + {type} + + ))} + + Exiting + + {weeklyAvg > 0 && ( + + Forecast avg + + )} +
+ {selectedWeek && (
@@ -330,6 +412,77 @@ export default function ForecastPage() {
)}
+ + {/* Weekly comparison table — Active / Exit counts by type × week. */} + {tableWeeks.length > 0 && typesInChart.length > 0 && ( +
+

Weekly Comparison — Active & Exiting by Project Type

+

Active / Exit counts per project type across the next four weeks.

+ + + + + {tableWeeks.map((w, i) => ( + + ))} + + + + + + ))} + + + + {tableWeeks.map((w) => ( + + + + + ))} + + + + {typesInChart.map((type, ti) => ( + + + {tableWeeks.map((w) => { + const act = w.activeAssetsByType?.[type] ?? 0; + const exi = w.exitingByType?.[type] ?? 0; + return ( + + + + + ); + })} + + ))} + +
Project Type + {i === 0 ? 'This Week' : i === 1 ? 'Next Week' : `Week +${i}`} +
+ {w.weekStart.slice(8, 10)}/{w.weekStart.slice(5, 7)} – {w.weekEnd.slice(8, 10)}/{w.weekEnd.slice(5, 7)} +
+
+ {tableWeeks.map((w) => ( + + ActiveExit
Total{w.activeAssets.toLocaleString()}{w.exitingAssets > 0 ? w.exitingAssets.toLocaleString() : '—'}
+ + + {type} + + + {act > 0 ? act.toLocaleString() : '—'} + + {exi > 0 ? exi.toLocaleString() : '—'} +
+
+ )}
{/* Sidebar */} diff --git a/frontend/src/pages/ProjectTypeSummary.tsx b/frontend/src/pages/ProjectTypeSummary.tsx index 6e6ea3a..4b1a2dc 100644 --- a/frontend/src/pages/ProjectTypeSummary.tsx +++ b/frontend/src/pages/ProjectTypeSummary.tsx @@ -3,6 +3,8 @@ import { Bar, CartesianGrid, ComposedChart, + LabelList, + ReferenceLine, ResponsiveContainer, Tooltip, XAxis, @@ -11,10 +13,8 @@ import { import { AlertTriangle, Eye } from 'lucide-react'; import Loading from '../components/Loading'; import ErrorBox from '../components/ErrorBox'; -import FilterBar from '../components/FilterBar'; import * as api from '../api/endpoints'; import { ApiError } from '../api/client'; -import { useAirtableData } from '../hooks/useAirtableData'; import { useDataContext } from '../hooks/useDataContext'; import type { ProjectTypeStatExtended, ProjectTypesResponse } from '../api/types'; import { filtersToQuery } from '../lib/filters'; @@ -35,8 +35,7 @@ import { fmt1, fmtMonth } from '../lib/department'; * 5. Bottom KPI tiles + "Insights & Recommended Actions" list. */ export default function ProjectTypeSummaryPage() { - const airtable = useAirtableData(false); - const { filters, dispatch, timelog, projectSummary, dimensions } = useDataContext(); + const { filters, timelog, projectSummary } = useDataContext(); const [data, setData] = useState(null); const [loading, setLoading] = useState(false); const [error, setError] = useState(null); @@ -117,26 +116,8 @@ export default function ProjectTypeSummaryPage() { }); }; - const names = useMemo( - () => airtable.resources.map((r) => r.name).sort((a, b) => a.localeCompare(b)), - [airtable.resources], - ); - return (
- - {error && } {loading && } @@ -295,66 +276,7 @@ export default function ProjectTypeSummaryPage() {
{/* Selected type detail panel */} - {selected && ( -
-
-
-

{selected.projectType} — monthly assets trend

-

- {selected.projectCount} projects · {selected.totalAssets} assets · {selected.totalHours}h -

-
- -
-
- - - - - - [v, 'Assets']} - /> - - - -
-

Project list

-
- - - - - - - - - - - - - {selected.projectList.map((p) => ( - - - - - - - - - ))} - -
TitleAssetsHoursStartEndDuration (wd)
{p.title}{p.assets}{fmt1(p.hours)}h{p.start ?? '—'}{p.end ?? '—'}{p.duration}
-
-
- )} + {selected && setSelectedType(null)} />} {/* Bottom KPI tiles + insights */}
@@ -515,3 +437,306 @@ function colourForType(type: string): string { for (let i = 0; i < type.length; i++) h = (h * 31 + type.charCodeAt(i)) >>> 0; return TYPE_COLOURS[h % TYPE_COLOURS.length]; } + +// ── Department colour map for the dept-hours distribution bar ───────── +const DEPT_PALETTE: Record = { + 'Creative Team': '#818cf8', + Creative: '#818cf8', + 'Project Management Team': '#f472b6', + 'PM Team': '#f472b6', + 'Syndication Team': '#34d399', + Syndication: '#34d399', + 'Transcreation Team': '#fbbf24', + Transcreation: '#fbbf24', + 'Opera Upload Team': '#60a5fa', + 'Opera Upload': '#60a5fa', + 'Operation Team': '#f87171', + Operations: '#f87171', + Unknown: '#64748b', +}; +function deptColour(d: string): string { + return DEPT_PALETTE[d] ?? '#64748b'; +} + +// ── Auto-insights for a single project type ────────────────────────── +function insightsFor(s: ProjectTypeStatExtended): Array<{ level: 'good' | 'watch' | 'action'; text: string }> { + const out: Array<{ level: 'good' | 'watch' | 'action'; text: string }> = []; + // Duration outlier + if (s.longestProjectDays > 0 && s.avgDurationDays > 0 && s.longestProjectDays > s.avgDurationDays * 2.5) { + out.push({ + level: 'watch', + text: `Duration outlier: longest project was ${s.longestProjectDays} working days vs avg ${Math.round(s.avgDurationDays)}d. Investigate what caused the extended timeline on those projects.`, + }); + } + // Concentration + const dh = s.deptHoursBreakdown ?? []; + if (dh.length > 0) { + const top = dh[0]; + if (top.pct > 85) { + out.push({ + level: 'watch', + text: `${top.pct.toFixed(0)}% of hours concentrated in ${top.dept}. High single-team dependency.`, + }); + } + } + // Sample size + if (s.projectCount < 5) { + out.push({ + level: 'watch', + text: `Only ${s.projectCount} completed projects — benchmark averages may not yet be statistically reliable.`, + }); + } + // Low h/asset + if (s.avgHoursPerAsset < 0.5) { + out.push({ + level: 'watch', + text: `Avg h/asset is very low (${s.avgHoursPerAsset.toFixed(2)}h) — verify the "No. of Assets" column is not inflated.`, + }); + } + if (out.length === 0) { + out.push({ + level: 'good', + text: `No anomalies detected. Benchmark looks stable across ${s.projectCount} completed projects.`, + }); + } + return out; +} + +function SelectedTypeDetail({ + selected, + onClose, +}: { + selected: ProjectTypeStatExtended; + onClose: () => void; +}) { + const dh = selected.deptHoursBreakdown ?? []; + const totalDH = dh.reduce((s, d) => s + d.hours, 0); + const monthlyAvg = useMemo( + () => selected.monthlyAvgHoursPerAsset ?? [], + [selected.monthlyAvgHoursPerAsset], + ); + const divisionList = useMemo(() => { + const s = new Set(); + for (const m of monthlyAvg) for (const d of Object.keys(m.byDivision)) s.add(d); + return Array.from(s).sort(); + }, [monthlyAvg]); + const chartData = useMemo(() => { + return monthlyAvg.map((m) => { + const row: Record = { + month: m.month, + monthLabel: fmtMonth(m.month), + overall: m.overall, + projectCount: m.projectCount, + }; + for (const div of divisionList) row[`dv_${div}`] = m.byDivision[div] ?? 0; + return row; + }); + }, [monthlyAvg, divisionList]); + const ins = insightsFor(selected); + const cleanName = selected.projectType; + const minD = selected.minDurationDays ?? 4; + + return ( +
+ {/* Header with KPI tiles and dept breakdown */} +
+
+
+
+

{cleanName}

+

{selected.projectCount} projects

+
+ +
+
+
+
avg h / asset
+
{selected.avgHoursPerAsset.toFixed(1)}h
+
+
+
avg duration
+
+ {selected.avgDurationDays > 0 ? `${Math.round(selected.avgDurationDays)}d` : '—'} +
+ {selected.longestProjectDays > 0 && ( +
range {minD}–{selected.longestProjectDays}d
+ )} +
+
+
+ {Math.round(selected.totalHours)}h total + {selected.totalAssets.toLocaleString()} assets + + {selected.projectCount > 0 ? (selected.totalAssets / selected.projectCount).toFixed(1) : '—'} assets/project + +
+ {totalDH > 0 && ( +
+
Dept hours
+
+ {dh.map((d) => ( +
+ {d.pct >= 8 && ( + + {Math.round(d.hours)}h + + )} +
+ ))} +
+
+ {dh.map((d) => ( + + + {d.dept.replace(' Team', '').replace('Project Management', 'PM').replace('Opera Upload', 'Opera')} + {' '}{d.pct.toFixed(0)}% + + ))} +
+
+ )} +
+ + {/* Insights & Recommended Actions */} +
+
+

Insights & Recommended Actions

+ + {selected.projectCount} projects · {selected.totalHours.toFixed(0)}h · {selected.totalAssets.toLocaleString()} assets + +
+
+ {ins.map((i, idx) => ( +
+ + {i.level === 'good' ? 'GOOD' : i.level === 'watch' ? 'WATCH' : 'ACTION'} + +

{i.text}

+
+ ))} +
+
+
+ + {/* Panel 1 — Avg h/asset by completion month */} + {chartData.length > 0 && ( +
+

Panel 1 — Avg h / asset by completion month

+

+ Stacked by division (proportional to project count) · Bar label = avg h/asset · Dashed = overall avg ({selected.avgHoursPerAsset.toFixed(1)}h) · Click a bar to drill into time logs +

+
+ + + + + + [`${(v as number).toFixed(2)}h`, 'avg h/asset']} + /> + + {divisionList.map((d, i) => ( + + ))} + + (v > 0 ? `${v.toFixed(1)}h` : '')} + /> + + + +
+
+ {divisionList.map((d, i) => ( + + + {d} + + ))} +
+
+ )} + + {/* Project list */} +
+

Project list

+
+ + + + + + + + + + + + + {selected.projectList.map((p) => ( + + + + + + + + + ))} + +
TitleAssetsHoursStartEndDuration (wd)
{p.title}{p.assets}{fmt1(p.hours)}h{p.start ?? '—'}{p.end ?? '—'}{p.duration}
+
+
+
+ ); +} + +const DIV_PALETTE = [ + '#f43f5e', '#6366f1', '#f59e0b', '#10b981', '#3b82f6', + '#d946ef', '#14b8a6', '#f97316', '#84cc16', '#8b5cf6', + '#06b6d4', '#ec4899', '#a16207', '#64748b', +]; diff --git a/frontend/src/pages/Resourcing.tsx b/frontend/src/pages/Resourcing.tsx index 4416d2d..5bd82c9 100644 --- a/frontend/src/pages/Resourcing.tsx +++ b/frontend/src/pages/Resourcing.tsx @@ -1,6 +1,5 @@ import { useCallback, useEffect, useMemo, useState } from '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'; @@ -10,7 +9,6 @@ import FTEvsFreelancer from '../components/charts/FTEvsFreelancer'; import Loading from '../components/Loading'; import ErrorBox from '../components/ErrorBox'; import ErrorBoundary from '../components/ErrorBoundary'; -import { useAirtableData } from '../hooks/useAirtableData'; import { useDataContext } from '../hooks/useDataContext'; import { filtersToQuery } from '../lib/filters'; import { downloadCsv, rowsToCsv } from '../lib/csv'; @@ -19,8 +17,7 @@ import { ApiError } from '../api/client'; import type { Booking, UtilisationSummaryRow, UtilisationTotals } from '../api/types'; export default function Resourcing() { - const airtable = useAirtableData(false); - const { filters, dispatch, timelog, dimensions } = useDataContext(); + const { filters, dispatch, timelog } = useDataContext(); const [bookings, setBookings] = useState([]); const [summary, setSummary] = useState([]); const [totals, setTotals] = useState(null); @@ -29,11 +26,6 @@ export default function Resourcing() { const [error, setError] = useState(null); const [selectedPeriod, setSelectedPeriod] = useState(null); - const names = useMemo( - () => airtable.resources.map((r) => r.name).sort((a, b) => a.localeCompare(b)), - [airtable.resources], - ); - const visibleBookings = useMemo(() => { const deptSet = new Set(filters.departments); const nameSet = new Set(filters.names); @@ -105,19 +97,6 @@ export default function Resourcing() { return (
- -