From c7388ba2d9a148a91c424b56d0339db8daebefdf Mon Sep 17 00:00:00 2001 From: Codex Review Integration Date: Wed, 16 Sep 2026 20:40:33 -0300 Subject: [PATCH] fix(dashboard): map breakdown type keys and open performance drill-downs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Work Order Breakdown object from the backend serializes its keys without JsonPropertyName, so they arrive lowercase (pm/emergency/reactive/ overdue/other). The mapper copied them straight into `status`, which the card uses as both label and the `types` drill-down id, and `workOrderTypeDrilldownSearch` only accepts canonical WOType values — so every breakdown row rendered lowercase and was inert (SH-354). Route both mapping branches through a normalizer that maps type keys back to their canonical labels case-insensitively; `other` is not a WOType, so it stays a labelled, non-drillable "Other" row, and unknown values (legacy array-shape lifecycle labels) pass through unchanged. Dispatcher Performance gated its drill-down on `completionRate > 0`, so a dispatcher with assigned work but no completions yet was inert here while drillable from Workload. The drill-down filters by dispatcher + range and never uses the rate, so gate on identity instead. Also mock useDashboardTrend in the dashboard page test so it no longer issues a real request that failed quietly in jsdom. --- .../dispatcher-performance-card.tsx | 8 ++++- src/domain/dashboard/api/dashboard-api.ts | 35 ++++++++++++++++--- .../dashboard/dashboard-page.test.tsx | 12 ++++++- .../domain/dashboard/dashboard-api.test.ts | 18 ++++++++++ 4 files changed, 67 insertions(+), 6 deletions(-) diff --git a/src/app/(protected)/dashboard/_components/dispatcher-performance-card.tsx b/src/app/(protected)/dashboard/_components/dispatcher-performance-card.tsx index 72130baa..9803a5df 100644 --- a/src/app/(protected)/dashboard/_components/dispatcher-performance-card.tsx +++ b/src/app/(protected)/dashboard/_components/dispatcher-performance-card.tsx @@ -30,7 +30,13 @@ function DispatcherNameCell({ range: DashboardRangeKey; }) { const navigate = useNavigate(); - const drillable = Boolean(row.dispatcherId) && row.completionRate > 0; + // A listed dispatcher is one with work in range; the drill-down filters Work + // Orders by dispatcher + range and never uses the completion rate, so gate on + // identity rather than the rate. (A dispatcher with assigned work but a 0% + // rate was previously inert here while drillable from Workload.) When SH-347's + // Assigned/Completed counts land on DispatcherPerformanceRow this should + // become `assigned > 0`, matching Workload's `totalWorkOrders > 0`. + const drillable = Boolean(row.dispatcherId); if (!drillable) { return {row.dispatcherName}; diff --git a/src/domain/dashboard/api/dashboard-api.ts b/src/domain/dashboard/api/dashboard-api.ts index f6655af7..274167b6 100644 --- a/src/domain/dashboard/api/dashboard-api.ts +++ b/src/domain/dashboard/api/dashboard-api.ts @@ -19,6 +19,7 @@ import { } from "@/domain/dashboard/types/dashboard-regions"; import type { DispatcherWorkloadRow } from "@/domain/dashboard/types/dashboard-workload"; import type { VendorInsightRow } from "@/domain/dashboard/types/dashboard-vendor-insights"; +import { WO_TYPES } from "@/domain/work-orders/types/work-order-wizard"; function toNumber(value: unknown): number { if (typeof value === "number" && Number.isFinite(value)) { @@ -100,11 +101,34 @@ function trendSearchParams(params?: DashboardTrendParams): Options | undefined { return { searchParams }; } +const BREAKDOWN_TYPE_BY_LOWER = new Map( + WO_TYPES.map((type) => [type.toLowerCase(), type]), +); + +/** + * The backend breakdown object (backend#138) serializes its keys with no + * JsonPropertyName, so `PM`/`Emergency`/`Reactive`/`Overdue`/`Other` arrive + * lowercase. The card uses the row label as both display text and the + * `types` drill-down id, and `workOrderTypeDrilldownSearch` only accepts + * canonical `WOType` values — so map lowercase type keys back to their + * canonical labels. `other` is not a WOType: keep it as a labelled, + * non-drillable "Other" row. Unknown values (e.g. lifecycle labels from the + * legacy array shape) pass through unchanged. + */ +function normalizeBreakdownStatus(raw: string): string { + const trimmed = raw.trim(); + const canonical = BREAKDOWN_TYPE_BY_LOWER.get(trimmed.toLowerCase()); + if (canonical) return canonical; + if (trimmed.toLowerCase() === "other") return "Other"; + return trimmed; +} + function mapBreakdownRow(raw: Record): DashboardBreakdownRow { + const status = asString( + readField(raw, "status") ?? readField(raw, "name") ?? readField(raw, "label"), + ); return { - status: - asString(readField(raw, "status") ?? readField(raw, "name") ?? readField(raw, "label")) || - UNMAPPED_REGION_LABEL, + status: status ? normalizeBreakdownStatus(status) : UNMAPPED_REGION_LABEL, count: toNumber(readField(raw, "count") ?? readField(raw, "value")), }; } @@ -116,7 +140,10 @@ function mapBreakdown(value: unknown): DashboardBreakdownRow[] { if (typeof value === "object" && value !== null) { return Object.entries(value as Record) - .map(([status, count]) => ({ status, count: toNumber(count) })) + .map(([status, count]) => ({ + status: normalizeBreakdownStatus(status), + count: toNumber(count), + })) .filter((row) => row.status !== ""); } diff --git a/src/test/app/(protected)/dashboard/dashboard-page.test.tsx b/src/test/app/(protected)/dashboard/dashboard-page.test.tsx index 1fcdcc7a..6c73211c 100644 --- a/src/test/app/(protected)/dashboard/dashboard-page.test.tsx +++ b/src/test/app/(protected)/dashboard/dashboard-page.test.tsx @@ -32,6 +32,15 @@ vi.mock("@/domain/dashboard/use-cases/use-dashboard-vendor-insights", () => ({ }), })); +vi.mock("@/domain/dashboard/use-cases/use-dashboard-trend", () => ({ + useDashboardTrend: () => ({ + data: { granularity: null, points: [] }, + isLoading: false, + isFetching: false, + error: null, + }), +})); + const statsData = { scheduledTomorrow: 3, pendingUplifts: 5, @@ -139,7 +148,8 @@ describe("DashboardPage", () => { it("shows the documented empty states when datasets are empty", () => { renderDashboard(); - expect(screen.getByText("No work orders match the current filters.")).toBeInTheDocument(); + // Breakdown and Trend share this empty copy, so both cards surface it. + expect(screen.getAllByText("No work orders match the current filters.")).toHaveLength(2); expect(screen.getAllByText("No dispatchers are set up yet.")).toHaveLength(2); expect(screen.getByText("No active vendor has any work orders yet.")).toBeInTheDocument(); }); diff --git a/src/test/domain/dashboard/dashboard-api.test.ts b/src/test/domain/dashboard/dashboard-api.test.ts index 7ac5c918..e24e30e5 100644 --- a/src/test/domain/dashboard/dashboard-api.test.ts +++ b/src/test/domain/dashboard/dashboard-api.test.ts @@ -58,6 +58,24 @@ describe("dashboardApi", () => { }); }); + it("maps the object breakdown shape to canonical WOType labels", async () => { + apiGet.mockResolvedValue({ + Data: { + Breakdown: { pm: 3, emergency: 1, reactive: "2", overdue: 4, other: 5 }, + }, + }); + + const stats = await dashboardApi.getStats(); + + expect(stats.breakdown).toEqual([ + { status: "PM", count: 3 }, + { status: "Emergency", count: 1 }, + { status: "Reactive", count: 2 }, + { status: "Overdue", count: 4 }, + { status: "Other", count: 5 }, + ]); + }); + it("omits search params for the all-time range", async () => { apiGet.mockResolvedValue({ scheduledTomorrow: 0, pendingUplifts: 0, avetaPending: 0 });