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