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:
parent
9f3d3cabfd
commit
d4c6576a95
17 changed files with 1258 additions and 296 deletions
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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,36 +265,50 @@ export default function DailyBreakdownModal({
|
|||
formatter={(v: number) => (v >= 1 ? fmt1(v) : '')}
|
||||
/>
|
||||
</Bar>
|
||||
{BILLING_GROUPS.map((g) => (
|
||||
<Bar
|
||||
key={g.id}
|
||||
dataKey={g.key}
|
||||
name={g.label}
|
||||
fill={g.color}
|
||||
stackId="stack"
|
||||
maxBarSize={42}
|
||||
>
|
||||
<LabelList
|
||||
{BILLING_GROUPS.map((g, idx) => {
|
||||
const isLast = idx === BILLING_GROUPS.length - 1;
|
||||
return (
|
||||
<Bar
|
||||
key={g.id}
|
||||
dataKey={g.key}
|
||||
position="center"
|
||||
style={{ fill: g.textOn, fontSize: 10, fontWeight: 700 }}
|
||||
formatter={(v: number) => (v >= 1 ? fmt1(v) : '')}
|
||||
/>
|
||||
</Bar>
|
||||
))}
|
||||
name={g.label}
|
||||
fill={g.color}
|
||||
stackId="logged"
|
||||
maxBarSize={36}
|
||||
radius={isLast ? [2, 2, 0, 0] : [0, 0, 0, 0]}
|
||||
>
|
||||
<LabelList
|
||||
dataKey={g.key}
|
||||
position="center"
|
||||
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>
|
||||
|
|
|
|||
98
frontend/src/components/DepartmentFilterBar.tsx
Normal file
98
frontend/src/components/DepartmentFilterBar.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
149
frontend/src/components/GlobalFilterBar.tsx
Normal file
149
frontend/src/components/GlobalFilterBar.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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, 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,49 +297,55 @@ export default function DeptWeeklyChart({ mode, title, subtitle }: Props) {
|
|||
formatter={(v: number) => (v >= 8 ? fmt1(v) : '')}
|
||||
/>
|
||||
</Bar>
|
||||
{BILLING_GROUPS.map((g) => (
|
||||
<Bar
|
||||
key={g.id}
|
||||
dataKey={g.key}
|
||||
name={g.label}
|
||||
fill={g.color}
|
||||
stackId="stack"
|
||||
maxBarSize={48}
|
||||
style={{ cursor: 'pointer' }}
|
||||
onClick={segmentClick(g.id)}
|
||||
>
|
||||
<LabelList
|
||||
{BILLING_GROUPS.map((g, idx) => {
|
||||
const isLast = idx === BILLING_GROUPS.length - 1;
|
||||
return (
|
||||
<Bar
|
||||
key={g.id}
|
||||
dataKey={g.key}
|
||||
position="center"
|
||||
style={{ fill: g.textOn, fontSize: 10, fontWeight: 700 }}
|
||||
formatter={(v: number) => (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]}
|
||||
>
|
||||
<LabelList
|
||||
dataKey="totalLogged"
|
||||
position="top"
|
||||
offset={4}
|
||||
style={{ fill: '#e2e8f0', fontSize: 10, fontWeight: 700 }}
|
||||
formatter={(v: number) => (v > 0 ? fmt1(v) : '')}
|
||||
dataKey={g.key}
|
||||
position="center"
|
||||
style={{ fill: g.textOn, fontSize: 10, fontWeight: 700 }}
|
||||
formatter={(v: number) => (v >= 5 ? fmt1(v) : '')}
|
||||
/>
|
||||
)}
|
||||
</Bar>
|
||||
))}
|
||||
{isLast && (
|
||||
<LabelList
|
||||
dataKey="totalLogged"
|
||||
position="top"
|
||||
offset={4}
|
||||
style={{ fill: '#e2e8f0', fontSize: 10, fontWeight: 700 }}
|
||||
formatter={(v: number) => (v > 0 ? fmt1(v) : '')}
|
||||
/>
|
||||
)}
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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'}
|
||||
|
|
|
|||
|
|
@ -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,34 +173,51 @@ 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')}
|
||||
{/* 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;
|
||||
return (
|
||||
<button
|
||||
key={d.label}
|
||||
type="button"
|
||||
onClick={() => setSelectedDept(d.id)}
|
||||
className={`px-3 py-1.5 text-xs rounded-md transition-colors ${
|
||||
active
|
||||
? 'bg-indigo-600 text-white'
|
||||
: 'bg-slate-800 text-slate-300 hover:bg-slate-700'
|
||||
}`}
|
||||
>
|
||||
{d.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Department pills */}
|
||||
<div className="flex flex-wrap gap-2" data-tutorial-id="dept-pills">
|
||||
{DEPT_PILLS.map((d) => {
|
||||
const active = (selectedDept ?? null) === d.id;
|
||||
return (
|
||||
<button
|
||||
key={d.label}
|
||||
type="button"
|
||||
onClick={() => setSelectedDept(d.id)}
|
||||
className={`px-3 py-1.5 text-xs rounded-md transition-colors ${
|
||||
active
|
||||
? 'bg-indigo-600 text-white'
|
||||
: 'bg-slate-800 text-slate-300 hover:bg-slate-700'
|
||||
}`}
|
||||
>
|
||||
{d.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
{/* 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 */}
|
||||
|
|
|
|||
|
|
@ -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) => ({
|
||||
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<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,
|
||||
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">
|
||||
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'}
|
||||
</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 & 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 */}
|
||||
|
|
|
|||
|
|
@ -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 & 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',
|
||||
];
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue