mirror of
https://github.com/Sea-Haven-Industries/shoc-frontend-new.git
synced 2026-09-30 06:53:12 +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 {
|
||||
title: string;
|
||||
/** Optional scope caption shown beside the title (e.g. SH-349's all-time note). */
|
||||
caption?: string;
|
||||
isLoading?: boolean;
|
||||
error?: unknown;
|
||||
isEmpty: boolean;
|
||||
|
|
@ -14,6 +16,7 @@ interface DashboardCardProps {
|
|||
|
||||
export function DashboardCard({
|
||||
title,
|
||||
caption,
|
||||
isLoading = false,
|
||||
error,
|
||||
isEmpty,
|
||||
|
|
@ -22,8 +25,13 @@ export function DashboardCard({
|
|||
}: DashboardCardProps) {
|
||||
return (
|
||||
<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>
|
||||
{!!caption && (
|
||||
<Text variant="description" tone="muted">
|
||||
{caption}
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
{isLoading && (
|
||||
<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();
|
||||
}
|
||||
|
||||
// SH-347: colour the completion rate green at 90+, amber 70–89, red below 70.
|
||||
function completionRateColor(rate: number): string {
|
||||
if (rate >= 90) return "success.main";
|
||||
if (rate >= 70) return "warning.main";
|
||||
return "error.main";
|
||||
}
|
||||
|
||||
function DispatcherNameCell({
|
||||
row,
|
||||
range,
|
||||
|
|
@ -38,9 +45,9 @@ function DispatcherNameCell({
|
|||
// A listed dispatcher is one with work in range; the drill-down filters Work
|
||||
// Orders by dispatcher + range and never uses the completion rate, so gate on
|
||||
// identity rather than the rate. (A dispatcher with assigned work but a 0%
|
||||
// rate was previously inert here while drillable from Workload.) When SH-347's
|
||||
// Assigned/Completed counts land on DispatcherPerformanceRow this should
|
||||
// become `assigned > 0`, matching Workload's `totalWorkOrders > 0`.
|
||||
// rate was previously inert here while drillable from Workload.) The gate stays
|
||||
// on identity rather than `assignedCount > 0` so a legacy response without the
|
||||
// SH-347 count field (assignedCount → 0) keeps its rows drillable.
|
||||
const drillable = Boolean(row.dispatcherId);
|
||||
|
||||
if (!drillable) {
|
||||
|
|
@ -84,6 +91,12 @@ export function DispatcherPerformanceCard({
|
|||
<TableHead>
|
||||
<TableRow>
|
||||
<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">
|
||||
Completion %
|
||||
</TableCell>
|
||||
|
|
@ -98,7 +111,14 @@ export function DispatcherPerformanceCard({
|
|||
<TableCell>
|
||||
<DispatcherNameCell row={row} range={range} />
|
||||
</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">
|
||||
{row.averageResolutionDays == null ? "—" : formatDays(row.averageResolutionDays)}
|
||||
</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 (
|
||||
<DashboardCard
|
||||
title="Vendor Insights"
|
||||
caption="All-time, company-wide"
|
||||
isLoading={isLoading}
|
||||
error={error}
|
||||
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 { DashboardCard } from "./dashboard-card";
|
||||
|
||||
|
|
@ -6,30 +15,42 @@ const EMPTY_MESSAGE = "No work orders match the current filters.";
|
|||
|
||||
interface WorkOrdersByRegionCardProps {
|
||||
rows: RegionWorkOrdersRow[];
|
||||
range: DashboardRangeKey;
|
||||
isLoading?: boolean;
|
||||
error?: unknown;
|
||||
}
|
||||
|
||||
export function WorkOrdersByRegionCard({
|
||||
rows,
|
||||
range,
|
||||
isLoading = false,
|
||||
error,
|
||||
}: 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 (
|
||||
<DashboardCard
|
||||
title="Work Orders by Region"
|
||||
isLoading={isLoading}
|
||||
error={error}
|
||||
isEmpty={!isLoading && !error && rows.length === 0}
|
||||
isEmpty={!isLoading && !error && zones.length === 0}
|
||||
emptyMessage={EMPTY_MESSAGE}
|
||||
>
|
||||
<DashboardBarList
|
||||
ariaLabel="Work Orders by Region"
|
||||
rows={rows.map((row) => ({
|
||||
rows={zones.map((row) => ({
|
||||
id: row.region,
|
||||
label: row.region,
|
||||
count: row.workOrderCount,
|
||||
}))}
|
||||
onSelectRow={(row) =>
|
||||
navigate(workOrderDrilldownUrl(workOrderRegionDrilldownSearch(row.id, range)))
|
||||
}
|
||||
/>
|
||||
</DashboardCard>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -4,11 +4,22 @@ import EventOutlinedIcon from "@mui/icons-material/EventOutlined";
|
|||
import PendingActionsIcon from "@mui/icons-material/PendingActions";
|
||||
import ShieldOutlinedIcon from "@mui/icons-material/ShieldOutlined";
|
||||
import { Box, Button, CircularProgress } from "@mui/material";
|
||||
import { Users } from "lucide-react";
|
||||
import { PageHeader } from "@/components/ui/page-header";
|
||||
import { Text } from "@/components/ui/text";
|
||||
import { canViewAllDispatchersOnDashboard } from "@/lib/auth/user-utils";
|
||||
import { useAuthContext } from "@/providers/auth-context";
|
||||
import { DashboardKpiCard } from "@/app/(protected)/dashboard/_components/dashboard-kpi-card";
|
||||
import { DashboardRangeToggle } from "@/app/(protected)/dashboard/_components/dashboard-range-toggle";
|
||||
import { DashboardTrendCard } from "@/app/(protected)/dashboard/_components/dashboard-trend-card";
|
||||
import { DashboardScopePicker } from "@/app/(protected)/dashboard/_components/dashboard-scope-picker";
|
||||
import {
|
||||
DASHBOARD_SCOPE_ALL,
|
||||
scopeDispatcherId,
|
||||
type DashboardScope,
|
||||
} from "@/app/(protected)/dashboard/_components/dashboard-scope";
|
||||
import { DispatcherTablesSection } from "@/app/(protected)/dashboard/_components/dispatcher-tables-section";
|
||||
import { StatusDistributionCard } from "@/app/(protected)/dashboard/_components/status-distribution-card";
|
||||
import { VendorInsightsCard } from "@/app/(protected)/dashboard/_components/vendor-insights-card";
|
||||
import { WorkOrderBreakdownCard } from "@/app/(protected)/dashboard/_components/work-order-breakdown-card";
|
||||
import { WorkOrdersByRegionCard } from "@/app/(protected)/dashboard/_components/work-orders-by-region-card";
|
||||
|
|
@ -21,6 +32,7 @@ import { useDashboardRegions } from "@/domain/dashboard/use-cases/use-dashboard-
|
|||
import { useDashboardStats } from "@/domain/dashboard/use-cases/use-dashboard-stats";
|
||||
import { useDashboardVendorInsights } from "@/domain/dashboard/use-cases/use-dashboard-vendor-insights";
|
||||
import { useDashboardWorkload } from "@/domain/dashboard/use-cases/use-dashboard-workload";
|
||||
import { useWorkOrderUsers } from "@/domain/work-orders/use-cases/use-work-orders-list";
|
||||
import {
|
||||
avetaPendingDrilldownSearch,
|
||||
scheduledTomorrowDrilldownSearch,
|
||||
|
|
@ -30,25 +42,58 @@ import {
|
|||
|
||||
export default function DashboardPage() {
|
||||
const navigate = useNavigate();
|
||||
const { user } = useAuthContext();
|
||||
// SH-336: only Scheduler/Admin may broaden the Dashboard past their own work
|
||||
// orders. A Dispatcher is locked to their own WOs (scoped server-side by
|
||||
// backend#126) and never sees the picker.
|
||||
const canViewAll = canViewAllDispatchersOnDashboard(user?.userRoles);
|
||||
|
||||
const [range, setRange] = useState<DashboardRangeKey>(DEFAULT_DASHBOARD_RANGE);
|
||||
const [scope, setScope] = useState<DashboardScope>(DASHBOARD_SCOPE_ALL);
|
||||
const [workloadPage, setWorkloadPage] = useState(1);
|
||||
const [performancePage, setPerformancePage] = useState(1);
|
||||
const stats = useDashboardStats(range);
|
||||
const workload = useDashboardWorkload(range, workloadPage);
|
||||
const performance = useDashboardPerformance(range, performancePage);
|
||||
const regions = useDashboardRegions(range);
|
||||
|
||||
const dispatcherUsers = useWorkOrderUsers();
|
||||
|
||||
// A Dispatcher sends no dispatcherId (the server already scopes them); only the
|
||||
// picker, available to Scheduler/Admin, drives an explicit scope.
|
||||
const dispatcherId = canViewAll ? scopeDispatcherId(scope, user?.id) : null;
|
||||
|
||||
const stats = useDashboardStats(range, dispatcherId);
|
||||
const workload = useDashboardWorkload(range, workloadPage, dispatcherId, canViewAll);
|
||||
const performance = useDashboardPerformance(range, performancePage, dispatcherId, canViewAll);
|
||||
const regions = useDashboardRegions(range, dispatcherId);
|
||||
const vendors = useDashboardVendorInsights();
|
||||
|
||||
// A range change re-scopes both dispatcher tables, so their page must reset —
|
||||
// otherwise a switch while on page 3 lands on an out-of-range empty page that
|
||||
// reads as a false "no dispatchers" state.
|
||||
const changeRange = (next: DashboardRangeKey) => {
|
||||
setRange(next);
|
||||
const resetDispatcherPages = () => {
|
||||
setWorkloadPage(1);
|
||||
setPerformancePage(1);
|
||||
};
|
||||
|
||||
const resetFilters = () => changeRange(DEFAULT_DASHBOARD_RANGE);
|
||||
const changeRange = (next: DashboardRangeKey) => {
|
||||
setRange(next);
|
||||
resetDispatcherPages();
|
||||
};
|
||||
|
||||
const changeScope = (next: DashboardScope) => {
|
||||
setScope(next);
|
||||
resetDispatcherPages();
|
||||
};
|
||||
|
||||
const resetFilters = () => {
|
||||
setRange(DEFAULT_DASHBOARD_RANGE);
|
||||
setScope(DASHBOARD_SCOPE_ALL);
|
||||
resetDispatcherPages();
|
||||
};
|
||||
|
||||
// A Dispatcher's forced own-WOs scope never triggers the reset affordance (there
|
||||
// is nothing for them to reset but the date range); a Scheduler/Admin's non-All
|
||||
// scope does.
|
||||
const scopeIsFiltered = canViewAll && scope.mode !== "all";
|
||||
const showReset = range !== DEFAULT_DASHBOARD_RANGE || scopeIsFiltered;
|
||||
|
||||
const isRefreshing =
|
||||
[stats, workload, performance, regions, vendors].some((query) => query.isFetching) &&
|
||||
|
|
@ -61,11 +106,30 @@ export default function DashboardPage() {
|
|||
actions={
|
||||
<Box className="flex items-center gap-2">
|
||||
{isRefreshing && <CircularProgress size={16} />}
|
||||
{range !== DEFAULT_DASHBOARD_RANGE && (
|
||||
{showReset && (
|
||||
<Button size="small" onClick={resetFilters}>
|
||||
Reset filters
|
||||
</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} />
|
||||
</Box>
|
||||
}
|
||||
|
|
@ -106,20 +170,29 @@ export default function DashboardPage() {
|
|||
isLoading={stats.isLoading}
|
||||
error={stats.error}
|
||||
/>
|
||||
<StatusDistributionCard
|
||||
rows={stats.data?.statusDistribution ?? []}
|
||||
range={range}
|
||||
isLoading={stats.isLoading}
|
||||
error={stats.error}
|
||||
/>
|
||||
<WorkOrdersByRegionCard
|
||||
rows={regions.data ?? []}
|
||||
range={range}
|
||||
isLoading={regions.isLoading}
|
||||
error={regions.error}
|
||||
/>
|
||||
<DispatcherTablesSection
|
||||
range={range}
|
||||
workload={workload}
|
||||
performance={performance}
|
||||
workloadPage={workloadPage}
|
||||
performancePage={performancePage}
|
||||
onWorkloadPageChange={setWorkloadPage}
|
||||
onPerformancePageChange={setPerformancePage}
|
||||
/>
|
||||
{canViewAll && (
|
||||
<DispatcherTablesSection
|
||||
range={range}
|
||||
workload={workload}
|
||||
performance={performance}
|
||||
workloadPage={workloadPage}
|
||||
performancePage={performancePage}
|
||||
onWorkloadPageChange={setWorkloadPage}
|
||||
onPerformancePageChange={setPerformancePage}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
<DashboardTrendCard range={range} />
|
||||
<VendorInsightsCard
|
||||
|
|
|
|||
|
|
@ -8,8 +8,15 @@ export function useDashboardDrilldownFilters(
|
|||
): boolean {
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
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);
|
||||
|
||||
useEffect(() => {
|
||||
onApplyRef.current = onApply;
|
||||
}, [onApply]);
|
||||
|
||||
useEffect(() => {
|
||||
if (appliedRef.current) {
|
||||
return;
|
||||
|
|
@ -22,9 +29,9 @@ export function useDashboardDrilldownFilters(
|
|||
|
||||
appliedRef.current = true;
|
||||
setFromDashboard(true);
|
||||
onApply(drilldown);
|
||||
onApplyRef.current(drilldown);
|
||||
setSearchParams({}, { replace: true });
|
||||
}, [searchParams, setSearchParams, onApply]);
|
||||
}, [searchParams, setSearchParams]);
|
||||
|
||||
return fromDashboard;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import type { DashboardRangeParams } from "@/domain/dashboard/types/dashboard-ra
|
|||
import type {
|
||||
DashboardBreakdownRow,
|
||||
DashboardStats,
|
||||
DashboardStatusBucketRow,
|
||||
} from "@/domain/dashboard/types/dashboard-stats";
|
||||
import type { DispatcherPerformanceRow } from "@/domain/dashboard/types/dashboard-performance";
|
||||
import {
|
||||
|
|
@ -79,27 +80,46 @@ function readCollection(value: unknown, key: string): Record<string, unknown>[]
|
|||
return asRecords(collection);
|
||||
}
|
||||
|
||||
function rangeSearchParams(range?: DashboardRangeParams): Options | undefined {
|
||||
if (!range?.dateFrom || !range?.dateTo) {
|
||||
return undefined;
|
||||
/**
|
||||
* Stats, Regions, Workload, and Performance share `DashboardStatsQueryDTO`
|
||||
* (backend#145): the range dates ride along when present, and `dispatcherId`
|
||||
* scopes the read to a single dispatcher (SH-336's picker — All sends nothing,
|
||||
* My WOs / an individual sends that id). Vendor Insights and Trend take no such
|
||||
* param and stay company-wide (SH-349).
|
||||
*/
|
||||
function scopedSearchParams(
|
||||
range?: DashboardRangeParams,
|
||||
dispatcherId?: string,
|
||||
): Options | undefined {
|
||||
const searchParams: Record<string, string> = {};
|
||||
if (range?.dateFrom && range?.dateTo) {
|
||||
searchParams.dateFrom = range.dateFrom;
|
||||
searchParams.dateTo = range.dateTo;
|
||||
}
|
||||
|
||||
return { searchParams: { dateFrom: range.dateFrom, dateTo: range.dateTo } };
|
||||
if (dispatcherId) {
|
||||
searchParams.dispatcherId = dispatcherId;
|
||||
}
|
||||
return Object.keys(searchParams).length > 0 ? { searchParams } : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Workload and Performance are paged server-side (backend#126/#127): `page` is
|
||||
* 1-based and always sent, and the range dates ride along when present.
|
||||
* 1-based and always sent, the range dates ride along when present, and
|
||||
* `dispatcherId` scopes to a single dispatcher (SH-336).
|
||||
*/
|
||||
function dispatcherPageSearchParams(
|
||||
range: DashboardRangeParams | undefined,
|
||||
page: number,
|
||||
dispatcherId?: string,
|
||||
): Options {
|
||||
const searchParams: Record<string, string | number> = { page };
|
||||
if (range?.dateFrom && range?.dateTo) {
|
||||
searchParams.dateFrom = range.dateFrom;
|
||||
searchParams.dateTo = range.dateTo;
|
||||
}
|
||||
if (dispatcherId) {
|
||||
searchParams.dispatcherId = dispatcherId;
|
||||
}
|
||||
return { searchParams };
|
||||
}
|
||||
|
||||
|
|
@ -204,6 +224,17 @@ function mapDispatcherIdentity(raw: Record<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 {
|
||||
const data = toRecord(raw);
|
||||
const average = readField(data, "averageResolutionDays");
|
||||
|
|
@ -221,6 +252,7 @@ function mapDashboardStats(raw: unknown): DashboardStats {
|
|||
pendingUplifts: toNumber(readField(data, "pendingUplifts")),
|
||||
avetaPending: toNumber(readField(data, "avetaPending")),
|
||||
breakdown: mapBreakdown(readField(data, "breakdown") ?? readField(data, "workOrderBreakdown")),
|
||||
statusDistribution: mapStatusDistribution(readField(data, "statusDistribution")),
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -256,6 +288,10 @@ function mapDispatcherPerformance(raw: unknown): DispatcherPerformanceRow[] {
|
|||
readField(rawRow, "averageResolutionDays") == null
|
||||
? null
|
||||
: toNumber(readField(rawRow, "averageResolutionDays")),
|
||||
assignedCount: toNumber(readField(rawRow, "assignedCount") ?? readField(rawRow, "assigned")),
|
||||
completedCount: toNumber(
|
||||
readField(rawRow, "completedCount") ?? readField(rawRow, "completed"),
|
||||
),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
|
@ -328,17 +364,24 @@ function mapDashboardTrend(raw: unknown): DashboardTrend {
|
|||
}
|
||||
|
||||
export const dashboardApi = {
|
||||
getStats: async (range?: DashboardRangeParams): Promise<DashboardStats> => {
|
||||
const data = await apiGet<unknown>(API_PATHS.dashboard.stats, rangeSearchParams(range));
|
||||
getStats: async (
|
||||
range?: DashboardRangeParams,
|
||||
dispatcherId?: string,
|
||||
): Promise<DashboardStats> => {
|
||||
const data = await apiGet<unknown>(
|
||||
API_PATHS.dashboard.stats,
|
||||
scopedSearchParams(range, dispatcherId),
|
||||
);
|
||||
return mapDashboardStats(handleApiResponse<unknown>(data));
|
||||
},
|
||||
getWorkload: async (
|
||||
range?: DashboardRangeParams,
|
||||
page = 1,
|
||||
dispatcherId?: string,
|
||||
): Promise<DispatcherPage<DispatcherWorkloadRow>> => {
|
||||
const data = await apiGet<unknown>(
|
||||
API_PATHS.dashboard.workload,
|
||||
dispatcherPageSearchParams(range, page),
|
||||
dispatcherPageSearchParams(range, page, dispatcherId),
|
||||
);
|
||||
const response = handleApiResponse<unknown>(data);
|
||||
const items = mapDispatcherWorkload(response);
|
||||
|
|
@ -347,17 +390,24 @@ export const dashboardApi = {
|
|||
getPerformance: async (
|
||||
range?: DashboardRangeParams,
|
||||
page = 1,
|
||||
dispatcherId?: string,
|
||||
): Promise<DispatcherPage<DispatcherPerformanceRow>> => {
|
||||
const data = await apiGet<unknown>(
|
||||
API_PATHS.dashboard.performance,
|
||||
dispatcherPageSearchParams(range, page),
|
||||
dispatcherPageSearchParams(range, page, dispatcherId),
|
||||
);
|
||||
const response = handleApiResponse<unknown>(data);
|
||||
const items = mapDispatcherPerformance(response);
|
||||
return { items, ...readDispatcherPageMeta(response, page, items.length) };
|
||||
},
|
||||
getRegions: async (range?: DashboardRangeParams): Promise<RegionWorkOrdersRow[]> => {
|
||||
const data = await apiGet<unknown>(API_PATHS.dashboard.regions, rangeSearchParams(range));
|
||||
getRegions: async (
|
||||
range?: DashboardRangeParams,
|
||||
dispatcherId?: string,
|
||||
): Promise<RegionWorkOrdersRow[]> => {
|
||||
const data = await apiGet<unknown>(
|
||||
API_PATHS.dashboard.regions,
|
||||
scopedSearchParams(range, dispatcherId),
|
||||
);
|
||||
return mapRegions(handleApiResponse<unknown>(data));
|
||||
},
|
||||
getVendorInsights: async (): Promise<VendorInsightRow[]> => {
|
||||
|
|
|
|||
|
|
@ -3,4 +3,6 @@ export interface DispatcherPerformanceRow {
|
|||
dispatcherName: string;
|
||||
completionRate: number;
|
||||
averageResolutionDays: number | null;
|
||||
assignedCount: number;
|
||||
completedCount: number;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,6 +3,11 @@ export interface DashboardBreakdownRow {
|
|||
count: number;
|
||||
}
|
||||
|
||||
export interface DashboardStatusBucketRow {
|
||||
status: string;
|
||||
count: number;
|
||||
}
|
||||
|
||||
export interface DashboardStats {
|
||||
total: number;
|
||||
open: number;
|
||||
|
|
@ -16,4 +21,5 @@ export interface DashboardStats {
|
|||
pendingUplifts: number;
|
||||
avetaPending: number;
|
||||
breakdown: DashboardBreakdownRow[];
|
||||
statusDistribution: DashboardStatusBucketRow[];
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,10 +9,14 @@ import { queryKeys } from "@/infra/query-key/query-key";
|
|||
export function useDashboardPerformance(
|
||||
range: DashboardRangeKey,
|
||||
page = 1,
|
||||
dispatcherId: string | null = null,
|
||||
enabled = true,
|
||||
): UseQueryResult<DispatcherPage<DispatcherPerformanceRow>, Error> {
|
||||
return useQuery({
|
||||
queryKey: queryKeys.dashboard.performance(range, page),
|
||||
queryFn: () => dashboardApi.getPerformance(dashboardRangeParams(range), page),
|
||||
queryKey: queryKeys.dashboard.performance(range, page, dispatcherId),
|
||||
queryFn: () =>
|
||||
dashboardApi.getPerformance(dashboardRangeParams(range), page, dispatcherId ?? undefined),
|
||||
staleTime: 60 * 1000,
|
||||
enabled,
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,10 +7,11 @@ import { queryKeys } from "@/infra/query-key/query-key";
|
|||
|
||||
export function useDashboardRegions(
|
||||
range: DashboardRangeKey,
|
||||
dispatcherId: string | null = null,
|
||||
): UseQueryResult<RegionWorkOrdersRow[], Error> {
|
||||
return useQuery({
|
||||
queryKey: queryKeys.dashboard.regions(range),
|
||||
queryFn: () => dashboardApi.getRegions(dashboardRangeParams(range)),
|
||||
queryKey: queryKeys.dashboard.regions(range, dispatcherId),
|
||||
queryFn: () => dashboardApi.getRegions(dashboardRangeParams(range), dispatcherId ?? undefined),
|
||||
staleTime: 60 * 1000,
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,10 +5,13 @@ import type { DashboardStats } from "@/domain/dashboard/types/dashboard-stats";
|
|||
import { dashboardRangeParams } from "@/domain/dashboard/utils/dashboard-range-utils";
|
||||
import { queryKeys } from "@/infra/query-key/query-key";
|
||||
|
||||
export function useDashboardStats(range: DashboardRangeKey): UseQueryResult<DashboardStats, Error> {
|
||||
export function useDashboardStats(
|
||||
range: DashboardRangeKey,
|
||||
dispatcherId: string | null = null,
|
||||
): UseQueryResult<DashboardStats, Error> {
|
||||
return useQuery({
|
||||
queryKey: queryKeys.dashboard.stats(range),
|
||||
queryFn: () => dashboardApi.getStats(dashboardRangeParams(range)),
|
||||
queryKey: queryKeys.dashboard.stats(range, dispatcherId),
|
||||
queryFn: () => dashboardApi.getStats(dashboardRangeParams(range), dispatcherId ?? undefined),
|
||||
staleTime: 60 * 1000,
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,10 +9,14 @@ import { queryKeys } from "@/infra/query-key/query-key";
|
|||
export function useDashboardWorkload(
|
||||
range: DashboardRangeKey,
|
||||
page = 1,
|
||||
dispatcherId: string | null = null,
|
||||
enabled = true,
|
||||
): UseQueryResult<DispatcherPage<DispatcherWorkloadRow>, Error> {
|
||||
return useQuery({
|
||||
queryKey: queryKeys.dashboard.workload(range, page),
|
||||
queryFn: () => dashboardApi.getWorkload(dashboardRangeParams(range), page),
|
||||
queryKey: queryKeys.dashboard.workload(range, page, dispatcherId),
|
||||
queryFn: () =>
|
||||
dashboardApi.getWorkload(dashboardRangeParams(range), page, dispatcherId ?? undefined),
|
||||
staleTime: 60 * 1000,
|
||||
enabled,
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -61,6 +61,15 @@ export function workOrderDispatcherDrilldownSearch(
|
|||
return search;
|
||||
}
|
||||
|
||||
export function workOrderRegionDrilldownSearch(
|
||||
region: string,
|
||||
range: DashboardRangeKey,
|
||||
): URLSearchParams {
|
||||
const search = workOrderRangeDrilldownSearch(range);
|
||||
search.set("regions", region);
|
||||
return search;
|
||||
}
|
||||
|
||||
export function scheduledTomorrowDrilldownSearch(
|
||||
today: string = businessTodayIso(),
|
||||
): URLSearchParams {
|
||||
|
|
|
|||
|
|
@ -32,6 +32,7 @@ export interface WorkOrderAdvancedFilters {
|
|||
types: Set<WOType>;
|
||||
severities: Set<WorkOrderSeverity>;
|
||||
dispatchers: Set<string>;
|
||||
regions: Set<string>;
|
||||
statuses: Set<WizardWOStatus>;
|
||||
rescheduled: boolean;
|
||||
carriedOver: boolean;
|
||||
|
|
@ -55,6 +56,7 @@ export const EMPTY_ADVANCED_FILTERS: WorkOrderAdvancedFilters = {
|
|||
types: new Set(),
|
||||
severities: new Set(),
|
||||
dispatchers: new Set(),
|
||||
regions: new Set(),
|
||||
statuses: new Set(),
|
||||
rescheduled: false,
|
||||
carriedOver: false,
|
||||
|
|
@ -97,6 +99,7 @@ export type AdvancedFilterSetKey =
|
|||
| "sites"
|
||||
| "types"
|
||||
| "dispatchers"
|
||||
| "regions"
|
||||
| "statuses"
|
||||
| "pmTypes"
|
||||
| "vendorTechs"
|
||||
|
|
|
|||
|
|
@ -287,6 +287,8 @@ export interface WorkOrderBoardSearchQueryParams {
|
|||
/** Past-due filter; independent of WorkOrderType (Other remains types=99). */
|
||||
overdue?: boolean;
|
||||
dispatchers?: string[];
|
||||
/** SH-348: region names (East/Central/West/California/Unmapped/Other). */
|
||||
regions?: string[];
|
||||
statuses?: number[];
|
||||
pmTypes?: string[];
|
||||
vendorIds?: number[];
|
||||
|
|
|
|||
|
|
@ -108,6 +108,15 @@ export function appendSetChips(
|
|||
nameOf: (v) => v,
|
||||
clear: (current) => ({ ...current, types: new Set() }),
|
||||
});
|
||||
|
||||
pushSetChip({
|
||||
chips,
|
||||
key: "regions",
|
||||
group: "Region",
|
||||
set: filters.regions,
|
||||
nameOf: (v) => v,
|
||||
clear: (current) => ({ ...current, regions: new Set() }),
|
||||
});
|
||||
}
|
||||
|
||||
export function appendSeverityChip(chips: AdvancedFilterChip[], filters: WorkOrderAdvancedFilters) {
|
||||
|
|
|
|||
|
|
@ -26,6 +26,7 @@ function cloneFilters(filters: WorkOrderAdvancedFilters): WorkOrderAdvancedFilte
|
|||
types: new Set(filters.types),
|
||||
severities: new Set(filters.severities ?? []),
|
||||
dispatchers: new Set(filters.dispatchers),
|
||||
regions: new Set(filters.regions ?? []),
|
||||
statuses: new Set(filters.statuses),
|
||||
pmTypes: new Set(filters.pmTypes),
|
||||
vendorTechs: new Set(filters.vendorTechs),
|
||||
|
|
@ -42,6 +43,7 @@ const ADVANCED_FILTER_ACTIVE_SIGNALS = (filters: WorkOrderAdvancedFilters): bool
|
|||
Boolean(filters.types?.size),
|
||||
Boolean(filters.severities?.size),
|
||||
Boolean(filters.dispatchers?.size),
|
||||
Boolean(filters.regions?.size),
|
||||
Boolean(filters.statuses?.size),
|
||||
filters.rescheduled,
|
||||
filters.carriedOver,
|
||||
|
|
|
|||
|
|
@ -217,6 +217,7 @@ export function toBoardSearchQueryParams(input: {
|
|||
types: typeFilters.types,
|
||||
overdue: typeFilters.overdue,
|
||||
dispatchers: mapDispatchers(filters.dispatchers),
|
||||
regions: filters.regions.size > 0 ? [...filters.regions] : undefined,
|
||||
statuses: mapStatuses(filters.statuses),
|
||||
pmTypes: filters.pmTypes.size > 0 ? [...filters.pmTypes] : undefined,
|
||||
vendorIds: resolveVendorIds(filters.vendorTechs, input.vendorIdsByCompanyName),
|
||||
|
|
|
|||
|
|
@ -10,6 +10,9 @@ const ISO_DATE_PATTERN = /^\d{4}-\d{2}-\d{2}$/;
|
|||
|
||||
const COMPLETION_DOC_FILTERS: CompletionDocFilter[] = ["No", "Yes", "NN"];
|
||||
|
||||
// SH-348: the board region filter (backend#146) accepts these region names.
|
||||
const BOARD_REGION_NAMES = ["East", "Central", "West", "California", "Unmapped/Other"];
|
||||
|
||||
const UPLIFT_FILTER_VALUES: UpliftFilterStatus[] = [
|
||||
"pending",
|
||||
"approved",
|
||||
|
|
@ -45,6 +48,11 @@ function parseCompletionDocs(raw: string | null): CompletionDocFilter[] {
|
|||
return [...new Set(parseList(raw))].filter((doc): doc is CompletionDocFilter => valid.has(doc));
|
||||
}
|
||||
|
||||
function parseRegions(raw: string | null): string[] {
|
||||
const valid = new Set<string>(BOARD_REGION_NAMES);
|
||||
return [...new Set(parseList(raw))].filter((region) => valid.has(region));
|
||||
}
|
||||
|
||||
function parseUpliftStatuses(raw: string | null): UpliftFilterStatus[] {
|
||||
const valid = new Set<string>(UPLIFT_FILTER_VALUES);
|
||||
return [...new Set(parseList(raw))].filter((value): value is UpliftFilterStatus =>
|
||||
|
|
@ -63,6 +71,7 @@ export function parseDashboardDrilldownParams(
|
|||
const statuses = parseStatuses(params.get("statuses"));
|
||||
const types = parseTypes(params.get("types"));
|
||||
const dispatchers = parseList(params.get("dispatchers"));
|
||||
const regions = parseRegions(params.get("regions"));
|
||||
const upliftStatuses = parseUpliftStatuses(params.get("upliftStatuses"));
|
||||
const hasUplift = params.get("hasUplift") === "1";
|
||||
|
||||
|
|
@ -73,6 +82,7 @@ export function parseDashboardDrilldownParams(
|
|||
statuses.length > 0 ||
|
||||
types.length > 0 ||
|
||||
dispatchers.length > 0 ||
|
||||
regions.length > 0 ||
|
||||
upliftStatuses.length > 0 ||
|
||||
hasUplift;
|
||||
|
||||
|
|
@ -101,6 +111,9 @@ export function parseDashboardDrilldownParams(
|
|||
if (dispatchers.length > 0) {
|
||||
filters.dispatchers = new Set(dispatchers);
|
||||
}
|
||||
if (regions.length > 0) {
|
||||
filters.regions = new Set(regions);
|
||||
}
|
||||
if (upliftStatuses.length > 0) {
|
||||
filters.upliftStatuses = new Set(upliftStatuses);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -51,12 +51,14 @@ export const queryKeys = {
|
|||
},
|
||||
dashboard: {
|
||||
all: ["dashboard"] as const,
|
||||
stats: (range: DashboardRangeKey) => [...queryKeys.dashboard.all, "stats", range] as const,
|
||||
workload: (range: DashboardRangeKey, page = 1) =>
|
||||
[...queryKeys.dashboard.all, "workload", range, page] as const,
|
||||
performance: (range: DashboardRangeKey, page = 1) =>
|
||||
[...queryKeys.dashboard.all, "performance", range, page] as const,
|
||||
regions: (range: DashboardRangeKey) => [...queryKeys.dashboard.all, "regions", range] as const,
|
||||
stats: (range: DashboardRangeKey, dispatcherId: string | null = null) =>
|
||||
[...queryKeys.dashboard.all, "stats", range, dispatcherId] as const,
|
||||
workload: (range: DashboardRangeKey, page = 1, dispatcherId: string | null = null) =>
|
||||
[...queryKeys.dashboard.all, "workload", range, page, dispatcherId] as const,
|
||||
performance: (range: DashboardRangeKey, page = 1, dispatcherId: string | null = null) =>
|
||||
[...queryKeys.dashboard.all, "performance", range, page, dispatcherId] as const,
|
||||
regions: (range: DashboardRangeKey, dispatcherId: string | null = null) =>
|
||||
[...queryKeys.dashboard.all, "regions", range, dispatcherId] as const,
|
||||
vendorInsights: () => [...queryKeys.dashboard.all, "vendorInsights"] as const,
|
||||
trend: (params?: DashboardTrendParams) =>
|
||||
[...queryKeys.dashboard.all, "trend", params ?? {}] as const,
|
||||
|
|
|
|||
|
|
@ -14,3 +14,18 @@ export function isAdminUser(userRoles: string | null | undefined): boolean {
|
|||
.map((role) => role.trim().toLowerCase())
|
||||
.includes("admin");
|
||||
}
|
||||
|
||||
/**
|
||||
* SH-336's `viewAllDispatchersOnDashboard` permission. The codebase has no
|
||||
* permission model, so it is derived from role: Admin and Scheduler may pick any
|
||||
* dispatcher (or the whole company) on the Dashboard; a Dispatcher may not and
|
||||
* is locked to their own work orders. Unknown roles default to the restrictive
|
||||
* (Dispatcher) behaviour so no one accidentally gains a company-wide view.
|
||||
*/
|
||||
export function canViewAllDispatchersOnDashboard(userRoles: string | null | undefined): boolean {
|
||||
if (!userRoles) {
|
||||
return false;
|
||||
}
|
||||
const roles = userRoles.split(",").map((role) => role.trim().toLowerCase());
|
||||
return roles.includes("admin") || roles.includes("scheduler");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,9 +5,10 @@ import { renderWithProviders } from "@/test/test-utils";
|
|||
|
||||
const navigate = vi.fn();
|
||||
const statsRange = vi.fn<(range: DashboardRangeKey) => void>();
|
||||
const { workloadMock, performanceMock } = vi.hoisted(() => ({
|
||||
const { workloadMock, performanceMock, authUser } = vi.hoisted(() => ({
|
||||
workloadMock: vi.fn(),
|
||||
performanceMock: vi.fn(),
|
||||
authUser: { current: { id: "99", userRoles: "Admin" } as { id: string; userRoles: string } },
|
||||
}));
|
||||
|
||||
const EMPTY_DISPATCHER_PAGE = { items: [], page: 1, pageSize: 10, totalDispatchers: 0 };
|
||||
|
|
@ -17,6 +18,17 @@ vi.mock("react-router", async (importOriginal) => ({
|
|||
useNavigate: () => navigate,
|
||||
}));
|
||||
|
||||
// An Admin has `viewAllDispatchersOnDashboard`, so the scope picker and the
|
||||
// dispatcher tables render (SH-336).
|
||||
vi.mock("@/providers/auth-context", async (importOriginal) => ({
|
||||
...(await importOriginal<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", () => ({
|
||||
useDashboardWorkload: workloadMock,
|
||||
}));
|
||||
|
|
@ -77,6 +89,7 @@ function renderDashboard() {
|
|||
|
||||
describe("DashboardPage", () => {
|
||||
beforeEach(() => {
|
||||
authUser.current = { id: "99", userRoles: "Admin" };
|
||||
navigate.mockReset();
|
||||
statsRange.mockReset();
|
||||
workloadMock.mockReset();
|
||||
|
|
@ -107,6 +120,7 @@ describe("DashboardPage", () => {
|
|||
"Pending Uplifts",
|
||||
"Aveta Pending",
|
||||
"Work Order Breakdown",
|
||||
"Status Distribution",
|
||||
"Dispatcher Workload",
|
||||
"Dispatcher Performance",
|
||||
"Work Orders by Region",
|
||||
|
|
@ -117,6 +131,27 @@ describe("DashboardPage", () => {
|
|||
expect(statsRange).toHaveBeenCalledWith("this-week");
|
||||
});
|
||||
|
||||
it("shows a Dispatcher the static My WOs scope and hides the dispatcher tables", () => {
|
||||
authUser.current = { id: "7", userRoles: "Dispatcher" };
|
||||
|
||||
renderDashboard();
|
||||
|
||||
expect(screen.getByText("My WOs")).toBeInTheDocument();
|
||||
expect(screen.queryByRole("button", { name: "All dispatchers" })).toBeNull();
|
||||
expect(screen.queryByText("Dispatcher Workload")).toBeNull();
|
||||
expect(screen.queryByText("Dispatcher Performance")).toBeNull();
|
||||
// Their scope is server-scoped, so the tables are never even queried.
|
||||
expect(workloadMock).toHaveBeenCalledWith("this-week", 1, null, false);
|
||||
});
|
||||
|
||||
it("lets an Admin open the dispatcher-scope picker", () => {
|
||||
renderDashboard();
|
||||
|
||||
expect(screen.getByRole("button", { name: /All dispatchers/ })).toBeInTheDocument();
|
||||
expect(screen.getByText("Dispatcher Workload")).toBeInTheDocument();
|
||||
expect(workloadMock).toHaveBeenCalledWith("this-week", 1, null, true);
|
||||
});
|
||||
|
||||
it("exposes positive KPI cards as keyboard-accessible drill-downs", () => {
|
||||
renderDashboard();
|
||||
|
||||
|
|
@ -168,8 +203,8 @@ describe("DashboardPage", () => {
|
|||
it("shows the documented empty states when datasets are empty", () => {
|
||||
renderDashboard();
|
||||
|
||||
// Breakdown and Trend share this empty copy, so both cards surface it.
|
||||
expect(screen.getAllByText("No work orders match the current filters.")).toHaveLength(2);
|
||||
// Region, Status Distribution, and Trend share this empty copy.
|
||||
expect(screen.getAllByText("No work orders match the current filters.")).toHaveLength(3);
|
||||
expect(screen.getAllByText("No dispatchers are set up yet.")).toHaveLength(2);
|
||||
expect(screen.getByText("No active vendor has any work orders yet.")).toBeInTheDocument();
|
||||
});
|
||||
|
|
|
|||
|
|
@ -31,6 +31,10 @@ describe("dashboardApi", () => {
|
|||
{ Status: "Scheduled", Count: "7" },
|
||||
{ Status: "Completed", Count: 4 },
|
||||
],
|
||||
StatusDistribution: [
|
||||
{ Status: "Scheduled", Count: "7" },
|
||||
{ Status: "Unknown", Count: 1 },
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
|
|
@ -52,6 +56,10 @@ describe("dashboardApi", () => {
|
|||
{ status: "Scheduled", count: 7 },
|
||||
{ status: "Completed", count: 4 },
|
||||
],
|
||||
statusDistribution: [
|
||||
{ status: "Scheduled", count: 7 },
|
||||
{ status: "Unknown", count: 1 },
|
||||
],
|
||||
});
|
||||
expect(apiGet).toHaveBeenCalledWith("Dashboard/Stats", {
|
||||
searchParams: { dateFrom: "2026-09-14", dateTo: "2026-09-18" },
|
||||
|
|
@ -116,6 +124,8 @@ describe("dashboardApi", () => {
|
|||
DispatcherName: "Dana Reyes",
|
||||
CompletionRate: 96.5,
|
||||
AverageResolutionDays: 3.25,
|
||||
AssignedCount: 40,
|
||||
CompletedCount: "38",
|
||||
},
|
||||
],
|
||||
Page: 2,
|
||||
|
|
@ -132,6 +142,8 @@ describe("dashboardApi", () => {
|
|||
dispatcherName: "Dana Reyes",
|
||||
completionRate: 96.5,
|
||||
averageResolutionDays: 3.25,
|
||||
assignedCount: 40,
|
||||
completedCount: 38,
|
||||
},
|
||||
],
|
||||
page: 2,
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
avetaPendingDrilldownSearch,
|
||||
scheduledTomorrowDrilldownSearch,
|
||||
|
|
@ -8,6 +8,10 @@ import {
|
|||
} from "@/domain/dashboard/utils/work-order-drilldown-links";
|
||||
|
||||
describe("work-order drilldown links", () => {
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("schedules the tomorrow drilldown on the business clock", () => {
|
||||
const search = scheduledTomorrowDrilldownSearch("2026-09-16");
|
||||
|
||||
|
|
@ -30,6 +34,11 @@ describe("work-order drilldown links", () => {
|
|||
});
|
||||
|
||||
it("carries the range into status drilldowns", () => {
|
||||
// "today" resolves against the business clock, so pin it (midday EDT on
|
||||
// 2026-09-16 → 2026-09-16 in America/New_York) to keep the test deterministic.
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date("2026-09-16T16:00:00Z"));
|
||||
|
||||
const search = workOrderStatusDrilldownSearch("Scheduled", "today");
|
||||
|
||||
expect(search.get("dateFrom")).toBe("2026-09-16");
|
||||
|
|
|
|||
|
|
@ -52,6 +52,14 @@ describe("parseDashboardDrilldownParams", () => {
|
|||
expect(filters?.upliftStatuses).toEqual(new Set(["pending"]));
|
||||
});
|
||||
|
||||
it("parses the region drilldown and drops unknown region names", () => {
|
||||
const filters = parseDashboardDrilldownParams(
|
||||
new URLSearchParams({ regions: "California, Atlantis" }),
|
||||
);
|
||||
|
||||
expect(filters?.regions).toEqual(new Set(["California"]));
|
||||
});
|
||||
|
||||
it("rejects partial or malformed date ranges", () => {
|
||||
expect(
|
||||
parseDashboardDrilldownParams(
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import { isAdminUser } from "@/lib/auth/user-utils";
|
||||
import { canViewAllDispatchersOnDashboard, isAdminUser } from "@/lib/auth/user-utils";
|
||||
|
||||
describe("isAdminUser", () => {
|
||||
it("returns true when Admin appears in roles", () => {
|
||||
|
|
@ -13,3 +13,17 @@ describe("isAdminUser", () => {
|
|||
expect(isAdminUser(undefined)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("canViewAllDispatchersOnDashboard", () => {
|
||||
it("grants the dashboard picker to Admin and Scheduler", () => {
|
||||
expect(canViewAllDispatchersOnDashboard("Admin")).toBe(true);
|
||||
expect(canViewAllDispatchersOnDashboard("Scheduler")).toBe(true);
|
||||
expect(canViewAllDispatchersOnDashboard("Dispatcher, Scheduler")).toBe(true);
|
||||
});
|
||||
|
||||
it("locks a Dispatcher (and unknown roles) to their own work orders", () => {
|
||||
expect(canViewAllDispatchersOnDashboard("Dispatcher")).toBe(false);
|
||||
expect(canViewAllDispatchersOnDashboard("Technician")).toBe(false);
|
||||
expect(canViewAllDispatchersOnDashboard(undefined)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue