parity v3: two-bar charts, airtable link fallbacks, filter split, weekly comparison, project-type detail

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 "<filename> · last updated <localised dd/mm/yyyy hh:mm:ss>"
  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) <noreply@anthropic.com>
This commit is contained in:
DJP 2026-05-18 08:50:21 -04:00
parent 9f3d3cabfd
commit d4c6576a95
17 changed files with 1258 additions and 296 deletions

View file

@ -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")

View file

@ -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)

View file

@ -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:
@ -100,6 +118,11 @@ def build_project_types(
"assets": 0.0,
"duration_days": [],
"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)

View file

@ -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,
}

View file

@ -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 (
<GlobalFilterBar
state={filters}
dispatch={dispatch}
brands={dimensions.brands}
divisions={dimensions.divisions}
hubs={dimensions.hubs}
userRoles={dimensions.userRoles}
/>
);
}
function ProtectedShell({ children, slug }: { children: React.ReactNode; slug: string }) {
return (
<AuthGate>
@ -31,6 +48,7 @@ function ProtectedShell({ children, slug }: { children: React.ReactNode; slug: s
<div className="flex min-h-screen flex-col bg-slate-950 text-slate-100">
<Navbar />
<StatsBar />
<GlobalFilters />
<main className="mx-auto w-full max-w-7xl flex-1 px-4 py-4 md:px-6 md:py-6">
<Suspense fallback={<Loading label="Loading view…" />}>{children}</Suspense>
</main>

View file

@ -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<string, number>;
exitingByType?: Record<string, number>;
}
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<string, number>;
}
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 {

View file

@ -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 (
<div
@ -126,33 +133,33 @@ export default function DailyBreakdownModal({
{data && (
<div className="px-5 pt-3 flex flex-wrap gap-2">
{data.summary.activeBookedH > 0 && (
{activeBookedH > 0 && (
<span
className="flex items-center gap-1.5 px-2.5 py-1 rounded-lg text-xs font-semibold"
style={{
background: '#4f46e522',
background: 'rgba(99, 102, 241, 0.18)',
border: '1px solid #4f46e5',
color: '#a5b4fc',
}}
>
<span className="w-2 h-2 rounded-sm" style={{ background: '#4f46e5' }} />
Active Booked: {fmt1(data.summary.activeBookedH)}h
<span className="w-2 h-2 rounded-sm" style={{ background: ACTIVE_BOOKED_COLOR }} />
Active Booked: {fmt1(activeBookedH)}h
</span>
)}
{data.summary.softBookedH > 0 && (
{softBookedH > 0 && (
<span
className="flex items-center gap-1.5 px-2.5 py-1 rounded-lg text-xs font-semibold"
style={{
background: '#0891b222',
background: 'rgba(8, 145, 178, 0.18)',
border: '1px solid #0891b2',
color: '#67e8f9',
}}
>
<span className="w-2 h-2 rounded-sm" style={{ background: '#0891b2' }} />
Soft Booked: {fmt1(data.summary.softBookedH)}h
<span className="w-2 h-2 rounded-sm" style={{ background: SOFT_BOOKED_COLOR }} />
Soft Booked: {fmt1(softBookedH)}h
</span>
)}
{data.summary.activeBookedH === 0 && data.summary.softBookedH === 0 && (
{activeBookedH === 0 && softBookedH === 0 && (
<span className="text-xs text-slate-500 italic">
No bookings in Airtable for this period
</span>
@ -167,12 +174,14 @@ export default function DailyBreakdownModal({
<div className="text-sm text-slate-500 italic">No data for this period.</div>
)}
{!loading && !error && chartData.length > 0 && (
<div style={{ width: chartWidth, height: 440 }}>
<div style={{ width: chartWidth, height: 460 }}>
<ComposedChart
width={chartWidth}
height={440}
height={460}
data={chartData}
margin={{ top: 36, right: 30, left: 8, bottom: 80 }}
barCategoryGap="20%"
barGap={4}
>
<CartesianGrid strokeDasharray="3 3" stroke="#1e293b" />
<XAxis
@ -201,12 +210,53 @@ export default function DailyBreakdownModal({
fontSize: 12,
}}
/>
{/* Bar 1 — Booked stack (Soft bottom → Active top) */}
<Bar
dataKey="softBooked"
name="softBooked"
stackId="booked"
fill={SOFT_BOOKED_COLOR}
maxBarSize={36}
radius={[0, 0, 0, 0]}
>
<LabelList
dataKey="softBooked"
position="center"
style={{ fill: '#ede9fe', fontSize: 10, fontWeight: 700 }}
formatter={(v: number) => (v >= 1 ? fmt1(v) : '')}
/>
</Bar>
<Bar
dataKey="activeBooked"
name="activeBooked"
stackId="booked"
fill={ACTIVE_BOOKED_COLOR}
maxBarSize={36}
radius={[2, 2, 0, 0]}
>
<LabelList
dataKey="activeBooked"
position="center"
style={{ fill: '#e0e7ff', fontSize: 10, fontWeight: 700 }}
formatter={(v: number) => (v >= 1 ? fmt1(v) : '')}
/>
<LabelList
dataKey="totalBooked"
position="top"
offset={4}
style={{ fill: '#67e8f9', fontSize: 10, fontWeight: 700 }}
formatter={(v: number) => (v > 0 ? fmt1(v) : '')}
/>
</Bar>
{/* Bar 2 — Allocated + billing stack */}
<Bar
dataKey="allocated"
name="Allocated hours"
fill={ALLOCATED_COLOR}
stackId="stack"
maxBarSize={42}
stackId="logged"
maxBarSize={36}
>
<LabelList
dataKey="allocated"
@ -215,14 +265,17 @@ export default function DailyBreakdownModal({
formatter={(v: number) => (v >= 1 ? fmt1(v) : '')}
/>
</Bar>
{BILLING_GROUPS.map((g) => (
{BILLING_GROUPS.map((g, idx) => {
const isLast = idx === BILLING_GROUPS.length - 1;
return (
<Bar
key={g.id}
dataKey={g.key}
name={g.label}
fill={g.color}
stackId="stack"
maxBarSize={42}
stackId="logged"
maxBarSize={36}
radius={isLast ? [2, 2, 0, 0] : [0, 0, 0, 0]}
>
<LabelList
dataKey={g.key}
@ -230,21 +283,32 @@ export default function DailyBreakdownModal({
style={{ fill: g.textOn, fontSize: 10, fontWeight: 700 }}
formatter={(v: number) => (v >= 1 ? fmt1(v) : '')}
/>
{isLast && (
<LabelList
dataKey="totalLogged"
position="top"
offset={4}
style={{ fill: '#e2e8f0', fontSize: 10, fontWeight: 700 }}
formatter={(v: number) => (v > 0 ? fmt1(v) : '')}
/>
)}
</Bar>
))}
);
})}
<Line
type="monotone"
dataKey="actualPct"
name="Avg % utilisation"
stroke={FORECAST_LINE_COLOR}
stroke={UTIL_LINE_COLOR}
strokeWidth={2.5}
dot={{ r: 3, fill: FORECAST_LINE_COLOR }}
dot={{ r: 3, fill: UTIL_LINE_COLOR }}
>
<LabelList
dataKey="actualPct"
position="top"
offset={14}
style={{ fill: FORECAST_LINE_COLOR, fontSize: 11, fontWeight: 700 }}
style={{ fill: UTIL_LINE_COLOR, fontSize: 11, fontWeight: 700 }}
formatter={(v: number) => (v > 0 ? `${Math.round(v)}%` : '')}
/>
</Line>
@ -252,13 +316,21 @@ export default function DailyBreakdownModal({
</div>
)}
</div>
<div className="px-5 pb-4 border-t border-slate-800 pt-3">
<div className="px-5 pb-4 border-t border-slate-800 pt-3 space-y-1.5">
<div className="flex flex-wrap gap-x-4 gap-y-1.5 text-xs">
<Leg color={ALLOCATED_COLOR} label="Allocated hours" />
{BILLING_GROUPS.map((g) => (
<Leg key={g.id} color={g.color} label={g.label} />
))}
<Leg color={FORECAST_LINE_COLOR} label="Avg % utilisation" line />
<Leg color={UTIL_LINE_COLOR} label="Avg % utilisation" line />
</div>
<div className="flex flex-wrap gap-x-4 gap-y-1.5 text-xs">
<Leg color="#fde047" label="Billable - Fee Related %" line />
<Leg color="#38bdf8" label="Billable - Client Related %" line />
<Leg color="#f472b6" label="Non-billable %" line />
<Leg color="#fb923c" label="Forecast Utilisation %" line />
<Leg color={ACTIVE_BOOKED_COLOR} label="Active Booked (h)" />
<Leg color={SOFT_BOOKED_COLOR} label="Soft Booked (h)" />
</div>
</div>
</div>

View file

@ -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<FilterAction>;
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 (
<label className="flex items-center gap-1.5 text-[10px] uppercase tracking-wider text-slate-500">
{label}
<select
value={value}
onChange={(e) => onChange(e.target.value)}
className="bg-slate-800 border border-slate-700 rounded px-2 py-0.5 text-xs text-slate-200 min-w-[110px]"
>
<option value="">All</option>
{options.map((o) => (
<option key={o} value={o}>
{o}
</option>
))}
</select>
</label>
);
}
export default function DepartmentFilterBar({
state,
dispatch,
names,
brands,
divisions,
employmentTypes,
}: Props) {
return (
<div
className="flex flex-wrap items-center gap-x-5 gap-y-2 py-2"
data-tutorial-id="dept-filter-bar"
>
<Inline
label="Name"
value={state.names[0] ?? ''}
options={names}
onChange={(v) => dispatch({ type: 'set-names', names: v ? [v] : [] })}
/>
<Inline
label="Division"
value={state.divisions[0] ?? ''}
options={divisions}
onChange={(v) => dispatch({ type: 'set-divisions', divisions: v ? [v] : [] })}
/>
<Inline
label="Brand"
value={state.brands[0] ?? ''}
options={brands}
onChange={(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.
*/}
<Inline
label="Employment"
value=""
options={employmentTypes}
onChange={() => {}}
/>
</div>
);
}

View file

@ -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<FilterAction>;
brands: string[];
divisions: string[];
hubs: string[];
userRoles: string[];
}
function Field({
label,
tutorialId,
children,
}: {
label: string;
tutorialId?: string;
children: React.ReactNode;
}) {
return (
<label
className="flex flex-col text-[10px] text-slate-400 gap-1 uppercase tracking-wide"
data-tutorial-id={tutorialId}
>
{label}
{children}
</label>
);
}
function Single({
value,
options,
onChange,
}: {
value: string;
options: string[];
onChange: (v: string) => void;
}) {
return (
<select
value={value}
onChange={(e) => onChange(e.target.value)}
className="bg-slate-800 border border-slate-700 rounded px-2 py-1 text-slate-200 text-xs min-w-[130px]"
>
<option value="">All</option>
{options.map((o) => (
<option key={o} value={o}>
{o}
</option>
))}
</select>
);
}
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 (
<div
className="bg-slate-900/60 border-b border-slate-800 px-4 py-2 md:px-6 flex flex-wrap items-end gap-3"
data-tutorial-id="filter-bar"
>
<Field label="Division" tutorialId="filter-division">
<Single
value={state.divisions[0] ?? ''}
options={divisions}
onChange={(v) => onSet({ type: 'set-divisions', divisions: v ? [v] : [] })}
/>
</Field>
<Field label="Brand" tutorialId="filter-brand">
<Single
value={state.brands[0] ?? ''}
options={brands}
onChange={(v) => onSet({ type: 'set-brands', brands: v ? [v] : [] })}
/>
</Field>
<Field label="Hub / Market" tutorialId="filter-hub">
<Single
value={state.hubs[0] ?? ''}
options={hubs}
onChange={(v) => onSet({ type: 'set-hubs', hubs: v ? [v] : [] })}
/>
</Field>
<Field label="Role" tutorialId="filter-user-role">
<Single
value={state.userRoles[0] ?? ''}
options={userRoles}
onChange={(v) => onSet({ type: 'set-user-roles', userRoles: v ? [v] : [] })}
/>
</Field>
<Field label="From">
<input
type="date"
value={range.from}
onChange={(e) =>
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"
/>
</Field>
<Field label="To">
<input
type="date"
value={range.to}
onChange={(e) =>
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"
/>
</Field>
<button
type="button"
onClick={() => dispatch({ type: 'reset' })}
className="text-xs text-slate-400 hover:text-slate-200 px-2 py-1 mb-0.5"
>
Reset
</button>
</div>
);
}

View file

@ -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 (
<header className="bg-slate-900 border-b border-slate-700" data-tutorial-id="navbar">

View file

@ -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 bottomtop: 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, unknown> },
): [string, string] | null => {
const p = (props?.payload ?? {}) as Record<string, number>;
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 = (
<div className="overflow-x-auto">
<div style={{ width: Math.max(minWidth, 800), height: chartHeight }}>
@ -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}
>
<CartesianGrid strokeDasharray="3 3" stroke="#1e293b" />
<XAxis dataKey="name" interval={0} tick={xAxisTick} height={80} />
@ -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) */}
<Bar
dataKey="softBooked"
name="softBooked"
stackId="booked"
fill={SOFT_BOOKED_COLOR}
maxBarSize={36}
radius={[0, 0, 0, 0]}
>
<LabelList
dataKey="softBooked"
position="center"
style={{ fill: '#ede9fe', fontSize: 10, fontWeight: 700 }}
formatter={(v: number) => (v >= 8 ? fmt1(v) : '')}
/>
</Bar>
<Bar
dataKey="activeBooked"
name="activeBooked"
stackId="booked"
fill={ACTIVE_BOOKED_COLOR}
maxBarSize={36}
radius={[2, 2, 0, 0]}
>
<LabelList
dataKey="activeBooked"
position="center"
style={{ fill: '#e0e7ff', fontSize: 10, fontWeight: 700 }}
formatter={(v: number) => (v >= 8 ? fmt1(v) : '')}
/>
<LabelList
dataKey="totalBooked"
position="top"
offset={4}
style={{ fill: '#67e8f9', fontSize: 10, fontWeight: 700 }}
formatter={(v: number) => (v > 0 ? fmt1(v) : '')}
/>
</Bar>
{/* Bar 2 — Allocated stack (Allocated base + billing segments) */}
<Bar
dataKey="allocated"
name="Allocated hours"
name="allocated"
fill={ALLOCATED_COLOR}
stackId="stack"
stackId="logged"
radius={[0, 0, 0, 0]}
maxBarSize={48}
maxBarSize={36}
style={{ cursor: mode.kind === 'departments' ? 'pointer' : 'default' }}
onClick={mode.kind === 'departments' ? segmentClick('allocated') : undefined}
>
@ -205,16 +297,19 @@ export default function DeptWeeklyChart({ mode, title, subtitle }: Props) {
formatter={(v: number) => (v >= 8 ? fmt1(v) : '')}
/>
</Bar>
{BILLING_GROUPS.map((g) => (
{BILLING_GROUPS.map((g, idx) => {
const isLast = idx === BILLING_GROUPS.length - 1;
return (
<Bar
key={g.id}
dataKey={g.key}
name={g.label}
name={g.key}
fill={g.color}
stackId="stack"
maxBarSize={48}
stackId="logged"
maxBarSize={36}
style={{ cursor: 'pointer' }}
onClick={segmentClick(g.id)}
radius={isLast ? [2, 2, 0, 0] : [0, 0, 0, 0]}
>
<LabelList
dataKey={g.key}
@ -222,7 +317,7 @@ export default function DeptWeeklyChart({ mode, title, subtitle }: Props) {
style={{ fill: g.textOn, fontSize: 10, fontWeight: 700 }}
formatter={(v: number) => (v >= 5 ? fmt1(v) : '')}
/>
{g.id === 'other' && (
{isLast && (
<LabelList
dataKey="totalLogged"
position="top"
@ -232,22 +327,25 @@ export default function DeptWeeklyChart({ mode, title, subtitle }: Props) {
/>
)}
</Bar>
))}
);
})}
{/* Avg % utilisation line — uses actual utilisation, not forecast. */}
<Line
type="monotone"
dataKey="forecastPct"
name="Forecast Utilisation %"
stroke={FORECAST_LINE_COLOR}
dataKey="utilisationPct"
name="utilisationLine"
stroke={UTIL_LINE_COLOR}
strokeWidth={2.5}
dot={{ r: 3, fill: FORECAST_LINE_COLOR }}
dot={{ r: 3, fill: UTIL_LINE_COLOR }}
activeDot={{ r: 5 }}
yAxisId={0}
>
<LabelList
dataKey="forecastPct"
dataKey="utilisationPct"
position="top"
offset={14}
style={{ fill: FORECAST_LINE_COLOR, fontSize: 11, fontWeight: 700 }}
style={{ fill: UTIL_LINE_COLOR, fontSize: 11, fontWeight: 700 }}
formatter={(v: number) => (v > 0 ? `${Math.round(v)}%` : '')}
/>
</Line>
@ -317,7 +415,9 @@ function Legend() {
{BILLING_GROUPS.map((g) => (
<LegItem key={g.id} color={g.color} label={g.label} />
))}
<LegItem color={FORECAST_LINE_COLOR} label="Forecast Utilisation %" line />
<LegItem color={UTIL_LINE_COLOR} label="Avg % utilisation" line />
<LegItem color={ACTIVE_BOOKED_COLOR} label="Active Booked (h)" />
<LegItem color={SOFT_BOOKED_COLOR} label="Soft Booked (h)" />
</div>
);
}

View file

@ -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,

View file

@ -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<Booking[]>([]);
const [cachedAt, setCachedAt] = useState<string | null>(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 (
<div className="space-y-4">
<FilterBar
state={filters}
dispatch={dispatch}
departments={airtable.meta?.departments ?? []}
names={names}
billingTypes={airtable.meta?.billingTypes ?? []}
brands={dimensions.brands}
divisions={dimensions.divisions}
hubs={dimensions.hubs}
userRoles={dimensions.userRoles}
showForecastToggle={false}
/>
<div className="flex flex-wrap items-center justify-between gap-2">
<div className="text-xs text-slate-400">
{total.toLocaleString()} booking{total === 1 ? '' : 's'}

View file

@ -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 (
<div className="space-y-4">
<FilterBar
{/* Airtable status line */}
{airtable.cachedAt && (
<div className="text-xs text-slate-400">
<span className="text-emerald-400 mr-1.5"></span>Airtable ·{' '}
{airtable.resources.length.toLocaleString()} resources · synced{' '}
{new Date(airtable.cachedAt).toLocaleString('en-GB')}
</div>
)}
<DepartmentFilterBar
state={filters}
dispatch={dispatch}
departments={airtable.meta?.departments ?? []}
names={names}
billingTypes={airtable.meta?.billingTypes ?? []}
brands={dimensions.brands}
divisions={dimensions.divisions}
hubs={dimensions.hubs}
userRoles={dimensions.userRoles}
showForecastToggle={false}
employmentTypes={airtable.meta?.employmentTypes ?? []}
/>
{/* Action row */}
@ -168,16 +173,8 @@ export default function Department() {
</button>
</div>
{/* Airtable status line */}
{airtable.cachedAt && (
<div className="text-xs text-slate-400">
<span className="text-emerald-400 mr-1.5"></span>Airtable ·{' '}
{airtable.resources.length.toLocaleString()} resources · synced{' '}
{new Date(airtable.cachedAt).toLocaleString('en-GB')}
</div>
)}
{/* Department pills */}
{/* Department pills + Adhoc People warning side panel */}
<div className="flex flex-wrap items-start gap-3">
<div className="flex flex-wrap gap-2" data-tutorial-id="dept-pills">
{DEPT_PILLS.map((d) => {
const active = (selectedDept ?? null) === d.id;
@ -197,6 +194,31 @@ export default function Department() {
);
})}
</div>
{/* Adhoc People — Zoho submitters not matched to an Airtable resource. */}
{data && data.people.filter((p) => p.department === 'Unknown').length > 0 && (
<div className="ml-auto rounded-lg border border-amber-700/40 bg-amber-950/30 px-3 py-2 text-[11px] text-amber-200 max-w-[360px]">
<div className="font-semibold uppercase tracking-wide text-amber-300 mb-1">
Adhoc People · {data.people.filter((p) => p.department === 'Unknown').length}
</div>
<div className="text-amber-200/80 mb-1.5">
Zoho emails with no Airtable Resource match review or add to the roster.
</div>
<div className="max-h-24 overflow-auto space-y-0.5">
{data.people
.filter((p) => p.department === 'Unknown')
.slice(0, 6)
.map((p) => (
<div key={p.email} className="flex items-center justify-between gap-2">
<span className="font-mono text-[10px] text-amber-100/80 truncate" title={p.email}>
{p.email || p.name}
</span>
<span className="text-[10px] text-amber-400 shrink-0">create / update</span>
</div>
))}
</div>
</div>
)}
</div>
{/* Week pills */}
<div className="flex items-center gap-2 flex-wrap">

View file

@ -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<ForecastResponse | null>(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) => ({
// 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<string>();
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<string, number | string> = {
label: w.weekLabel,
weekStart: w.weekStart,
active: w.activeAssets,
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 (
<div className="space-y-4">
<FilterBar
state={filters}
dispatch={dispatch}
departments={airtable.meta?.departments ?? []}
names={names}
billingTypes={airtable.meta?.billingTypes ?? []}
brands={dimensions.brands}
divisions={dimensions.divisions}
hubs={dimensions.hubs}
userRoles={dimensions.userRoles}
showForecastToggle={false}
/>
{error && <ErrorBox message={error} onRetry={load} />}
{loading && <Loading label="Computing forecast…" />}
{!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}
</p>
<p className="text-xs text-slate-500 mt-0.5">
Monyesterday actual hours · timesheet &amp; deliverable data are both 1 day delayed
{weekOffset === 0
? 'Monyesterday actual hours · timesheet & deliverable data are both 1 day delayed'
: 'Full week actual hours (MonFri) · timesheet & deliverable data are both 1 day delayed'}
</p>
</div>
<div className="flex items-center gap-2">
@ -283,37 +319,83 @@ export default function ForecastPage() {
<ReferenceLine
yAxisId="left"
y={weeklyAvg}
stroke="#6366f1"
stroke="#06b6d4"
strokeDasharray="6 4"
label={{
value: `Forecast avg · ${fmt1(weeklyAvg)} assets/wk`,
position: 'insideTopLeft',
fill: '#a5b4fc',
fill: '#67e8f9',
fontSize: 11,
}}
/>
<Bar yAxisId="left" dataKey="active" name="Active assets" fill="#6366f1" radius={[3, 3, 0, 0]}>
{typesInChart.map((type, i) => (
<Bar
key={type}
yAxisId="left"
dataKey={type}
stackId="active"
name={type}
fill={typeColour(type, i)}
maxBarSize={80}
/>
))}
{/* Total label rides on the top of the stack uses an
invisible bar so Recharts renders it at the right Y. */}
<Bar
yAxisId="left"
dataKey="total"
stackId="active"
fill="transparent"
legendType="none"
maxBarSize={80}
>
<LabelList
dataKey="active"
dataKey="total"
position="top"
style={{ fill: '#c7d2fe', fontSize: 10 }}
offset={6}
style={{ fill: '#e2e8f0', fontSize: 11, fontWeight: 700 }}
formatter={(v: number) => (v > 0 ? fmt1(v) : '')}
/>
</Bar>
<Bar yAxisId="left" dataKey="exiting" name="Exiting" fill="#f59e0b" radius={[3, 3, 0, 0]} />
<Line
yAxisId="right"
yAxisId="left"
type="monotone"
dataKey="exitRate"
name="Exit rate %"
dataKey="exiting"
name="Exiting"
stroke="#f43f5e"
strokeWidth={2.5}
dot={{ r: 4, fill: '#f43f5e' }}
>
<LabelList
dataKey="exiting"
position="top"
offset={4}
style={{ fill: '#f43f5e', fontSize: 10, fontWeight: 700 }}
formatter={(v: number) => (v > 0 ? fmt1(v) : '')}
/>
</Line>
</ComposedChart>
</ResponsiveContainer>
</div>
{/* Project-type legend — colour-keyed swatches + Exiting & Forecast-avg markers */}
<div className="flex flex-wrap gap-x-4 gap-y-1.5 mt-3">
{typesInChart.map((type, i) => (
<span key={type} className="flex items-center gap-1.5 text-xs text-slate-400">
<span className="w-2.5 h-2.5 rounded-sm shrink-0" style={{ background: typeColour(type, i) }} />
{type}
</span>
))}
<span className="flex items-center gap-1.5 text-xs text-rose-400">
<span className="w-5 border-t-2 border-solid border-rose-400 inline-block" /> Exiting
</span>
{weeklyAvg > 0 && (
<span className="flex items-center gap-1.5 text-xs text-cyan-400">
<span className="w-5 border-t-2 border-dashed border-cyan-400 inline-block" /> Forecast avg
</span>
)}
</div>
{selectedWeek && (
<div className="mt-3 border border-slate-700 rounded-md bg-slate-950 p-3">
<div className="text-xs text-slate-300">
@ -330,6 +412,77 @@ export default function ForecastPage() {
</div>
)}
</div>
{/* Weekly comparison table — Active / Exit counts by type × week. */}
{tableWeeks.length > 0 && typesInChart.length > 0 && (
<div className="bg-slate-900 rounded-xl border border-slate-800 p-4 overflow-x-auto">
<p className="text-sm font-semibold text-slate-200">Weekly Comparison Active &amp; Exiting by Project Type</p>
<p className="text-xs text-slate-500 mt-0.5">Active / Exit counts per project type across the next four weeks.</p>
<table className="w-full text-xs mt-3 min-w-[680px]">
<thead>
<tr className="border-b border-slate-700 text-slate-400">
<th className="text-left py-2 pr-3 font-medium">Project Type</th>
{tableWeeks.map((w, i) => (
<th
key={w.weekStart}
colSpan={2}
className="text-center py-2 px-2 font-medium whitespace-nowrap text-slate-300"
>
{i === 0 ? 'This Week' : i === 1 ? 'Next Week' : `Week +${i}`}
<div className="text-[10px] text-slate-500 font-normal">
{w.weekStart.slice(8, 10)}/{w.weekStart.slice(5, 7)} {w.weekEnd.slice(8, 10)}/{w.weekEnd.slice(5, 7)}
</div>
</th>
))}
</tr>
<tr className="border-b border-slate-800/60 text-[10px] text-slate-500">
<th />
{tableWeeks.map((w) => (
<Fragment key={`hdr-${w.weekStart}`}>
<th className="text-right py-1 px-2 font-medium">Active</th>
<th className="text-right py-1 px-2 font-medium">Exit</th>
</Fragment>
))}
</tr>
<tr className="border-b border-slate-700 font-semibold bg-slate-800/30">
<td className="py-2 pr-3 text-slate-200">Total</td>
{tableWeeks.map((w) => (
<Fragment key={`tot-${w.weekStart}`}>
<td className="py-2 px-2 text-right text-amber-300 tabular-nums">{w.activeAssets.toLocaleString()}</td>
<td className="py-2 px-2 text-right text-rose-300 tabular-nums">{w.exitingAssets > 0 ? w.exitingAssets.toLocaleString() : '—'}</td>
</Fragment>
))}
</tr>
</thead>
<tbody>
{typesInChart.map((type, ti) => (
<tr key={type} className={`border-b border-slate-800/40 ${ti % 2 === 1 ? 'bg-slate-800/15' : ''}`}>
<td className="py-2 pr-3 text-slate-300 whitespace-nowrap">
<span className="inline-flex items-center gap-1.5">
<span className="w-2 h-2 rounded-sm shrink-0" style={{ background: typeColour(type, ti) }} />
{type}
</span>
</td>
{tableWeeks.map((w) => {
const act = w.activeAssetsByType?.[type] ?? 0;
const exi = w.exitingByType?.[type] ?? 0;
return (
<Fragment key={`${type}-${w.weekStart}`}>
<td className="py-2 px-2 text-right text-amber-300/90 tabular-nums">
{act > 0 ? act.toLocaleString() : '—'}
</td>
<td className="py-2 px-2 text-right text-rose-300/80 tabular-nums">
{exi > 0 ? exi.toLocaleString() : '—'}
</td>
</Fragment>
);
})}
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
{/* Sidebar */}

View file

@ -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<ProjectTypesResponse | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(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 (
<div className="space-y-4">
<FilterBar
state={filters}
dispatch={dispatch}
departments={airtable.meta?.departments ?? []}
names={names}
billingTypes={airtable.meta?.billingTypes ?? []}
brands={dimensions.brands}
divisions={dimensions.divisions}
hubs={dimensions.hubs}
userRoles={dimensions.userRoles}
showForecastToggle={false}
/>
{error && <ErrorBox message={error} onRetry={load} />}
{loading && <Loading label="Computing project-type benchmarks…" />}
@ -295,66 +276,7 @@ export default function ProjectTypeSummaryPage() {
</div>
{/* Selected type detail panel */}
{selected && (
<div className="bg-slate-900 rounded-xl border border-slate-800 p-4 space-y-3">
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-semibold text-slate-200">{selected.projectType} monthly assets trend</p>
<p className="text-xs text-slate-500 mt-0.5">
{selected.projectCount} projects · {selected.totalAssets} assets · {selected.totalHours}h
</p>
</div>
<button
type="button"
onClick={() => setSelectedType(null)}
className="text-xs text-slate-500 hover:text-slate-300"
>
clear
</button>
</div>
<div className="h-56">
<ResponsiveContainer>
<ComposedChart data={selected.monthlyAssets} margin={{ top: 10, right: 20, left: 0, bottom: 8 }}>
<CartesianGrid strokeDasharray="3 3" stroke="#1e293b" />
<XAxis dataKey="month" tick={{ fill: '#94a3b8', fontSize: 11 }} stroke="#475569" />
<YAxis tick={{ fill: '#94a3b8', fontSize: 11 }} stroke="#475569" />
<Tooltip
contentStyle={{ background: '#0f172a', border: '1px solid #334155', color: '#e2e8f0' }}
formatter={(v: number) => [v, 'Assets']}
/>
<Bar dataKey="assets" fill={colourForType(selected.projectType)} radius={[3, 3, 0, 0]} />
</ComposedChart>
</ResponsiveContainer>
</div>
<p className="text-xs font-semibold text-slate-300 uppercase tracking-wide mt-3">Project list</p>
<div className="overflow-x-auto">
<table className="w-full text-xs">
<thead>
<tr className="border-b border-slate-800 text-slate-500">
<th className="text-left py-1.5 pr-3 font-medium">Title</th>
<th className="text-right py-1.5 px-3 font-medium">Assets</th>
<th className="text-right py-1.5 px-3 font-medium">Hours</th>
<th className="text-left py-1.5 px-3 font-medium">Start</th>
<th className="text-left py-1.5 px-3 font-medium">End</th>
<th className="text-right py-1.5 pl-3 font-medium">Duration (wd)</th>
</tr>
</thead>
<tbody>
{selected.projectList.map((p) => (
<tr key={p.title} className="border-b border-slate-800/40">
<td className="py-1.5 pr-3 text-slate-200 max-w-[280px] truncate" title={p.title}>{p.title}</td>
<td className="py-1.5 px-3 text-right text-slate-300 tabular-nums">{p.assets}</td>
<td className="py-1.5 px-3 text-right text-slate-300 tabular-nums">{fmt1(p.hours)}h</td>
<td className="py-1.5 px-3 text-slate-500 font-mono text-[11px]">{p.start ?? '—'}</td>
<td className="py-1.5 px-3 text-slate-500 font-mono text-[11px]">{p.end ?? '—'}</td>
<td className="py-1.5 pl-3 text-right text-slate-300 tabular-nums">{p.duration}</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)}
{selected && <SelectedTypeDetail selected={selected} onClose={() => setSelectedType(null)} />}
{/* Bottom KPI tiles + insights */}
<div className="grid grid-cols-1 lg:grid-cols-[260px_1fr] gap-3">
@ -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<string, string> = {
'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<string>();
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<string, number | string> = {
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 (
<div className="space-y-4">
{/* Header with KPI tiles and dept breakdown */}
<div className="bg-slate-900 rounded-xl border border-slate-800 p-5 grid grid-cols-1 lg:grid-cols-[1fr_1fr] gap-5">
<div>
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-semibold text-slate-100">{cleanName}</p>
<p className="text-xs text-slate-500 mt-0.5">{selected.projectCount} projects</p>
</div>
<button
type="button"
onClick={onClose}
className="text-xs text-slate-500 hover:text-slate-300"
>
clear
</button>
</div>
<div className="grid grid-cols-2 gap-3 mt-3">
<div>
<div className="text-[10px] text-slate-500 uppercase tracking-wide mb-0.5">avg h / asset</div>
<div className="text-3xl font-bold text-cyan-300 tabular-nums">{selected.avgHoursPerAsset.toFixed(1)}h</div>
</div>
<div>
<div className="text-[10px] text-slate-500 uppercase tracking-wide mb-0.5">avg duration</div>
<div className="text-3xl font-bold text-emerald-300 tabular-nums">
{selected.avgDurationDays > 0 ? `${Math.round(selected.avgDurationDays)}d` : '—'}
</div>
{selected.longestProjectDays > 0 && (
<div className="text-[10px] text-slate-500">range {minD}{selected.longestProjectDays}d</div>
)}
</div>
</div>
<div className="flex flex-wrap gap-3 text-xs mt-3">
<span className="text-teal-400">{Math.round(selected.totalHours)}h total</span>
<span className="text-amber-400">{selected.totalAssets.toLocaleString()} assets</span>
<span className="text-violet-300">
{selected.projectCount > 0 ? (selected.totalAssets / selected.projectCount).toFixed(1) : '—'} assets/project
</span>
</div>
{totalDH > 0 && (
<div className="mt-4">
<div className="text-[10px] text-slate-500 uppercase tracking-wide mb-1">Dept hours</div>
<div className="flex h-6 rounded overflow-hidden gap-px">
{dh.map((d) => (
<div
key={d.dept}
style={{ width: `${d.pct}%`, background: deptColour(d.dept) }}
className="flex items-center justify-center overflow-hidden"
title={`${d.dept}: ${Math.round(d.hours)}h (${d.pct.toFixed(0)}%)`}
>
{d.pct >= 8 && (
<span className="text-[9px] font-bold text-slate-900 whitespace-nowrap leading-none px-0.5">
{Math.round(d.hours)}h
</span>
)}
</div>
))}
</div>
<div className="flex flex-wrap gap-x-2 gap-y-0.5 mt-1">
{dh.map((d) => (
<span key={d.dept} className="flex items-center gap-0.5 text-[10px] text-slate-500">
<span className="w-1.5 h-1.5 rounded-sm" style={{ background: deptColour(d.dept) }} />
{d.dept.replace(' Team', '').replace('Project Management', 'PM').replace('Opera Upload', 'Opera')}
{' '}{d.pct.toFixed(0)}%
</span>
))}
</div>
</div>
)}
</div>
{/* Insights & Recommended Actions */}
<div className="bg-slate-950/40 rounded-lg border border-slate-800/60 p-4 self-start">
<div className="flex items-center gap-3 mb-3">
<h3 className="text-sm font-semibold text-white">Insights &amp; Recommended Actions</h3>
<span className="text-[10px] bg-slate-800 text-slate-400 px-2 py-0.5 rounded-full">
{selected.projectCount} projects · {selected.totalHours.toFixed(0)}h · {selected.totalAssets.toLocaleString()} assets
</span>
</div>
<div className="space-y-2">
{ins.map((i, idx) => (
<div
key={idx}
className={`flex items-start gap-2.5 px-3 py-2 rounded-lg border ${
i.level === 'good'
? 'bg-emerald-950/40 border-emerald-800/40'
: i.level === 'watch'
? 'bg-amber-950/30 border-amber-800/40'
: 'bg-red-950/30 border-red-800/40'
}`}
>
<span
className={`text-[10px] font-bold uppercase tracking-wide shrink-0 ${
i.level === 'good'
? 'text-emerald-400'
: i.level === 'watch'
? 'text-amber-400'
: 'text-red-400'
}`}
>
{i.level === 'good' ? 'GOOD' : i.level === 'watch' ? 'WATCH' : 'ACTION'}
</span>
<p className="text-xs text-slate-300 leading-relaxed">{i.text}</p>
</div>
))}
</div>
</div>
</div>
{/* Panel 1 — Avg h/asset by completion month */}
{chartData.length > 0 && (
<div className="bg-slate-900 rounded-xl border border-slate-800 p-4">
<p className="text-sm font-semibold text-slate-200">Panel 1 Avg h / asset by completion month</p>
<p className="text-xs text-slate-500 mt-0.5">
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
</p>
<div className="mt-3 h-72 w-full">
<ResponsiveContainer>
<ComposedChart data={chartData} margin={{ top: 24, right: 30, left: 8, bottom: 18 }}>
<CartesianGrid strokeDasharray="3 3" stroke="#1e293b" />
<XAxis dataKey="monthLabel" tick={{ fontSize: 11, fill: '#94a3b8' }} stroke="#475569" />
<YAxis tick={{ fontSize: 11, fill: '#94a3b8' }} stroke="#475569" />
<Tooltip
contentStyle={{ background: '#0f172a', border: '1px solid #334155', color: '#e2e8f0' }}
formatter={(v: number) => [`${(v as number).toFixed(2)}h`, 'avg h/asset']}
/>
<ReferenceLine
y={selected.avgHoursPerAsset}
stroke="#94a3b8"
strokeDasharray="6 4"
label={{ value: `avg ${selected.avgHoursPerAsset.toFixed(1)}h`, position: 'insideTopRight', fill: '#cbd5e1', fontSize: 10 }}
/>
{divisionList.map((d, i) => (
<Bar
key={d}
dataKey={`dv_${d}`}
stackId="hpa"
fill={DIV_PALETTE[i % DIV_PALETTE.length]}
name={d}
/>
))}
<Bar dataKey="overall" stackId="_label" fill="transparent" legendType="none">
<LabelList
dataKey="overall"
position="top"
offset={4}
style={{ fill: '#e2e8f0', fontSize: 11, fontWeight: 700 }}
formatter={(v: number) => (v > 0 ? `${v.toFixed(1)}h` : '')}
/>
</Bar>
</ComposedChart>
</ResponsiveContainer>
</div>
<div className="flex flex-wrap gap-x-4 gap-y-1.5 mt-3">
{divisionList.map((d, i) => (
<span key={d} className="flex items-center gap-1.5 text-xs text-slate-400">
<span className="w-2.5 h-2.5 rounded-sm shrink-0" style={{ background: DIV_PALETTE[i % DIV_PALETTE.length] }} />
{d}
</span>
))}
</div>
</div>
)}
{/* Project list */}
<div className="bg-slate-900 rounded-xl border border-slate-800 p-4">
<p className="text-xs font-semibold text-slate-300 uppercase tracking-wide">Project list</p>
<div className="overflow-x-auto mt-2">
<table className="w-full text-xs">
<thead>
<tr className="border-b border-slate-800 text-slate-500">
<th className="text-left py-1.5 pr-3 font-medium">Title</th>
<th className="text-right py-1.5 px-3 font-medium">Assets</th>
<th className="text-right py-1.5 px-3 font-medium">Hours</th>
<th className="text-left py-1.5 px-3 font-medium">Start</th>
<th className="text-left py-1.5 px-3 font-medium">End</th>
<th className="text-right py-1.5 pl-3 font-medium">Duration (wd)</th>
</tr>
</thead>
<tbody>
{selected.projectList.map((p) => (
<tr key={p.title} className="border-b border-slate-800/40">
<td className="py-1.5 pr-3 text-slate-200 max-w-[280px] truncate" title={p.title}>{p.title}</td>
<td className="py-1.5 px-3 text-right text-slate-300 tabular-nums">{p.assets}</td>
<td className="py-1.5 px-3 text-right text-slate-300 tabular-nums">{fmt1(p.hours)}h</td>
<td className="py-1.5 px-3 text-slate-500 font-mono text-[11px]">{p.start ?? '—'}</td>
<td className="py-1.5 px-3 text-slate-500 font-mono text-[11px]">{p.end ?? '—'}</td>
<td className="py-1.5 pl-3 text-right text-slate-300 tabular-nums">{p.duration}</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
</div>
);
}
const DIV_PALETTE = [
'#f43f5e', '#6366f1', '#f59e0b', '#10b981', '#3b82f6',
'#d946ef', '#14b8a6', '#f97316', '#84cc16', '#8b5cf6',
'#06b6d4', '#ec4899', '#a16207', '#64748b',
];

View file

@ -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<Booking[]>([]);
const [summary, setSummary] = useState<UtilisationSummaryRow[]>([]);
const [totals, setTotals] = useState<UtilisationTotals | null>(null);
@ -29,11 +26,6 @@ export default function Resourcing() {
const [error, setError] = useState<string | null>(null);
const [selectedPeriod, setSelectedPeriod] = useState<string | null>(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 (
<div className="space-y-4">
<FilterBar
state={filters}
dispatch={dispatch}
departments={airtable.meta?.departments ?? []}
names={names}
billingTypes={airtable.meta?.billingTypes ?? []}
brands={dimensions.brands}
divisions={dimensions.divisions}
hubs={dimensions.hubs}
userRoles={dimensions.userRoles}
showForecastToggle={false}
/>
<div className="flex flex-wrap items-center justify-between gap-2">
<PeriodToggle
value={filters.period}