feat(dashboard): dispatcher scope picker, status distribution, region drilldown, perf counts

- SH-336: add the dispatcher-scope picker (All / My WOs / individual) for
  Scheduler/Admin via a new viewAllDispatchersOnDashboard role gate, a static
  'My WOs' label for Dispatcher, and hide the dispatcher tables (and skip their
  queries) when the viewer cannot see all dispatchers. Scope drives dispatcherId
  on stats, workload, performance, and regions (SH-347 contract).
- SH-352: add the Status Distribution module with its empty state and status
  drill-down.
- SH-347: show Assigned/Completed counts and colour the completion rate
  (green >=90, amber 70-89, red <70) on Dispatcher Performance.
- SH-348: drill a region bar into the board region filter; hold the
  unconfirmed 'Unmapped/Other' bucket off the chart.
- SH-349: label Vendor Insights as all-time, company-wide.
- Make the drilldown-filters effect fire only on URL change; add a deterministic
  clock to the status-drilldown test; add the dashboard visual regression spec
  and baselines.
This commit is contained in:
Codex Review Integration 2026-09-17 02:56:31 -03:00
parent 078933ad9a
commit 1271d3c11e
33 changed files with 843 additions and 59 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 137 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 117 KiB

View file

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

View file

@ -5,6 +5,8 @@ import { Text } from "@/components/ui/text";
interface DashboardCardProps { interface DashboardCardProps {
title: string; title: string;
/** Optional scope caption shown beside the title (e.g. SH-349's all-time note). */
caption?: string;
isLoading?: boolean; isLoading?: boolean;
error?: unknown; error?: unknown;
isEmpty: boolean; isEmpty: boolean;
@ -14,6 +16,7 @@ interface DashboardCardProps {
export function DashboardCard({ export function DashboardCard({
title, title,
caption,
isLoading = false, isLoading = false,
error, error,
isEmpty, isEmpty,
@ -22,8 +25,13 @@ export function DashboardCard({
}: DashboardCardProps) { }: DashboardCardProps) {
return ( return (
<Paper variant="outlined" className="flex flex-col overflow-hidden"> <Paper variant="outlined" className="flex flex-col overflow-hidden">
<Box className="border-b border-border px-4 py-3"> <Box className="flex items-baseline justify-between gap-2 border-b border-border px-4 py-3">
<Text variant="heading">{title}</Text> <Text variant="heading">{title}</Text>
{!!caption && (
<Text variant="description" tone="muted">
{caption}
</Text>
)}
</Box> </Box>
{isLoading && ( {isLoading && (
<Box className="flex items-center justify-center p-8"> <Box className="flex items-center justify-center p-8">

View file

@ -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 (
<Skeleton
variant="rounded"
width={140}
height={18}
animation="wave"
aria-label="Loading dispatchers"
/>
);
}
return (
<>
<MenuAnchorButton
onAnchor={setAnchor}
open={open}
className="flex w-auto shrink-0 items-center gap-1.5 whitespace-nowrap transition-colors"
style={{ fontSize: 13, fontWeight: 500, color: "var(--muted-foreground)" }}
aria-haspopup="listbox"
>
<Users size={13} aria-hidden style={{ color: "var(--muted-foreground)" }} />
{label}
<ChevronDown size={13} aria-hidden style={{ color: "var(--muted-foreground)" }} />
</MenuAnchorButton>
<Popover
open={open}
anchorEl={anchorEl}
onClose={close}
anchorOrigin={{ vertical: "bottom", horizontal: "left" }}
transformOrigin={{ vertical: "top", horizontal: "left" }}
slotProps={{
paper: {
className: "mt-1 w-[220px] rounded-lg border border-border p-1.5 shadow-md",
},
}}
>
<CheckboxMenuItem
checked={scope.mode === "all"}
label="All dispatchers"
onClick={() => select({ mode: "all" })}
/>
{meId != null && (
<CheckboxMenuItem
checked={scope.mode === "mine"}
label="My WOs only"
labelClassName="font-medium text-primary"
className="mb-1 pb-2"
style={{ borderBottom: "1px solid var(--color-border)" }}
onClick={() => select({ mode: "mine" })}
/>
)}
{dispatchers.map((user) => {
const id = String(user.id);
return (
<CheckboxMenuItem
key={id}
checked={scope.mode === "individual" && scope.id === id}
label={user.name}
right={
meId === id ? (
<span className="text-[10px] text-muted-foreground">me</span>
) : undefined
}
onClick={() => select({ mode: "individual", id })}
/>
);
})}
</Popover>
</>
);
}

View file

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

View file

@ -27,6 +27,13 @@ function formatDays(value: number): string {
return (Math.round(value * 10) / 10).toLocaleString(); 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({ function DispatcherNameCell({
row, row,
range, range,
@ -38,9 +45,9 @@ function DispatcherNameCell({
// A listed dispatcher is one with work in range; the drill-down filters Work // 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 // 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% // 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 // rate was previously inert here while drillable from Workload.) The gate stays
// Assigned/Completed counts land on DispatcherPerformanceRow this should // on identity rather than `assignedCount > 0` so a legacy response without the
// become `assigned > 0`, matching Workload's `totalWorkOrders > 0`. // SH-347 count field (assignedCount → 0) keeps its rows drillable.
const drillable = Boolean(row.dispatcherId); const drillable = Boolean(row.dispatcherId);
if (!drillable) { if (!drillable) {
@ -84,6 +91,12 @@ export function DispatcherPerformanceCard({
<TableHead> <TableHead>
<TableRow> <TableRow>
<TableCell className="font-semibold">Dispatcher</TableCell> <TableCell className="font-semibold">Dispatcher</TableCell>
<TableCell align="right" className="font-semibold">
Assigned
</TableCell>
<TableCell align="right" className="font-semibold">
Completed
</TableCell>
<TableCell align="right" className="font-semibold"> <TableCell align="right" className="font-semibold">
Completion % Completion %
</TableCell> </TableCell>
@ -98,7 +111,14 @@ export function DispatcherPerformanceCard({
<TableCell> <TableCell>
<DispatcherNameCell row={row} range={range} /> <DispatcherNameCell row={row} range={range} />
</TableCell> </TableCell>
<TableCell align="right">{`${row.completionRate.toLocaleString()}%`}</TableCell> <TableCell align="right">{row.assignedCount.toLocaleString()}</TableCell>
<TableCell align="right">{row.completedCount.toLocaleString()}</TableCell>
<TableCell
align="right"
sx={{ color: completionRateColor(row.completionRate), fontWeight: 600 }}
>
{`${row.completionRate.toLocaleString()}%`}
</TableCell>
<TableCell align="right"> <TableCell align="right">
{row.averageResolutionDays == null ? "—" : formatDays(row.averageResolutionDays)} {row.averageResolutionDays == null ? "—" : formatDays(row.averageResolutionDays)}
</TableCell> </TableCell>

View file

@ -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<string>(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 (
<DashboardCard
title="Status Distribution"
isLoading={isLoading}
error={error}
isEmpty={!isLoading && !error && rows.length === 0}
emptyMessage={EMPTY_MESSAGE}
>
<DashboardBarList
ariaLabel="Status Distribution"
rows={rows.map((row) => ({
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)))
}
/>
</DashboardCard>
);
}

View file

@ -15,6 +15,7 @@ export function VendorInsightsCard({ rows, isLoading = false, error }: VendorIns
return ( return (
<DashboardCard <DashboardCard
title="Vendor Insights" title="Vendor Insights"
caption="All-time, company-wide"
isLoading={isLoading} isLoading={isLoading}
error={error} error={error}
isEmpty={!isLoading && !error && rows.length === 0} isEmpty={!isLoading && !error && rows.length === 0}

View file

@ -1,4 +1,13 @@
import type { RegionWorkOrdersRow } from "@/domain/dashboard/types/dashboard-regions"; import { useNavigate } from "react-router";
import type { DashboardRangeKey } from "@/domain/dashboard/types/dashboard-range";
import {
UNMAPPED_REGION_LABEL,
type RegionWorkOrdersRow,
} from "@/domain/dashboard/types/dashboard-regions";
import {
workOrderDrilldownUrl,
workOrderRegionDrilldownSearch,
} from "@/domain/dashboard/utils/work-order-drilldown-links";
import { DashboardBarList } from "./dashboard-bar-list"; import { DashboardBarList } from "./dashboard-bar-list";
import { DashboardCard } from "./dashboard-card"; import { DashboardCard } from "./dashboard-card";
@ -6,30 +15,42 @@ const EMPTY_MESSAGE = "No work orders match the current filters.";
interface WorkOrdersByRegionCardProps { interface WorkOrdersByRegionCardProps {
rows: RegionWorkOrdersRow[]; rows: RegionWorkOrdersRow[];
range: DashboardRangeKey;
isLoading?: boolean; isLoading?: boolean;
error?: unknown; error?: unknown;
} }
export function WorkOrdersByRegionCard({ export function WorkOrdersByRegionCard({
rows, rows,
range,
isLoading = false, isLoading = false,
error, error,
}: WorkOrdersByRegionCardProps) { }: WorkOrdersByRegionCardProps) {
const navigate = useNavigate();
// SH-348 open point: the "Unmapped/Other" bucket is proposed but not yet
// confirmed with the client ("do not implement until confirmed"), so only the
// four canonical zones are shown.
const zones = rows.filter((row) => row.region !== UNMAPPED_REGION_LABEL);
return ( return (
<DashboardCard <DashboardCard
title="Work Orders by Region" title="Work Orders by Region"
isLoading={isLoading} isLoading={isLoading}
error={error} error={error}
isEmpty={!isLoading && !error && rows.length === 0} isEmpty={!isLoading && !error && zones.length === 0}
emptyMessage={EMPTY_MESSAGE} emptyMessage={EMPTY_MESSAGE}
> >
<DashboardBarList <DashboardBarList
ariaLabel="Work Orders by Region" ariaLabel="Work Orders by Region"
rows={rows.map((row) => ({ rows={zones.map((row) => ({
id: row.region, id: row.region,
label: row.region, label: row.region,
count: row.workOrderCount, count: row.workOrderCount,
}))} }))}
onSelectRow={(row) =>
navigate(workOrderDrilldownUrl(workOrderRegionDrilldownSearch(row.id, range)))
}
/> />
</DashboardCard> </DashboardCard>
); );

View file

@ -4,11 +4,22 @@ import EventOutlinedIcon from "@mui/icons-material/EventOutlined";
import PendingActionsIcon from "@mui/icons-material/PendingActions"; import PendingActionsIcon from "@mui/icons-material/PendingActions";
import ShieldOutlinedIcon from "@mui/icons-material/ShieldOutlined"; import ShieldOutlinedIcon from "@mui/icons-material/ShieldOutlined";
import { Box, Button, CircularProgress } from "@mui/material"; import { Box, Button, CircularProgress } from "@mui/material";
import { Users } from "lucide-react";
import { PageHeader } from "@/components/ui/page-header"; 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 { DashboardKpiCard } from "@/app/(protected)/dashboard/_components/dashboard-kpi-card";
import { DashboardRangeToggle } from "@/app/(protected)/dashboard/_components/dashboard-range-toggle"; import { DashboardRangeToggle } from "@/app/(protected)/dashboard/_components/dashboard-range-toggle";
import { DashboardTrendCard } from "@/app/(protected)/dashboard/_components/dashboard-trend-card"; 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 { 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 { VendorInsightsCard } from "@/app/(protected)/dashboard/_components/vendor-insights-card";
import { WorkOrderBreakdownCard } from "@/app/(protected)/dashboard/_components/work-order-breakdown-card"; import { WorkOrderBreakdownCard } from "@/app/(protected)/dashboard/_components/work-order-breakdown-card";
import { WorkOrdersByRegionCard } from "@/app/(protected)/dashboard/_components/work-orders-by-region-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 { useDashboardStats } from "@/domain/dashboard/use-cases/use-dashboard-stats";
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 { import {
avetaPendingDrilldownSearch, avetaPendingDrilldownSearch,
scheduledTomorrowDrilldownSearch, scheduledTomorrowDrilldownSearch,
@ -30,25 +42,58 @@ import {
export default function DashboardPage() { export default function DashboardPage() {
const navigate = useNavigate(); 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<DashboardRangeKey>(DEFAULT_DASHBOARD_RANGE); const [range, setRange] = useState<DashboardRangeKey>(DEFAULT_DASHBOARD_RANGE);
const [scope, setScope] = useState<DashboardScope>(DASHBOARD_SCOPE_ALL);
const [workloadPage, setWorkloadPage] = useState(1); const [workloadPage, setWorkloadPage] = useState(1);
const [performancePage, setPerformancePage] = useState(1); const [performancePage, setPerformancePage] = useState(1);
const stats = useDashboardStats(range);
const workload = useDashboardWorkload(range, workloadPage); const dispatcherUsers = useWorkOrderUsers();
const performance = useDashboardPerformance(range, performancePage);
const regions = useDashboardRegions(range); // 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(); const vendors = useDashboardVendorInsights();
// A range change re-scopes both dispatcher tables, so their page must reset — // 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 // otherwise a switch while on page 3 lands on an out-of-range empty page that
// reads as a false "no dispatchers" state. // reads as a false "no dispatchers" state.
const changeRange = (next: DashboardRangeKey) => { const resetDispatcherPages = () => {
setRange(next);
setWorkloadPage(1); setWorkloadPage(1);
setPerformancePage(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 = const isRefreshing =
[stats, workload, performance, regions, vendors].some((query) => query.isFetching) && [stats, workload, performance, regions, vendors].some((query) => query.isFetching) &&
@ -61,11 +106,30 @@ export default function DashboardPage() {
actions={ actions={
<Box className="flex items-center gap-2"> <Box className="flex items-center gap-2">
{isRefreshing && <CircularProgress size={16} />} {isRefreshing && <CircularProgress size={16} />}
{range !== DEFAULT_DASHBOARD_RANGE && ( {showReset && (
<Button size="small" onClick={resetFilters}> <Button size="small" onClick={resetFilters}>
Reset filters Reset filters
</Button> </Button>
)} )}
{canViewAll ? (
<DashboardScopePicker
scope={scope}
onChange={changeScope}
users={dispatcherUsers.data ?? []}
usersLoading={dispatcherUsers.isLoading}
currentUserId={user?.id}
/>
) : (
<Box
className="flex shrink-0 items-center gap-1.5 whitespace-nowrap"
style={{ fontSize: 13, fontWeight: 500, color: "var(--muted-foreground)" }}
>
<Users size={13} aria-hidden style={{ color: "var(--muted-foreground)" }} />
<Text variant="description" tone="muted">
My WOs
</Text>
</Box>
)}
<DashboardRangeToggle value={range} onChange={changeRange} /> <DashboardRangeToggle value={range} onChange={changeRange} />
</Box> </Box>
} }
@ -106,20 +170,29 @@ export default function DashboardPage() {
isLoading={stats.isLoading} isLoading={stats.isLoading}
error={stats.error} error={stats.error}
/> />
<StatusDistributionCard
rows={stats.data?.statusDistribution ?? []}
range={range}
isLoading={stats.isLoading}
error={stats.error}
/>
<WorkOrdersByRegionCard <WorkOrdersByRegionCard
rows={regions.data ?? []} rows={regions.data ?? []}
range={range}
isLoading={regions.isLoading} isLoading={regions.isLoading}
error={regions.error} error={regions.error}
/> />
<DispatcherTablesSection {canViewAll && (
range={range} <DispatcherTablesSection
workload={workload} range={range}
performance={performance} workload={workload}
workloadPage={workloadPage} performance={performance}
performancePage={performancePage} workloadPage={workloadPage}
onWorkloadPageChange={setWorkloadPage} performancePage={performancePage}
onPerformancePageChange={setPerformancePage} onWorkloadPageChange={setWorkloadPage}
/> onPerformancePageChange={setPerformancePage}
/>
)}
</Box> </Box>
<DashboardTrendCard range={range} /> <DashboardTrendCard range={range} />
<VendorInsightsCard <VendorInsightsCard

View file

@ -8,8 +8,15 @@ export function useDashboardDrilldownFilters(
): boolean { ): boolean {
const [searchParams, setSearchParams] = useSearchParams(); const [searchParams, setSearchParams] = useSearchParams();
const appliedRef = useRef(false); const appliedRef = useRef(false);
// Hold onApply in a ref so a caller passing a fresh closure each render does not
// re-run the apply effect; it should fire only when the URL search params change.
const onApplyRef = useRef(onApply);
const [fromDashboard, setFromDashboard] = useState(false); const [fromDashboard, setFromDashboard] = useState(false);
useEffect(() => {
onApplyRef.current = onApply;
}, [onApply]);
useEffect(() => { useEffect(() => {
if (appliedRef.current) { if (appliedRef.current) {
return; return;
@ -22,9 +29,9 @@ export function useDashboardDrilldownFilters(
appliedRef.current = true; appliedRef.current = true;
setFromDashboard(true); setFromDashboard(true);
onApply(drilldown); onApplyRef.current(drilldown);
setSearchParams({}, { replace: true }); setSearchParams({}, { replace: true });
}, [searchParams, setSearchParams, onApply]); }, [searchParams, setSearchParams]);
return fromDashboard; return fromDashboard;
} }

View file

@ -6,6 +6,7 @@ import type { DashboardRangeParams } from "@/domain/dashboard/types/dashboard-ra
import type { import type {
DashboardBreakdownRow, DashboardBreakdownRow,
DashboardStats, DashboardStats,
DashboardStatusBucketRow,
} from "@/domain/dashboard/types/dashboard-stats"; } from "@/domain/dashboard/types/dashboard-stats";
import type { DispatcherPerformanceRow } from "@/domain/dashboard/types/dashboard-performance"; import type { DispatcherPerformanceRow } from "@/domain/dashboard/types/dashboard-performance";
import { import {
@ -79,27 +80,46 @@ function readCollection(value: unknown, key: string): Record<string, unknown>[]
return asRecords(collection); return asRecords(collection);
} }
function rangeSearchParams(range?: DashboardRangeParams): Options | undefined { /**
if (!range?.dateFrom || !range?.dateTo) { * Stats, Regions, Workload, and Performance share `DashboardStatsQueryDTO`
return undefined; * (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<string, string> = {};
if (range?.dateFrom && range?.dateTo) {
searchParams.dateFrom = range.dateFrom;
searchParams.dateTo = range.dateTo;
} }
if (dispatcherId) {
return { searchParams: { dateFrom: range.dateFrom, dateTo: range.dateTo } }; searchParams.dispatcherId = dispatcherId;
}
return Object.keys(searchParams).length > 0 ? { searchParams } : undefined;
} }
/** /**
* Workload and Performance are paged server-side (backend#126/#127): `page` is * 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( function dispatcherPageSearchParams(
range: DashboardRangeParams | undefined, range: DashboardRangeParams | undefined,
page: number, page: number,
dispatcherId?: string,
): Options { ): Options {
const searchParams: Record<string, string | number> = { page }; const searchParams: Record<string, string | number> = { page };
if (range?.dateFrom && range?.dateTo) { if (range?.dateFrom && range?.dateTo) {
searchParams.dateFrom = range.dateFrom; searchParams.dateFrom = range.dateFrom;
searchParams.dateTo = range.dateTo; searchParams.dateTo = range.dateTo;
} }
if (dispatcherId) {
searchParams.dispatcherId = dispatcherId;
}
return { searchParams }; return { searchParams };
} }
@ -204,6 +224,17 @@ function mapDispatcherIdentity(raw: Record<string, unknown>): {
}; };
} }
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 { function mapDashboardStats(raw: unknown): DashboardStats {
const data = toRecord(raw); const data = toRecord(raw);
const average = readField(data, "averageResolutionDays"); const average = readField(data, "averageResolutionDays");
@ -221,6 +252,7 @@ function mapDashboardStats(raw: unknown): DashboardStats {
pendingUplifts: toNumber(readField(data, "pendingUplifts")), pendingUplifts: toNumber(readField(data, "pendingUplifts")),
avetaPending: toNumber(readField(data, "avetaPending")), avetaPending: toNumber(readField(data, "avetaPending")),
breakdown: mapBreakdown(readField(data, "breakdown") ?? readField(data, "workOrderBreakdown")), 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 readField(rawRow, "averageResolutionDays") == null
? null ? null
: toNumber(readField(rawRow, "averageResolutionDays")), : 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 = { export const dashboardApi = {
getStats: async (range?: DashboardRangeParams): Promise<DashboardStats> => { getStats: async (
const data = await apiGet<unknown>(API_PATHS.dashboard.stats, rangeSearchParams(range)); range?: DashboardRangeParams,
dispatcherId?: string,
): Promise<DashboardStats> => {
const data = await apiGet<unknown>(
API_PATHS.dashboard.stats,
scopedSearchParams(range, dispatcherId),
);
return mapDashboardStats(handleApiResponse<unknown>(data)); return mapDashboardStats(handleApiResponse<unknown>(data));
}, },
getWorkload: async ( getWorkload: async (
range?: DashboardRangeParams, range?: DashboardRangeParams,
page = 1, page = 1,
dispatcherId?: string,
): Promise<DispatcherPage<DispatcherWorkloadRow>> => { ): Promise<DispatcherPage<DispatcherWorkloadRow>> => {
const data = await apiGet<unknown>( const data = await apiGet<unknown>(
API_PATHS.dashboard.workload, API_PATHS.dashboard.workload,
dispatcherPageSearchParams(range, page), dispatcherPageSearchParams(range, page, dispatcherId),
); );
const response = handleApiResponse<unknown>(data); const response = handleApiResponse<unknown>(data);
const items = mapDispatcherWorkload(response); const items = mapDispatcherWorkload(response);
@ -347,17 +390,24 @@ export const dashboardApi = {
getPerformance: async ( getPerformance: async (
range?: DashboardRangeParams, range?: DashboardRangeParams,
page = 1, page = 1,
dispatcherId?: string,
): Promise<DispatcherPage<DispatcherPerformanceRow>> => { ): Promise<DispatcherPage<DispatcherPerformanceRow>> => {
const data = await apiGet<unknown>( const data = await apiGet<unknown>(
API_PATHS.dashboard.performance, API_PATHS.dashboard.performance,
dispatcherPageSearchParams(range, page), dispatcherPageSearchParams(range, page, dispatcherId),
); );
const response = handleApiResponse<unknown>(data); const response = handleApiResponse<unknown>(data);
const items = mapDispatcherPerformance(response); const items = mapDispatcherPerformance(response);
return { items, ...readDispatcherPageMeta(response, page, items.length) }; return { items, ...readDispatcherPageMeta(response, page, items.length) };
}, },
getRegions: async (range?: DashboardRangeParams): Promise<RegionWorkOrdersRow[]> => { getRegions: async (
const data = await apiGet<unknown>(API_PATHS.dashboard.regions, rangeSearchParams(range)); range?: DashboardRangeParams,
dispatcherId?: string,
): Promise<RegionWorkOrdersRow[]> => {
const data = await apiGet<unknown>(
API_PATHS.dashboard.regions,
scopedSearchParams(range, dispatcherId),
);
return mapRegions(handleApiResponse<unknown>(data)); return mapRegions(handleApiResponse<unknown>(data));
}, },
getVendorInsights: async (): Promise<VendorInsightRow[]> => { getVendorInsights: async (): Promise<VendorInsightRow[]> => {

View file

@ -3,4 +3,6 @@ export interface DispatcherPerformanceRow {
dispatcherName: string; dispatcherName: string;
completionRate: number; completionRate: number;
averageResolutionDays: number | null; averageResolutionDays: number | null;
assignedCount: number;
completedCount: number;
} }

View file

@ -3,6 +3,11 @@ export interface DashboardBreakdownRow {
count: number; count: number;
} }
export interface DashboardStatusBucketRow {
status: string;
count: number;
}
export interface DashboardStats { export interface DashboardStats {
total: number; total: number;
open: number; open: number;
@ -16,4 +21,5 @@ export interface DashboardStats {
pendingUplifts: number; pendingUplifts: number;
avetaPending: number; avetaPending: number;
breakdown: DashboardBreakdownRow[]; breakdown: DashboardBreakdownRow[];
statusDistribution: DashboardStatusBucketRow[];
} }

View file

@ -9,10 +9,14 @@ import { queryKeys } from "@/infra/query-key/query-key";
export function useDashboardPerformance( export function useDashboardPerformance(
range: DashboardRangeKey, range: DashboardRangeKey,
page = 1, page = 1,
dispatcherId: string | null = null,
enabled = true,
): UseQueryResult<DispatcherPage<DispatcherPerformanceRow>, Error> { ): UseQueryResult<DispatcherPage<DispatcherPerformanceRow>, Error> {
return useQuery({ return useQuery({
queryKey: queryKeys.dashboard.performance(range, page), queryKey: queryKeys.dashboard.performance(range, page, dispatcherId),
queryFn: () => dashboardApi.getPerformance(dashboardRangeParams(range), page), queryFn: () =>
dashboardApi.getPerformance(dashboardRangeParams(range), page, dispatcherId ?? undefined),
staleTime: 60 * 1000, staleTime: 60 * 1000,
enabled,
}); });
} }

View file

@ -7,10 +7,11 @@ import { queryKeys } from "@/infra/query-key/query-key";
export function useDashboardRegions( export function useDashboardRegions(
range: DashboardRangeKey, range: DashboardRangeKey,
dispatcherId: string | null = null,
): UseQueryResult<RegionWorkOrdersRow[], Error> { ): UseQueryResult<RegionWorkOrdersRow[], Error> {
return useQuery({ return useQuery({
queryKey: queryKeys.dashboard.regions(range), queryKey: queryKeys.dashboard.regions(range, dispatcherId),
queryFn: () => dashboardApi.getRegions(dashboardRangeParams(range)), queryFn: () => dashboardApi.getRegions(dashboardRangeParams(range), dispatcherId ?? undefined),
staleTime: 60 * 1000, staleTime: 60 * 1000,
}); });
} }

View file

@ -5,10 +5,13 @@ import type { DashboardStats } from "@/domain/dashboard/types/dashboard-stats";
import { dashboardRangeParams } from "@/domain/dashboard/utils/dashboard-range-utils"; import { dashboardRangeParams } from "@/domain/dashboard/utils/dashboard-range-utils";
import { queryKeys } from "@/infra/query-key/query-key"; import { queryKeys } from "@/infra/query-key/query-key";
export function useDashboardStats(range: DashboardRangeKey): UseQueryResult<DashboardStats, Error> { export function useDashboardStats(
range: DashboardRangeKey,
dispatcherId: string | null = null,
): UseQueryResult<DashboardStats, Error> {
return useQuery({ return useQuery({
queryKey: queryKeys.dashboard.stats(range), queryKey: queryKeys.dashboard.stats(range, dispatcherId),
queryFn: () => dashboardApi.getStats(dashboardRangeParams(range)), queryFn: () => dashboardApi.getStats(dashboardRangeParams(range), dispatcherId ?? undefined),
staleTime: 60 * 1000, staleTime: 60 * 1000,
}); });
} }

View file

@ -9,10 +9,14 @@ import { queryKeys } from "@/infra/query-key/query-key";
export function useDashboardWorkload( export function useDashboardWorkload(
range: DashboardRangeKey, range: DashboardRangeKey,
page = 1, page = 1,
dispatcherId: string | null = null,
enabled = true,
): UseQueryResult<DispatcherPage<DispatcherWorkloadRow>, Error> { ): UseQueryResult<DispatcherPage<DispatcherWorkloadRow>, Error> {
return useQuery({ return useQuery({
queryKey: queryKeys.dashboard.workload(range, page), queryKey: queryKeys.dashboard.workload(range, page, dispatcherId),
queryFn: () => dashboardApi.getWorkload(dashboardRangeParams(range), page), queryFn: () =>
dashboardApi.getWorkload(dashboardRangeParams(range), page, dispatcherId ?? undefined),
staleTime: 60 * 1000, staleTime: 60 * 1000,
enabled,
}); });
} }

View file

@ -61,6 +61,15 @@ export function workOrderDispatcherDrilldownSearch(
return search; return search;
} }
export function workOrderRegionDrilldownSearch(
region: string,
range: DashboardRangeKey,
): URLSearchParams {
const search = workOrderRangeDrilldownSearch(range);
search.set("regions", region);
return search;
}
export function scheduledTomorrowDrilldownSearch( export function scheduledTomorrowDrilldownSearch(
today: string = businessTodayIso(), today: string = businessTodayIso(),
): URLSearchParams { ): URLSearchParams {

View file

@ -32,6 +32,7 @@ export interface WorkOrderAdvancedFilters {
types: Set<WOType>; types: Set<WOType>;
severities: Set<WorkOrderSeverity>; severities: Set<WorkOrderSeverity>;
dispatchers: Set<string>; dispatchers: Set<string>;
regions: Set<string>;
statuses: Set<WizardWOStatus>; statuses: Set<WizardWOStatus>;
rescheduled: boolean; rescheduled: boolean;
carriedOver: boolean; carriedOver: boolean;
@ -55,6 +56,7 @@ export const EMPTY_ADVANCED_FILTERS: WorkOrderAdvancedFilters = {
types: new Set(), types: new Set(),
severities: new Set(), severities: new Set(),
dispatchers: new Set(), dispatchers: new Set(),
regions: new Set(),
statuses: new Set(), statuses: new Set(),
rescheduled: false, rescheduled: false,
carriedOver: false, carriedOver: false,
@ -97,6 +99,7 @@ export type AdvancedFilterSetKey =
| "sites" | "sites"
| "types" | "types"
| "dispatchers" | "dispatchers"
| "regions"
| "statuses" | "statuses"
| "pmTypes" | "pmTypes"
| "vendorTechs" | "vendorTechs"

View file

@ -287,6 +287,8 @@ export interface WorkOrderBoardSearchQueryParams {
/** Past-due filter; independent of WorkOrderType (Other remains types=99). */ /** Past-due filter; independent of WorkOrderType (Other remains types=99). */
overdue?: boolean; overdue?: boolean;
dispatchers?: string[]; dispatchers?: string[];
/** SH-348: region names (East/Central/West/California/Unmapped/Other). */
regions?: string[];
statuses?: number[]; statuses?: number[];
pmTypes?: string[]; pmTypes?: string[];
vendorIds?: number[]; vendorIds?: number[];

View file

@ -108,6 +108,15 @@ export function appendSetChips(
nameOf: (v) => v, nameOf: (v) => v,
clear: (current) => ({ ...current, types: new Set() }), 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) { export function appendSeverityChip(chips: AdvancedFilterChip[], filters: WorkOrderAdvancedFilters) {

View file

@ -26,6 +26,7 @@ function cloneFilters(filters: WorkOrderAdvancedFilters): WorkOrderAdvancedFilte
types: new Set(filters.types), types: new Set(filters.types),
severities: new Set(filters.severities ?? []), severities: new Set(filters.severities ?? []),
dispatchers: new Set(filters.dispatchers), dispatchers: new Set(filters.dispatchers),
regions: new Set(filters.regions ?? []),
statuses: new Set(filters.statuses), statuses: new Set(filters.statuses),
pmTypes: new Set(filters.pmTypes), pmTypes: new Set(filters.pmTypes),
vendorTechs: new Set(filters.vendorTechs), vendorTechs: new Set(filters.vendorTechs),
@ -42,6 +43,7 @@ const ADVANCED_FILTER_ACTIVE_SIGNALS = (filters: WorkOrderAdvancedFilters): bool
Boolean(filters.types?.size), Boolean(filters.types?.size),
Boolean(filters.severities?.size), Boolean(filters.severities?.size),
Boolean(filters.dispatchers?.size), Boolean(filters.dispatchers?.size),
Boolean(filters.regions?.size),
Boolean(filters.statuses?.size), Boolean(filters.statuses?.size),
filters.rescheduled, filters.rescheduled,
filters.carriedOver, filters.carriedOver,

View file

@ -217,6 +217,7 @@ export function toBoardSearchQueryParams(input: {
types: typeFilters.types, types: typeFilters.types,
overdue: typeFilters.overdue, overdue: typeFilters.overdue,
dispatchers: mapDispatchers(filters.dispatchers), dispatchers: mapDispatchers(filters.dispatchers),
regions: filters.regions.size > 0 ? [...filters.regions] : undefined,
statuses: mapStatuses(filters.statuses), statuses: mapStatuses(filters.statuses),
pmTypes: filters.pmTypes.size > 0 ? [...filters.pmTypes] : undefined, pmTypes: filters.pmTypes.size > 0 ? [...filters.pmTypes] : undefined,
vendorIds: resolveVendorIds(filters.vendorTechs, input.vendorIdsByCompanyName), vendorIds: resolveVendorIds(filters.vendorTechs, input.vendorIdsByCompanyName),

View file

@ -10,6 +10,9 @@ const ISO_DATE_PATTERN = /^\d{4}-\d{2}-\d{2}$/;
const COMPLETION_DOC_FILTERS: CompletionDocFilter[] = ["No", "Yes", "NN"]; 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[] = [ const UPLIFT_FILTER_VALUES: UpliftFilterStatus[] = [
"pending", "pending",
"approved", "approved",
@ -45,6 +48,11 @@ function parseCompletionDocs(raw: string | null): CompletionDocFilter[] {
return [...new Set(parseList(raw))].filter((doc): doc is CompletionDocFilter => valid.has(doc)); return [...new Set(parseList(raw))].filter((doc): doc is CompletionDocFilter => valid.has(doc));
} }
function parseRegions(raw: string | null): string[] {
const valid = new Set<string>(BOARD_REGION_NAMES);
return [...new Set(parseList(raw))].filter((region) => valid.has(region));
}
function parseUpliftStatuses(raw: string | null): UpliftFilterStatus[] { function parseUpliftStatuses(raw: string | null): UpliftFilterStatus[] {
const valid = new Set<string>(UPLIFT_FILTER_VALUES); const valid = new Set<string>(UPLIFT_FILTER_VALUES);
return [...new Set(parseList(raw))].filter((value): value is UpliftFilterStatus => return [...new Set(parseList(raw))].filter((value): value is UpliftFilterStatus =>
@ -63,6 +71,7 @@ export function parseDashboardDrilldownParams(
const statuses = parseStatuses(params.get("statuses")); const statuses = parseStatuses(params.get("statuses"));
const types = parseTypes(params.get("types")); const types = parseTypes(params.get("types"));
const dispatchers = parseList(params.get("dispatchers")); const dispatchers = parseList(params.get("dispatchers"));
const regions = parseRegions(params.get("regions"));
const upliftStatuses = parseUpliftStatuses(params.get("upliftStatuses")); const upliftStatuses = parseUpliftStatuses(params.get("upliftStatuses"));
const hasUplift = params.get("hasUplift") === "1"; const hasUplift = params.get("hasUplift") === "1";
@ -73,6 +82,7 @@ export function parseDashboardDrilldownParams(
statuses.length > 0 || statuses.length > 0 ||
types.length > 0 || types.length > 0 ||
dispatchers.length > 0 || dispatchers.length > 0 ||
regions.length > 0 ||
upliftStatuses.length > 0 || upliftStatuses.length > 0 ||
hasUplift; hasUplift;
@ -101,6 +111,9 @@ export function parseDashboardDrilldownParams(
if (dispatchers.length > 0) { if (dispatchers.length > 0) {
filters.dispatchers = new Set(dispatchers); filters.dispatchers = new Set(dispatchers);
} }
if (regions.length > 0) {
filters.regions = new Set(regions);
}
if (upliftStatuses.length > 0) { if (upliftStatuses.length > 0) {
filters.upliftStatuses = new Set(upliftStatuses); filters.upliftStatuses = new Set(upliftStatuses);
} }

View file

@ -51,12 +51,14 @@ export const queryKeys = {
}, },
dashboard: { dashboard: {
all: ["dashboard"] as const, all: ["dashboard"] as const,
stats: (range: DashboardRangeKey) => [...queryKeys.dashboard.all, "stats", range] as const, stats: (range: DashboardRangeKey, dispatcherId: string | null = null) =>
workload: (range: DashboardRangeKey, page = 1) => [...queryKeys.dashboard.all, "stats", range, dispatcherId] as const,
[...queryKeys.dashboard.all, "workload", range, page] as const, workload: (range: DashboardRangeKey, page = 1, dispatcherId: string | null = null) =>
performance: (range: DashboardRangeKey, page = 1) => [...queryKeys.dashboard.all, "workload", range, page, dispatcherId] as const,
[...queryKeys.dashboard.all, "performance", range, page] as const, performance: (range: DashboardRangeKey, page = 1, dispatcherId: string | null = null) =>
regions: (range: DashboardRangeKey) => [...queryKeys.dashboard.all, "regions", range] as const, [...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, vendorInsights: () => [...queryKeys.dashboard.all, "vendorInsights"] as const,
trend: (params?: DashboardTrendParams) => trend: (params?: DashboardTrendParams) =>
[...queryKeys.dashboard.all, "trend", params ?? {}] as const, [...queryKeys.dashboard.all, "trend", params ?? {}] as const,

View file

@ -14,3 +14,18 @@ export function isAdminUser(userRoles: string | null | undefined): boolean {
.map((role) => role.trim().toLowerCase()) .map((role) => role.trim().toLowerCase())
.includes("admin"); .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");
}

View file

@ -5,9 +5,10 @@ import { renderWithProviders } from "@/test/test-utils";
const navigate = vi.fn(); const navigate = vi.fn();
const statsRange = vi.fn<(range: DashboardRangeKey) => void>(); const statsRange = vi.fn<(range: DashboardRangeKey) => void>();
const { workloadMock, performanceMock } = vi.hoisted(() => ({ const { workloadMock, performanceMock, authUser } = vi.hoisted(() => ({
workloadMock: vi.fn(), workloadMock: vi.fn(),
performanceMock: 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 }; const EMPTY_DISPATCHER_PAGE = { items: [], page: 1, pageSize: 10, totalDispatchers: 0 };
@ -17,6 +18,17 @@ vi.mock("react-router", async (importOriginal) => ({
useNavigate: () => navigate, 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<typeof import("@/providers/auth-context")>()),
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", () => ({ vi.mock("@/domain/dashboard/use-cases/use-dashboard-workload", () => ({
useDashboardWorkload: workloadMock, useDashboardWorkload: workloadMock,
})); }));
@ -77,6 +89,7 @@ function renderDashboard() {
describe("DashboardPage", () => { describe("DashboardPage", () => {
beforeEach(() => { beforeEach(() => {
authUser.current = { id: "99", userRoles: "Admin" };
navigate.mockReset(); navigate.mockReset();
statsRange.mockReset(); statsRange.mockReset();
workloadMock.mockReset(); workloadMock.mockReset();
@ -107,6 +120,7 @@ describe("DashboardPage", () => {
"Pending Uplifts", "Pending Uplifts",
"Aveta Pending", "Aveta Pending",
"Work Order Breakdown", "Work Order Breakdown",
"Status Distribution",
"Dispatcher Workload", "Dispatcher Workload",
"Dispatcher Performance", "Dispatcher Performance",
"Work Orders by Region", "Work Orders by Region",
@ -117,6 +131,27 @@ describe("DashboardPage", () => {
expect(statsRange).toHaveBeenCalledWith("this-week"); 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", () => { it("exposes positive KPI cards as keyboard-accessible drill-downs", () => {
renderDashboard(); renderDashboard();
@ -168,8 +203,8 @@ describe("DashboardPage", () => {
it("shows the documented empty states when datasets are empty", () => { it("shows the documented empty states when datasets are empty", () => {
renderDashboard(); renderDashboard();
// Breakdown and Trend share this empty copy, so both cards surface it. // Region, Status Distribution, and Trend share this empty copy.
expect(screen.getAllByText("No work orders match the current filters.")).toHaveLength(2); 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.getAllByText("No dispatchers are set up yet.")).toHaveLength(2);
expect(screen.getByText("No active vendor has any work orders yet.")).toBeInTheDocument(); expect(screen.getByText("No active vendor has any work orders yet.")).toBeInTheDocument();
}); });

View file

@ -31,6 +31,10 @@ describe("dashboardApi", () => {
{ Status: "Scheduled", Count: "7" }, { Status: "Scheduled", Count: "7" },
{ Status: "Completed", Count: 4 }, { Status: "Completed", Count: 4 },
], ],
StatusDistribution: [
{ Status: "Scheduled", Count: "7" },
{ Status: "Unknown", Count: 1 },
],
}, },
}); });
@ -52,6 +56,10 @@ describe("dashboardApi", () => {
{ status: "Scheduled", count: 7 }, { status: "Scheduled", count: 7 },
{ status: "Completed", count: 4 }, { status: "Completed", count: 4 },
], ],
statusDistribution: [
{ status: "Scheduled", count: 7 },
{ status: "Unknown", count: 1 },
],
}); });
expect(apiGet).toHaveBeenCalledWith("Dashboard/Stats", { expect(apiGet).toHaveBeenCalledWith("Dashboard/Stats", {
searchParams: { dateFrom: "2026-09-14", dateTo: "2026-09-18" }, searchParams: { dateFrom: "2026-09-14", dateTo: "2026-09-18" },
@ -116,6 +124,8 @@ describe("dashboardApi", () => {
DispatcherName: "Dana Reyes", DispatcherName: "Dana Reyes",
CompletionRate: 96.5, CompletionRate: 96.5,
AverageResolutionDays: 3.25, AverageResolutionDays: 3.25,
AssignedCount: 40,
CompletedCount: "38",
}, },
], ],
Page: 2, Page: 2,
@ -132,6 +142,8 @@ describe("dashboardApi", () => {
dispatcherName: "Dana Reyes", dispatcherName: "Dana Reyes",
completionRate: 96.5, completionRate: 96.5,
averageResolutionDays: 3.25, averageResolutionDays: 3.25,
assignedCount: 40,
completedCount: 38,
}, },
], ],
page: 2, page: 2,

View file

@ -1,4 +1,4 @@
import { describe, expect, it } from "vitest"; import { afterEach, describe, expect, it, vi } from "vitest";
import { import {
avetaPendingDrilldownSearch, avetaPendingDrilldownSearch,
scheduledTomorrowDrilldownSearch, scheduledTomorrowDrilldownSearch,
@ -8,6 +8,10 @@ import {
} from "@/domain/dashboard/utils/work-order-drilldown-links"; } from "@/domain/dashboard/utils/work-order-drilldown-links";
describe("work-order drilldown links", () => { describe("work-order drilldown links", () => {
afterEach(() => {
vi.useRealTimers();
});
it("schedules the tomorrow drilldown on the business clock", () => { it("schedules the tomorrow drilldown on the business clock", () => {
const search = scheduledTomorrowDrilldownSearch("2026-09-16"); const search = scheduledTomorrowDrilldownSearch("2026-09-16");
@ -30,6 +34,11 @@ describe("work-order drilldown links", () => {
}); });
it("carries the range into status drilldowns", () => { 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"); const search = workOrderStatusDrilldownSearch("Scheduled", "today");
expect(search.get("dateFrom")).toBe("2026-09-16"); expect(search.get("dateFrom")).toBe("2026-09-16");

View file

@ -52,6 +52,14 @@ describe("parseDashboardDrilldownParams", () => {
expect(filters?.upliftStatuses).toEqual(new Set(["pending"])); 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", () => { it("rejects partial or malformed date ranges", () => {
expect( expect(
parseDashboardDrilldownParams( parseDashboardDrilldownParams(

View file

@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { isAdminUser } from "@/lib/auth/user-utils"; import { canViewAllDispatchersOnDashboard, isAdminUser } from "@/lib/auth/user-utils";
describe("isAdminUser", () => { describe("isAdminUser", () => {
it("returns true when Admin appears in roles", () => { it("returns true when Admin appears in roles", () => {
@ -13,3 +13,17 @@ describe("isAdminUser", () => {
expect(isAdminUser(undefined)).toBe(false); 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);
});
});