mirror of
https://github.com/Sea-Haven-Industries/shoc-frontend-new.git
synced 2026-10-07 01:12:03 +00:00
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:
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 |
256
e2e/dashboard/dashboard.visual.spec.ts
Normal file
256
e2e/dashboard/dashboard.visual.spec.ts
Normal 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");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -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">
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
21
src/app/(protected)/dashboard/_components/dashboard-scope.ts
Normal file
21
src/app/(protected)/dashboard/_components/dashboard-scope.ts
Normal 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;
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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[]> => {
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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[];
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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[];
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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),
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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");
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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");
|
||||||
|
|
|
||||||
|
|
@ -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(
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue