diff --git a/src/app/(protected)/dashboard/_components/dashboard-kpi-cards.tsx b/src/app/(protected)/dashboard/_components/dashboard-kpi-cards.tsx new file mode 100644 index 00000000..5329b2d2 --- /dev/null +++ b/src/app/(protected)/dashboard/_components/dashboard-kpi-cards.tsx @@ -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 ( + + } + tone="primary" + value={stats?.scheduledTomorrow} + loading={loading} + active={scheduledTomorrow > 0} + onSelect={() => navigate(workOrderDrilldownUrl(scheduledTomorrowDrilldownSearch()))} + /> + } + tone="info" + value={stats?.pendingUplifts} + loading={loading} + active={pendingUplifts > 0} + onSelect={() => navigate(UPLIFTS_ROUTE)} + /> + } + tone="warning" + value={stats?.avetaPending} + loading={loading} + active={avetaPending > 0} + onSelect={() => navigate(workOrderDrilldownUrl(avetaPendingDrilldownSearch()))} + /> + } + tone="warning" + value={stats?.unassigned} + loading={loading} + active={unassigned > 0} + onSelect={() => navigate(workOrderDrilldownUrl(unassignedDrilldownSearch(range)))} + /> + + ); +} diff --git a/src/app/(protected)/dashboard/index.tsx b/src/app/(protected)/dashboard/index.tsx index 60795d8f..9dd4f6ff 100644 --- a/src/app/(protected)/dashboard/index.tsx +++ b/src/app/(protected)/dashboard/index.tsx @@ -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 { PageHeader } from "@/components/ui/page-header"; 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 { DispatcherTablesSection } from "@/app/(protected)/dashboard/_components/dispatcher-tables-section"; 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 { useDashboardWorkload } from "@/domain/dashboard/use-cases/use-dashboard-workload"; 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() { - const navigate = useNavigate(); const scope = useDashboardScope(); const { canViewAll, range, dispatcherId, workloadPage, performancePage } = scope; @@ -62,35 +51,7 @@ export default function DashboardPage() { /> } /> - - } - tone="primary" - value={stats.data?.scheduledTomorrow} - loading={stats.isLoading} - active={(stats.data?.scheduledTomorrow ?? 0) > 0} - onSelect={() => navigate(workOrderDrilldownUrl(scheduledTomorrowDrilldownSearch()))} - /> - } - tone="info" - value={stats.data?.pendingUplifts} - loading={stats.isLoading} - active={(stats.data?.pendingUplifts ?? 0) > 0} - onSelect={() => navigate(UPLIFTS_ROUTE)} - /> - } - tone="warning" - value={stats.data?.avetaPending} - loading={stats.isLoading} - active={(stats.data?.avetaPending ?? 0) > 0} - onSelect={() => navigate(workOrderDrilldownUrl(avetaPendingDrilldownSearch()))} - /> - + ); } +function lastNavigation(): string { + const calls = navigate.mock.calls; + return String(calls[calls.length - 1]?.[0]); +} + describe("DashboardPage", () => { beforeEach(() => { authUser.current = { id: "99", userRoles: "Admin" }; @@ -120,6 +126,7 @@ describe("DashboardPage", () => { "Scheduled Tomorrow", "Pending Uplifts", "Aveta Pending", + "Unassigned", "Work Order Breakdown", "Status Distribution", "Dispatcher Workload", @@ -257,6 +264,29 @@ describe("DashboardPage", () => { 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", () => { const today = businessTodayIso(); diff --git a/src/test/domain/dashboard/dashboard-api.test.ts b/src/test/domain/dashboard/dashboard-api.test.ts index 95f8c258..27a87695 100644 --- a/src/test/domain/dashboard/dashboard-api.test.ts +++ b/src/test/domain/dashboard/dashboard-api.test.ts @@ -27,6 +27,7 @@ describe("dashboardApi", () => { ScheduledTomorrow: "3", PendingUplifts: 2, AvetaPending: "1", + Unassigned: "6", Breakdown: [ { Status: "Scheduled", Count: "7" }, { Status: "Completed", Count: 4 }, @@ -52,6 +53,7 @@ describe("dashboardApi", () => { scheduledTomorrow: 3, pendingUplifts: 2, avetaPending: 1, + unassigned: 6, breakdown: [ { status: "Scheduled", count: 7 }, { status: "Completed", count: 4 }, diff --git a/src/test/domain/work-orders/dashboard-drilldown-params.test.ts b/src/test/domain/work-orders/dashboard-drilldown-params.test.ts index 8ff30f72..6557acd2 100644 --- a/src/test/domain/work-orders/dashboard-drilldown-params.test.ts +++ b/src/test/domain/work-orders/dashboard-drilldown-params.test.ts @@ -1,6 +1,11 @@ import { describe, expect, it } from "vitest"; 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", () => { it("returns null when no recognized param is present", () => { @@ -60,6 +65,29 @@ describe("parseDashboardDrilldownParams", () => { 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", () => { expect( parseDashboardDrilldownParams(