diff --git a/e2e/__screenshots__/dashboard/dashboard.visual.spec.ts/dashboard-admin.png b/e2e/__screenshots__/dashboard/dashboard.visual.spec.ts/dashboard-admin.png new file mode 100644 index 00000000..215400e7 Binary files /dev/null and b/e2e/__screenshots__/dashboard/dashboard.visual.spec.ts/dashboard-admin.png differ diff --git a/e2e/__screenshots__/dashboard/dashboard.visual.spec.ts/dashboard-dispatcher.png b/e2e/__screenshots__/dashboard/dashboard.visual.spec.ts/dashboard-dispatcher.png new file mode 100644 index 00000000..56942561 Binary files /dev/null and b/e2e/__screenshots__/dashboard/dashboard.visual.spec.ts/dashboard-dispatcher.png differ diff --git a/e2e/dashboard/dashboard.visual.spec.ts b/e2e/dashboard/dashboard.visual.spec.ts new file mode 100644 index 00000000..ab51543b --- /dev/null +++ b/e2e/dashboard/dashboard.visual.spec.ts @@ -0,0 +1,256 @@ +import { expect, test, type Page, type Route } from "@playwright/test"; + +const FROZEN_NOW = "2026-08-19T15:00:00.000Z"; + +function testUser(userRoles: string) { + return { + token: "dashboard-visual-token", + expiration: "2030-01-01T00:00:00.000Z", + email: "admin@seahavenind.com", + userRoles, + phoneNumber: "5551234567", + fullname: "Dashboard Visual User", + id: "1", + }; +} + +const DISPATCHERS = [ + { id: "1", name: "Pat Dispatcher" }, + { id: "2", name: "Riley Chen" }, + { id: "3", name: "Sam Okafor" }, +]; + +const STATS = { + total: 128, + open: 52, + notDispatched: 8, + completed: 64, + dueCount: 40, + completedDueCount: 30, + completionRate: 82, + averageResolutionDays: 3.4, + scheduledTomorrow: 6, + pendingUplifts: 4, + avetaPending: 3, + breakdown: [ + { status: "PM", count: 24 }, + { status: "Emergency", count: 6 }, + { status: "Reactive", count: 12 }, + { status: "Overdue", count: 9 }, + ], + statusDistribution: [ + { status: "Scheduled", count: 30 }, + { status: "In Progress", count: 18 }, + { status: "Completed", count: 64 }, + { status: "Pending", count: 16 }, + ], +}; + +const WORKLOAD = { + items: [ + { + dispatcherId: "1", + dispatcherName: "Pat Dispatcher", + openWorkOrders: 12, + totalWorkOrders: 20, + }, + { dispatcherId: "2", dispatcherName: "Riley Chen", openWorkOrders: 7, totalWorkOrders: 15 }, + ], + page: 1, + pageSize: 10, + totalDispatchers: 2, +}; + +const PERFORMANCE = { + items: [ + { + dispatcherId: "1", + dispatcherName: "Pat Dispatcher", + completionRate: 94, + averageResolutionDays: 2.5, + assignedCount: 20, + completedCount: 18, + }, + { + dispatcherId: "2", + dispatcherName: "Riley Chen", + completionRate: 78, + averageResolutionDays: 3.1, + assignedCount: 15, + completedCount: 11, + }, + { + dispatcherId: "3", + dispatcherName: "Sam Okafor", + completionRate: 61, + averageResolutionDays: 4.4, + assignedCount: 9, + completedCount: 5, + }, + ], + page: 1, + pageSize: 10, + totalDispatchers: 3, +}; + +const REGIONS = { + items: [ + { region: "East", workOrderCount: 44 }, + { region: "Central", workOrderCount: 31 }, + { region: "West", workOrderCount: 22 }, + { region: "California", workOrderCount: 18 }, + { region: "Unmapped/Other", workOrderCount: 5 }, + ], +}; + +const VENDOR_INSIGHTS = { + items: [ + { + vendorId: "101", + vendorName: "Gateway Plumbing", + completionRate: 88, + rescheduleRate: 12, + averageResolutionDays: 2.9, + totalJobs: 40, + }, + { + vendorId: "102", + vendorName: "Summit HVAC", + completionRate: 76, + rescheduleRate: 20, + averageResolutionDays: 3.6, + totalJobs: 28, + }, + ], +}; + +const TREND = { + granularity: "week", + buckets: [ + { + date: "2026-07-27", + label: "Jul 27", + total: 18, + open: 4, + completed: 12, + canceled: 1, + overdue: 1, + isCurrent: false, + }, + { + date: "2026-08-03", + label: "Aug 3", + total: 22, + open: 6, + completed: 13, + canceled: 1, + overdue: 2, + isCurrent: false, + }, + { + date: "2026-08-10", + label: "Aug 10", + total: 26, + open: 8, + completed: 15, + canceled: 0, + overdue: 3, + isCurrent: false, + }, + { + date: "2026-08-17", + label: "Aug 17", + total: 20, + open: 9, + completed: 9, + canceled: 1, + overdue: 1, + isCurrent: true, + }, + ], +}; + +async function fulfillJson(route: Route, body: unknown, status = 200) { + await route.fulfill({ status, contentType: "application/json", body: JSON.stringify(body) }); +} + +async function mockDashboardApi(page: Page, userRoles: string) { + await page.clock.setFixedTime(new Date(FROZEN_NOW)); + await page.addInitScript( + (user) => localStorage.setItem("auth", JSON.stringify(user)), + testUser(userRoles), + ); + + await page.route("**/api/**", async (route) => { + const pathname = new URL(route.request().url()).pathname; + + // Only stub real backend calls; let Vite dev module requests (e.g. + // /src/domain/auth/api/*.ts) through untouched. + if (!pathname.startsWith("/api/")) { + await route.continue(); + return; + } + + if (pathname.includes("/Dashboard/Stats")) return fulfillJson(route, { data: STATS }); + if (pathname.includes("/Dashboard/Workload")) return fulfillJson(route, { data: WORKLOAD }); + if (pathname.includes("/Dashboard/Performance")) + return fulfillJson(route, { data: PERFORMANCE }); + if (pathname.includes("/Dashboard/Regions")) return fulfillJson(route, { data: REGIONS }); + if (pathname.includes("/Dashboard/VendorInsights")) { + return fulfillJson(route, { data: VENDOR_INSIGHTS }); + } + if (pathname.includes("/Dashboard/Trend")) return fulfillJson(route, { data: TREND }); + if (pathname.includes("/workorders/lookups/dispatchers")) { + return fulfillJson(route, { data: DISPATCHERS }); + } + + return fulfillJson(route, { data: [] }); + }); +} + +async function openDashboard(page: Page, userRoles: string) { + await mockDashboardApi(page, userRoles); + await page.goto("/"); + await page.evaluate(() => document.fonts.ready); + await page.addStyleTag({ + content: ` + *, *::before, *::after { transition: none !important; animation: none !important; } + .MuiDrawer-paper { transform: none !important; } + .Toastify__toast-container { display: none !important; } + .tsqd-parent-container { display: none !important; } + `, + }); +} + +async function expectStableScreenshot(page: Page, name: string) { + await page.waitForTimeout(250); + await page.evaluate( + () => + new Promise((resolve) => + requestAnimationFrame(() => requestAnimationFrame(() => resolve())), + ), + ); + await expect(page).toHaveScreenshot(name, { fullPage: true }); +} + +test.describe("Dashboard deterministic pixel regression", () => { + test("admin dashboard", async ({ page }) => { + await openDashboard(page, "Admin"); + await expect(page.getByRole("heading", { name: "Dashboard" })).toBeVisible(); + await expect(page.getByText("Status Distribution")).toBeVisible(); + await expect(page.getByText("Dispatcher Performance")).toBeVisible(); + // The scope picker is available to Admin/Scheduler (SH-336). + await expect(page.getByRole("button", { name: /All dispatchers/ })).toBeVisible(); + await expectStableScreenshot(page, "dashboard-admin.png"); + }); + + test("dispatcher scoped dashboard", async ({ page }) => { + await openDashboard(page, "Dispatcher"); + await expect(page.getByRole("heading", { name: "Dashboard" })).toBeVisible(); + // A Dispatcher gets the static "My WOs" label and no dispatcher tables (SH-336). + await expect(page.getByText("My WOs")).toBeVisible(); + await expect(page.getByText("Dispatcher Performance")).toHaveCount(0); + await expect(page.getByText("Status Distribution")).toBeVisible(); + await expectStableScreenshot(page, "dashboard-dispatcher.png"); + }); +}); diff --git a/src/app/(protected)/dashboard/_components/dashboard-card.tsx b/src/app/(protected)/dashboard/_components/dashboard-card.tsx index 71f40dfd..46938810 100644 --- a/src/app/(protected)/dashboard/_components/dashboard-card.tsx +++ b/src/app/(protected)/dashboard/_components/dashboard-card.tsx @@ -5,6 +5,8 @@ import { Text } from "@/components/ui/text"; interface DashboardCardProps { title: string; + /** Optional scope caption shown beside the title (e.g. SH-349's all-time note). */ + caption?: string; isLoading?: boolean; error?: unknown; isEmpty: boolean; @@ -14,6 +16,7 @@ interface DashboardCardProps { export function DashboardCard({ title, + caption, isLoading = false, error, isEmpty, @@ -22,8 +25,13 @@ export function DashboardCard({ }: DashboardCardProps) { return ( - + {title} + {!!caption && ( + + {caption} + + )} {isLoading && ( diff --git a/src/app/(protected)/dashboard/_components/dashboard-scope-picker.tsx b/src/app/(protected)/dashboard/_components/dashboard-scope-picker.tsx new file mode 100644 index 00000000..66c63dae --- /dev/null +++ b/src/app/(protected)/dashboard/_components/dashboard-scope-picker.tsx @@ -0,0 +1,118 @@ +import { useMemo } from "react"; +import { Popover, Skeleton } from "@mui/material"; +import { ChevronDown, Users } from "lucide-react"; +import type { WorkOrderUser } from "@/domain/work-orders/types/work-order"; +import { CheckboxMenuItem } from "@/components/ui/checkbox-menu-item"; +import { MenuAnchorButton } from "@/components/ui/menu-anchor-button"; +import { useMenuAnchor } from "@/hooks/use-menu-anchor"; +import type { DashboardScope } from "./dashboard-scope"; + +type DashboardScopePickerProps = { + scope: DashboardScope; + onChange: (scope: DashboardScope) => void; + users: WorkOrderUser[]; + usersLoading?: boolean; + currentUserId?: string | number; +}; + +export function DashboardScopePicker({ + scope, + onChange, + users, + usersLoading = false, + currentUserId, +}: DashboardScopePickerProps) { + const { anchorEl, open, setAnchor, close } = useMenuAnchor(); + const meId = currentUserId != null && currentUserId !== "" ? String(currentUserId) : null; + + const dispatchers = useMemo( + () => [...users].sort((a, b) => a.name.localeCompare(b.name)), + [users], + ); + + const label = useMemo(() => { + if (scope.mode === "all") return "All dispatchers"; + if (scope.mode === "mine") return "My WOs"; + return dispatchers.find((user) => String(user.id) === scope.id)?.name ?? "Dispatcher"; + }, [scope, dispatchers]); + + const select = (next: DashboardScope) => { + onChange(next); + close(); + }; + + if (usersLoading && users.length === 0) { + return ( + + ); + } + + return ( + <> + + + {label} + + + + + select({ mode: "all" })} + /> + + {meId != null && ( + select({ mode: "mine" })} + /> + )} + + {dispatchers.map((user) => { + const id = String(user.id); + return ( + me + ) : undefined + } + onClick={() => select({ mode: "individual", id })} + /> + ); + })} + + + ); +} diff --git a/src/app/(protected)/dashboard/_components/dashboard-scope.ts b/src/app/(protected)/dashboard/_components/dashboard-scope.ts new file mode 100644 index 00000000..62e6e7c8 --- /dev/null +++ b/src/app/(protected)/dashboard/_components/dashboard-scope.ts @@ -0,0 +1,21 @@ +/** + * SH-336 dispatcher-scope selection. Single-select — the Dashboard scopes to at + * most one dispatcher at a time (All company / My WOs / one individual), unlike + * the board's multi-select dispatcher filter. + */ +export type DashboardScope = + { mode: "all" } | { mode: "mine" } | { mode: "individual"; id: string }; + +export const DASHBOARD_SCOPE_ALL: DashboardScope = { mode: "all" }; + +/** Resolves the scope to the `dispatcherId` query param, or null for company-wide. */ +export function scopeDispatcherId( + scope: DashboardScope, + currentUserId: string | number | undefined, +): string | null { + if (scope.mode === "all") return null; + if (scope.mode === "mine") { + return currentUserId != null && currentUserId !== "" ? String(currentUserId) : null; + } + return scope.id || null; +} diff --git a/src/app/(protected)/dashboard/_components/dispatcher-performance-card.tsx b/src/app/(protected)/dashboard/_components/dispatcher-performance-card.tsx index 94231dbd..eb491726 100644 --- a/src/app/(protected)/dashboard/_components/dispatcher-performance-card.tsx +++ b/src/app/(protected)/dashboard/_components/dispatcher-performance-card.tsx @@ -27,6 +27,13 @@ function formatDays(value: number): string { return (Math.round(value * 10) / 10).toLocaleString(); } +// SH-347: colour the completion rate green at 90+, amber 70–89, red below 70. +function completionRateColor(rate: number): string { + if (rate >= 90) return "success.main"; + if (rate >= 70) return "warning.main"; + return "error.main"; +} + function DispatcherNameCell({ row, range, @@ -38,9 +45,9 @@ function DispatcherNameCell({ // 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`. + // rate was previously inert here while drillable from Workload.) The gate stays + // on identity rather than `assignedCount > 0` so a legacy response without the + // SH-347 count field (assignedCount → 0) keeps its rows drillable. const drillable = Boolean(row.dispatcherId); if (!drillable) { @@ -84,6 +91,12 @@ export function DispatcherPerformanceCard({ Dispatcher + + Assigned + + + Completed + Completion % @@ -98,7 +111,14 @@ export function DispatcherPerformanceCard({ - {`${row.completionRate.toLocaleString()}%`} + {row.assignedCount.toLocaleString()} + {row.completedCount.toLocaleString()} + + {`${row.completionRate.toLocaleString()}%`} + {row.averageResolutionDays == null ? "—" : formatDays(row.averageResolutionDays)} diff --git a/src/app/(protected)/dashboard/_components/status-distribution-card.tsx b/src/app/(protected)/dashboard/_components/status-distribution-card.tsx new file mode 100644 index 00000000..956735eb --- /dev/null +++ b/src/app/(protected)/dashboard/_components/status-distribution-card.tsx @@ -0,0 +1,55 @@ +import { useNavigate } from "react-router"; +import { ALL_WIZARD_STATUSES } from "@/domain/work-orders/types/work-order-wizard"; +import type { DashboardRangeKey } from "@/domain/dashboard/types/dashboard-range"; +import type { DashboardStatusBucketRow } from "@/domain/dashboard/types/dashboard-stats"; +import { + workOrderDrilldownUrl, + workOrderStatusDrilldownSearch, +} from "@/domain/dashboard/utils/work-order-drilldown-links"; +import { DashboardBarList } from "./dashboard-bar-list"; +import { DashboardCard } from "./dashboard-card"; + +const EMPTY_MESSAGE = "No work orders match the current filters."; + +const DRILLABLE_STATUSES = new Set(ALL_WIZARD_STATUSES); + +interface StatusDistributionCardProps { + rows: DashboardStatusBucketRow[]; + range: DashboardRangeKey; + isLoading?: boolean; + error?: unknown; +} + +export function StatusDistributionCard({ + rows, + range, + isLoading = false, + error, +}: StatusDistributionCardProps) { + const navigate = useNavigate(); + + return ( + + ({ + id: row.status, + label: row.status, + count: row.count, + }))} + // Lifecycle labels the board can filter on are drillable; the "Unknown" + // bucket (unparseable statuses) has no board filter, so leave it inert. + isSelectable={(row) => DRILLABLE_STATUSES.has(row.id)} + onSelectRow={(row) => + navigate(workOrderDrilldownUrl(workOrderStatusDrilldownSearch(row.id, range))) + } + /> + + ); +} diff --git a/src/app/(protected)/dashboard/_components/vendor-insights-card.tsx b/src/app/(protected)/dashboard/_components/vendor-insights-card.tsx index 0e98910f..d23776ff 100644 --- a/src/app/(protected)/dashboard/_components/vendor-insights-card.tsx +++ b/src/app/(protected)/dashboard/_components/vendor-insights-card.tsx @@ -15,6 +15,7 @@ export function VendorInsightsCard({ rows, isLoading = false, error }: VendorIns return ( row.region !== UNMAPPED_REGION_LABEL); + return ( ({ + rows={zones.map((row) => ({ id: row.region, label: row.region, count: row.workOrderCount, }))} + onSelectRow={(row) => + navigate(workOrderDrilldownUrl(workOrderRegionDrilldownSearch(row.id, range))) + } /> ); diff --git a/src/app/(protected)/dashboard/index.tsx b/src/app/(protected)/dashboard/index.tsx index cb20c9b4..60d6748a 100644 --- a/src/app/(protected)/dashboard/index.tsx +++ b/src/app/(protected)/dashboard/index.tsx @@ -4,11 +4,22 @@ import EventOutlinedIcon from "@mui/icons-material/EventOutlined"; import PendingActionsIcon from "@mui/icons-material/PendingActions"; import ShieldOutlinedIcon from "@mui/icons-material/ShieldOutlined"; import { Box, Button, CircularProgress } from "@mui/material"; +import { Users } from "lucide-react"; import { PageHeader } from "@/components/ui/page-header"; +import { Text } from "@/components/ui/text"; +import { canViewAllDispatchersOnDashboard } from "@/lib/auth/user-utils"; +import { useAuthContext } from "@/providers/auth-context"; import { DashboardKpiCard } from "@/app/(protected)/dashboard/_components/dashboard-kpi-card"; import { DashboardRangeToggle } from "@/app/(protected)/dashboard/_components/dashboard-range-toggle"; import { DashboardTrendCard } from "@/app/(protected)/dashboard/_components/dashboard-trend-card"; +import { DashboardScopePicker } from "@/app/(protected)/dashboard/_components/dashboard-scope-picker"; +import { + DASHBOARD_SCOPE_ALL, + scopeDispatcherId, + type DashboardScope, +} from "@/app/(protected)/dashboard/_components/dashboard-scope"; import { DispatcherTablesSection } from "@/app/(protected)/dashboard/_components/dispatcher-tables-section"; +import { StatusDistributionCard } from "@/app/(protected)/dashboard/_components/status-distribution-card"; import { VendorInsightsCard } from "@/app/(protected)/dashboard/_components/vendor-insights-card"; import { WorkOrderBreakdownCard } from "@/app/(protected)/dashboard/_components/work-order-breakdown-card"; import { WorkOrdersByRegionCard } from "@/app/(protected)/dashboard/_components/work-orders-by-region-card"; @@ -21,6 +32,7 @@ import { useDashboardRegions } from "@/domain/dashboard/use-cases/use-dashboard- import { useDashboardStats } from "@/domain/dashboard/use-cases/use-dashboard-stats"; 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, @@ -30,25 +42,58 @@ import { export default function DashboardPage() { const navigate = useNavigate(); + const { user } = useAuthContext(); + // SH-336: only Scheduler/Admin may broaden the Dashboard past their own work + // orders. A Dispatcher is locked to their own WOs (scoped server-side by + // backend#126) and never sees the picker. + const canViewAll = canViewAllDispatchersOnDashboard(user?.userRoles); + const [range, setRange] = useState(DEFAULT_DASHBOARD_RANGE); + const [scope, setScope] = useState(DASHBOARD_SCOPE_ALL); const [workloadPage, setWorkloadPage] = useState(1); const [performancePage, setPerformancePage] = useState(1); - const stats = useDashboardStats(range); - const workload = useDashboardWorkload(range, workloadPage); - const performance = useDashboardPerformance(range, performancePage); - const regions = useDashboardRegions(range); + + const dispatcherUsers = useWorkOrderUsers(); + + // A Dispatcher sends no dispatcherId (the server already scopes them); only the + // picker, available to Scheduler/Admin, drives an explicit scope. + const dispatcherId = canViewAll ? scopeDispatcherId(scope, user?.id) : null; + + const stats = useDashboardStats(range, dispatcherId); + const workload = useDashboardWorkload(range, workloadPage, dispatcherId, canViewAll); + const performance = useDashboardPerformance(range, performancePage, dispatcherId, canViewAll); + const regions = useDashboardRegions(range, dispatcherId); const vendors = useDashboardVendorInsights(); // A range change re-scopes both dispatcher tables, so their page must reset — // otherwise a switch while on page 3 lands on an out-of-range empty page that // reads as a false "no dispatchers" state. - const changeRange = (next: DashboardRangeKey) => { - setRange(next); + const resetDispatcherPages = () => { setWorkloadPage(1); setPerformancePage(1); }; - const resetFilters = () => changeRange(DEFAULT_DASHBOARD_RANGE); + const changeRange = (next: DashboardRangeKey) => { + setRange(next); + resetDispatcherPages(); + }; + + const changeScope = (next: DashboardScope) => { + setScope(next); + resetDispatcherPages(); + }; + + const resetFilters = () => { + setRange(DEFAULT_DASHBOARD_RANGE); + setScope(DASHBOARD_SCOPE_ALL); + resetDispatcherPages(); + }; + + // A Dispatcher's forced own-WOs scope never triggers the reset affordance (there + // is nothing for them to reset but the date range); a Scheduler/Admin's non-All + // scope does. + const scopeIsFiltered = canViewAll && scope.mode !== "all"; + const showReset = range !== DEFAULT_DASHBOARD_RANGE || scopeIsFiltered; const isRefreshing = [stats, workload, performance, regions, vendors].some((query) => query.isFetching) && @@ -61,11 +106,30 @@ export default function DashboardPage() { actions={ {isRefreshing && } - {range !== DEFAULT_DASHBOARD_RANGE && ( + {showReset && ( )} + {canViewAll ? ( + + ) : ( + + + + My WOs + + + )} } @@ -106,20 +170,29 @@ export default function DashboardPage() { isLoading={stats.isLoading} error={stats.error} /> + - + {canViewAll && ( + + )} { + onApplyRef.current = onApply; + }, [onApply]); + useEffect(() => { if (appliedRef.current) { return; @@ -22,9 +29,9 @@ export function useDashboardDrilldownFilters( appliedRef.current = true; setFromDashboard(true); - onApply(drilldown); + onApplyRef.current(drilldown); setSearchParams({}, { replace: true }); - }, [searchParams, setSearchParams, onApply]); + }, [searchParams, setSearchParams]); return fromDashboard; } diff --git a/src/domain/dashboard/api/dashboard-api.ts b/src/domain/dashboard/api/dashboard-api.ts index 7d3a76f9..5fdd902f 100644 --- a/src/domain/dashboard/api/dashboard-api.ts +++ b/src/domain/dashboard/api/dashboard-api.ts @@ -6,6 +6,7 @@ import type { DashboardRangeParams } from "@/domain/dashboard/types/dashboard-ra import type { DashboardBreakdownRow, DashboardStats, + DashboardStatusBucketRow, } from "@/domain/dashboard/types/dashboard-stats"; import type { DispatcherPerformanceRow } from "@/domain/dashboard/types/dashboard-performance"; import { @@ -79,27 +80,46 @@ function readCollection(value: unknown, key: string): Record[] return asRecords(collection); } -function rangeSearchParams(range?: DashboardRangeParams): Options | undefined { - if (!range?.dateFrom || !range?.dateTo) { - return undefined; +/** + * Stats, Regions, Workload, and Performance share `DashboardStatsQueryDTO` + * (backend#145): the range dates ride along when present, and `dispatcherId` + * scopes the read to a single dispatcher (SH-336's picker — All sends nothing, + * My WOs / an individual sends that id). Vendor Insights and Trend take no such + * param and stay company-wide (SH-349). + */ +function scopedSearchParams( + range?: DashboardRangeParams, + dispatcherId?: string, +): Options | undefined { + const searchParams: Record = {}; + if (range?.dateFrom && range?.dateTo) { + searchParams.dateFrom = range.dateFrom; + searchParams.dateTo = range.dateTo; } - - return { searchParams: { dateFrom: range.dateFrom, dateTo: range.dateTo } }; + if (dispatcherId) { + searchParams.dispatcherId = dispatcherId; + } + return Object.keys(searchParams).length > 0 ? { searchParams } : undefined; } /** * Workload and Performance are paged server-side (backend#126/#127): `page` is - * 1-based and always sent, and the range dates ride along when present. + * 1-based and always sent, the range dates ride along when present, and + * `dispatcherId` scopes to a single dispatcher (SH-336). */ function dispatcherPageSearchParams( range: DashboardRangeParams | undefined, page: number, + dispatcherId?: string, ): Options { const searchParams: Record = { page }; if (range?.dateFrom && range?.dateTo) { searchParams.dateFrom = range.dateFrom; searchParams.dateTo = range.dateTo; } + if (dispatcherId) { + searchParams.dispatcherId = dispatcherId; + } return { searchParams }; } @@ -204,6 +224,17 @@ function mapDispatcherIdentity(raw: Record): { }; } +function mapStatusDistribution(value: unknown): DashboardStatusBucketRow[] { + return asRecords(value) + .map((row) => ({ + status: asString( + readField(row, "status") ?? readField(row, "name") ?? readField(row, "label"), + ), + count: toNumber(readField(row, "count") ?? readField(row, "value")), + })) + .filter((row) => row.status !== ""); +} + function mapDashboardStats(raw: unknown): DashboardStats { const data = toRecord(raw); const average = readField(data, "averageResolutionDays"); @@ -221,6 +252,7 @@ function mapDashboardStats(raw: unknown): DashboardStats { pendingUplifts: toNumber(readField(data, "pendingUplifts")), avetaPending: toNumber(readField(data, "avetaPending")), breakdown: mapBreakdown(readField(data, "breakdown") ?? readField(data, "workOrderBreakdown")), + statusDistribution: mapStatusDistribution(readField(data, "statusDistribution")), }; } @@ -256,6 +288,10 @@ function mapDispatcherPerformance(raw: unknown): DispatcherPerformanceRow[] { readField(rawRow, "averageResolutionDays") == null ? null : toNumber(readField(rawRow, "averageResolutionDays")), + assignedCount: toNumber(readField(rawRow, "assignedCount") ?? readField(rawRow, "assigned")), + completedCount: toNumber( + readField(rawRow, "completedCount") ?? readField(rawRow, "completed"), + ), }; }); } @@ -328,17 +364,24 @@ function mapDashboardTrend(raw: unknown): DashboardTrend { } export const dashboardApi = { - getStats: async (range?: DashboardRangeParams): Promise => { - const data = await apiGet(API_PATHS.dashboard.stats, rangeSearchParams(range)); + getStats: async ( + range?: DashboardRangeParams, + dispatcherId?: string, + ): Promise => { + const data = await apiGet( + API_PATHS.dashboard.stats, + scopedSearchParams(range, dispatcherId), + ); return mapDashboardStats(handleApiResponse(data)); }, getWorkload: async ( range?: DashboardRangeParams, page = 1, + dispatcherId?: string, ): Promise> => { const data = await apiGet( API_PATHS.dashboard.workload, - dispatcherPageSearchParams(range, page), + dispatcherPageSearchParams(range, page, dispatcherId), ); const response = handleApiResponse(data); const items = mapDispatcherWorkload(response); @@ -347,17 +390,24 @@ export const dashboardApi = { getPerformance: async ( range?: DashboardRangeParams, page = 1, + dispatcherId?: string, ): Promise> => { const data = await apiGet( API_PATHS.dashboard.performance, - dispatcherPageSearchParams(range, page), + dispatcherPageSearchParams(range, page, dispatcherId), ); const response = handleApiResponse(data); const items = mapDispatcherPerformance(response); return { items, ...readDispatcherPageMeta(response, page, items.length) }; }, - getRegions: async (range?: DashboardRangeParams): Promise => { - const data = await apiGet(API_PATHS.dashboard.regions, rangeSearchParams(range)); + getRegions: async ( + range?: DashboardRangeParams, + dispatcherId?: string, + ): Promise => { + const data = await apiGet( + API_PATHS.dashboard.regions, + scopedSearchParams(range, dispatcherId), + ); return mapRegions(handleApiResponse(data)); }, getVendorInsights: async (): Promise => { diff --git a/src/domain/dashboard/types/dashboard-performance.ts b/src/domain/dashboard/types/dashboard-performance.ts index dca8f1a4..03c49371 100644 --- a/src/domain/dashboard/types/dashboard-performance.ts +++ b/src/domain/dashboard/types/dashboard-performance.ts @@ -3,4 +3,6 @@ export interface DispatcherPerformanceRow { dispatcherName: string; completionRate: number; averageResolutionDays: number | null; + assignedCount: number; + completedCount: number; } diff --git a/src/domain/dashboard/types/dashboard-stats.ts b/src/domain/dashboard/types/dashboard-stats.ts index 82442c01..1394a801 100644 --- a/src/domain/dashboard/types/dashboard-stats.ts +++ b/src/domain/dashboard/types/dashboard-stats.ts @@ -3,6 +3,11 @@ export interface DashboardBreakdownRow { count: number; } +export interface DashboardStatusBucketRow { + status: string; + count: number; +} + export interface DashboardStats { total: number; open: number; @@ -16,4 +21,5 @@ export interface DashboardStats { pendingUplifts: number; avetaPending: number; breakdown: DashboardBreakdownRow[]; + statusDistribution: DashboardStatusBucketRow[]; } diff --git a/src/domain/dashboard/use-cases/use-dashboard-performance.ts b/src/domain/dashboard/use-cases/use-dashboard-performance.ts index 06491072..3e7c4f71 100644 --- a/src/domain/dashboard/use-cases/use-dashboard-performance.ts +++ b/src/domain/dashboard/use-cases/use-dashboard-performance.ts @@ -9,10 +9,14 @@ import { queryKeys } from "@/infra/query-key/query-key"; export function useDashboardPerformance( range: DashboardRangeKey, page = 1, + dispatcherId: string | null = null, + enabled = true, ): UseQueryResult, Error> { return useQuery({ - queryKey: queryKeys.dashboard.performance(range, page), - queryFn: () => dashboardApi.getPerformance(dashboardRangeParams(range), page), + queryKey: queryKeys.dashboard.performance(range, page, dispatcherId), + queryFn: () => + dashboardApi.getPerformance(dashboardRangeParams(range), page, dispatcherId ?? undefined), staleTime: 60 * 1000, + enabled, }); } diff --git a/src/domain/dashboard/use-cases/use-dashboard-regions.ts b/src/domain/dashboard/use-cases/use-dashboard-regions.ts index 996fc212..787d7477 100644 --- a/src/domain/dashboard/use-cases/use-dashboard-regions.ts +++ b/src/domain/dashboard/use-cases/use-dashboard-regions.ts @@ -7,10 +7,11 @@ import { queryKeys } from "@/infra/query-key/query-key"; export function useDashboardRegions( range: DashboardRangeKey, + dispatcherId: string | null = null, ): UseQueryResult { return useQuery({ - queryKey: queryKeys.dashboard.regions(range), - queryFn: () => dashboardApi.getRegions(dashboardRangeParams(range)), + queryKey: queryKeys.dashboard.regions(range, dispatcherId), + queryFn: () => dashboardApi.getRegions(dashboardRangeParams(range), dispatcherId ?? undefined), staleTime: 60 * 1000, }); } diff --git a/src/domain/dashboard/use-cases/use-dashboard-stats.ts b/src/domain/dashboard/use-cases/use-dashboard-stats.ts index d9e93ee2..9266d7ba 100644 --- a/src/domain/dashboard/use-cases/use-dashboard-stats.ts +++ b/src/domain/dashboard/use-cases/use-dashboard-stats.ts @@ -5,10 +5,13 @@ import type { DashboardStats } from "@/domain/dashboard/types/dashboard-stats"; import { dashboardRangeParams } from "@/domain/dashboard/utils/dashboard-range-utils"; import { queryKeys } from "@/infra/query-key/query-key"; -export function useDashboardStats(range: DashboardRangeKey): UseQueryResult { +export function useDashboardStats( + range: DashboardRangeKey, + dispatcherId: string | null = null, +): UseQueryResult { return useQuery({ - queryKey: queryKeys.dashboard.stats(range), - queryFn: () => dashboardApi.getStats(dashboardRangeParams(range)), + queryKey: queryKeys.dashboard.stats(range, dispatcherId), + queryFn: () => dashboardApi.getStats(dashboardRangeParams(range), dispatcherId ?? undefined), staleTime: 60 * 1000, }); } diff --git a/src/domain/dashboard/use-cases/use-dashboard-workload.ts b/src/domain/dashboard/use-cases/use-dashboard-workload.ts index e2276768..5a970598 100644 --- a/src/domain/dashboard/use-cases/use-dashboard-workload.ts +++ b/src/domain/dashboard/use-cases/use-dashboard-workload.ts @@ -9,10 +9,14 @@ import { queryKeys } from "@/infra/query-key/query-key"; export function useDashboardWorkload( range: DashboardRangeKey, page = 1, + dispatcherId: string | null = null, + enabled = true, ): UseQueryResult, Error> { return useQuery({ - queryKey: queryKeys.dashboard.workload(range, page), - queryFn: () => dashboardApi.getWorkload(dashboardRangeParams(range), page), + queryKey: queryKeys.dashboard.workload(range, page, dispatcherId), + queryFn: () => + dashboardApi.getWorkload(dashboardRangeParams(range), page, dispatcherId ?? undefined), staleTime: 60 * 1000, + enabled, }); } diff --git a/src/domain/dashboard/utils/work-order-drilldown-links.ts b/src/domain/dashboard/utils/work-order-drilldown-links.ts index 837fd104..c83d0592 100644 --- a/src/domain/dashboard/utils/work-order-drilldown-links.ts +++ b/src/domain/dashboard/utils/work-order-drilldown-links.ts @@ -61,6 +61,15 @@ export function workOrderDispatcherDrilldownSearch( return search; } +export function workOrderRegionDrilldownSearch( + region: string, + range: DashboardRangeKey, +): URLSearchParams { + const search = workOrderRangeDrilldownSearch(range); + search.set("regions", region); + return search; +} + export function scheduledTomorrowDrilldownSearch( today: string = businessTodayIso(), ): URLSearchParams { diff --git a/src/domain/work-orders/types/work-order-advanced-filters.ts b/src/domain/work-orders/types/work-order-advanced-filters.ts index e9ad34f9..c187aee8 100644 --- a/src/domain/work-orders/types/work-order-advanced-filters.ts +++ b/src/domain/work-orders/types/work-order-advanced-filters.ts @@ -32,6 +32,7 @@ export interface WorkOrderAdvancedFilters { types: Set; severities: Set; dispatchers: Set; + regions: Set; statuses: Set; rescheduled: boolean; carriedOver: boolean; @@ -55,6 +56,7 @@ export const EMPTY_ADVANCED_FILTERS: WorkOrderAdvancedFilters = { types: new Set(), severities: new Set(), dispatchers: new Set(), + regions: new Set(), statuses: new Set(), rescheduled: false, carriedOver: false, @@ -97,6 +99,7 @@ export type AdvancedFilterSetKey = | "sites" | "types" | "dispatchers" + | "regions" | "statuses" | "pmTypes" | "vendorTechs" diff --git a/src/domain/work-orders/types/work-order-board.ts b/src/domain/work-orders/types/work-order-board.ts index 4c5606b4..3c0d5619 100644 --- a/src/domain/work-orders/types/work-order-board.ts +++ b/src/domain/work-orders/types/work-order-board.ts @@ -287,6 +287,8 @@ export interface WorkOrderBoardSearchQueryParams { /** Past-due filter; independent of WorkOrderType (Other remains types=99). */ overdue?: boolean; dispatchers?: string[]; + /** SH-348: region names (East/Central/West/California/Unmapped/Other). */ + regions?: string[]; statuses?: number[]; pmTypes?: string[]; vendorIds?: number[]; diff --git a/src/domain/work-orders/utils/advanced-filter-chip-builders.ts b/src/domain/work-orders/utils/advanced-filter-chip-builders.ts index 57bdab98..edc707a0 100644 --- a/src/domain/work-orders/utils/advanced-filter-chip-builders.ts +++ b/src/domain/work-orders/utils/advanced-filter-chip-builders.ts @@ -108,6 +108,15 @@ export function appendSetChips( nameOf: (v) => v, clear: (current) => ({ ...current, types: new Set() }), }); + + pushSetChip({ + chips, + key: "regions", + group: "Region", + set: filters.regions, + nameOf: (v) => v, + clear: (current) => ({ ...current, regions: new Set() }), + }); } export function appendSeverityChip(chips: AdvancedFilterChip[], filters: WorkOrderAdvancedFilters) { diff --git a/src/domain/work-orders/utils/advanced-filters-utils.ts b/src/domain/work-orders/utils/advanced-filters-utils.ts index 97e503f2..3f8f5ce5 100644 --- a/src/domain/work-orders/utils/advanced-filters-utils.ts +++ b/src/domain/work-orders/utils/advanced-filters-utils.ts @@ -26,6 +26,7 @@ function cloneFilters(filters: WorkOrderAdvancedFilters): WorkOrderAdvancedFilte types: new Set(filters.types), severities: new Set(filters.severities ?? []), dispatchers: new Set(filters.dispatchers), + regions: new Set(filters.regions ?? []), statuses: new Set(filters.statuses), pmTypes: new Set(filters.pmTypes), vendorTechs: new Set(filters.vendorTechs), @@ -42,6 +43,7 @@ const ADVANCED_FILTER_ACTIVE_SIGNALS = (filters: WorkOrderAdvancedFilters): bool Boolean(filters.types?.size), Boolean(filters.severities?.size), Boolean(filters.dispatchers?.size), + Boolean(filters.regions?.size), Boolean(filters.statuses?.size), filters.rescheduled, filters.carriedOver, diff --git a/src/domain/work-orders/utils/board-query-params.ts b/src/domain/work-orders/utils/board-query-params.ts index 29780d1a..c10519b8 100644 --- a/src/domain/work-orders/utils/board-query-params.ts +++ b/src/domain/work-orders/utils/board-query-params.ts @@ -217,6 +217,7 @@ export function toBoardSearchQueryParams(input: { types: typeFilters.types, overdue: typeFilters.overdue, dispatchers: mapDispatchers(filters.dispatchers), + regions: filters.regions.size > 0 ? [...filters.regions] : undefined, statuses: mapStatuses(filters.statuses), pmTypes: filters.pmTypes.size > 0 ? [...filters.pmTypes] : undefined, vendorIds: resolveVendorIds(filters.vendorTechs, input.vendorIdsByCompanyName), diff --git a/src/domain/work-orders/utils/dashboard-drilldown-params.ts b/src/domain/work-orders/utils/dashboard-drilldown-params.ts index 0d030c6c..c67315c3 100644 --- a/src/domain/work-orders/utils/dashboard-drilldown-params.ts +++ b/src/domain/work-orders/utils/dashboard-drilldown-params.ts @@ -10,6 +10,9 @@ const ISO_DATE_PATTERN = /^\d{4}-\d{2}-\d{2}$/; const COMPLETION_DOC_FILTERS: CompletionDocFilter[] = ["No", "Yes", "NN"]; +// SH-348: the board region filter (backend#146) accepts these region names. +const BOARD_REGION_NAMES = ["East", "Central", "West", "California", "Unmapped/Other"]; + const UPLIFT_FILTER_VALUES: UpliftFilterStatus[] = [ "pending", "approved", @@ -45,6 +48,11 @@ function parseCompletionDocs(raw: string | null): CompletionDocFilter[] { return [...new Set(parseList(raw))].filter((doc): doc is CompletionDocFilter => valid.has(doc)); } +function parseRegions(raw: string | null): string[] { + const valid = new Set(BOARD_REGION_NAMES); + return [...new Set(parseList(raw))].filter((region) => valid.has(region)); +} + function parseUpliftStatuses(raw: string | null): UpliftFilterStatus[] { const valid = new Set(UPLIFT_FILTER_VALUES); return [...new Set(parseList(raw))].filter((value): value is UpliftFilterStatus => @@ -63,6 +71,7 @@ export function parseDashboardDrilldownParams( const statuses = parseStatuses(params.get("statuses")); const types = parseTypes(params.get("types")); const dispatchers = parseList(params.get("dispatchers")); + const regions = parseRegions(params.get("regions")); const upliftStatuses = parseUpliftStatuses(params.get("upliftStatuses")); const hasUplift = params.get("hasUplift") === "1"; @@ -73,6 +82,7 @@ export function parseDashboardDrilldownParams( statuses.length > 0 || types.length > 0 || dispatchers.length > 0 || + regions.length > 0 || upliftStatuses.length > 0 || hasUplift; @@ -101,6 +111,9 @@ export function parseDashboardDrilldownParams( if (dispatchers.length > 0) { filters.dispatchers = new Set(dispatchers); } + if (regions.length > 0) { + filters.regions = new Set(regions); + } if (upliftStatuses.length > 0) { filters.upliftStatuses = new Set(upliftStatuses); } diff --git a/src/infra/query-key/query-key.ts b/src/infra/query-key/query-key.ts index 6760619e..8cf4bb75 100644 --- a/src/infra/query-key/query-key.ts +++ b/src/infra/query-key/query-key.ts @@ -51,12 +51,14 @@ export const queryKeys = { }, dashboard: { all: ["dashboard"] as const, - stats: (range: DashboardRangeKey) => [...queryKeys.dashboard.all, "stats", range] as const, - workload: (range: DashboardRangeKey, page = 1) => - [...queryKeys.dashboard.all, "workload", range, page] as const, - performance: (range: DashboardRangeKey, page = 1) => - [...queryKeys.dashboard.all, "performance", range, page] as const, - regions: (range: DashboardRangeKey) => [...queryKeys.dashboard.all, "regions", range] as const, + stats: (range: DashboardRangeKey, dispatcherId: string | null = null) => + [...queryKeys.dashboard.all, "stats", range, dispatcherId] as const, + workload: (range: DashboardRangeKey, page = 1, dispatcherId: string | null = null) => + [...queryKeys.dashboard.all, "workload", range, page, dispatcherId] as const, + performance: (range: DashboardRangeKey, page = 1, dispatcherId: string | null = null) => + [...queryKeys.dashboard.all, "performance", range, page, dispatcherId] as const, + regions: (range: DashboardRangeKey, dispatcherId: string | null = null) => + [...queryKeys.dashboard.all, "regions", range, dispatcherId] as const, vendorInsights: () => [...queryKeys.dashboard.all, "vendorInsights"] as const, trend: (params?: DashboardTrendParams) => [...queryKeys.dashboard.all, "trend", params ?? {}] as const, diff --git a/src/lib/auth/user-utils.ts b/src/lib/auth/user-utils.ts index 55ca7411..1fcd3f4d 100644 --- a/src/lib/auth/user-utils.ts +++ b/src/lib/auth/user-utils.ts @@ -14,3 +14,18 @@ export function isAdminUser(userRoles: string | null | undefined): boolean { .map((role) => role.trim().toLowerCase()) .includes("admin"); } + +/** + * SH-336's `viewAllDispatchersOnDashboard` permission. The codebase has no + * permission model, so it is derived from role: Admin and Scheduler may pick any + * dispatcher (or the whole company) on the Dashboard; a Dispatcher may not and + * is locked to their own work orders. Unknown roles default to the restrictive + * (Dispatcher) behaviour so no one accidentally gains a company-wide view. + */ +export function canViewAllDispatchersOnDashboard(userRoles: string | null | undefined): boolean { + if (!userRoles) { + return false; + } + const roles = userRoles.split(",").map((role) => role.trim().toLowerCase()); + return roles.includes("admin") || roles.includes("scheduler"); +} diff --git a/src/test/app/(protected)/dashboard/dashboard-page.test.tsx b/src/test/app/(protected)/dashboard/dashboard-page.test.tsx index e74ab664..5ad7396e 100644 --- a/src/test/app/(protected)/dashboard/dashboard-page.test.tsx +++ b/src/test/app/(protected)/dashboard/dashboard-page.test.tsx @@ -5,9 +5,10 @@ import { renderWithProviders } from "@/test/test-utils"; const navigate = vi.fn(); const statsRange = vi.fn<(range: DashboardRangeKey) => void>(); -const { workloadMock, performanceMock } = vi.hoisted(() => ({ +const { workloadMock, performanceMock, authUser } = vi.hoisted(() => ({ workloadMock: vi.fn(), performanceMock: vi.fn(), + authUser: { current: { id: "99", userRoles: "Admin" } as { id: string; userRoles: string } }, })); const EMPTY_DISPATCHER_PAGE = { items: [], page: 1, pageSize: 10, totalDispatchers: 0 }; @@ -17,6 +18,17 @@ vi.mock("react-router", async (importOriginal) => ({ useNavigate: () => navigate, })); +// An Admin has `viewAllDispatchersOnDashboard`, so the scope picker and the +// dispatcher tables render (SH-336). +vi.mock("@/providers/auth-context", async (importOriginal) => ({ + ...(await importOriginal()), + useAuthContext: () => ({ user: authUser.current }), +})); + +vi.mock("@/domain/work-orders/use-cases/use-work-orders-list", () => ({ + useWorkOrderUsers: () => ({ data: [], isLoading: false, isFetching: false, error: null }), +})); + vi.mock("@/domain/dashboard/use-cases/use-dashboard-workload", () => ({ useDashboardWorkload: workloadMock, })); @@ -77,6 +89,7 @@ function renderDashboard() { describe("DashboardPage", () => { beforeEach(() => { + authUser.current = { id: "99", userRoles: "Admin" }; navigate.mockReset(); statsRange.mockReset(); workloadMock.mockReset(); @@ -107,6 +120,7 @@ describe("DashboardPage", () => { "Pending Uplifts", "Aveta Pending", "Work Order Breakdown", + "Status Distribution", "Dispatcher Workload", "Dispatcher Performance", "Work Orders by Region", @@ -117,6 +131,27 @@ describe("DashboardPage", () => { expect(statsRange).toHaveBeenCalledWith("this-week"); }); + it("shows a Dispatcher the static My WOs scope and hides the dispatcher tables", () => { + authUser.current = { id: "7", userRoles: "Dispatcher" }; + + renderDashboard(); + + expect(screen.getByText("My WOs")).toBeInTheDocument(); + expect(screen.queryByRole("button", { name: "All dispatchers" })).toBeNull(); + expect(screen.queryByText("Dispatcher Workload")).toBeNull(); + expect(screen.queryByText("Dispatcher Performance")).toBeNull(); + // Their scope is server-scoped, so the tables are never even queried. + expect(workloadMock).toHaveBeenCalledWith("this-week", 1, null, false); + }); + + it("lets an Admin open the dispatcher-scope picker", () => { + renderDashboard(); + + expect(screen.getByRole("button", { name: /All dispatchers/ })).toBeInTheDocument(); + expect(screen.getByText("Dispatcher Workload")).toBeInTheDocument(); + expect(workloadMock).toHaveBeenCalledWith("this-week", 1, null, true); + }); + it("exposes positive KPI cards as keyboard-accessible drill-downs", () => { renderDashboard(); @@ -168,8 +203,8 @@ describe("DashboardPage", () => { it("shows the documented empty states when datasets are empty", () => { renderDashboard(); - // Breakdown and Trend share this empty copy, so both cards surface it. - expect(screen.getAllByText("No work orders match the current filters.")).toHaveLength(2); + // Region, Status Distribution, and Trend share this empty copy. + expect(screen.getAllByText("No work orders match the current filters.")).toHaveLength(3); 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 9a73b50a..95f8c258 100644 --- a/src/test/domain/dashboard/dashboard-api.test.ts +++ b/src/test/domain/dashboard/dashboard-api.test.ts @@ -31,6 +31,10 @@ describe("dashboardApi", () => { { Status: "Scheduled", Count: "7" }, { Status: "Completed", Count: 4 }, ], + StatusDistribution: [ + { Status: "Scheduled", Count: "7" }, + { Status: "Unknown", Count: 1 }, + ], }, }); @@ -52,6 +56,10 @@ describe("dashboardApi", () => { { status: "Scheduled", count: 7 }, { status: "Completed", count: 4 }, ], + statusDistribution: [ + { status: "Scheduled", count: 7 }, + { status: "Unknown", count: 1 }, + ], }); expect(apiGet).toHaveBeenCalledWith("Dashboard/Stats", { searchParams: { dateFrom: "2026-09-14", dateTo: "2026-09-18" }, @@ -116,6 +124,8 @@ describe("dashboardApi", () => { DispatcherName: "Dana Reyes", CompletionRate: 96.5, AverageResolutionDays: 3.25, + AssignedCount: 40, + CompletedCount: "38", }, ], Page: 2, @@ -132,6 +142,8 @@ describe("dashboardApi", () => { dispatcherName: "Dana Reyes", completionRate: 96.5, averageResolutionDays: 3.25, + assignedCount: 40, + completedCount: 38, }, ], page: 2, diff --git a/src/test/domain/dashboard/work-order-drilldown-links.test.ts b/src/test/domain/dashboard/work-order-drilldown-links.test.ts index 2c28e20c..836da9e6 100644 --- a/src/test/domain/dashboard/work-order-drilldown-links.test.ts +++ b/src/test/domain/dashboard/work-order-drilldown-links.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it } from "vitest"; +import { afterEach, describe, expect, it, vi } from "vitest"; import { avetaPendingDrilldownSearch, scheduledTomorrowDrilldownSearch, @@ -8,6 +8,10 @@ import { } from "@/domain/dashboard/utils/work-order-drilldown-links"; describe("work-order drilldown links", () => { + afterEach(() => { + vi.useRealTimers(); + }); + it("schedules the tomorrow drilldown on the business clock", () => { const search = scheduledTomorrowDrilldownSearch("2026-09-16"); @@ -30,6 +34,11 @@ describe("work-order drilldown links", () => { }); it("carries the range into status drilldowns", () => { + // "today" resolves against the business clock, so pin it (midday EDT on + // 2026-09-16 → 2026-09-16 in America/New_York) to keep the test deterministic. + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-09-16T16:00:00Z")); + const search = workOrderStatusDrilldownSearch("Scheduled", "today"); expect(search.get("dateFrom")).toBe("2026-09-16"); 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 97518591..8ff30f72 100644 --- a/src/test/domain/work-orders/dashboard-drilldown-params.test.ts +++ b/src/test/domain/work-orders/dashboard-drilldown-params.test.ts @@ -52,6 +52,14 @@ describe("parseDashboardDrilldownParams", () => { expect(filters?.upliftStatuses).toEqual(new Set(["pending"])); }); + it("parses the region drilldown and drops unknown region names", () => { + const filters = parseDashboardDrilldownParams( + new URLSearchParams({ regions: "California, Atlantis" }), + ); + + expect(filters?.regions).toEqual(new Set(["California"])); + }); + it("rejects partial or malformed date ranges", () => { expect( parseDashboardDrilldownParams( diff --git a/src/test/lib/auth/user-utils.test.ts b/src/test/lib/auth/user-utils.test.ts index eddb88c5..ac185d49 100644 --- a/src/test/lib/auth/user-utils.test.ts +++ b/src/test/lib/auth/user-utils.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { isAdminUser } from "@/lib/auth/user-utils"; +import { canViewAllDispatchersOnDashboard, isAdminUser } from "@/lib/auth/user-utils"; describe("isAdminUser", () => { it("returns true when Admin appears in roles", () => { @@ -13,3 +13,17 @@ describe("isAdminUser", () => { expect(isAdminUser(undefined)).toBe(false); }); }); + +describe("canViewAllDispatchersOnDashboard", () => { + it("grants the dashboard picker to Admin and Scheduler", () => { + expect(canViewAllDispatchersOnDashboard("Admin")).toBe(true); + expect(canViewAllDispatchersOnDashboard("Scheduler")).toBe(true); + expect(canViewAllDispatchersOnDashboard("Dispatcher, Scheduler")).toBe(true); + }); + + it("locks a Dispatcher (and unknown roles) to their own work orders", () => { + expect(canViewAllDispatchersOnDashboard("Dispatcher")).toBe(false); + expect(canViewAllDispatchersOnDashboard("Technician")).toBe(false); + expect(canViewAllDispatchersOnDashboard(undefined)).toBe(false); + }); +});