SH-392: Unassigned KPI tile on the Dashboard

Shows open work orders with no dispatcher for the selected period and
drills into the same list on the board (dispatcher Unassigned, open
statuses, same dates; All time includes undated work orders).
This commit is contained in:
Alexandre Brandizzi 2026-09-24 21:09:04 -03:00
parent 51ca244524
commit ed931ea2eb
8 changed files with 150 additions and 42 deletions

View file

@ -0,0 +1,71 @@
import { useNavigate } from "react-router";
import AssignmentLateOutlinedIcon from "@mui/icons-material/AssignmentLateOutlined";
import EventOutlinedIcon from "@mui/icons-material/EventOutlined";
import PendingActionsIcon from "@mui/icons-material/PendingActions";
import ShieldOutlinedIcon from "@mui/icons-material/ShieldOutlined";
import { Box } from "@mui/material";
import { DashboardKpiCard } from "@/app/(protected)/dashboard/_components/dashboard-kpi-card";
import type { DashboardRangeKey } from "@/domain/dashboard/types/dashboard-range";
import type { DashboardStats } from "@/domain/dashboard/types/dashboard-stats";
import {
avetaPendingDrilldownSearch,
scheduledTomorrowDrilldownSearch,
unassignedDrilldownSearch,
UPLIFTS_ROUTE,
workOrderDrilldownUrl,
} from "@/domain/dashboard/utils/work-order-drilldown-links";
interface DashboardKpiCardsProps {
stats?: DashboardStats;
loading: boolean;
range: DashboardRangeKey;
}
export function DashboardKpiCards({ stats, loading, range }: DashboardKpiCardsProps) {
const navigate = useNavigate();
const scheduledTomorrow = stats?.scheduledTomorrow ?? 0;
const pendingUplifts = stats?.pendingUplifts ?? 0;
const avetaPending = stats?.avetaPending ?? 0;
const unassigned = stats?.unassigned ?? 0;
return (
<Box className="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-4">
<DashboardKpiCard
title="Scheduled Tomorrow"
icon={<EventOutlinedIcon />}
tone="primary"
value={stats?.scheduledTomorrow}
loading={loading}
active={scheduledTomorrow > 0}
onSelect={() => navigate(workOrderDrilldownUrl(scheduledTomorrowDrilldownSearch()))}
/>
<DashboardKpiCard
title="Pending Uplifts"
icon={<PendingActionsIcon />}
tone="info"
value={stats?.pendingUplifts}
loading={loading}
active={pendingUplifts > 0}
onSelect={() => navigate(UPLIFTS_ROUTE)}
/>
<DashboardKpiCard
title="Aveta Pending"
icon={<ShieldOutlinedIcon />}
tone="warning"
value={stats?.avetaPending}
loading={loading}
active={avetaPending > 0}
onSelect={() => navigate(workOrderDrilldownUrl(avetaPendingDrilldownSearch()))}
/>
<DashboardKpiCard
title="Unassigned"
icon={<AssignmentLateOutlinedIcon />}
tone="warning"
value={stats?.unassigned}
loading={loading}
active={unassigned > 0}
onSelect={() => navigate(workOrderDrilldownUrl(unassignedDrilldownSearch(range)))}
/>
</Box>
);
}

View file

@ -1,11 +1,7 @@
import { useNavigate } from "react-router";
import EventOutlinedIcon from "@mui/icons-material/EventOutlined";
import PendingActionsIcon from "@mui/icons-material/PendingActions";
import ShieldOutlinedIcon from "@mui/icons-material/ShieldOutlined";
import { Box } from "@mui/material"; import { Box } from "@mui/material";
import { PageHeader } from "@/components/ui/page-header"; import { PageHeader } from "@/components/ui/page-header";
import { DashboardHeaderActions } from "@/app/(protected)/dashboard/_components/dashboard-header-actions"; import { DashboardHeaderActions } from "@/app/(protected)/dashboard/_components/dashboard-header-actions";
import { DashboardKpiCard } from "@/app/(protected)/dashboard/_components/dashboard-kpi-card"; import { DashboardKpiCards } from "@/app/(protected)/dashboard/_components/dashboard-kpi-cards";
import { DashboardTrendCard } from "@/app/(protected)/dashboard/_components/dashboard-trend-card"; import { DashboardTrendCard } from "@/app/(protected)/dashboard/_components/dashboard-trend-card";
import { DispatcherTablesSection } from "@/app/(protected)/dashboard/_components/dispatcher-tables-section"; import { DispatcherTablesSection } from "@/app/(protected)/dashboard/_components/dispatcher-tables-section";
import { StatusDistributionCard } from "@/app/(protected)/dashboard/_components/status-distribution-card"; import { StatusDistributionCard } from "@/app/(protected)/dashboard/_components/status-distribution-card";
@ -19,15 +15,8 @@ import { useDashboardStats } from "@/domain/dashboard/use-cases/use-dashboard-st
import { useDashboardVendorInsights } from "@/domain/dashboard/use-cases/use-dashboard-vendor-insights"; import { useDashboardVendorInsights } from "@/domain/dashboard/use-cases/use-dashboard-vendor-insights";
import { useDashboardWorkload } from "@/domain/dashboard/use-cases/use-dashboard-workload"; import { useDashboardWorkload } from "@/domain/dashboard/use-cases/use-dashboard-workload";
import { useWorkOrderUsers } from "@/domain/work-orders/use-cases/use-work-orders-list"; import { useWorkOrderUsers } from "@/domain/work-orders/use-cases/use-work-orders-list";
import {
avetaPendingDrilldownSearch,
scheduledTomorrowDrilldownSearch,
UPLIFTS_ROUTE,
workOrderDrilldownUrl,
} from "@/domain/dashboard/utils/work-order-drilldown-links";
export default function DashboardPage() { export default function DashboardPage() {
const navigate = useNavigate();
const scope = useDashboardScope(); const scope = useDashboardScope();
const { canViewAll, range, dispatcherId, workloadPage, performancePage } = scope; const { canViewAll, range, dispatcherId, workloadPage, performancePage } = scope;
@ -62,35 +51,7 @@ export default function DashboardPage() {
/> />
} }
/> />
<Box className="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3"> <DashboardKpiCards stats={stats.data} loading={stats.isLoading} range={range} />
<DashboardKpiCard
title="Scheduled Tomorrow"
icon={<EventOutlinedIcon />}
tone="primary"
value={stats.data?.scheduledTomorrow}
loading={stats.isLoading}
active={(stats.data?.scheduledTomorrow ?? 0) > 0}
onSelect={() => navigate(workOrderDrilldownUrl(scheduledTomorrowDrilldownSearch()))}
/>
<DashboardKpiCard
title="Pending Uplifts"
icon={<PendingActionsIcon />}
tone="info"
value={stats.data?.pendingUplifts}
loading={stats.isLoading}
active={(stats.data?.pendingUplifts ?? 0) > 0}
onSelect={() => navigate(UPLIFTS_ROUTE)}
/>
<DashboardKpiCard
title="Aveta Pending"
icon={<ShieldOutlinedIcon />}
tone="warning"
value={stats.data?.avetaPending}
loading={stats.isLoading}
active={(stats.data?.avetaPending ?? 0) > 0}
onSelect={() => navigate(workOrderDrilldownUrl(avetaPendingDrilldownSearch()))}
/>
</Box>
<Box className="grid grid-cols-1 gap-4 lg:grid-cols-2"> <Box className="grid grid-cols-1 gap-4 lg:grid-cols-2">
<WorkOrderBreakdownCard <WorkOrderBreakdownCard
breakdown={stats.data?.breakdown ?? []} breakdown={stats.data?.breakdown ?? []}

View file

@ -251,6 +251,7 @@ function mapDashboardStats(raw: unknown): DashboardStats {
scheduledTomorrow: toNumber(readField(data, "scheduledTomorrow")), scheduledTomorrow: toNumber(readField(data, "scheduledTomorrow")),
pendingUplifts: toNumber(readField(data, "pendingUplifts")), pendingUplifts: toNumber(readField(data, "pendingUplifts")),
avetaPending: toNumber(readField(data, "avetaPending")), avetaPending: toNumber(readField(data, "avetaPending")),
unassigned: toNumber(readField(data, "unassigned")),
breakdown: mapBreakdown(readField(data, "breakdown") ?? readField(data, "workOrderBreakdown")), breakdown: mapBreakdown(readField(data, "breakdown") ?? readField(data, "workOrderBreakdown")),
statusDistribution: mapStatusDistribution(readField(data, "statusDistribution")), statusDistribution: mapStatusDistribution(readField(data, "statusDistribution")),
}; };

View file

@ -20,6 +20,8 @@ export interface DashboardStats {
scheduledTomorrow: number; scheduledTomorrow: number;
pendingUplifts: number; pendingUplifts: number;
avetaPending: number; avetaPending: number;
/** Open WOs with no dispatcher in the selected period (SH-392). */
unassigned: number;
breakdown: DashboardBreakdownRow[]; breakdown: DashboardBreakdownRow[];
statusDistribution: DashboardStatusBucketRow[]; statusDistribution: DashboardStatusBucketRow[];
} }

View file

@ -1,6 +1,7 @@
import { ALL_WIZARD_STATUSES } from "@/domain/work-orders/types/work-order-wizard"; import { ALL_WIZARD_STATUSES } from "@/domain/work-orders/types/work-order-wizard";
import { WO_TYPES, type WOType } from "@/domain/work-orders/types/work-order-wizard"; import { WO_TYPES, type WOType } from "@/domain/work-orders/types/work-order-wizard";
import type { DashboardRangeKey } from "@/domain/dashboard/types/dashboard-range"; import type { DashboardRangeKey } from "@/domain/dashboard/types/dashboard-range";
import { BOARD_DISPATCHER_UNASSIGNED } from "@/domain/work-orders/types/work-order-board";
import { import {
addDaysIso, addDaysIso,
businessTodayIso, businessTodayIso,
@ -77,6 +78,18 @@ export function workOrderRegionDrilldownSearch(
return search; return search;
} }
/**
* The Unassigned tile's list (SH-392): open WOs with no dispatcher in the
* Dashboard period, the same set the backend counts. All time sends no dates,
* so the board searches every week, undated WOs included.
*/
export function unassignedDrilldownSearch(range: DashboardRangeKey): URLSearchParams {
const search = workOrderRangeDrilldownSearch(range);
search.set("dispatchers", BOARD_DISPATCHER_UNASSIGNED);
search.set("statuses", OPEN_WIZARD_STATUSES.join(","));
return search;
}
export function scheduledTomorrowDrilldownSearch( export function scheduledTomorrowDrilldownSearch(
today: string = businessTodayIso(), today: string = businessTodayIso(),
): URLSearchParams { ): URLSearchParams {

View file

@ -63,6 +63,7 @@ const statsData = {
scheduledTomorrow: 3, scheduledTomorrow: 3,
pendingUplifts: 5, pendingUplifts: 5,
avetaPending: 0, avetaPending: 0,
unassigned: 4,
breakdown: [ breakdown: [
{ status: "PM", count: 7 }, { status: "PM", count: 7 },
{ status: "Completed", count: 0 }, { status: "Completed", count: 0 },
@ -88,6 +89,11 @@ function renderDashboard() {
return renderWithProviders(<DashboardPage />); return renderWithProviders(<DashboardPage />);
} }
function lastNavigation(): string {
const calls = navigate.mock.calls;
return String(calls[calls.length - 1]?.[0]);
}
describe("DashboardPage", () => { describe("DashboardPage", () => {
beforeEach(() => { beforeEach(() => {
authUser.current = { id: "99", userRoles: "Admin" }; authUser.current = { id: "99", userRoles: "Admin" };
@ -120,6 +126,7 @@ describe("DashboardPage", () => {
"Scheduled Tomorrow", "Scheduled Tomorrow",
"Pending Uplifts", "Pending Uplifts",
"Aveta Pending", "Aveta Pending",
"Unassigned",
"Work Order Breakdown", "Work Order Breakdown",
"Status Distribution", "Status Distribution",
"Dispatcher Workload", "Dispatcher Workload",
@ -257,6 +264,29 @@ describe("DashboardPage", () => {
expect(lastRequestedPage).toBe(1); expect(lastRequestedPage).toBe(1);
}); });
it("shows unassigned WOs for the selected period and drills into that list (SH-392)", () => {
renderDashboard();
const card = screen.getByRole("button", { name: /Unassigned/ });
expect(card).toHaveTextContent("4");
fireEvent.click(card);
const weekUrl = new URL(lastNavigation(), "http://x");
expect(weekUrl.pathname).toBe("/workorders");
expect(weekUrl.searchParams.get("dispatchers")).toBe("__unassigned__");
expect(weekUrl.searchParams.get("statuses")).not.toContain("Completed");
expect(weekUrl.searchParams.get("dateFrom")).toMatch(/^\d{4}-\d{2}-\d{2}$/);
expect(weekUrl.searchParams.get("dateTo")).toMatch(/^\d{4}-\d{2}-\d{2}$/);
fireEvent.click(screen.getByRole("button", { name: "All time" }));
fireEvent.click(screen.getByRole("button", { name: /Unassigned/ }));
const allTimeUrl = new URL(lastNavigation(), "http://x");
expect(allTimeUrl.searchParams.get("dispatchers")).toBe("__unassigned__");
expect(allTimeUrl.searchParams.has("dateFrom")).toBe(false);
expect(allTimeUrl.searchParams.has("dateTo")).toBe(false);
});
it("keeps drilldown dates deterministic on the business clock", () => { it("keeps drilldown dates deterministic on the business clock", () => {
const today = businessTodayIso(); const today = businessTodayIso();

View file

@ -27,6 +27,7 @@ describe("dashboardApi", () => {
ScheduledTomorrow: "3", ScheduledTomorrow: "3",
PendingUplifts: 2, PendingUplifts: 2,
AvetaPending: "1", AvetaPending: "1",
Unassigned: "6",
Breakdown: [ Breakdown: [
{ Status: "Scheduled", Count: "7" }, { Status: "Scheduled", Count: "7" },
{ Status: "Completed", Count: 4 }, { Status: "Completed", Count: 4 },
@ -52,6 +53,7 @@ describe("dashboardApi", () => {
scheduledTomorrow: 3, scheduledTomorrow: 3,
pendingUplifts: 2, pendingUplifts: 2,
avetaPending: 1, avetaPending: 1,
unassigned: 6,
breakdown: [ breakdown: [
{ status: "Scheduled", count: 7 }, { status: "Scheduled", count: 7 },
{ status: "Completed", count: 4 }, { status: "Completed", count: 4 },

View file

@ -1,6 +1,11 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { parseDashboardDrilldownParams } from "@/domain/work-orders/utils/dashboard-drilldown-params"; import { parseDashboardDrilldownParams } from "@/domain/work-orders/utils/dashboard-drilldown-params";
import { avetaPendingDrilldownSearch } from "@/domain/dashboard/utils/work-order-drilldown-links"; import {
avetaPendingDrilldownSearch,
unassignedDrilldownSearch,
} from "@/domain/dashboard/utils/work-order-drilldown-links";
import { NON_TERMINAL_LIFECYCLE_STATUSES } from "@/domain/work-orders/types/work-order-board";
import { toBoardSearchQueryParams } from "@/domain/work-orders/utils/board-query-params";
describe("parseDashboardDrilldownParams", () => { describe("parseDashboardDrilldownParams", () => {
it("returns null when no recognized param is present", () => { it("returns null when no recognized param is present", () => {
@ -60,6 +65,29 @@ describe("parseDashboardDrilldownParams", () => {
expect(filters?.regions).toEqual(new Set(["California"])); expect(filters?.regions).toEqual(new Set(["California"]));
}); });
it("turns the Unassigned tile link into the board search the Dashboard counts (SH-392)", () => {
const allTime = parseDashboardDrilldownParams(unassignedDrilldownSearch("all-time"));
expect(allTime).not.toBeNull();
expect(toBoardSearchQueryParams({ filters: allTime! })).toMatchObject({
dispatchers: ["__unassigned__"],
statuses: [...NON_TERMINAL_LIFECYCLE_STATUSES],
datePreset: "Custom",
dateFrom: "2000-01-01",
dateTo: "2099-12-31",
includeDateless: true,
});
const week = parseDashboardDrilldownParams(unassignedDrilldownSearch("this-week"));
const weekParams = toBoardSearchQueryParams({ filters: week! });
expect(week?.dateRange).toBe("custom");
expect(weekParams).toMatchObject({
dispatchers: ["__unassigned__"],
statuses: [...NON_TERMINAL_LIFECYCLE_STATUSES],
datePreset: "Custom",
});
expect(weekParams.includeDateless).toBeUndefined();
});
it("rejects partial or malformed date ranges", () => { it("rejects partial or malformed date ranges", () => {
expect( expect(
parseDashboardDrilldownParams( parseDashboardDrilldownParams(