fix(dashboard): map breakdown type keys and open performance drill-downs

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.
This commit is contained in:
Codex Review Integration 2026-09-16 20:40:33 -03:00
parent 1b17cbec01
commit c7388ba2d9
4 changed files with 67 additions and 6 deletions

View file

@ -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 <Text variant="description">{row.dispatcherName}</Text>;

View file

@ -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<string, string>(
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<string, unknown>): 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<string, unknown>)
.map(([status, count]) => ({ status, count: toNumber(count) }))
.map(([status, count]) => ({
status: normalizeBreakdownStatus(status),
count: toNumber(count),
}))
.filter((row) => row.status !== "");
}

View file

@ -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();
});

View file

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