Compare commits

...

57 commits

Author SHA1 Message Date
Alexandre Brandizzi
d53507731e
Merge branch 'dev' into feat/ab/sh-326-team-member 2026-09-17 15:30:16 -03:00
Alexandre Brandizzi
73e59a0d9e
Merge pull request #213 from Sea-Haven-Industries/feat/ab/sh-336-dashboard
SH-336: Build dashboard experience
2026-09-17 15:28:18 -03:00
Alexandre Brandizzi
6eacdc09f6
Merge branch 'dev' into feat/ab/sh-336-dashboard 2026-09-17 15:16:22 -03:00
Alexandre Brandizzi
873e3e32de
Merge branch 'dev' into feat/ab/sh-326-team-member 2026-09-17 15:16:18 -03:00
Alexandre Brandizzi
1e0a984ec3
Merge pull request #205 from Sea-Haven-Industries/fix/ab/sh-321-wizard-vendor-notes
SH-321: save wizard vendor company notes
2026-09-17 15:14:19 -03:00
Alexandre Brandizzi
0876e9c3f0
Merge branch 'dev' into feat/ab/sh-336-dashboard 2026-09-17 15:10:22 -03:00
Alexandre Brandizzi
5b6991c93a
Merge branch 'dev' into feat/ab/sh-326-team-member 2026-09-17 15:10:19 -03:00
Alexandre Brandizzi
bf916019f9
Merge branch 'dev' into fix/ab/sh-321-wizard-vendor-notes 2026-09-17 15:02:13 -03:00
Alexandre Brandizzi
27883e3c7a
Merge pull request #217 from Sea-Haven-Industries/fix/ab/frontend-test-stability
SH-376: Past Due uses the local calendar day, and stabilise slow suites
2026-09-17 15:00:17 -03:00
Alexandre Brandizzi
318859fb1b
Merge branch 'dev' into fix/ab/sh-321-wizard-vendor-notes 2026-09-17 14:48:19 -03:00
Alexandre Brandizzi
2d5e7cdd42
Merge branch 'dev' into fix/ab/frontend-test-stability 2026-09-17 14:48:13 -03:00
Alexandre Brandizzi
dfa3476a2c
Merge pull request #216 from Sea-Haven-Industries/feat/ab/sh-207-uplift-queue-frontend
SH-207: Deliver uplift approval queue experience
2026-09-17 14:46:26 -03:00
Codex Review Integration
6a18cc321a test(dashboard): update visual baselines for Team Members nav item 2026-09-17 14:46:16 -03:00
Codex Review Integration
827d52add7 Merge remote-tracking branch 'origin/dev' into feat/ab/sh-326-team-member 2026-09-17 14:36:45 -03:00
Alexandre Brandizzi
b48d1a24be
Merge branch 'dev' into fix/ab/sh-321-wizard-vendor-notes 2026-09-17 14:34:36 -03:00
Alexandre Brandizzi
c614d43900
Merge branch 'dev' into feat/ab/sh-336-dashboard 2026-09-17 14:34:30 -03:00
Alexandre Brandizzi
99481c49e9
Merge branch 'dev' into feat/ab/sh-207-uplift-queue-frontend 2026-09-17 14:34:27 -03:00
Alexandre Brandizzi
6c88df6c5f
Merge branch 'dev' into fix/ab/frontend-test-stability 2026-09-17 14:34:24 -03:00
Alexandre Brandizzi
6420b0d091
Merge pull request #214 from Sea-Haven-Industries/feat/ab/sh-325-add-team-member
Some checks are pending
Frontend checks / Build and test (push) Waiting to run
Frontend checks / governance (push) Waiting to run
Frontend checks / Visual regression (push) Waiting to run
Deploy dev content / Deploy shoc-frontend-new-dev through Terraform (push) Waiting to run
SH-325: add team member form
2026-09-17 14:32:23 -03:00
Alexandre Brandizzi
e5df5b46c0
Merge branch 'dev' into fix/ab/sh-321-wizard-vendor-notes 2026-09-17 14:26:33 -03:00
Alexandre Brandizzi
b2b25d7bae
Merge branch 'dev' into feat/ab/sh-336-dashboard 2026-09-17 14:26:27 -03:00
Alexandre Brandizzi
1a13787ecb
Merge branch 'dev' into feat/ab/sh-207-uplift-queue-frontend 2026-09-17 14:26:21 -03:00
Alexandre Brandizzi
a81fd14d7d
Merge branch 'dev' into feat/ab/sh-325-add-team-member 2026-09-17 14:20:16 -03:00
Alexandre Brandizzi
3550b64ba3
Merge branch 'dev' into fix/ab/frontend-test-stability 2026-09-17 14:20:12 -03:00
Alexandre Brandizzi
0055f5c3b9
Merge branch 'dev' into fix/ab/frontend-test-stability 2026-09-17 14:04:21 -03:00
Codex Review Integration
a65f282b51 Merge remote-tracking branch 'origin/dev' into feat/ab/sh-325-add-team-member 2026-09-17 13:47:06 -03:00
Codex Review Integration
dee81b0a44 Merge remote-tracking branch 'origin/dev' into fix/ab/sh-321-wizard-vendor-notes
# Conflicts:
#	src/app/(protected)/workorders/_components/wizard/use-new-wo-wizard-controller.ts
2026-09-17 13:20:31 -03:00
Codex Review Integration
827f8ca8ba refactor(dashboard): extract scope hook and header actions to satisfy governance caps
Split DashboardPage's scope/paging state into useDashboardScope and the header
controls into DashboardHeaderActions, and flatten parseDashboardDrilldownParams'
hasAny check, keeping every function under the complexity/line-count gates.
2026-09-17 03:14:15 -03:00
Codex Review Integration
1271d3c11e 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.
2026-09-17 02:56:31 -03:00
Codex Review Integration
ebbbd91f83 test(uplifts): serve can-approve in the uplift e2e workflow mock 2026-09-17 02:49:24 -03:00
Codex Review Integration
8bc8ff30b6 fix(uplifts): gate Approve and Reject on the backend tier permission (SH-207)
Wire the backend's GET uplifts/can-approve tier decision into the
pending row and detail modal actions. Approve/Reject now stay disabled
with the plain 'Requires Tier N role' tooltip unless the backend says
the current user may decide at that tier, so roles the backend refuses
(Dispatcher) never get a clickable action or a refusal toast.
2026-09-17 02:33:29 -03:00
Codex Review Integration
078933ad9a Merge remote-tracking branch 'origin/dev' into feat/ab/sh-336-dashboard 2026-09-17 02:21:15 -03:00
Codex Review Integration
7679c28682 refactor(dashboard): extract dispatcher tables into a section component
Move the two paged dispatcher cards and their paging view-model derivation out
of DashboardPage into DispatcherTablesSection (a fragment, so both cards remain
direct children of the dashboard grid). This keeps DashboardPage under the
project complexity threshold after the pager additions, with no change to the
rendered layout or behaviour.
2026-09-17 00:46:39 -03:00
Codex Review Integration
0279cfac64 feat(dashboard): paginate dispatcher tables and fix Aveta drilldown
Workload and Performance page server-side at 10 rows and now return Page,
PageSize, and TotalDispatchers on dev (backend#126/#127), so wire the pager
that SH-346 and SH-347 require: getWorkload/getPerformance send a 1-based
`page` and parse the paging envelope, the hooks and query keys carry the page,
and each dispatcher table renders a 10-row pager. Page state is per-table and
resets on a range change so switching range never lands on an out-of-range
empty page that reads as a false "no dispatchers" state.

Drop `docs=No` from the Aveta Pending drilldown. `docs` filters on
completion-document status, not the Aveta clearance document the tile counts
(SH-294 / backend#138), so it narrowed to the wrong rows. The board has no
missing-Aveta-document filter, so the drilldown is now the Aveta-required work
in the two-day open window — a deliberate superset of the count.
2026-09-17 00:36:55 -03:00
Alexandre Brandizzi
6972972afe test(visual): regenerate visual baselines for team members nav group 2026-09-17 00:30:36 -03:00
Codex Review Integration
dd1b029124 style(uplifts): apply prettier to the uplift detail modal 2026-09-17 00:18:32 -03:00
Codex Review Integration
4bffdac175 fix(uplifts): restrict revoke to admins and quiet permission failures (SH-207)
- Gate the Revoke action on the approved queue row and the detail modal
  to admin users; non-admins get a disabled button with a plain
  'Only admins can revoke uplifts' tooltip and the dialog cannot open.
- Stop nesting an h2 inside DialogTitle in the uplift detail modal
  (hydration error) by rendering the title text as a non-heading.
- Handle work-order detail/uplift query 403s quietly in the detail
  modal: sections fall back to 'Unavailable' and the queries suppress
  the global error toast (QueryCache now honors suppressErrorToast).
- Extract UpliftDecisionDialogs and openUpliftEvidence from the page to
  meet the changed-file maintainability gate (complexity 23 -> pass).
2026-09-17 00:13:54 -03:00
Codex Review Integration
16a2ab5511 Merge remote-tracking branch 'origin/dev' into feat/ab/sh-325-add-team-member 2026-09-17 00:06:18 -03:00
Codex Review Integration
1207df6ed8 Merge remote-tracking branch 'origin/dev' into feat/ab/sh-325-add-team-member
# Conflicts:
#	e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-add.png
#	e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-detail.png
#	e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-edit.png
#	e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-empty.png
#	e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-error.png
#	e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-filter.png
#	e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-inactive.png
#	e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-list.png
#	e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-mobile-navigation.png
#	e2e/__screenshots__/work-orders/work-orders.visual.spec.ts/wo-detail.png
#	e2e/__screenshots__/work-orders/work-orders.visual.spec.ts/wo-empty.png
#	e2e/__screenshots__/work-orders/work-orders.visual.spec.ts/wo-error.png
#	e2e/__screenshots__/work-orders/work-orders.visual.spec.ts/wo-filters.png
#	e2e/__screenshots__/work-orders/work-orders.visual.spec.ts/wo-list.png
#	e2e/__screenshots__/work-orders/work-orders.visual.spec.ts/wo-new.png
2026-09-17 00:04:46 -03:00
Codex Review Integration
a24b43b219 test(team-members): refresh visual baselines (SH-325) 2026-09-16 23:15:17 -03:00
Codex Review Integration
0ac8c4473e fix(team-members): harden team member flows (SH-325) 2026-09-16 23:14:51 -03:00
Codex Review Integration
6a2b9eb80c test(uplifts): cover detail evidence callback (SH-209) 2026-09-16 22:50:40 -03:00
Codex Review Integration
5aed31392c fix(uplifts): complete approval queue review fixes (SH-208) 2026-09-16 22:44:37 -03:00
Codex Review Integration
6f8b502116 fix(uplifts): align approval queue contracts (SH-207) 2026-09-16 22:40:48 -03:00
Codex Review Integration
d1b36ed1c0 style(work-orders): apply prettier to day-view hook 2026-09-16 22:21:17 -03:00
Codex Review Integration
6b84db18ca fix(uplifts): align approval journey test (SH-207) 2026-09-16 22:07:54 -03:00
Codex Review Integration
58fc323ea2 fix(work-orders): use local calendar day for Past Due and stabilize slow suites
computeIsPastDue and the day view's todayKey derived today from
toISOString() (UTC). West of UTC that rolls over hours early, so work
scheduled for today showed as Past Due every evening. Both now use the
local todayIso() helper the board already uses; regression tests pin
23:30 and 00:30 in America/Sao_Paulo with fake Date.

Test stability:
- adv-search page sync: scope pagination queries with within() instead of
  scanning ~330 buttons / 3000 nodes on every waitFor poll
- pm-schedules-list-actions: static import of the edit page so module
  transform does not count against the 5s test budget
- vendor create modal / detail drawer: userEvent.setup({ delay: null });
  15s describe budget for full MUI dialog renders under parallel load
2026-09-16 22:06:33 -03:00
Codex Review Integration
0d95a32071 feat(uplifts): refresh work-order views after decisions (SH-214) 2026-09-16 21:12:22 -03:00
Codex Review Integration
111b813e9e fix(uplifts): show scheduled work order date (SH-209) 2026-09-16 21:02:21 -03:00
Codex Review Integration
85024b40f3 fix(uplifts): use admin revoke contract (SH-212) 2026-09-16 20:54:33 -03:00
Codex Review Integration
3f98ac380f feat(uplifts): deliver approval queue experience (SH-207) 2026-09-16 20:43:12 -03:00
Codex Review Integration
c7388ba2d9 fix(dashboard): map breakdown type keys and open performance drill-downs
The Work Order Breakdown object from the backend serializes its keys
without JsonPropertyName, so they arrive lowercase (pm/emergency/reactive/
overdue/other). The mapper copied them straight into `status`, which the
card uses as both label and the `types` drill-down id, and
`workOrderTypeDrilldownSearch` only accepts canonical WOType values — so
every breakdown row rendered lowercase and was inert (SH-354). Route both
mapping branches through a normalizer that maps type keys back to their
canonical labels case-insensitively; `other` is not a WOType, so it stays a
labelled, non-drillable "Other" row, and unknown values (legacy array-shape
lifecycle labels) pass through unchanged.

Dispatcher Performance gated its drill-down on `completionRate > 0`, so a
dispatcher with assigned work but no completions yet was inert here while
drillable from Workload. The drill-down filters by dispatcher + range and
never uses the rate, so gate on identity instead.

Also mock useDashboardTrend in the dashboard page test so it no longer
issues a real request that failed quietly in jsdom.
2026-09-16 20:40:33 -03:00
Codex Review Integration
1b17cbec01 feat(dashboard): build dashboard experience (SH-336) 2026-09-16 19:37:15 -03:00
Codex Review Integration
1fa9f6ec02 fix(work-orders): close wizard before saving vendor notes on create
The create mutation's onSuccess awaited saveCompanyNotes before calling
onOpenChange(false). Because isCreating is createMutation.isPending ||
isCheckingDuplicate, and isPending drops to false the moment the work-order
create resolves, the dialog stayed open with Create re-enabled and pointer
events unlocked for the length of the roster GET plus notes PATCH. A second
Create click ran handleCreate again; with a blank provisional WO number the
duplicate lookup short-circuits without an API call, so createMutation.mutate
fired a second time and created a duplicate work order.

Close the dialog first, then fire saveCompanyNotes unawaited. The save does
not need the dialog open: fetchCurrentBaseline reads the roster when the
baseline is missing, and the failure toast is raised from the mutation's
option-level onError, so it still surfaces after the dialog unmounts.
2026-09-16 19:29:29 -03:00
Codex Review Integration
cd5d4ad1d8 fix(work-orders): keep wizard vendor notes bound to the selected company
Late roster data no longer overwrites notes typed for the current selection.
The notes baseline is keyed by vendor, so a save never patches a previous
company. Changing the primary service clears leftover notes, and a save
with a missing baseline reads the current roster first. Also drops ticket
keys from shipped comments.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-16 15:18:06 -03:00
Codex Review Integration
7c090571c5 test(work-orders): cover wizard vendor company notes journey 2026-09-16 13:20:32 -03:00
Codex Review Integration
31982f9baa fix(work-orders): save wizard vendor notes to the vendor company 2026-09-16 13:17:02 -03:00
101 changed files with 6304 additions and 1067 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 140 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 120 KiB

View file

@ -0,0 +1,256 @@
import { expect, test, type Page, type Route } from "@playwright/test";
const FROZEN_NOW = "2026-08-19T15:00:00.000Z";
function testUser(userRoles: string) {
return {
token: "dashboard-visual-token",
expiration: "2030-01-01T00:00:00.000Z",
email: "admin@seahavenind.com",
userRoles,
phoneNumber: "5551234567",
fullname: "Dashboard Visual User",
id: "1",
};
}
const DISPATCHERS = [
{ id: "1", name: "Pat Dispatcher" },
{ id: "2", name: "Riley Chen" },
{ id: "3", name: "Sam Okafor" },
];
const STATS = {
total: 128,
open: 52,
notDispatched: 8,
completed: 64,
dueCount: 40,
completedDueCount: 30,
completionRate: 82,
averageResolutionDays: 3.4,
scheduledTomorrow: 6,
pendingUplifts: 4,
avetaPending: 3,
breakdown: [
{ status: "PM", count: 24 },
{ status: "Emergency", count: 6 },
{ status: "Reactive", count: 12 },
{ status: "Overdue", count: 9 },
],
statusDistribution: [
{ status: "Scheduled", count: 30 },
{ status: "In Progress", count: 18 },
{ status: "Completed", count: 64 },
{ status: "Pending", count: 16 },
],
};
const WORKLOAD = {
items: [
{
dispatcherId: "1",
dispatcherName: "Pat Dispatcher",
openWorkOrders: 12,
totalWorkOrders: 20,
},
{ dispatcherId: "2", dispatcherName: "Riley Chen", openWorkOrders: 7, totalWorkOrders: 15 },
],
page: 1,
pageSize: 10,
totalDispatchers: 2,
};
const PERFORMANCE = {
items: [
{
dispatcherId: "1",
dispatcherName: "Pat Dispatcher",
completionRate: 94,
averageResolutionDays: 2.5,
assignedCount: 20,
completedCount: 18,
},
{
dispatcherId: "2",
dispatcherName: "Riley Chen",
completionRate: 78,
averageResolutionDays: 3.1,
assignedCount: 15,
completedCount: 11,
},
{
dispatcherId: "3",
dispatcherName: "Sam Okafor",
completionRate: 61,
averageResolutionDays: 4.4,
assignedCount: 9,
completedCount: 5,
},
],
page: 1,
pageSize: 10,
totalDispatchers: 3,
};
const REGIONS = {
items: [
{ region: "East", workOrderCount: 44 },
{ region: "Central", workOrderCount: 31 },
{ region: "West", workOrderCount: 22 },
{ region: "California", workOrderCount: 18 },
{ region: "Unmapped/Other", workOrderCount: 5 },
],
};
const VENDOR_INSIGHTS = {
items: [
{
vendorId: "101",
vendorName: "Gateway Plumbing",
completionRate: 88,
rescheduleRate: 12,
averageResolutionDays: 2.9,
totalJobs: 40,
},
{
vendorId: "102",
vendorName: "Summit HVAC",
completionRate: 76,
rescheduleRate: 20,
averageResolutionDays: 3.6,
totalJobs: 28,
},
],
};
const TREND = {
granularity: "week",
buckets: [
{
date: "2026-07-27",
label: "Jul 27",
total: 18,
open: 4,
completed: 12,
canceled: 1,
overdue: 1,
isCurrent: false,
},
{
date: "2026-08-03",
label: "Aug 3",
total: 22,
open: 6,
completed: 13,
canceled: 1,
overdue: 2,
isCurrent: false,
},
{
date: "2026-08-10",
label: "Aug 10",
total: 26,
open: 8,
completed: 15,
canceled: 0,
overdue: 3,
isCurrent: false,
},
{
date: "2026-08-17",
label: "Aug 17",
total: 20,
open: 9,
completed: 9,
canceled: 1,
overdue: 1,
isCurrent: true,
},
],
};
async function fulfillJson(route: Route, body: unknown, status = 200) {
await route.fulfill({ status, contentType: "application/json", body: JSON.stringify(body) });
}
async function mockDashboardApi(page: Page, userRoles: string) {
await page.clock.setFixedTime(new Date(FROZEN_NOW));
await page.addInitScript(
(user) => localStorage.setItem("auth", JSON.stringify(user)),
testUser(userRoles),
);
await page.route("**/api/**", async (route) => {
const pathname = new URL(route.request().url()).pathname;
// Only stub real backend calls; let Vite dev module requests (e.g.
// /src/domain/auth/api/*.ts) through untouched.
if (!pathname.startsWith("/api/")) {
await route.continue();
return;
}
if (pathname.includes("/Dashboard/Stats")) return fulfillJson(route, { data: STATS });
if (pathname.includes("/Dashboard/Workload")) return fulfillJson(route, { data: WORKLOAD });
if (pathname.includes("/Dashboard/Performance"))
return fulfillJson(route, { data: PERFORMANCE });
if (pathname.includes("/Dashboard/Regions")) return fulfillJson(route, { data: REGIONS });
if (pathname.includes("/Dashboard/VendorInsights")) {
return fulfillJson(route, { data: VENDOR_INSIGHTS });
}
if (pathname.includes("/Dashboard/Trend")) return fulfillJson(route, { data: TREND });
if (pathname.includes("/workorders/lookups/dispatchers")) {
return fulfillJson(route, { data: DISPATCHERS });
}
return fulfillJson(route, { data: [] });
});
}
async function openDashboard(page: Page, userRoles: string) {
await mockDashboardApi(page, userRoles);
await page.goto("/");
await page.evaluate(() => document.fonts.ready);
await page.addStyleTag({
content: `
*, *::before, *::after { transition: none !important; animation: none !important; }
.MuiDrawer-paper { transform: none !important; }
.Toastify__toast-container { display: none !important; }
.tsqd-parent-container { display: none !important; }
`,
});
}
async function expectStableScreenshot(page: Page, name: string) {
await page.waitForTimeout(250);
await page.evaluate(
() =>
new Promise<void>((resolve) =>
requestAnimationFrame(() => requestAnimationFrame(() => resolve())),
),
);
await expect(page).toHaveScreenshot(name, { fullPage: true });
}
test.describe("Dashboard deterministic pixel regression", () => {
test("admin dashboard", async ({ page }) => {
await openDashboard(page, "Admin");
await expect(page.getByRole("heading", { name: "Dashboard" })).toBeVisible();
await expect(page.getByText("Status Distribution")).toBeVisible();
await expect(page.getByText("Dispatcher Performance")).toBeVisible();
// The scope picker is available to Admin/Scheduler (SH-336).
await expect(page.getByRole("button", { name: /All dispatchers/ })).toBeVisible();
await expectStableScreenshot(page, "dashboard-admin.png");
});
test("dispatcher scoped dashboard", async ({ page }) => {
await openDashboard(page, "Dispatcher");
await expect(page.getByRole("heading", { name: "Dashboard" })).toBeVisible();
// A Dispatcher gets the static "My WOs" label and no dispatcher tables (SH-336).
await expect(page.getByText("My WOs")).toBeVisible();
await expect(page.getByText("Dispatcher Performance")).toHaveCount(0);
await expect(page.getByText("Status Distribution")).toBeVisible();
await expectStableScreenshot(page, "dashboard-dispatcher.png");
});
});

View file

@ -268,7 +268,7 @@ test("vendor revises a changes-requested uplift using its existing evidence", as
});
});
test("internal approver can approve and request changes with audited notes", async ({ page }) => {
test("internal approver can approve, reject, and revoke with audited notes", async ({ page }) => {
const decisions: Array<{ path: string; body: unknown }> = [];
const statuses = new Map<number, string>([
[41, "Pending"],
@ -284,73 +284,95 @@ test("internal approver can approve and request changes with audited notes", asy
await page.route(/\/api\/uplifts(?:[/?]|$)/, async (route) => {
const request = route.request();
const path = new URL(request.url()).pathname;
const action = path.match(/\/uplifts\/(41|42)\/(approve|request-changes)$/);
const action = path.match(/\/uplifts\/(41|42)\/(approve|reject|revoke)$/);
if (action && request.method() === "POST") {
const id = Number(action[1]);
statuses.set(id, action[2] === "approve" ? "Approved" : "ChangesRequested");
statuses.set(
id,
action[2] === "approve" ? "Approved" : action[2] === "revoke" ? "Revoked" : "Rejected",
);
decisions.push({ path, body: request.postDataJSON() });
await fulfillJson(route, { id, status: statuses.get(id) });
return;
}
if (path.endsWith("/uplifts/can-approve")) {
await fulfillJson(route, { canApprove: true });
return;
}
const requestedStatus = new URL(request.url()).searchParams.get("status");
const items = [
{
id: 41,
dispatchId: 7,
workOrderId: 501,
dispatchNumber: "DSP-41",
poNumber: "PO-41",
vendorCompanyName: "Gateway Plumbing",
currentNTE: 100,
requestedNTE: 125,
delta: 25,
requiredTier: 1,
status: statuses.get(41),
canDecide: true,
evidenceDocumentId: 22,
evidenceFileName: "estimate.pdf",
evidenceContentType: "application/pdf",
evidenceSizeBytes: 2048,
},
{
id: 42,
dispatchId: 8,
workOrderId: 502,
dispatchNumber: "DSP-42",
poNumber: "PO-42",
vendorCompanyName: "Metro HVAC",
currentNTE: 200,
requestedNTE: 260,
delta: 60,
requiredTier: 2,
status: statuses.get(42),
canDecide: true,
evidenceDocumentId: 23,
evidenceFileName: "quote.pdf",
},
].filter((item) => requestedStatus == null || item.status === requestedStatus);
await fulfillJson(route, {
total: 2,
items: [
{
id: 41,
dispatchId: 7,
dispatchNumber: "DSP-41",
poNumber: "PO-41",
vendorCompanyName: "Gateway Plumbing",
currentNTE: 100,
requestedNTE: 125,
delta: 25,
requiredTier: 1,
status: statuses.get(41),
canDecide: true,
evidenceDocumentId: 22,
evidenceFileName: "estimate.pdf",
evidenceContentType: "application/pdf",
evidenceSizeBytes: 2048,
},
{
id: 42,
dispatchId: 8,
dispatchNumber: "DSP-42",
poNumber: "PO-42",
vendorCompanyName: "Metro HVAC",
currentNTE: 200,
requestedNTE: 260,
delta: 60,
requiredTier: 2,
status: statuses.get(42),
canDecide: true,
evidenceDocumentId: 23,
evidenceFileName: "quote.pdf",
},
],
total: items.length,
items,
});
});
await page.goto("/uplifts");
await expect(page.getByRole("heading", { name: "Uplift Queue" })).toBeVisible();
await expect(page.getByRole("heading", { name: "Uplift Approvals" })).toBeVisible();
const approvalRow = page.getByRole("row").filter({ hasText: "DSP-41" });
await approvalRow.getByRole("button", { name: "Approve" }).click();
await page.getByRole("button", { name: "Confirm" }).click();
const changesRow = page.getByRole("row").filter({ hasText: "DSP-42" });
await changesRow.getByRole("button", { name: "Request Changes" }).click();
await page.getByLabel("Note (required)").fill("Please itemize labor and parts.");
await page.getByRole("button", { name: "Confirm" }).click();
await changesRow.getByRole("button", { name: "Reject" }).click();
await page.getByLabel("Reason for rejection").fill("Please itemize labor and parts.");
await page.getByRole("button", { name: "Reject uplift" }).click();
await page.getByRole("tab", { name: "Approved" }).click();
const approvedRow = page.getByRole("row").filter({ hasText: "DSP-41" });
await approvedRow.getByRole("button", { name: "Revoke" }).click();
await page.getByLabel("Reason for revoking").fill("Approval was made in error.");
await page.getByRole("button", { name: "Revoke $125" }).click();
await expect
.poll(() => decisions)
.toEqual([
{ path: "/api/uplifts/41/approve", body: {} },
{
path: "/api/uplifts/42/request-changes",
path: "/api/uplifts/42/reject",
body: { note: "Please itemize labor and parts." },
},
{
path: "/api/uplifts/41/revoke",
body: { note: "Approval was made in error." },
},
]);
});

View file

@ -0,0 +1,168 @@
import { expect, test, type Page, type Route } from "@playwright/test";
const FROZEN_NOW = "2026-08-19T15:00:00.000Z";
const TEST_USER = {
token: "wizard-vendor-notes-e2e-token",
expiration: "2030-01-01T00:00:00.000Z",
email: "admin@seahavenind.com",
userRoles: "Admin",
phoneNumber: "5551234567",
fullname: "Work Order E2E Admin",
id: "1",
};
const LOCATIONS = [
{
id: 10,
name: "STL-01 · St. Louis, MO",
siteCode: "STL-01",
city: "St. Louis",
state: "MO",
code: "STL-01",
},
];
const VENDORS = [
{
id: 101,
companyName: "Gateway Plumbing",
contactName: "Adam Whyte",
tradeSpecialties: "Plumbing",
address: "1 Market St",
},
];
type Captured = { method: string; path: string; body: unknown };
async function fulfillJson(route: Route, body: unknown, status = 200) {
await route.fulfill({ status, contentType: "application/json", body: JSON.stringify(body) });
}
async function mockApi(page: Page, captured: Captured[]) {
await page.route(
(url) => url.pathname.startsWith("/api/"),
async (route) => {
const request = route.request();
const { pathname } = new URL(request.url());
if (request.method() !== "GET") {
captured.push({ method: request.method(), path: pathname, body: request.postDataJSON() });
}
if (pathname.endsWith("/workorders/board") && request.method() === "POST") {
await fulfillJson(route, { data: { id: 900, woNumber: "SH00900" } });
return;
}
if (pathname.endsWith("/vendor-company-roster/55") && request.method() === "PATCH") {
await fulfillJson(route, {
companyId: 55,
rowVersion: "rv-2",
name: "Gateway Plumbing",
notes: "Gate code 4411",
technicians: [],
});
return;
}
if (pathname.endsWith("/vendor-company-roster") && request.method() === "GET") {
await fulfillJson(route, {
companyId: 55,
rowVersion: "rv-1",
name: "Gateway Plumbing",
notes: "Call before arrival",
technicians: [{ id: 101, contactName: "Adam Whyte", isActive: true }],
});
return;
}
if (pathname.includes("workorders/board")) {
await fulfillJson(route, {
weekStart: "2026-08-17",
weekEnd: "2026-08-21",
counts: { returned: 0, total: 0 },
unscheduled: [],
scheduled: [],
items: [],
});
return;
}
if (pathname.endsWith("/locations/10")) {
await fulfillJson(route, {
data: {
id: 10,
name: "STL-01 · St. Louis, MO",
contact: "Jamie Site",
phone: "3145550100",
zipCode: "63101",
},
});
return;
}
if (pathname.includes("/locations/sites") || pathname.includes("/Contact/GetLocations")) {
await fulfillJson(route, { data: LOCATIONS });
return;
}
if (pathname.toLowerCase().includes("/vendors/dropdown")) {
await fulfillJson(route, { data: VENDORS });
return;
}
await fulfillJson(route, { data: [], items: [] });
},
);
}
test.describe("wizard vendor company notes (SH-321)", () => {
test.setTimeout(60_000);
test("pre-fills the company notes and saves edits to the vendor company on create", async ({
page,
}) => {
const captured: Captured[] = [];
await page.clock.install({ time: new Date(FROZEN_NOW) });
await page.clock.resume();
await page.addInitScript((user) => {
localStorage.setItem("auth", JSON.stringify(user));
const style = document.createElement("style");
style.textContent = ".tsqd-parent-container { display: none !important; }";
document.documentElement.appendChild(style);
}, TEST_USER);
await mockApi(page, captured);
await page.goto("/workorders");
await page.getByRole("button", { name: "New WO" }).click();
await expect(page.getByRole("heading", { name: "Type & schedule" })).toBeVisible({
timeout: 20_000,
});
await page.getByRole("button", { name: "Continue" }).click();
await page.getByRole("button", { name: /Select site/ }).click();
await page.getByRole("button", { name: "STL-01 · St. Louis, MO" }).first().click();
await expect(page.getByRole("button", { name: "Continue" })).toBeEnabled();
await page.getByRole("button", { name: "Continue" }).click();
await page.getByRole("button", { name: /Select technician/ }).click();
await page
.getByRole("button", { name: /Adam Whyte/ })
.last()
.first()
.click();
const notes = page.getByPlaceholder("Contact preferences, availability, etc.");
await expect(notes).toHaveValue("Call before arrival");
await notes.fill("Gate code 4411");
await page.getByRole("button", { name: /Create/ }).click();
await expect
.poll(() => captured.find((c) => c.method === "PATCH"))
.toEqual({
method: "PATCH",
path: "/api/vendor-company-roster/55",
body: {
rowVersion: "rv-1",
addTechnicians: [],
companyFields: { notes: "Gate code 4411" },
},
});
expect(captured.some((c) => c.method === "POST" && c.path.endsWith("/workorders/board"))).toBe(
true,
);
});
});

View file

@ -153,6 +153,11 @@ export const API_PATHS = {
},
dashboard: {
stats: "Dashboard/Stats",
workload: "Dashboard/Workload",
performance: "Dashboard/Performance",
regions: "Dashboard/Regions",
vendorInsights: "Dashboard/VendorInsights",
trend: "Dashboard/Trend",
},
dropdownOptions: {
byCategory: "DropdownOptions/ByCategory",

View file

@ -1,240 +0,0 @@
import type { ReactNode } from "react";
import { useNavigate } from "react-router";
import AddIcon from "@mui/icons-material/Add";
import CheckCircleOutlineIcon from "@mui/icons-material/CheckCircleOutlined";
import FolderOpenOutlinedIcon from "@mui/icons-material/FolderOpenOutlined";
import ScheduleOutlinedIcon from "@mui/icons-material/ScheduleOutlined";
import {
Alert,
Box,
CircularProgress,
Paper,
Stack,
Table,
TableBody,
TableCell,
TableContainer,
TableHead,
TableRow,
Typography,
} from "@mui/material";
import { useDashboardStats } from "@/domain/dashboard/use-cases/use-dashboard-stats";
const INVOICE_STATUSES = ["Draft", "Challenged", "Pending Approval", "Approved", "Paid"] as const;
const PRIORITY_LEVELS = [5, 4, 3, 2, 1] as const;
interface KpiCardProps {
title: string;
description?: string;
value?: string | number;
icon: ReactNode;
color: "success" | "primary" | "info" | "warning";
onClick: () => void;
loading?: boolean;
}
function KpiCard({ title, description, value, icon, color, onClick, loading }: KpiCardProps) {
const colorMap = {
success: "success.main",
primary: "primary.main",
info: "info.main",
warning: "warning.main",
} as const;
return (
<Paper
variant="outlined"
onClick={onClick}
className="flex min-h-[140px] flex-1 cursor-pointer transition-shadow hover:shadow-md"
sx={{
borderLeftWidth: 4,
borderLeftStyle: "solid",
borderLeftColor: colorMap[color],
}}
>
<Box className="flex w-full gap-4 p-4">
<Box
className="flex h-12 w-12 shrink-0 items-center justify-center rounded-full"
sx={{ bgcolor: `${colorMap[color]}22`, color: colorMap[color] }}
>
{icon}
</Box>
<Box className="flex min-w-0 flex-col justify-center gap-1">
<Typography variant="subtitle1" className="font-semibold">
{title}
</Typography>
{value !== undefined && (
<Typography variant="h4" component="p" className="font-bold leading-tight">
{loading ? "..." : typeof value === "number" ? value.toLocaleString() : value}
</Typography>
)}
{Boolean(description) && (
<Typography
variant="body2"
sx={{
color: "text.secondary",
}}
>
{description}
</Typography>
)}
</Box>
</Box>
</Paper>
);
}
export default function DashboardPage() {
const navigate = useNavigate();
const { data: stats, isLoading, isFetching, error } = useDashboardStats();
const formatStat = (value: number | undefined) => (isLoading ? undefined : (value ?? 0));
return (
<Box className="flex flex-col gap-4 p-4">
<Stack
direction="row"
spacing={2}
sx={{
alignItems: "baseline",
}}
>
<Typography variant="h5" component="h1">
Dashboard
</Typography>
{isFetching && !isLoading && <CircularProgress size={16} />}
</Stack>
{Boolean(error) && (
<Alert severity="error">
{error instanceof Error ? error.message : "Failed to load dashboard stats"}
</Alert>
)}
<Stack
direction={{ xs: "column", lg: "row" }}
spacing={2}
useFlexGap
sx={{
flexWrap: "wrap",
}}
>
<KpiCard
title="Create a Work Order"
description="Report a problem you need fixed"
icon={<AddIcon />}
color="success"
onClick={() => navigate("/workorders/new")}
/>
<KpiCard
title="Open Work Orders"
value={formatStat(stats?.open)}
icon={<FolderOpenOutlinedIcon />}
color="primary"
loading={isLoading}
onClick={() => navigate("/workorders")}
/>
<KpiCard
title="Not Dispatched"
value={formatStat(stats?.notDispatched)}
icon={<ScheduleOutlinedIcon />}
color="info"
loading={isLoading}
onClick={() => navigate("/workorders")}
/>
<KpiCard
title="Completed Orders"
value={formatStat(stats?.completed)}
icon={<CheckCircleOutlineIcon />}
color="warning"
loading={isLoading}
onClick={() => navigate("/workorders")}
/>
</Stack>
<Paper variant="outlined" className="overflow-hidden">
<Box className="border-b border-border px-4 py-3">
<Typography variant="h6" component="h2">
Priority Analysis
</Typography>
<Typography
variant="body2"
sx={{
color: "text.secondary",
}}
>
Age of open Work Orders by Priority
</Typography>
</Box>
<TableContainer>
<Table size="small">
<TableHead>
<TableRow>
<TableCell className="font-semibold">Priority</TableCell>
<TableCell align="right" className="font-semibold">
Total
</TableCell>
<TableCell align="right" className="font-semibold">
0-14 Days
</TableCell>
<TableCell align="right" className="font-semibold">
15-29 Days
</TableCell>
<TableCell align="right" className="font-semibold">
30-59 Days
</TableCell>
<TableCell align="right" className="font-semibold">
60+ Days
</TableCell>
</TableRow>
</TableHead>
<TableBody>
{PRIORITY_LEVELS.map((priority) => (
<TableRow key={priority}>
<TableCell>{priority}</TableCell>
<TableCell align="right">—</TableCell>
<TableCell align="right">—</TableCell>
<TableCell align="right">—</TableCell>
<TableCell align="right">—</TableCell>
<TableCell align="right">—</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</TableContainer>
</Paper>
<Paper variant="outlined" className="overflow-hidden">
<Box className="border-b border-border px-4 py-3">
<Typography variant="h6" component="h2">
Invoice Analysis
</Typography>
</Box>
<TableContainer>
<Table size="small">
<TableHead>
<TableRow>
<TableCell className="font-semibold">Status</TableCell>
<TableCell align="right" className="font-semibold">
Count
</TableCell>
<TableCell align="right" className="font-semibold">
Amount
</TableCell>
</TableRow>
</TableHead>
<TableBody>
{INVOICE_STATUSES.map((status) => (
<TableRow key={status}>
<TableCell>{status}</TableCell>
<TableCell align="right">0</TableCell>
<TableCell align="right">$0.00</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</TableContainer>
</Paper>
</Box>
);
}

View file

@ -0,0 +1,86 @@
import { Box, ButtonBase } from "@mui/material";
import { Text } from "@/components/ui/text";
export interface DashboardBarListRow {
id: string;
label: string;
count: number;
}
interface DashboardBarListProps {
rows: DashboardBarListRow[];
onSelectRow?: (row: DashboardBarListRow) => void;
isSelectable?: (row: DashboardBarListRow) => boolean;
ariaLabel: string;
}
function percentOf(count: number, max: number): number {
if (max <= 0 || count <= 0) {
return 0;
}
return Math.round((count / max) * 100);
}
function BarListRow({ row, width }: { row: DashboardBarListRow; width: number }) {
return (
<Box className="flex w-full flex-col gap-1 py-1.5">
<Box className="flex items-baseline justify-between gap-2">
<Text variant="description" className="truncate">
{row.label}
</Text>
<Text variant="description" tone="muted">
{row.count.toLocaleString()}
</Text>
</Box>
<Box className="h-2 w-full overflow-hidden rounded-full" sx={{ bgcolor: "action.hover" }}>
{width > 0 && (
<Box
data-testid="dashboard-bar-mark"
className="h-full rounded-full"
sx={{ width: `${width}%`, bgcolor: "primary.main" }}
/>
)}
</Box>
</Box>
);
}
export function DashboardBarList({
rows,
onSelectRow,
isSelectable,
ariaLabel,
}: DashboardBarListProps) {
const max = Math.max(...rows.map((row) => row.count), 0);
return (
<Box aria-label={ariaLabel} className="flex flex-col">
{rows.map((row) => {
const width = percentOf(row.count, max);
const selectable = Boolean(onSelectRow) && row.count > 0 && (isSelectable?.(row) ?? true);
return selectable ? (
<ButtonBase
key={row.id}
onClick={() => onSelectRow?.(row)}
sx={{
width: "100%",
textAlign: "left",
borderRadius: 1,
px: 1,
mx: -1,
cursor: "pointer",
}}
>
<BarListRow row={row} width={width} />
</ButtonBase>
) : (
<Box key={row.id} sx={{ px: 1, mx: -1 }}>
<BarListRow row={row} width={width} />
</Box>
);
})}
</Box>
);
}

View file

@ -0,0 +1,52 @@
import type { ReactNode } from "react";
import { Box, CircularProgress, Paper } from "@mui/material";
import { QueryErrorPanel } from "@/components/ui/query-error-panel";
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;
emptyMessage: string;
children: ReactNode;
}
export function DashboardCard({
title,
caption,
isLoading = false,
error,
isEmpty,
emptyMessage,
children,
}: DashboardCardProps) {
return (
<Paper variant="outlined" className="flex flex-col overflow-hidden">
<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">
<CircularProgress size={24} />
</Box>
)}
{!isLoading && Boolean(error) && <QueryErrorPanel compact className="m-4" />}
{!isLoading && !error && isEmpty && (
<Box className="p-6">
<Text variant="description" tone="muted">
{emptyMessage}
</Text>
</Box>
)}
{!isLoading && !error && !isEmpty && <Box className="p-4">{children}</Box>}
</Paper>
);
}

View file

@ -0,0 +1,67 @@
import { Box, Button, CircularProgress } from "@mui/material";
import { Users } from "lucide-react";
import { Text } from "@/components/ui/text";
import type { WorkOrderUser } from "@/domain/work-orders/types/work-order";
import type { DashboardRangeKey } from "@/domain/dashboard/types/dashboard-range";
import { DashboardRangeToggle } from "./dashboard-range-toggle";
import { DashboardScopePicker } from "./dashboard-scope-picker";
import type { DashboardScope } from "./dashboard-scope";
interface DashboardHeaderActionsProps {
isRefreshing: boolean;
showReset: boolean;
onResetFilters: () => void;
canViewAll: boolean;
scope: DashboardScope;
onScopeChange: (scope: DashboardScope) => void;
dispatcherUsers: WorkOrderUser[];
dispatcherUsersLoading: boolean;
currentUserId: string | number | undefined;
range: DashboardRangeKey;
onRangeChange: (range: DashboardRangeKey) => void;
}
export function DashboardHeaderActions({
isRefreshing,
showReset,
onResetFilters,
canViewAll,
scope,
onScopeChange,
dispatcherUsers,
dispatcherUsersLoading,
currentUserId,
range,
onRangeChange,
}: DashboardHeaderActionsProps) {
return (
<Box className="flex items-center gap-2">
{isRefreshing && <CircularProgress size={16} />}
{showReset && (
<Button size="small" onClick={onResetFilters}>
Reset filters
</Button>
)}
{canViewAll ? (
<DashboardScopePicker
scope={scope}
onChange={onScopeChange}
users={dispatcherUsers}
usersLoading={dispatcherUsersLoading}
currentUserId={currentUserId}
/>
) : (
<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={onRangeChange} />
</Box>
);
}

View file

@ -0,0 +1,68 @@
import type { ReactNode } from "react";
import { Box, ButtonBase, Paper } from "@mui/material";
import { Text } from "@/components/ui/text";
type KpiTone = "success" | "primary" | "info" | "warning";
const toneColor: Record<KpiTone, string> = {
success: "success.main",
primary: "primary.main",
info: "info.main",
warning: "warning.main",
};
interface DashboardKpiCardProps {
title: string;
value?: number;
icon: ReactNode;
tone: KpiTone;
loading?: boolean;
active?: boolean;
onSelect?: () => void;
}
export function DashboardKpiCard({
title,
value,
icon,
tone,
loading = false,
active = false,
onSelect,
}: DashboardKpiCardProps) {
const color = toneColor[tone];
const content = (
<Box className="flex w-full items-center gap-4 p-4" sx={{ textAlign: "left" }}>
<Box
className="flex h-12 w-12 shrink-0 items-center justify-center rounded-full"
sx={{ bgcolor: `${color}22`, color }}
>
{icon}
</Box>
<Box className="flex min-w-0 flex-col gap-0.5">
<Text variant="label" tone="muted">
{title}
</Text>
<Text variant="label" sx={{ fontSize: 28, fontWeight: 700, lineHeight: 1.2 }}>
{loading ? "…" : (value ?? 0).toLocaleString()}
</Text>
</Box>
</Box>
);
return (
<Paper
variant="outlined"
className="min-h-[104px]"
sx={{ borderLeftWidth: 4, borderLeftStyle: "solid", borderLeftColor: color }}
>
{active && Boolean(onSelect) ? (
<ButtonBase sx={{ width: "100%", cursor: "pointer" }} onClick={onSelect}>
{content}
</ButtonBase>
) : (
content
)}
</Paper>
);
}

View file

@ -0,0 +1,42 @@
import { ToggleButton, ToggleButtonGroup } from "@mui/material";
import { DASHBOARD_RANGES, type DashboardRangeKey } from "@/domain/dashboard/types/dashboard-range";
const RANGE_LABELS: Record<DashboardRangeKey, string> = {
"this-week": "This week",
"last-3-months": "Last 3 months",
today: "Today",
"all-time": "All time",
};
interface DashboardRangeToggleProps {
value: DashboardRangeKey;
onChange: (value: DashboardRangeKey) => void;
disabled?: boolean;
}
export function DashboardRangeToggle({
value,
onChange,
disabled = false,
}: DashboardRangeToggleProps) {
return (
<ToggleButtonGroup
exclusive
size="small"
value={value}
disabled={disabled}
aria-label="Dashboard date range"
onChange={(_, next: DashboardRangeKey | null) => {
if (next) {
onChange(next);
}
}}
>
{DASHBOARD_RANGES.map((range) => (
<ToggleButton key={range} value={range}>
{RANGE_LABELS[range]}
</ToggleButton>
))}
</ToggleButtonGroup>
);
}

View file

@ -0,0 +1,118 @@
import { useMemo } from "react";
import { Popover, Skeleton } from "@mui/material";
import { ChevronDown, Users } from "lucide-react";
import type { WorkOrderUser } from "@/domain/work-orders/types/work-order";
import { CheckboxMenuItem } from "@/components/ui/checkbox-menu-item";
import { MenuAnchorButton } from "@/components/ui/menu-anchor-button";
import { useMenuAnchor } from "@/hooks/use-menu-anchor";
import type { DashboardScope } from "./dashboard-scope";
type DashboardScopePickerProps = {
scope: DashboardScope;
onChange: (scope: DashboardScope) => void;
users: WorkOrderUser[];
usersLoading?: boolean;
currentUserId?: string | number;
};
export function DashboardScopePicker({
scope,
onChange,
users,
usersLoading = false,
currentUserId,
}: DashboardScopePickerProps) {
const { anchorEl, open, setAnchor, close } = useMenuAnchor();
const meId = currentUserId != null && currentUserId !== "" ? String(currentUserId) : null;
const dispatchers = useMemo(
() => [...users].sort((a, b) => a.name.localeCompare(b.name)),
[users],
);
const label = useMemo(() => {
if (scope.mode === "all") return "All dispatchers";
if (scope.mode === "mine") return "My WOs";
return dispatchers.find((user) => String(user.id) === scope.id)?.name ?? "Dispatcher";
}, [scope, dispatchers]);
const select = (next: DashboardScope) => {
onChange(next);
close();
};
if (usersLoading && users.length === 0) {
return (
<Skeleton
variant="rounded"
width={140}
height={18}
animation="wave"
aria-label="Loading dispatchers"
/>
);
}
return (
<>
<MenuAnchorButton
onAnchor={setAnchor}
open={open}
className="flex w-auto shrink-0 items-center gap-1.5 whitespace-nowrap transition-colors"
style={{ fontSize: 13, fontWeight: 500, color: "var(--muted-foreground)" }}
aria-haspopup="listbox"
>
<Users size={13} aria-hidden style={{ color: "var(--muted-foreground)" }} />
{label}
<ChevronDown size={13} aria-hidden style={{ color: "var(--muted-foreground)" }} />
</MenuAnchorButton>
<Popover
open={open}
anchorEl={anchorEl}
onClose={close}
anchorOrigin={{ vertical: "bottom", horizontal: "left" }}
transformOrigin={{ vertical: "top", horizontal: "left" }}
slotProps={{
paper: {
className: "mt-1 w-[220px] rounded-lg border border-border p-1.5 shadow-md",
},
}}
>
<CheckboxMenuItem
checked={scope.mode === "all"}
label="All dispatchers"
onClick={() => select({ mode: "all" })}
/>
{meId != null && (
<CheckboxMenuItem
checked={scope.mode === "mine"}
label="My WOs only"
labelClassName="font-medium text-primary"
className="mb-1 pb-2"
style={{ borderBottom: "1px solid var(--color-border)" }}
onClick={() => select({ mode: "mine" })}
/>
)}
{dispatchers.map((user) => {
const id = String(user.id);
return (
<CheckboxMenuItem
key={id}
checked={scope.mode === "individual" && scope.id === id}
label={user.name}
right={
meId === id ? (
<span className="text-[10px] text-muted-foreground">me</span>
) : undefined
}
onClick={() => select({ mode: "individual", id })}
/>
);
})}
</Popover>
</>
);
}

View file

@ -0,0 +1,21 @@
/**
* SH-336 dispatcher-scope selection. Single-select — the Dashboard scopes to at
* most one dispatcher at a time (All company / My WOs / one individual), unlike
* the board's multi-select dispatcher filter.
*/
export type DashboardScope =
{ mode: "all" } | { mode: "mine" } | { mode: "individual"; id: string };
export const DASHBOARD_SCOPE_ALL: DashboardScope = { mode: "all" };
/** Resolves the scope to the `dispatcherId` query param, or null for company-wide. */
export function scopeDispatcherId(
scope: DashboardScope,
currentUserId: string | number | undefined,
): string | null {
if (scope.mode === "all") return null;
if (scope.mode === "mine") {
return currentUserId != null && currentUserId !== "" ? String(currentUserId) : null;
}
return scope.id || null;
}

View file

@ -0,0 +1,119 @@
import { useMemo, useState } from "react";
import { Box, IconButton } from "@mui/material";
import ArrowBackOutlinedIcon from "@mui/icons-material/ArrowBackOutlined";
import ArrowForwardOutlinedIcon from "@mui/icons-material/ArrowForwardOutlined";
import { Text } from "@/components/ui/text";
import type { DashboardRangeKey } from "@/domain/dashboard/types/dashboard-range";
import type { DashboardTrendPoint } from "@/domain/dashboard/types/dashboard-trend";
import { useDashboardTrend } from "@/domain/dashboard/use-cases/use-dashboard-trend";
import {
businessTodayIso,
dashboardRangeParams,
} from "@/domain/dashboard/utils/dashboard-range-utils";
import { DashboardCard } from "./dashboard-card";
interface DashboardTrendCardProps {
range: DashboardRangeKey;
}
function trendParams(range: DashboardRangeKey, year: number) {
if (range === "all-time") return { year };
const params = dashboardRangeParams(range);
return {
...params,
...(range === "last-3-months" ? { range: "3m" as const } : {}),
};
}
function barHeight(point: DashboardTrendPoint, max: number): number {
if (point.total <= 0 || max <= 0) return 0;
return Math.max(6, Math.round((point.total / max) * 100));
}
function TrendBar({ point, max }: { point: DashboardTrendPoint; max: number }) {
const height = barHeight(point, max);
return (
<Box
className="flex min-w-[42px] flex-1 flex-col items-center gap-1"
data-testid="dashboard-trend-point"
sx={{ borderLeft: point.isCurrent ? "2px dashed" : undefined, borderColor: "warning.main" }}
>
<Text variant="description" tone="muted">
{point.total.toLocaleString()}
</Text>
<Box className="flex h-32 items-end">
{height > 0 && (
<Box
data-testid="dashboard-trend-bar"
className="w-6 rounded-t"
sx={{
height: `${height}%`,
bgcolor: point.isCurrent ? "warning.main" : "primary.main",
}}
/>
)}
</Box>
<Text variant="description" className="max-w-16 truncate text-center" title={point.label}>
{point.label}
</Text>
{point.isCurrent && <Text variant="description">Today</Text>}
</Box>
);
}
export function DashboardTrendCard({ range }: DashboardTrendCardProps) {
const currentYear = Number(businessTodayIso().slice(0, 4));
const [year, setYear] = useState(currentYear);
const params = useMemo(() => trendParams(range, year), [range, year]);
const trend = useDashboardTrend(params);
const points = trend.data?.points ?? [];
const max = Math.max(...points.map((point) => point.total), 0);
return (
<DashboardCard
title="Work Order Trend"
isLoading={trend.isLoading}
error={trend.error}
isEmpty={!trend.isLoading && !trend.error && points.length === 0}
emptyMessage="No work orders match the current filters."
>
<Box className="flex flex-col gap-3">
<Box className="flex items-center justify-between">
<Text variant="description" tone="muted">
{trend.data?.granularity ? `${trend.data.granularity} buckets` : "Trend"}
</Text>
{range === "all-time" && (
<Box className="flex items-center">
<IconButton
aria-label="Previous year"
size="small"
onClick={() => setYear((value) => value - 1)}
>
<ArrowBackOutlinedIcon fontSize="small" />
</IconButton>
<Text variant="description">{year}</Text>
<IconButton
aria-label="Next year"
size="small"
onClick={() => setYear((value) => value + 1)}
>
<ArrowForwardOutlinedIcon fontSize="small" />
</IconButton>
</Box>
)}
</Box>
<Box
className="flex min-h-48 items-end gap-1 overflow-x-auto pb-1"
aria-label="Work Order Trend chart"
>
{points.map((point) => (
<TrendBar key={`${point.date}-${point.label}`} point={point} max={max} />
))}
</Box>
<Text variant="description" tone="muted">
The dashed marker and “Today” label identify the current business date.
</Text>
</Box>
</DashboardCard>
);
}

View file

@ -0,0 +1,139 @@
import { useNavigate } from "react-router";
import { Table, TableBody, TableCell, TableContainer, TableHead, TableRow } from "@mui/material";
import { Text } from "@/components/ui/text";
import type { DashboardRangeKey } from "@/domain/dashboard/types/dashboard-range";
import type { DispatcherPerformanceRow } from "@/domain/dashboard/types/dashboard-performance";
import {
workOrderDispatcherDrilldownSearch,
workOrderDrilldownUrl,
} from "@/domain/dashboard/utils/work-order-drilldown-links";
import { DashboardCard } from "./dashboard-card";
import { DispatcherTablePager } from "./dispatcher-table-pager";
const EMPTY_MESSAGE = "No dispatchers are set up yet.";
interface DispatcherPerformanceCardProps {
rows: DispatcherPerformanceRow[];
range: DashboardRangeKey;
page: number;
pageSize: number;
totalDispatchers: number;
onPageChange: (page: number) => void;
isLoading?: boolean;
error?: unknown;
}
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,
}: {
row: DispatcherPerformanceRow;
range: DashboardRangeKey;
}) {
const navigate = useNavigate();
// 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.) 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) {
return <Text variant="description">{row.dispatcherName}</Text>;
}
return (
<Text
variant="description"
as="button"
className="cursor-pointer text-left font-medium underline-offset-2 hover:underline"
onClick={() =>
navigate(workOrderDrilldownUrl(workOrderDispatcherDrilldownSearch(row.dispatcherId, range)))
}
>
{row.dispatcherName}
</Text>
);
}
export function DispatcherPerformanceCard({
rows,
range,
page,
pageSize,
totalDispatchers,
onPageChange,
isLoading = false,
error,
}: DispatcherPerformanceCardProps) {
return (
<DashboardCard
title="Dispatcher Performance"
isLoading={isLoading}
error={error}
isEmpty={!isLoading && !error && rows.length === 0}
emptyMessage={EMPTY_MESSAGE}
>
<TableContainer>
<Table size="small" aria-label="Dispatcher Performance">
<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>
<TableCell align="right" className="font-semibold">
Avg days
</TableCell>
</TableRow>
</TableHead>
<TableBody>
{rows.map((row) => (
<TableRow key={row.dispatcherId || row.dispatcherName}>
<TableCell>
<DispatcherNameCell row={row} range={range} />
</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>
</TableRow>
))}
</TableBody>
</Table>
</TableContainer>
<DispatcherTablePager
page={page}
pageSize={pageSize}
totalDispatchers={totalDispatchers}
onPageChange={onPageChange}
label="Dispatcher Performance pagination"
/>
</DashboardCard>
);
}

View file

@ -0,0 +1,40 @@
import { Box, Pagination } from "@mui/material";
interface DispatcherTablePagerProps {
page: number;
pageSize: number;
totalDispatchers: number;
onPageChange: (page: number) => void;
label: string;
}
/**
* 10-row pager for the dispatcher tables (SH-346/SH-347). Renders nothing until
* there is more than one page, so single-page tables are unchanged.
*/
export function DispatcherTablePager({
page,
pageSize,
totalDispatchers,
onPageChange,
label,
}: DispatcherTablePagerProps) {
const pageCount = pageSize > 0 ? Math.ceil(totalDispatchers / pageSize) : 1;
if (pageCount <= 1) {
return null;
}
return (
<Box className="mt-3 flex justify-end">
<Pagination
size="small"
color="primary"
count={pageCount}
page={page}
onChange={(_event, value) => onPageChange(value)}
aria-label={label}
/>
</Box>
);
}

View file

@ -0,0 +1,60 @@
import type { UseQueryResult } from "@tanstack/react-query";
import {
DISPATCHER_PAGE_SIZE,
type DispatcherPage,
} from "@/domain/dashboard/types/dashboard-dispatcher-page";
import type { DashboardRangeKey } from "@/domain/dashboard/types/dashboard-range";
import type { DispatcherPerformanceRow } from "@/domain/dashboard/types/dashboard-performance";
import type { DispatcherWorkloadRow } from "@/domain/dashboard/types/dashboard-workload";
import { DispatcherPerformanceCard } from "./dispatcher-performance-card";
import { DispatcherWorkloadCard } from "./dispatcher-workload-card";
interface DispatcherTablesSectionProps {
range: DashboardRangeKey;
workload: UseQueryResult<DispatcherPage<DispatcherWorkloadRow>, Error>;
performance: UseQueryResult<DispatcherPage<DispatcherPerformanceRow>, Error>;
workloadPage: number;
performancePage: number;
onWorkloadPageChange: (page: number) => void;
onPerformancePageChange: (page: number) => void;
}
/**
* The two paged dispatcher tables. Kept as a fragment so both cards stay direct
* children of the dashboard grid, and so the paging view-model derivation lives
* here rather than inflating the page component.
*/
export function DispatcherTablesSection({
range,
workload,
performance,
workloadPage,
performancePage,
onWorkloadPageChange,
onPerformancePageChange,
}: DispatcherTablesSectionProps) {
return (
<>
<DispatcherWorkloadCard
rows={workload.data?.items ?? []}
range={range}
page={workloadPage}
pageSize={workload.data?.pageSize ?? DISPATCHER_PAGE_SIZE}
totalDispatchers={workload.data?.totalDispatchers ?? 0}
onPageChange={onWorkloadPageChange}
isLoading={workload.isLoading}
error={workload.error}
/>
<DispatcherPerformanceCard
rows={performance.data?.items ?? []}
range={range}
page={performancePage}
pageSize={performance.data?.pageSize ?? DISPATCHER_PAGE_SIZE}
totalDispatchers={performance.data?.totalDispatchers ?? 0}
onPageChange={onPerformancePageChange}
isLoading={performance.isLoading}
error={performance.error}
/>
</>
);
}

View file

@ -0,0 +1,107 @@
import { useNavigate } from "react-router";
import { Table, TableBody, TableCell, TableContainer, TableHead, TableRow } from "@mui/material";
import { Text } from "@/components/ui/text";
import type { DashboardRangeKey } from "@/domain/dashboard/types/dashboard-range";
import type { DispatcherWorkloadRow } from "@/domain/dashboard/types/dashboard-workload";
import {
workOrderDispatcherDrilldownSearch,
workOrderDrilldownUrl,
} from "@/domain/dashboard/utils/work-order-drilldown-links";
import { DashboardCard } from "./dashboard-card";
import { DispatcherTablePager } from "./dispatcher-table-pager";
const EMPTY_MESSAGE = "No dispatchers are set up yet.";
interface DispatcherWorkloadCardProps {
rows: DispatcherWorkloadRow[];
range: DashboardRangeKey;
page: number;
pageSize: number;
totalDispatchers: number;
onPageChange: (page: number) => void;
isLoading?: boolean;
error?: unknown;
}
function DispatcherNameCell({
row,
range,
}: {
row: DispatcherWorkloadRow;
range: DashboardRangeKey;
}) {
const navigate = useNavigate();
const drillable = Boolean(row.dispatcherId) && row.totalWorkOrders > 0;
if (!drillable) {
return <Text variant="description">{row.dispatcherName}</Text>;
}
return (
<Text
variant="description"
as="button"
className="cursor-pointer text-left font-medium underline-offset-2 hover:underline"
onClick={() =>
navigate(workOrderDrilldownUrl(workOrderDispatcherDrilldownSearch(row.dispatcherId, range)))
}
>
{row.dispatcherName}
</Text>
);
}
export function DispatcherWorkloadCard({
rows,
range,
page,
pageSize,
totalDispatchers,
onPageChange,
isLoading = false,
error,
}: DispatcherWorkloadCardProps) {
return (
<DashboardCard
title="Dispatcher Workload"
isLoading={isLoading}
error={error}
isEmpty={!isLoading && !error && rows.length === 0}
emptyMessage={EMPTY_MESSAGE}
>
<TableContainer>
<Table size="small" aria-label="Dispatcher Workload">
<TableHead>
<TableRow>
<TableCell className="font-semibold">Dispatcher</TableCell>
<TableCell align="right" className="font-semibold">
Open
</TableCell>
<TableCell align="right" className="font-semibold">
Total
</TableCell>
</TableRow>
</TableHead>
<TableBody>
{rows.map((row) => (
<TableRow key={row.dispatcherId || row.dispatcherName}>
<TableCell>
<DispatcherNameCell row={row} range={range} />
</TableCell>
<TableCell align="right">{row.openWorkOrders.toLocaleString()}</TableCell>
<TableCell align="right">{row.totalWorkOrders.toLocaleString()}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</TableContainer>
<DispatcherTablePager
page={page}
pageSize={pageSize}
totalDispatchers={totalDispatchers}
onPageChange={onPageChange}
label="Dispatcher Workload pagination"
/>
</DashboardCard>
);
}

View file

@ -0,0 +1,55 @@
import { useNavigate } from "react-router";
import { ALL_WIZARD_STATUSES } from "@/domain/work-orders/types/work-order-wizard";
import type { DashboardRangeKey } from "@/domain/dashboard/types/dashboard-range";
import type { DashboardStatusBucketRow } from "@/domain/dashboard/types/dashboard-stats";
import {
workOrderDrilldownUrl,
workOrderStatusDrilldownSearch,
} from "@/domain/dashboard/utils/work-order-drilldown-links";
import { DashboardBarList } from "./dashboard-bar-list";
import { DashboardCard } from "./dashboard-card";
const EMPTY_MESSAGE = "No work orders match the current filters.";
const DRILLABLE_STATUSES = new Set<string>(ALL_WIZARD_STATUSES);
interface StatusDistributionCardProps {
rows: DashboardStatusBucketRow[];
range: DashboardRangeKey;
isLoading?: boolean;
error?: unknown;
}
export function StatusDistributionCard({
rows,
range,
isLoading = false,
error,
}: StatusDistributionCardProps) {
const navigate = useNavigate();
return (
<DashboardCard
title="Status Distribution"
isLoading={isLoading}
error={error}
isEmpty={!isLoading && !error && rows.length === 0}
emptyMessage={EMPTY_MESSAGE}
>
<DashboardBarList
ariaLabel="Status Distribution"
rows={rows.map((row) => ({
id: row.status,
label: row.status,
count: row.count,
}))}
// Lifecycle labels the board can filter on are drillable; the "Unknown"
// bucket (unparseable statuses) has no board filter, so leave it inert.
isSelectable={(row) => DRILLABLE_STATUSES.has(row.id)}
onSelectRow={(row) =>
navigate(workOrderDrilldownUrl(workOrderStatusDrilldownSearch(row.id, range)))
}
/>
</DashboardCard>
);
}

View file

@ -0,0 +1,87 @@
import { useState } from "react";
import { canViewAllDispatchersOnDashboard } from "@/lib/auth/user-utils";
import { useAuthContext } from "@/providers/auth-context";
import {
DEFAULT_DASHBOARD_RANGE,
type DashboardRangeKey,
} from "@/domain/dashboard/types/dashboard-range";
import { DASHBOARD_SCOPE_ALL, scopeDispatcherId, type DashboardScope } from "./dashboard-scope";
export interface DashboardScopeState {
canViewAll: boolean;
currentUserId: string | number | undefined;
range: DashboardRangeKey;
scope: DashboardScope;
/** The `dispatcherId` query param for the scoped endpoints (null = company-wide). */
dispatcherId: string | null;
workloadPage: number;
performancePage: number;
showReset: boolean;
setWorkloadPage: (page: number) => void;
setPerformancePage: (page: number) => void;
changeRange: (next: DashboardRangeKey) => void;
changeScope: (next: DashboardScope) => void;
resetFilters: () => void;
}
/**
* SH-336 dashboard scope/paging state. A Dispatcher (no
* `viewAllDispatchersOnDashboard`) is locked to their own work orders and sends
* no dispatcherId; Scheduler/Admin drive an explicit scope through the picker.
*/
export function useDashboardScope(): DashboardScopeState {
const { user } = useAuthContext();
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 dispatcherId = canViewAll ? scopeDispatcherId(scope, user?.id) : null;
// A range or scope 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 resetPages = () => {
setWorkloadPage(1);
setPerformancePage(1);
};
const changeRange = (next: DashboardRangeKey) => {
setRange(next);
resetPages();
};
const changeScope = (next: DashboardScope) => {
setScope(next);
resetPages();
};
const resetFilters = () => {
setRange(DEFAULT_DASHBOARD_RANGE);
setScope(DASHBOARD_SCOPE_ALL);
resetPages();
};
// A Dispatcher's forced own-WOs scope never triggers the reset affordance (only
// an active date range can, for that role); a Scheduler/Admin's non-All scope does.
const scopeIsFiltered = canViewAll && scope.mode !== "all";
const showReset = range !== DEFAULT_DASHBOARD_RANGE || scopeIsFiltered;
return {
canViewAll,
currentUserId: user?.id,
range,
scope,
dispatcherId,
workloadPage,
performancePage,
showReset,
setWorkloadPage,
setPerformancePage,
changeRange,
changeScope,
resetFilters,
};
}

View file

@ -0,0 +1,64 @@
import { Table, TableBody, TableCell, TableContainer, TableHead, TableRow } from "@mui/material";
import { Text } from "@/components/ui/text";
import type { VendorInsightRow } from "@/domain/dashboard/types/dashboard-vendor-insights";
import { DashboardCard } from "./dashboard-card";
const EMPTY_MESSAGE = "No active vendor has any work orders yet.";
interface VendorInsightsCardProps {
rows: VendorInsightRow[];
isLoading?: boolean;
error?: unknown;
}
export function VendorInsightsCard({ rows, isLoading = false, error }: VendorInsightsCardProps) {
return (
<DashboardCard
title="Vendor Insights"
caption="All-time, company-wide"
isLoading={isLoading}
error={error}
isEmpty={!isLoading && !error && rows.length === 0}
emptyMessage={EMPTY_MESSAGE}
>
<TableContainer>
<Table size="small" aria-label="Vendor Insights">
<TableHead>
<TableRow>
<TableCell className="font-semibold">Vendor</TableCell>
<TableCell align="right" className="font-semibold">
Completion %
</TableCell>
<TableCell align="right" className="font-semibold">
Reschedule %
</TableCell>
<TableCell align="right" className="font-semibold">
Avg days
</TableCell>
<TableCell align="right" className="font-semibold">
Jobs
</TableCell>
</TableRow>
</TableHead>
<TableBody>
{rows.map((row) => (
<TableRow key={row.vendorId || row.vendorName}>
<TableCell>
<Text variant="description">{row.vendorName}</Text>
</TableCell>
<TableCell align="right">{`${row.completionRate.toLocaleString()}%`}</TableCell>
<TableCell align="right">{`${row.rescheduleRate.toLocaleString()}%`}</TableCell>
<TableCell align="right">
{row.averageResolutionDays == null
? "—"
: row.averageResolutionDays.toLocaleString()}
</TableCell>
<TableCell align="right">{row.totalJobs.toLocaleString()}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</TableContainer>
</DashboardCard>
);
}

View file

@ -0,0 +1,47 @@
import { useNavigate } from "react-router";
import type { DashboardRangeKey } from "@/domain/dashboard/types/dashboard-range";
import type { DashboardBreakdownRow } from "@/domain/dashboard/types/dashboard-stats";
import {
workOrderDrilldownUrl,
workOrderTypeDrilldownSearch,
} 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.";
interface WorkOrderBreakdownCardProps {
breakdown: DashboardBreakdownRow[];
range: DashboardRangeKey;
isLoading?: boolean;
error?: unknown;
}
export function WorkOrderBreakdownCard({
breakdown,
range,
isLoading = false,
error,
}: WorkOrderBreakdownCardProps) {
const navigate = useNavigate();
return (
<DashboardCard
title="Work Order Breakdown"
isLoading={isLoading}
error={error}
isEmpty={!isLoading && !error && breakdown.length === 0}
emptyMessage={EMPTY_MESSAGE}
>
<DashboardBarList
ariaLabel="Work Order Breakdown"
rows={breakdown.map((row) => ({ id: row.status, label: row.status, count: row.count }))}
isSelectable={(row) => workOrderTypeDrilldownSearch(row.id, range) !== null}
onSelectRow={(row) => {
const search = workOrderTypeDrilldownSearch(row.id, range);
if (search) navigate(workOrderDrilldownUrl(search));
}}
/>
</DashboardCard>
);
}

View file

@ -0,0 +1,57 @@
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";
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 && zones.length === 0}
emptyMessage={EMPTY_MESSAGE}
>
<DashboardBarList
ariaLabel="Work Orders by Region"
rows={zones.map((row) => ({
id: row.region,
label: row.region,
count: row.workOrderCount,
}))}
onSelectRow={(row) =>
navigate(workOrderDrilldownUrl(workOrderRegionDrilldownSearch(row.id, range)))
}
/>
</DashboardCard>
);
}

View file

@ -0,0 +1,133 @@
import { useNavigate } from "react-router";
import EventOutlinedIcon from "@mui/icons-material/EventOutlined";
import PendingActionsIcon from "@mui/icons-material/PendingActions";
import ShieldOutlinedIcon from "@mui/icons-material/ShieldOutlined";
import { Box } from "@mui/material";
import { PageHeader } from "@/components/ui/page-header";
import { DashboardHeaderActions } from "@/app/(protected)/dashboard/_components/dashboard-header-actions";
import { DashboardKpiCard } from "@/app/(protected)/dashboard/_components/dashboard-kpi-card";
import { DashboardTrendCard } from "@/app/(protected)/dashboard/_components/dashboard-trend-card";
import { DispatcherTablesSection } from "@/app/(protected)/dashboard/_components/dispatcher-tables-section";
import { StatusDistributionCard } from "@/app/(protected)/dashboard/_components/status-distribution-card";
import { useDashboardScope } from "@/app/(protected)/dashboard/_components/use-dashboard-scope";
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";
import { useDashboardPerformance } from "@/domain/dashboard/use-cases/use-dashboard-performance";
import { useDashboardRegions } from "@/domain/dashboard/use-cases/use-dashboard-regions";
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,
UPLIFTS_ROUTE,
workOrderDrilldownUrl,
} from "@/domain/dashboard/utils/work-order-drilldown-links";
export default function DashboardPage() {
const navigate = useNavigate();
const scope = useDashboardScope();
const { canViewAll, range, dispatcherId, workloadPage, performancePage } = scope;
const dispatcherUsers = useWorkOrderUsers();
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 isRefreshing =
[stats, workload, performance, regions, vendors].some((query) => query.isFetching) &&
!stats.isLoading;
return (
<Box className="flex flex-col gap-4 p-4">
<PageHeader
title="Dashboard"
actions={
<DashboardHeaderActions
isRefreshing={isRefreshing}
showReset={scope.showReset}
onResetFilters={scope.resetFilters}
canViewAll={canViewAll}
scope={scope.scope}
onScopeChange={scope.changeScope}
dispatcherUsers={dispatcherUsers.data ?? []}
dispatcherUsersLoading={dispatcherUsers.isLoading}
currentUserId={scope.currentUserId}
range={range}
onRangeChange={scope.changeRange}
/>
}
/>
<Box className="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3">
<DashboardKpiCard
title="Scheduled Tomorrow"
icon={<EventOutlinedIcon />}
tone="primary"
value={stats.data?.scheduledTomorrow}
loading={stats.isLoading}
active={(stats.data?.scheduledTomorrow ?? 0) > 0}
onSelect={() => navigate(workOrderDrilldownUrl(scheduledTomorrowDrilldownSearch()))}
/>
<DashboardKpiCard
title="Pending Uplifts"
icon={<PendingActionsIcon />}
tone="info"
value={stats.data?.pendingUplifts}
loading={stats.isLoading}
active={(stats.data?.pendingUplifts ?? 0) > 0}
onSelect={() => navigate(UPLIFTS_ROUTE)}
/>
<DashboardKpiCard
title="Aveta Pending"
icon={<ShieldOutlinedIcon />}
tone="warning"
value={stats.data?.avetaPending}
loading={stats.isLoading}
active={(stats.data?.avetaPending ?? 0) > 0}
onSelect={() => navigate(workOrderDrilldownUrl(avetaPendingDrilldownSearch()))}
/>
</Box>
<Box className="grid grid-cols-1 gap-4 lg:grid-cols-2">
<WorkOrderBreakdownCard
breakdown={stats.data?.breakdown ?? []}
range={range}
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}
/>
{canViewAll && (
<DispatcherTablesSection
range={range}
workload={workload}
performance={performance}
workloadPage={workloadPage}
performancePage={performancePage}
onWorkloadPageChange={scope.setWorkloadPage}
onPerformancePageChange={scope.setPerformancePage}
/>
)}
</Box>
<DashboardTrendCard range={range} />
<VendorInsightsCard
rows={vendors.data ?? []}
isLoading={vendors.isLoading}
error={vendors.error}
/>
</Box>
);
}

View file

@ -1,240 +1,5 @@
import type { ReactNode } from "react";
import { useNavigate } from "react-router";
import AddIcon from "@mui/icons-material/Add";
import CheckCircleOutlineIcon from "@mui/icons-material/CheckCircleOutlined";
import FolderOpenOutlinedIcon from "@mui/icons-material/FolderOpenOutlined";
import ScheduleOutlinedIcon from "@mui/icons-material/ScheduleOutlined";
import {
Alert,
Box,
CircularProgress,
Paper,
Stack,
Table,
TableBody,
TableCell,
TableContainer,
TableHead,
TableRow,
Typography,
} from "@mui/material";
import { useDashboardStats } from "@/domain/dashboard/use-cases/use-dashboard-stats";
import DashboardPage from "@/app/(protected)/dashboard";
const INVOICE_STATUSES = ["Draft", "Challenged", "Pending Approval", "Approved", "Paid"] as const;
const PRIORITY_LEVELS = [5, 4, 3, 2, 1] as const;
interface KpiCardProps {
title: string;
description?: string;
value?: string | number;
icon: ReactNode;
color: "success" | "primary" | "info" | "warning";
onClick: () => void;
loading?: boolean;
}
function KpiCard({ title, description, value, icon, color, onClick, loading }: KpiCardProps) {
const colorMap = {
success: "success.main",
primary: "primary.main",
info: "info.main",
warning: "warning.main",
} as const;
return (
<Paper
variant="outlined"
onClick={onClick}
className="flex min-h-[140px] flex-1 cursor-pointer transition-shadow hover:shadow-md"
sx={{
borderLeftWidth: 4,
borderLeftStyle: "solid",
borderLeftColor: colorMap[color],
}}
>
<Box className="flex w-full gap-4 p-4">
<Box
className="flex h-12 w-12 shrink-0 items-center justify-center rounded-full"
sx={{ bgcolor: `${colorMap[color]}22`, color: colorMap[color] }}
>
{icon}
</Box>
<Box className="flex min-w-0 flex-col justify-center gap-1">
<Typography variant="subtitle1" className="font-semibold">
{title}
</Typography>
{value !== undefined && (
<Typography variant="h4" component="p" className="font-bold leading-tight">
{loading ? "..." : typeof value === "number" ? value.toLocaleString() : value}
</Typography>
)}
{Boolean(description) && (
<Typography
variant="body2"
sx={{
color: "text.secondary",
}}
>
{description}
</Typography>
)}
</Box>
</Box>
</Paper>
);
}
export default function DashboardPage() {
const navigate = useNavigate();
const { data: stats, isLoading, isFetching, error } = useDashboardStats();
const formatStat = (value: number | undefined) => (isLoading ? undefined : (value ?? 0));
return (
<Box className="flex flex-col gap-4 p-4">
<Stack
direction="row"
spacing={2}
sx={{
alignItems: "baseline",
}}
>
<Typography variant="h5" component="h1">
Dashboard
</Typography>
{isFetching && !isLoading && <CircularProgress size={16} />}
</Stack>
{Boolean(error) && (
<Alert severity="error">
{error instanceof Error ? error.message : "Failed to load dashboard stats"}
</Alert>
)}
<Stack
direction={{ xs: "column", lg: "row" }}
spacing={2}
useFlexGap
sx={{
flexWrap: "wrap",
}}
>
<KpiCard
title="Create a Work Order"
description="Report a problem you need fixed"
icon={<AddIcon />}
color="success"
onClick={() => navigate("/workorders/new")}
/>
<KpiCard
title="Open Work Orders"
value={formatStat(stats?.open)}
icon={<FolderOpenOutlinedIcon />}
color="primary"
loading={isLoading}
onClick={() => navigate("/workorders")}
/>
<KpiCard
title="Not Dispatched"
value={formatStat(stats?.notDispatched)}
icon={<ScheduleOutlinedIcon />}
color="info"
loading={isLoading}
onClick={() => navigate("/workorders")}
/>
<KpiCard
title="Completed Orders"
value={formatStat(stats?.completed)}
icon={<CheckCircleOutlineIcon />}
color="warning"
loading={isLoading}
onClick={() => navigate("/workorders")}
/>
</Stack>
<Paper variant="outlined" className="overflow-hidden">
<Box className="border-b border-border px-4 py-3">
<Typography variant="h6" component="h2">
Priority Analysis
</Typography>
<Typography
variant="body2"
sx={{
color: "text.secondary",
}}
>
Age of open Work Orders by Priority
</Typography>
</Box>
<TableContainer>
<Table size="small">
<TableHead>
<TableRow>
<TableCell className="font-semibold">Priority</TableCell>
<TableCell align="right" className="font-semibold">
Total
</TableCell>
<TableCell align="right" className="font-semibold">
0-14 Days
</TableCell>
<TableCell align="right" className="font-semibold">
15-29 Days
</TableCell>
<TableCell align="right" className="font-semibold">
30-59 Days
</TableCell>
<TableCell align="right" className="font-semibold">
60+ Days
</TableCell>
</TableRow>
</TableHead>
<TableBody>
{PRIORITY_LEVELS.map((priority) => (
<TableRow key={priority}>
<TableCell>{priority}</TableCell>
<TableCell align="right">—</TableCell>
<TableCell align="right">—</TableCell>
<TableCell align="right">—</TableCell>
<TableCell align="right">—</TableCell>
<TableCell align="right">—</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</TableContainer>
</Paper>
<Paper variant="outlined" className="overflow-hidden">
<Box className="border-b border-border px-4 py-3">
<Typography variant="h6" component="h2">
Invoice Analysis
</Typography>
</Box>
<TableContainer>
<Table size="small">
<TableHead>
<TableRow>
<TableCell className="font-semibold">Status</TableCell>
<TableCell align="right" className="font-semibold">
Count
</TableCell>
<TableCell align="right" className="font-semibold">
Amount
</TableCell>
</TableRow>
</TableHead>
<TableBody>
{INVOICE_STATUSES.map((status) => (
<TableRow key={status}>
<TableCell>{status}</TableCell>
<TableCell align="right">0</TableCell>
<TableCell align="right">$0.00</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</TableContainer>
</Paper>
</Box>
);
export default function HomePage() {
return <DashboardPage />;
}

View file

@ -1,4 +1,4 @@
import { useEffect, useMemo, useState, type Dispatch, type SetStateAction } from "react";
import { useEffect, useMemo, useRef, useState, type Dispatch, type SetStateAction } from "react";
import {
Accordion,
AccordionDetails,
@ -278,15 +278,25 @@ export function TeamMemberFormFields({
export function AddTeamMemberDialog({ open, onClose }: { open: boolean; onClose: () => void }) {
const [form, setForm] = useState(emptyForm);
const [submitted, setSubmitted] = useState(false);
const createTeamMember = useCreateTeamMember();
const mutationError = createTeamMember.error;
const submissionId = useRef(0);
const { error: mutationError, isPending, mutate, reset } = useCreateTeamMember();
useEffect(() => {
submissionId.current += 1;
reset();
if (open) {
setForm(emptyForm());
setSubmitted(false);
}
}, [open]);
}, [open, reset]);
const handleClose = () => {
if (isPending) return;
submissionId.current += 1;
reset();
onClose();
};
const emailIsValid = /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(form.email.trim());
const errors = useMemo(
@ -340,11 +350,18 @@ export function AddTeamMemberDialog({ open, onClose }: { open: boolean; onClose:
serviceAreas: form.serviceAreas,
permissionOverrides: permissionOverrides(form.role, form.permissions),
};
createTeamMember.mutate(input, { onSuccess: onClose });
const currentSubmissionId = submissionId.current;
mutate(input, {
onSuccess: () => {
if (submissionId.current !== currentSubmissionId) return;
reset();
onClose();
},
});
};
return (
<Dialog open={open} onClose={onClose} fullWidth maxWidth="md" scroll="paper">
<Dialog open={open} onClose={handleClose} fullWidth maxWidth="md" scroll="paper">
<DialogTitle>Add Member</DialogTitle>
<DialogContent dividers>
<Typography color="text.secondary" sx={{ mb: 3 }}>
@ -365,15 +382,11 @@ export function AddTeamMemberDialog({ open, onClose }: { open: boolean; onClose:
/>
</DialogContent>
<DialogActions>
<Button onClick={onClose} disabled={createTeamMember.isPending}>
<Button onClick={handleClose} disabled={isPending}>
Cancel
</Button>
<Button variant="contained" onClick={submit} disabled={createTeamMember.isPending}>
{createTeamMember.isPending ? (
<CircularProgress size={20} color="inherit" />
) : (
"Add Member"
)}
<Button variant="contained" onClick={submit} disabled={isPending}>
{isPending ? <CircularProgress size={20} color="inherit" /> : "Add Member"}
</Button>
</DialogActions>
</Dialog>

View file

@ -0,0 +1,13 @@
import { toast } from "react-toastify";
import type { UpliftQueueItem } from "@/domain/uplifts/types/uplift";
import { upliftsApi } from "@/domain/uplifts/api/uplifts-api";
export function openUpliftEvidence(row: UpliftQueueItem): void {
void upliftsApi
.downloadEvidence(row.id, row.evidenceFileName || "uplift-evidence")
.catch((error: unknown) => {
toast.error(
error instanceof Error ? error.message : "Unable to download evidence right now.",
);
});
}

View file

@ -0,0 +1,371 @@
import {
Box,
Button,
Chip,
CircularProgress,
Paper,
Table,
TableBody,
TableCell,
TableContainer,
TableHead,
TableRow,
Tooltip,
} from "@mui/material";
import { formatUpliftMoney } from "@/app/(protected)/uplifts/_components/format-uplift-money";
import { Text } from "@/components/ui/text";
import { useUpliftsCanApprove } from "@/domain/uplifts/use-cases/use-uplifts-can-approve";
import type { UpliftQueueItem } from "@/domain/uplifts/types/uplift";
import { formatDateTime, getWaitTimeTextClass, timeSince, waitTimeColor } from "@/lib/time-utils";
export type UpliftApprovalTab = "pending" | "approved";
const CLOSED_WO_TOOLTIP = "This work order is closed. Uplifts can no longer be revoked.";
const ADMIN_ONLY_REVOKE_TOOLTIP = "Only admins can revoke uplifts";
function workOrderLabel(row: UpliftQueueItem): string {
return row.woNumber || row.dispatchNumber || "—";
}
function WaitingCell({ row }: { row: UpliftQueueItem }) {
if (!row.requestedAt) {
return (
<Text variant="caption" tone="muted">
—
</Text>
);
}
const color = waitTimeColor(row.requestedAt);
const className = getWaitTimeTextClass(color);
return (
<Text variant="caption" className={className}>
{timeSince(row.requestedAt)}
</Text>
);
}
function AttachmentsCell({
row,
onOpenAttachment,
}: {
row: UpliftQueueItem;
onOpenAttachment: (row: UpliftQueueItem) => void;
}) {
if (row.evidenceDocumentId == null && !row.evidenceFileName) {
return (
<Text variant="caption" tone="muted">
—
</Text>
);
}
const extraCount =
typeof row.attachmentCount === "number" && row.attachmentCount > 1
? row.attachmentCount - 1
: 0;
return (
<Box sx={{ display: "flex", alignItems: "center", gap: 0.5 }}>
<Chip
size="small"
label={row.evidenceFileName || "Attachment"}
onClick={row.evidenceDocumentId != null ? () => onOpenAttachment(row) : undefined}
clickable={row.evidenceDocumentId != null}
/>
{extraCount > 0 && <Chip size="small" label={`+${extraCount}`} />}
</Box>
);
}
function PendingRowActions({
row,
onApprove,
onReject,
isDecisionPending,
}: {
row: UpliftQueueItem;
onApprove: (row: UpliftQueueItem) => void;
onReject: (row: UpliftQueueItem) => void;
isDecisionPending: boolean;
}) {
const canApproveTier = useUpliftsCanApprove(row.requiredTier);
const canDecide = row.canDecide && canApproveTier.data === true;
const tooltip = canDecide ? "" : `Requires Tier ${row.requiredTier} role`;
return (
<Box sx={{ display: "flex", gap: 1 }} onClick={(e) => e.stopPropagation()}>
<Tooltip title={tooltip}>
<span>
<Button
size="small"
variant="contained"
color="success"
disabled={!canDecide || isDecisionPending}
onClick={() => onApprove(row)}
>
Approve
</Button>
</span>
</Tooltip>
<Tooltip title={tooltip}>
<span>
<Button
size="small"
variant="contained"
color="error"
disabled={!canDecide || isDecisionPending}
onClick={() => onReject(row)}
>
Reject
</Button>
</span>
</Tooltip>
</Box>
);
}
function ApprovedRowActions({
row,
canRevoke,
onRevoke,
isDecisionPending,
}: {
row: UpliftQueueItem;
canRevoke: boolean;
onRevoke: (row: UpliftQueueItem) => void;
isDecisionPending: boolean;
}) {
const closed = row.workOrderClosed === true;
const tooltip = !canRevoke ? ADMIN_ONLY_REVOKE_TOOLTIP : closed ? CLOSED_WO_TOOLTIP : "";
return (
<Box sx={{ display: "flex", gap: 1 }} onClick={(e) => e.stopPropagation()}>
<Tooltip title={tooltip}>
<span>
<Button
size="small"
variant="outlined"
color="error"
disabled={!canRevoke || isDecisionPending || closed || row.workOrderId == null}
onClick={() => onRevoke(row)}
>
Revoke
</Button>
</span>
</Tooltip>
</Box>
);
}
function UpliftApprovalRow({
row,
tab,
canRevoke,
onOpenDetail,
onApprove,
onReject,
onRevoke,
onOpenAttachment,
isDecisionPending,
}: {
row: UpliftQueueItem;
tab: UpliftApprovalTab;
canRevoke: boolean;
onOpenDetail: (row: UpliftQueueItem) => void;
onApprove: (row: UpliftQueueItem) => void;
onReject: (row: UpliftQueueItem) => void;
onRevoke: (row: UpliftQueueItem) => void;
onOpenAttachment: (row: UpliftQueueItem) => void;
isDecisionPending: boolean;
}) {
return (
<TableRow
hover
sx={{ cursor: "pointer" }}
onClick={() => onOpenDetail(row)}
onKeyDown={(e) => {
if (e.key === "Enter") onOpenDetail(row);
}}
tabIndex={0}
aria-label={`Open uplift details for ${workOrderLabel(row)}`}
>
<TableCell>
<Box>
<Text variant="body" className="font-medium">
{workOrderLabel(row)}
</Text>
{Boolean(row.site) && (
<Text variant="caption" tone="muted" className="block">
{row.site}
</Text>
)}
</Box>
</TableCell>
<TableCell>
<Box>
{Boolean(row.serviceName) && (
<Text variant="body" className="block">
{row.serviceName}
</Text>
)}
<Text variant="caption" tone="muted">
{row.vendorCompanyName || "—"}
</Text>
</Box>
</TableCell>
<TableCell>
<Text variant="body" className="font-medium">
{formatUpliftMoney(row.requestedNTE)}
</Text>
</TableCell>
<TableCell>
{row.approvedOnWoTotal != null ? (
<Text variant="body">{formatUpliftMoney(row.approvedOnWoTotal)}</Text>
) : (
<Text variant="caption" tone="muted">
—
</Text>
)}
</TableCell>
<TableCell sx={{ maxWidth: 220 }}>
<Text variant="caption" sx={{ whiteSpace: "pre-wrap" }}>
{row.vendorReason}
</Text>
</TableCell>
<TableCell>
<AttachmentsCell row={row} onOpenAttachment={onOpenAttachment} />
</TableCell>
<TableCell>{row.requestedByVendorName || "—"}</TableCell>
{tab === "pending" ? (
<TableCell>
<WaitingCell row={row} />
</TableCell>
) : (
<>
<TableCell>{row.decidedByName || "—"}</TableCell>
<TableCell>{row.decidedAt ? formatDateTime(row.decidedAt) : "—"}</TableCell>
</>
)}
<TableCell>
{tab === "pending" ? (
<PendingRowActions
row={row}
onApprove={onApprove}
onReject={onReject}
isDecisionPending={isDecisionPending}
/>
) : (
<ApprovedRowActions
row={row}
canRevoke={canRevoke}
onRevoke={onRevoke}
isDecisionPending={isDecisionPending}
/>
)}
</TableCell>
</TableRow>
);
}
const PENDING_HEADER_CELLS = [
"Work Order",
"Service / Vendor",
"Amount",
"Approved on WO",
"Notes",
"Attachments",
"Requested By",
"Waiting",
"Actions",
] as const;
const APPROVED_HEADER_CELLS = [
"Work Order",
"Service / Vendor",
"Amount",
"Approved on WO",
"Notes",
"Attachments",
"Requested By",
"Approved By",
"Approved At",
"Actions",
] as const;
export function UpliftApprovalsTable({
tab,
isLoading,
rows,
canRevoke,
onOpenDetail,
onApprove,
onReject,
onRevoke,
onOpenAttachment,
isDecisionPending,
}: {
tab: UpliftApprovalTab;
isLoading: boolean;
rows: UpliftQueueItem[];
canRevoke: boolean;
onOpenDetail: (row: UpliftQueueItem) => void;
onApprove: (row: UpliftQueueItem) => void;
onReject: (row: UpliftQueueItem) => void;
onRevoke: (row: UpliftQueueItem) => void;
onOpenAttachment: (row: UpliftQueueItem) => void;
isDecisionPending: boolean;
}) {
const headerCells = tab === "pending" ? PENDING_HEADER_CELLS : APPROVED_HEADER_CELLS;
return (
<TableContainer component={Paper} variant="outlined">
<Table size="small" aria-label={`${tab} uplift requests`}>
<TableHead>
<TableRow>
{headerCells.map((label) => (
<TableCell key={label} className="font-semibold">
{label}
</TableCell>
))}
</TableRow>
</TableHead>
<TableBody>
{isLoading ? (
<TableRow>
<TableCell colSpan={headerCells.length} align="center" className="py-10">
<CircularProgress size={28} />
</TableCell>
</TableRow>
) : rows.length === 0 ? (
<TableRow>
<TableCell colSpan={headerCells.length} align="center" className="py-10">
{tab === "pending" ? (
<Text variant="description" tone="muted">
No uplifts waiting for approval.
</Text>
) : (
<Box>
<Text variant="description">No approved uplifts yet</Text>
<Text variant="caption" tone="muted">
Uplifts you approve are archived here for reference.
</Text>
</Box>
)}
</TableCell>
</TableRow>
) : (
rows.map((row) => (
<UpliftApprovalRow
key={String(row.id)}
row={row}
tab={tab}
canRevoke={canRevoke}
onOpenDetail={onOpenDetail}
onApprove={onApprove}
onReject={onReject}
onRevoke={onRevoke}
onOpenAttachment={onOpenAttachment}
isDecisionPending={isDecisionPending}
/>
))
)}
</TableBody>
</Table>
</TableContainer>
);
}

View file

@ -0,0 +1,42 @@
import { RejectDialog } from "@/app/(protected)/uplifts/_components/reject-dialog";
import { RevokeDialog } from "@/app/(protected)/uplifts/_components/revoke-dialog";
import type { useUpliftApprovalController } from "@/domain/uplifts/use-cases/use-uplift-approval-controller";
export type UpliftApprovalController = ReturnType<typeof useUpliftApprovalController>;
function woFallbackNumber(target: UpliftApprovalController["rejectTarget"]): string {
return target?.woNumber || String(target?.workOrderId ?? "");
}
export function UpliftDecisionDialogs({ controller }: { controller: UpliftApprovalController }) {
const {
rejectTarget,
revokeTarget,
rejectPending,
revokePending,
handleRejectConfirm,
handleRevokeConfirm,
closeReject,
closeRevoke,
} = controller;
return (
<>
<RejectDialog
open={rejectTarget != null}
amount={rejectTarget?.requestedNTE}
woNumber={woFallbackNumber(rejectTarget)}
onClose={closeReject}
onConfirm={handleRejectConfirm}
isPending={rejectPending}
/>
<RevokeDialog
open={revokeTarget != null}
amount={revokeTarget?.requestedNTE}
woNumber={woFallbackNumber(revokeTarget)}
onClose={closeRevoke}
onConfirm={handleRevokeConfirm}
isPending={revokePending}
/>
</>
);
}

View file

@ -0,0 +1,418 @@
import { useMemo, type ReactNode } from "react";
import {
Box,
Button,
Chip,
Dialog,
DialogActions,
DialogContent,
DialogTitle,
Tooltip,
} from "@mui/material";
import { formatUpliftMoney } from "@/app/(protected)/uplifts/_components/format-uplift-money";
import { Text } from "@/components/ui/text";
import { useWorkOrderBoardDetail } from "@/domain/work-orders/use-cases/use-work-order-detail";
import { useWorkOrderUplifts } from "@/domain/work-orders/use-cases/use-work-order-uplifts";
import { useUpliftsCanApprove } from "@/domain/uplifts/use-cases/use-uplifts-can-approve";
import { isWorkOrderUpliftsReadOnly } from "@/domain/work-orders/utils/uplift-display-utils";
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
import type { UpliftQueueItem } from "@/domain/uplifts/types/uplift";
import { getWaitTimeTextClass, timeSince, waitTimeColor } from "@/lib/time-utils";
const CLOSED_WO_TOOLTIP = "This work order is closed. Uplifts can no longer be revoked.";
const ADMIN_ONLY_REVOKE_TOOLTIP = "Only admins can revoke uplifts";
const UNAVAILABLE_LABEL = "Unavailable";
type ModalCallbacks = {
onClose: () => void;
onApprove: (row: UpliftQueueItem) => void;
onReject: (row: UpliftQueueItem) => void;
onRevoke: (row: UpliftQueueItem) => void;
onOpenAttachment: (row: UpliftQueueItem) => void;
};
function DetailField({ label, children }: { label: string; children: ReactNode }) {
return (
<Box>
<Text variant="label" tone="muted" className="block">
{label}
</Text>
{children}
</Box>
);
}
function DetailSection({ title, children }: { title: string; children: ReactNode }) {
return (
<Box>
<Text variant="heading" className="mb-1 block">
{title}
</Text>
<Box sx={{ display: "grid", gap: 1.5 }}>{children}</Box>
</Box>
);
}
function UpliftAttachments({
item,
onOpenAttachment,
}: {
item: UpliftQueueItem;
onOpenAttachment: (row: UpliftQueueItem) => void;
}) {
if (item.evidenceDocumentId == null && !item.evidenceFileName) {
return (
<Text variant="description" tone="muted">
No attachments
</Text>
);
}
const extraCount =
typeof item.attachmentCount === "number" && item.attachmentCount > 1
? item.attachmentCount - 1
: 0;
return (
<Box sx={{ display: "flex", alignItems: "center", gap: 0.5, flexWrap: "wrap" }}>
<Chip
size="small"
label={item.evidenceFileName || "Attachment"}
onClick={item.evidenceDocumentId != null ? () => onOpenAttachment(item) : undefined}
clickable={item.evidenceDocumentId != null}
/>
{extraCount > 0 && <Chip size="small" label={`+${extraCount}`} />}
</Box>
);
}
function WorkOrderSection({
item,
info,
unavailable,
}: {
item: UpliftQueueItem;
info?: WorkOrderTableRow;
unavailable: boolean;
}) {
const statusFallback = unavailable ? UNAVAILABLE_LABEL : "—";
return (
<DetailSection title="Work order">
<DetailField label="Work order #">
<Text variant="body">{item.woNumber || "—"}</Text>
</DetailField>
<DetailField label="Site">
<Text variant="body">{info?.site || item.site || "—"}</Text>
</DetailField>
<DetailField label="Status">
<Text variant="body">{info?.status || statusFallback}</Text>
</DetailField>
<DetailField label="Scheduled">
<Text variant="body">
{info?.scheduledOn || (unavailable ? UNAVAILABLE_LABEL : "Unscheduled")}
</Text>
</DetailField>
<DetailField label="Vendor">
<Text variant="body">{item.vendorCompanyName || "—"}</Text>
</DetailField>
</DetailSection>
);
}
function RequestSection({
item,
onOpenAttachment,
}: {
item: UpliftQueueItem;
onOpenAttachment: (row: UpliftQueueItem) => void;
}) {
const waitingClass = item.requestedAt
? getWaitTimeTextClass(waitTimeColor(item.requestedAt))
: undefined;
return (
<DetailSection title="Request">
<DetailField label="Amount">
<Text variant="body">
{formatUpliftMoney(item.currentNTE ?? 0)} → {formatUpliftMoney(item.requestedNTE)}
</Text>
</DetailField>
<DetailField label="Requested by">
<Text variant="body">{item.requestedByVendorName || "—"}</Text>
</DetailField>
<DetailField label="Waiting">
<Text variant="body" className={waitingClass}>
{item.requestedAt ? timeSince(item.requestedAt) : "—"}
</Text>
</DetailField>
<DetailField label="Notes">
<Text variant="description">{item.vendorReason || "No justification provided."}</Text>
</DetailField>
<DetailField label="Attachments">
<UpliftAttachments item={item} onOpenAttachment={onOpenAttachment} />
</DetailField>
</DetailSection>
);
}
function ApprovedOnWoBreakdown({
auto,
admin,
total,
unavailable,
}: {
auto: number | null;
admin: number | null;
total: number | null;
unavailable: boolean;
}) {
const rows: Array<{ label: string; value: number | null; strong: boolean }> = [
{ label: "Auto-approved", value: auto, strong: false },
{ label: "Admin-approved", value: admin, strong: false },
{ label: "Total", value: total, strong: true },
];
return (
<Box sx={{ display: "grid", gap: 0.5 }}>
{rows.map((row) => (
<BreakdownRow key={row.label} unavailable={unavailable} {...row} />
))}
</Box>
);
}
function BreakdownRow({
label,
value,
strong,
unavailable,
}: {
label: string;
value: number | null;
strong: boolean;
unavailable: boolean;
}) {
return (
<Box sx={{ display: "flex", justifyContent: "space-between" }}>
<Text
variant={strong ? "body" : "description"}
tone={strong ? "default" : "muted"}
className={strong ? "font-medium" : undefined}
>
{label}
</Text>
<Text
variant={strong ? "body" : "description"}
className={strong ? "font-medium" : undefined}
>
{value != null ? formatUpliftMoney(value) : unavailable ? UNAVAILABLE_LABEL : "—"}
</Text>
</Box>
);
}
function PendingModalActions({
item,
approvePending,
onApprove,
onReject,
}: {
item: UpliftQueueItem;
approvePending: boolean;
onApprove: (row: UpliftQueueItem) => void;
onReject: (row: UpliftQueueItem) => void;
}) {
const canApproveTier = useUpliftsCanApprove(item.requiredTier);
const canDecide = item.canDecide && canApproveTier.data === true;
const tooltip = canDecide ? "" : `Requires Tier ${item.requiredTier} role`;
return (
<>
<Tooltip title={tooltip}>
<span>
<Button
size="small"
variant="contained"
color="success"
disabled={!canDecide || approvePending}
onClick={() => onApprove(item)}
>
Approve
</Button>
</span>
</Tooltip>
<Tooltip title={tooltip}>
<span>
<Button
size="small"
variant="contained"
color="error"
disabled={!canDecide}
onClick={() => onReject(item)}
>
Reject
</Button>
</span>
</Tooltip>
</>
);
}
function UpliftModalActions({
item,
closed,
canRevoke,
approvePending,
revokePending,
onApprove,
onReject,
onRevoke,
}: {
item: UpliftQueueItem;
closed: boolean;
canRevoke: boolean;
approvePending: boolean;
revokePending: boolean;
onApprove: (row: UpliftQueueItem) => void;
onReject: (row: UpliftQueueItem) => void;
onRevoke: (row: UpliftQueueItem) => void;
}) {
const revokeTooltip = !canRevoke ? ADMIN_ONLY_REVOKE_TOOLTIP : closed ? CLOSED_WO_TOOLTIP : "";
return (
<>
{item.status === "Pending" && (
<PendingModalActions
item={item}
approvePending={approvePending}
onApprove={onApprove}
onReject={onReject}
/>
)}
{item.status === "Approved" && (
<Tooltip title={revokeTooltip}>
<span>
<Button
size="small"
variant="outlined"
color="error"
disabled={!canRevoke || closed || item.workOrderId == null || revokePending}
onClick={() => onRevoke(item)}
>
Revoke
</Button>
</span>
</Tooltip>
)}
</>
);
}
export function UpliftDetailModal({
item,
canRevoke,
approvePending,
revokePending,
onClose,
onApprove,
onReject,
onRevoke,
onOpenAttachment,
}: {
item: UpliftQueueItem | null;
canRevoke: boolean;
approvePending: boolean;
revokePending: boolean;
} & ModalCallbacks) {
const open = item != null;
// Permission failures (e.g. 403 for account-scoped users) are handled quietly
// in this modal: dependent sections fall back to "Unavailable" instead of toasting.
const quietMeta = { suppressErrorToast: true } as const;
const boardDetail = useWorkOrderBoardDetail(
item != null && item.workOrderId != null ? item.workOrderId : undefined,
true,
quietMeta,
);
const woUpliftsQuery = useWorkOrderUplifts(
item != null && item.workOrderId != null ? item.workOrderId : null,
quietMeta,
);
const info = boardDetail.data?.info;
const closed = useMemo(() => {
if (item?.workOrderClosed === true) return true;
return info ? isWorkOrderUpliftsReadOnly(info.status) : false;
}, [item?.workOrderClosed, info]);
const breakdown = useMemo(() => {
const woUplifts = woUpliftsQuery.data ?? [];
const autoFallback = sumUpliftAmounts(woUplifts, "auto_approved");
const adminFallback = sumUpliftAmounts(woUplifts, "approved");
const hasWoData = woUplifts.length > 0;
return {
auto: item?.approvedOnWoAuto ?? (hasWoData ? autoFallback : null),
admin: item?.approvedOnWoAdmin ?? (hasWoData ? adminFallback : null),
total: item?.approvedOnWoTotal ?? (hasWoData ? autoFallback + adminFallback : null),
};
}, [
item?.approvedOnWoAuto,
item?.approvedOnWoAdmin,
item?.approvedOnWoTotal,
woUpliftsQuery.data,
]);
if (!open) {
return null;
}
return (
<Dialog
open={open}
onClose={onClose}
maxWidth="sm"
fullWidth
aria-label="Uplift request details"
>
<DialogTitle>
<Text variant="title" as="div">
Uplift request
</Text>
<Text variant="description" tone="muted">
{item.woNumber || item.dispatchNumber || "Work order"} · {item.status}
</Text>
</DialogTitle>
<DialogContent>
<Box sx={{ display: "grid", gap: 3 }}>
<WorkOrderSection item={item} info={info} unavailable={boardDetail.isError} />
<RequestSection item={item} onOpenAttachment={onOpenAttachment} />
<DetailSection title="Approved on work order">
<ApprovedOnWoBreakdown
auto={breakdown.auto}
admin={breakdown.admin}
total={breakdown.total}
unavailable={woUpliftsQuery.isError}
/>
</DetailSection>
</Box>
</DialogContent>
<DialogActions>
<Button size="small" variant="outlined" onClick={onClose}>
Close
</Button>
<UpliftModalActions
item={item}
closed={closed}
canRevoke={canRevoke}
approvePending={approvePending}
revokePending={revokePending}
onApprove={onApprove}
onReject={onReject}
onRevoke={onRevoke}
/>
</DialogActions>
</Dialog>
);
}
function sumUpliftAmounts(
uplifts: ReadonlyArray<{ status: string; amount: number }>,
status: string,
): number {
return uplifts
.filter((uplift) => uplift.status === status)
.reduce((sum, uplift) => sum + uplift.amount, 0);
}

View file

@ -1,473 +1,190 @@
import { useMemo, useState } from "react";
import { useNavigate } from "react-router";
import { useState } from "react";
import { Alert, Box, Chip, Stack, Tab, Tabs, TablePagination } from "@mui/material";
import {
Alert,
Box,
Button,
Chip,
CircularProgress,
Dialog,
DialogActions,
DialogContent,
DialogTitle,
FormControl,
InputLabel,
MenuItem,
Paper,
Select,
Stack,
Table,
TableBody,
TableCell,
TableContainer,
TableHead,
TablePagination,
TableRow,
TextField,
Tooltip,
Typography,
} from "@mui/material";
import { upliftsApi } from "@/domain/uplifts/api/uplifts-api";
import type { UpliftQueueItem } from "@/domain/uplifts/types/uplift";
import { UPLIFT_STATUSES } from "@/domain/uplifts/types/uplift";
import {
useApproveUplift,
useRejectUplift,
useRequestChangesUplift,
} from "@/domain/uplifts/use-cases/use-uplift-actions";
UpliftApprovalsTable,
type UpliftApprovalTab,
} from "@/app/(protected)/uplifts/_components/uplift-approvals-table";
import { UpliftDecisionDialogs } from "@/app/(protected)/uplifts/_components/uplift-decision-dialogs";
import { UpliftDetailModal } from "@/app/(protected)/uplifts/_components/uplift-detail-modal";
import { openUpliftEvidence } from "@/app/(protected)/uplifts/_components/open-uplift-evidence";
import { formatUpliftMoney } from "@/app/(protected)/uplifts/_components/format-uplift-money";
import { isAdminUser } from "@/lib/auth/user-utils";
import { useAuthContext } from "@/providers/auth-context";
import { useUpliftApprovalController } from "@/domain/uplifts/use-cases/use-uplift-approval-controller";
import { useUpliftsQueue } from "@/domain/uplifts/use-cases/use-uplifts-queue";
import { formatDateTime } from "@/lib/time-utils";
import { toast } from "react-toastify";
import { RejectDialog } from "@/app/(protected)/uplifts/_components/reject-dialog";
import { Text } from "@/components/ui/text";
const STATUS_OPTIONS = ["", ...UPLIFT_STATUSES];
const PAGE_SIZE = 25;
type UpliftActionType = "approve" | "reject" | "request-changes";
type UpliftDecision = { type: UpliftActionType; item: UpliftQueueItem };
function formatMoney(value: number | null | undefined): string {
return `$${Number(value ?? 0).toFixed(2)}`;
}
function formatBytes(value: number | null | undefined): string {
if (value == null) return "";
if (value < 1024) return `${value} B`;
if (value < 1024 * 1024) return `${(value / 1024).toFixed(0)} KB`;
return `${(value / (1024 * 1024)).toFixed(1)} MB`;
}
function UpliftQueueHeader({
totalCount,
isFetching,
isLoading,
}: {
totalCount: number;
isFetching: boolean;
isLoading: boolean;
}) {
function UpliftApprovalsHeader({ pendingExposureTotal }: { pendingExposureTotal: number | null }) {
return (
<Stack direction="row" spacing={2} sx={{ alignItems: "baseline" }}>
<Typography variant="h5" component="h1">
Uplift Queue
</Typography>
<Typography variant="body2" sx={{ color: "text.secondary" }}>
{totalCount} total
</Typography>
{isFetching && !isLoading && <CircularProgress size={16} />}
</Stack>
<Box>
<Text variant="title" as="h1">
Uplift Approvals
</Text>
<Text variant="description" tone="muted">
Requests to increase the approved spend on a work order, above its remaining auto-approval
allowance.
</Text>
<Text when={pendingExposureTotal != null} variant="caption" tone="muted">
Pending exposure:{" "}
{pendingExposureTotal && pendingExposureTotal > 0
? formatUpliftMoney(pendingExposureTotal)
: "—"}
</Text>
</Box>
);
}
function UpliftQueueFilters({
status,
tier,
onStatusChange,
onTierChange,
function UpliftApprovalsTabs({
tab,
pendingCount,
onChange,
}: {
status: string;
tier: string;
onStatusChange: (value: string) => void;
onTierChange: (value: string) => void;
tab: UpliftApprovalTab;
pendingCount: number;
onChange: (_event: unknown, value: string) => void;
}) {
return (
<Stack direction="row" spacing={2}>
<FormControl size="small" className="min-w-[140px]">
<InputLabel>Status</InputLabel>
<Select label="Status" value={status} onChange={(e) => onStatusChange(e.target.value)}>
{STATUS_OPTIONS.map((s) => (
<MenuItem key={s || "all"} value={s}>
{s || "All"}
</MenuItem>
))}
</Select>
</FormControl>
<FormControl size="small" className="min-w-[120px]">
<InputLabel>Tier</InputLabel>
<Select label="Tier" value={tier} onChange={(e) => onTierChange(e.target.value)}>
<MenuItem value="">All</MenuItem>
<MenuItem value="1">Tier 1</MenuItem>
<MenuItem value="2">Tier 2</MenuItem>
</Select>
</FormControl>
</Stack>
);
}
function UpliftQueueRow({
row,
onViewWorkOrder,
onDownloadEvidence,
onDecide,
}: {
row: UpliftQueueItem;
onViewWorkOrder: (row: UpliftQueueItem) => void;
onDownloadEvidence: (row: UpliftQueueItem) => void;
onDecide: (type: UpliftActionType, row: UpliftQueueItem) => void;
}) {
const decisionable = row.status === "Pending";
return (
<TableRow hover>
<TableCell>{row.dispatchNumber}</TableCell>
<TableCell>{row.poNumber}</TableCell>
<TableCell>{row.vendorCompanyName}</TableCell>
<TableCell>{formatMoney(row.currentNTE)}</TableCell>
<TableCell>{formatMoney(row.requestedNTE)}</TableCell>
<TableCell>
<Chip size="small" label={`Tier ${row.requiredTier}`} />
</TableCell>
<TableCell>
<Chip size="small" label={row.status} />
</TableCell>
<TableCell>
{row.evidenceDocumentId != null ? (
<Stack direction="column" spacing={0.5}>
<Typography variant="caption">{row.evidenceFileName}</Typography>
{Boolean(row.evidenceContentType) && (
<Typography variant="caption" color="text.secondary">
{row.evidenceContentType} · {formatBytes(row.evidenceSizeBytes)}
</Typography>
)}
<Button size="small" variant="text" onClick={() => void onDownloadEvidence(row)}>
Download
</Button>
<Tabs value={tab} onChange={onChange}>
<Tab
value="pending"
label={
<Stack direction="row" spacing={1} sx={{ alignItems: "center" }}>
<span>Pending</span>
<Chip
size="small"
label={pendingCount}
aria-label={`${pendingCount} pending uplifts`}
/>
</Stack>
) : (
<Typography variant="caption" color="text.secondary">
—
</Typography>
)}
</TableCell>
<TableCell>{row.requestedAt ? formatDateTime(row.requestedAt) : ""}</TableCell>
<TableCell>
<Stack direction="row" spacing={1}>
{Boolean(row.workOrderId) && (
<Button size="small" variant="outlined" onClick={() => onViewWorkOrder(row)}>
View
</Button>
)}
{decisionable && (
<>
<Tooltip title={!row.canDecide ? `Requires Tier ${row.requiredTier} role` : ""}>
<span>
<Button
size="small"
variant="contained"
color="success"
disabled={!row.canDecide}
onClick={() => onDecide("approve", row)}
>
Approve
</Button>
</span>
</Tooltip>
<Tooltip title={!row.canDecide ? `Requires Tier ${row.requiredTier} role` : ""}>
<span>
<Button
size="small"
variant="contained"
color="error"
disabled={!row.canDecide}
onClick={() => onDecide("reject", row)}
>
Reject
</Button>
</span>
</Tooltip>
<Tooltip title={!row.canDecide ? `Requires Tier ${row.requiredTier} role` : ""}>
<span>
<Button
size="small"
variant="outlined"
disabled={!row.canDecide}
onClick={() => onDecide("request-changes", row)}
>
Request Changes
</Button>
</span>
</Tooltip>
</>
)}
</Stack>
</TableCell>
</TableRow>
}
/>
<Tab value="approved" label="Approved" />
</Tabs>
);
}
function UpliftQueueTable({
isLoading,
rows,
onViewWorkOrder,
onDownloadEvidence,
onDecide,
function UpliftApprovalsPagination({
tab,
count,
pendingPage,
approvedPage,
onPendingPageChange,
onApprovedPageChange,
}: {
isLoading: boolean;
rows: UpliftQueueItem[];
onViewWorkOrder: (row: UpliftQueueItem) => void;
onDownloadEvidence: (row: UpliftQueueItem) => void;
onDecide: (type: UpliftActionType, row: UpliftQueueItem) => void;
tab: UpliftApprovalTab;
count: number;
pendingPage: number;
approvedPage: number;
onPendingPageChange: (page: number) => void;
onApprovedPageChange: (page: number) => void;
}) {
return (
<TableContainer component={Paper} variant="outlined">
<Table size="small">
<TableHead>
<TableRow>
<TableCell className="font-semibold">Dispatch</TableCell>
<TableCell className="font-semibold">PO</TableCell>
<TableCell className="font-semibold">Vendor</TableCell>
<TableCell className="font-semibold">Current NTE</TableCell>
<TableCell className="font-semibold">Requested</TableCell>
<TableCell className="font-semibold">Tier</TableCell>
<TableCell className="font-semibold">Status</TableCell>
<TableCell className="font-semibold">Evidence</TableCell>
<TableCell className="font-semibold">Requested At</TableCell>
<TableCell className="font-semibold">Actions</TableCell>
</TableRow>
</TableHead>
<TableBody>
{isLoading ? (
<TableRow>
<TableCell colSpan={10} align="center" className="py-10">
<CircularProgress size={28} />
</TableCell>
</TableRow>
) : rows.length === 0 ? (
<TableRow>
<TableCell colSpan={10} align="center" className="py-10">
<Typography sx={{ color: "text.secondary" }}>
No uplift requests match these filters.
</Typography>
</TableCell>
</TableRow>
) : (
rows.map((row) => (
<UpliftQueueRow
key={String(row.id)}
row={row}
onViewWorkOrder={onViewWorkOrder}
onDownloadEvidence={onDownloadEvidence}
onDecide={onDecide}
/>
))
)}
</TableBody>
</Table>
</TableContainer>
<TablePagination
component="div"
count={count}
page={tab === "pending" ? pendingPage : approvedPage}
onPageChange={(_event, nextPage) =>
tab === "pending" ? onPendingPageChange(nextPage) : onApprovedPageChange(nextPage)
}
rowsPerPage={PAGE_SIZE}
rowsPerPageOptions={[PAGE_SIZE]}
/>
);
}
function UpliftDecisionDialog({
action,
note,
noteRequired,
decisionPending,
onNoteChange,
onClose,
onConfirm,
}: {
action: UpliftDecision | null;
note: string;
noteRequired: boolean;
decisionPending: boolean;
onNoteChange: (value: string) => void;
onClose: () => void;
onConfirm: () => void;
}) {
function UpliftQueueError({ error }: { error: unknown }) {
if (!error) return null;
return (
<Dialog open={Boolean(action)} onClose={onClose}>
<DialogTitle>
{action?.type === "approve"
? `Approve uplift to ${formatMoney(action.item.requestedNTE)}?`
: "Request Changes"}
</DialogTitle>
<DialogContent>
{action?.type === "approve" && (
<Typography variant="body2" className="mt-1 mb-3">
{formatMoney(action.item.currentNTE)} → {formatMoney(action.item.requestedNTE)}
</Typography>
)}
<TextField
fullWidth
multiline
minRows={2}
className="mt-2"
label={noteRequired ? "Note (required)" : "Optional note"}
value={note}
required={noteRequired}
onChange={(e) => onNoteChange(e.target.value)}
/>
</DialogContent>
<DialogActions>
<Button onClick={onClose}>Cancel</Button>
<Button
variant="contained"
color={action?.type === "approve" ? "success" : "primary"}
disabled={noteRequired ? !note.trim() || decisionPending : decisionPending}
onClick={onConfirm}
>
Confirm
</Button>
</DialogActions>
</Dialog>
<Alert severity="error">
{error instanceof Error ? error.message : "Failed to load uplift queue"}
</Alert>
);
}
export default function UpliftQueuePage() {
const navigate = useNavigate();
const [status, setStatus] = useState("Pending");
const [tier, setTier] = useState("");
const [page, setPage] = useState(0);
const [pageSize] = useState(25);
const [upliftAction, setUpliftAction] = useState<UpliftDecision | null>(null);
const [note, setNote] = useState("");
const [tab, setTab] = useState<UpliftApprovalTab>("pending");
const [pendingPage, setPendingPage] = useState(0);
const [approvedPage, setApprovedPage] = useState(0);
const listParams = useMemo(
() => ({ status, tier, page: page + 1, pageSize }),
[status, tier, page, pageSize],
);
const { user } = useAuthContext();
const canRevokeUplifts = isAdminUser(user?.userRoles);
const { data, isLoading, isFetching, error, refetch } = useUpliftsQueue(listParams);
const approveUplift = useApproveUplift();
const rejectUplift = useRejectUplift();
const requestChangesUplift = useRequestChangesUplift();
const pendingQuery = useUpliftsQueue({
status: "Pending",
page: pendingPage + 1,
pageSize: PAGE_SIZE,
});
const approvedQuery = useUpliftsQueue({
status: "Approved",
page: approvedPage + 1,
pageSize: PAGE_SIZE,
});
const rows = data?.items ?? [];
const totalCount = data?.totalCount ?? 0;
const genericAction = upliftAction?.type === "reject" ? null : upliftAction;
const noteRequired = genericAction?.type === "request-changes";
const decisionPending = approveUplift.isPending || requestChangesUplift.isPending;
const controller = useUpliftApprovalController();
const {
detailItem,
approvePending,
rejectPending,
revokePending,
handleApprove,
handleRejectRequest,
handleRevokeRequest,
closeDetail,
openDetail,
} = controller;
const handleStatusChange = (value: string) => {
setStatus(value);
setPage(0);
};
const handleTierChange = (value: string) => {
setTier(value);
setPage(0);
};
const activeQuery = tab === "pending" ? pendingQuery : approvedQuery;
const rows = activeQuery.data?.items ?? [];
const pendingCount = pendingQuery.data?.totalCount ?? 0;
const pendingExposureTotal = pendingQuery.data?.pendingExposureTotal ?? null;
const closeDialog = () => {
setUpliftAction(null);
setNote("");
};
const handleConfirm = () => {
if (!upliftAction) return;
if (noteRequired && !note.trim()) return;
const id = upliftAction.item.id;
const onDone = () => {
closeDialog();
void refetch();
};
if (upliftAction.type === "approve") {
approveUplift.mutate({ id, note: note || undefined }, { onSuccess: onDone });
return;
const handleTabChange = (_event: unknown, value: string) => {
if (value === "pending" || value === "approved") {
setTab(value);
}
if (upliftAction.type === "request-changes") {
requestChangesUplift.mutate({ id, note: note.trim() }, { onSuccess: onDone });
}
};
const handleRejectConfirm = (reason: string) => {
if (upliftAction?.type !== "reject") return;
rejectUplift.mutate(
{ id: upliftAction.item.id, note: reason },
{
onSuccess: () => {
closeDialog();
void refetch();
},
},
);
};
const downloadEvidence = async (item: UpliftQueueItem) => {
try {
await upliftsApi.downloadEvidence(item.id, item.evidenceFileName || "uplift-evidence");
} catch (err) {
toast.error(err instanceof Error ? err.message : "Unable to download evidence.");
}
};
const handleDecide = (type: UpliftActionType, item: UpliftQueueItem) => {
setUpliftAction({ type, item });
setNote("");
};
return (
<Box className="flex flex-col gap-4 p-4">
<Stack
direction={{ xs: "column", md: "row" }}
spacing={2}
sx={{
alignItems: { xs: "stretch", md: "center" },
justifyContent: "space-between",
}}
>
<UpliftQueueHeader totalCount={totalCount} isFetching={isFetching} isLoading={isLoading} />
<UpliftQueueFilters
status={status}
tier={tier}
onStatusChange={handleStatusChange}
onTierChange={handleTierChange}
/>
</Stack>
{Boolean(error) && (
<Alert severity="error">
{error instanceof Error ? error.message : "Failed to load uplift queue"}
</Alert>
)}
<UpliftQueueTable
isLoading={isLoading}
<UpliftApprovalsHeader pendingExposureTotal={pendingExposureTotal} />
<UpliftApprovalsTabs tab={tab} pendingCount={pendingCount} onChange={handleTabChange} />
<UpliftQueueError error={activeQuery.error} />
<UpliftApprovalsTable
tab={tab}
isLoading={activeQuery.isLoading}
rows={rows}
onViewWorkOrder={(r) => navigate(`/workorders/${r.workOrderId}`)}
onDownloadEvidence={(r) => void downloadEvidence(r)}
onDecide={handleDecide}
canRevoke={canRevokeUplifts}
onOpenDetail={openDetail}
onApprove={handleApprove}
onReject={handleRejectRequest}
onRevoke={handleRevokeRequest}
onOpenAttachment={openUpliftEvidence}
isDecisionPending={approvePending || rejectPending || revokePending}
/>
<TablePagination
component="div"
count={totalCount}
page={page}
onPageChange={(_e, nextPage) => setPage(nextPage)}
rowsPerPage={pageSize}
rowsPerPageOptions={[25]}
/>
<RejectDialog
open={upliftAction?.type === "reject"}
amount={upliftAction?.item.requestedNTE}
onClose={closeDialog}
onConfirm={handleRejectConfirm}
isPending={rejectUplift.isPending}
/>
<UpliftDecisionDialog
action={genericAction}
note={note}
noteRequired={noteRequired}
decisionPending={decisionPending}
onNoteChange={setNote}
onClose={closeDialog}
onConfirm={handleConfirm}
<UpliftApprovalsPagination
tab={tab}
count={activeQuery.data?.totalCount ?? 0}
pendingPage={pendingPage}
approvedPage={approvedPage}
onPendingPageChange={setPendingPage}
onApprovedPageChange={setApprovedPage}
/>
{detailItem != null && (
<UpliftDetailModal
item={detailItem}
canRevoke={canRevokeUplifts}
approvePending={approvePending}
revokePending={revokePending}
onClose={closeDetail}
onApprove={handleApprove}
onReject={handleRejectRequest}
onRevoke={handleRevokeRequest}
onOpenAttachment={openUpliftEvidence}
/>
)}
<UpliftDecisionDialogs controller={controller} />
</Box>
);
}

View file

@ -14,6 +14,7 @@ import { useCreateWorkOrderFromWizard } from "@/domain/work-orders/use-cases/use
import { useNewWoWizardDraft } from "@/app/(protected)/workorders/_components/wizard/use-new-wo-wizard-draft";
import { useWizardDuplicateActions } from "@/app/(protected)/workorders/_components/wizard/use-wizard-duplicate-actions";
import { useWizardPocAutofill } from "@/app/(protected)/workorders/_components/wizard/use-wizard-poc-autofill";
import { useWizardVendorCompanyNotes } from "@/app/(protected)/workorders/_components/wizard/use-wizard-vendor-company-notes";
import { useServicesForWorkOrderType } from "@/domain/services/use-cases/use-services-for-work-order-type";
type UseNewWoWizardControllerArgs = {
@ -49,6 +50,12 @@ export function useNewWoWizardController({
draftState.setDraft,
);
const { saveCompanyNotes } = useWizardVendorCompanyNotes(
open,
draftState.draft.vendorId,
draftState.setDraft,
);
const patchDraft = (patch: Partial<WorkOrderWizardDraft>) => {
draftState.patchDraft(patch);
if (patch.locationId !== undefined) {
@ -56,7 +63,15 @@ export function useNewWoWizardController({
}
};
const createMutation = useCreateWorkOrderFromWizard(() => onOpenChange(false));
const createMutation = useCreateWorkOrderFromWizard((createdDraft) => {
// Close the dialog first: the work-order create's isPending has already dropped, so
// holding the dialog open through the notes save would re-enable Create and unlock
// pointer events, and a second click would create a second work order. The save runs
// independently of the dialog — fetchCurrentBaseline covers a missing baseline and the
// note-save failure toast is surfaced from the mutation itself, not the dialog.
onOpenChange(false);
void saveCompanyNotes(createdDraft);
});
const { duplicateRow, setDuplicateRow, handleDuplicateFound, handleCreate, isCreating } =
useWizardDuplicateActions({
open,

View file

@ -0,0 +1,120 @@
import { useEffect, useState } from "react";
import { useQueryClient } from "@tanstack/react-query";
import { toast } from "react-toastify";
import type { WorkOrderWizardDraft } from "@/domain/work-orders/types/work-order-wizard";
import { useVendorCompanyRoster } from "@/domain/vendors/use-cases/use-vendor-company-roster";
import {
NOTES_SAVE_FAILED_MESSAGE,
useSaveVendorCompanyNotes,
} from "@/domain/vendors/use-cases/use-save-vendor-company-notes";
import { vendorCompanyRosterApi } from "@/domain/vendors/api/vendor-company-roster-api";
import { queryKeys } from "@/infra/query-key/query-key";
import {
buildCompanyNotesUpdate,
type VendorCompanyNotesBaseline,
} from "@/domain/vendors/utils/build-company-notes-patch";
type SetDraft = (updater: (current: WorkOrderWizardDraft) => WorkOrderWizardDraft) => void;
type VendorNotesBaseline = VendorCompanyNotesBaseline & { vendorId: string };
/**
* The wizard's Vendor Notes field is the vendor company's notes (same field as the Vendors
* page). Picking a technician pre-fills the company's current notes once per company, so
* switching technicians within the same company keeps the dispatcher's edits. Notes typed
* for the current selection always win over roster data that arrives late, and a save whose
* baseline is missing or belongs to another selection reads the current roster before
* building the PATCH.
*/
export function useWizardVendorCompanyNotes(open: boolean, vendorId: string, setDraft: SetDraft) {
const queryClient = useQueryClient();
const { data: roster } = useVendorCompanyRoster(vendorId || undefined);
const saveNotes = useSaveVendorCompanyNotes();
const [baseline, setBaseline] = useState<VendorNotesBaseline | null>(null);
useEffect(() => {
if (open) {
setBaseline(null);
}
}, [open]);
useEffect(() => {
if (!vendorId) {
// Cleared technician: the next pick re-seeds even for the same company.
if (baseline) setBaseline(null);
return;
}
if (!roster || roster.companyId === null) {
return;
}
if (
baseline &&
baseline.vendorId === vendorId &&
String(baseline.companyId) === String(roster.companyId)
) {
return;
}
setBaseline({
vendorId,
companyId: roster.companyId,
rowVersion: roster.rowVersion,
notes: roster.notes,
});
setDraft((current) => {
if (current.vendorId !== vendorId) {
return current;
}
// Seed only empty content or content still holding the previous company's unedited
// notes; anything else is the dispatcher's typing and must never be overwritten.
if (current.vendorNotes !== "" && current.vendorNotes !== baseline?.notes) {
return current;
}
return { ...current, vendorNotes: roster.notes };
});
}, [vendorId, roster, baseline, setDraft]);
async function fetchCurrentBaseline(vendorId: string): Promise<VendorNotesBaseline | null> {
try {
const current = await queryClient.fetchQuery({
queryKey: queryKeys.vendors.roster("vendorId", vendorId),
queryFn: () => vendorCompanyRosterApi.get({ vendorId }),
});
if (!current || current.companyId === null) {
return null;
}
return {
vendorId,
companyId: current.companyId,
rowVersion: current.rowVersion,
notes: current.notes,
};
} catch {
toast.error(NOTES_SAVE_FAILED_MESSAGE);
return null;
}
}
async function saveCompanyNotes(draft: WorkOrderWizardDraft): Promise<void> {
if (!draft.vendorId) {
return;
}
const active =
baseline && baseline.vendorId === draft.vendorId
? baseline
: await fetchCurrentBaseline(draft.vendorId);
if (!active) {
return;
}
const update = buildCompanyNotesUpdate(active, draft.vendorNotes);
if (!update) {
return;
}
try {
await saveNotes.mutateAsync(update);
} catch {
// The mutation already surfaces the note-save failure toast.
}
}
return { saveCompanyNotes };
}

View file

@ -29,7 +29,7 @@ export function WizardStepLocationServiceSelect({
onPatch,
}: WizardStepLocationServiceSelectProps) {
const clearVendorIfPrimaryChanged = (nextPm: string): Partial<WorkOrderWizardDraft> =>
nextPm !== draft.pm ? { vendorId: "", vendorName: "", techPhone: "" } : {};
nextPm !== draft.pm ? { vendorId: "", vendorName: "", techPhone: "", vendorNotes: "" } : {};
const selectSvc = (p: string) => {
const selected = services.find((service) => service.value === p);

View file

@ -206,7 +206,7 @@ export function WizardStepVendorTime({
optional
onClear={
draft.vendorId !== ""
? () => onPatch({ vendorId: "", vendorName: "", techPhone: "" })
? () => onPatch({ vendorId: "", vendorName: "", techPhone: "", vendorNotes: "" })
: undefined
}
>

View file

@ -0,0 +1,37 @@
import { useEffect, useRef, useState } from "react";
import { useSearchParams } from "react-router";
import type { WorkOrderAdvancedFilters } from "@/domain/work-orders/types/work-order-advanced-filters";
import { parseDashboardDrilldownParams } from "@/domain/work-orders/utils/dashboard-drilldown-params";
export function useDashboardDrilldownFilters(
onApply: (filters: WorkOrderAdvancedFilters) => void,
): 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;
}
const drilldown = parseDashboardDrilldownParams(searchParams);
if (!drilldown) {
return;
}
appliedRef.current = true;
setFromDashboard(true);
onApplyRef.current(drilldown);
setSearchParams({}, { replace: true });
}, [searchParams, setSearchParams]);
return fromDashboard;
}

View file

@ -4,6 +4,7 @@ import { useWorkOrdersDayPagination } from "@/app/(protected)/workorders/_hooks/
import { currentWeekMonday } from "@/domain/work-orders/utils/list-week-utils";
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
import { deriveDayFields } from "@/domain/work-orders/utils/table-day-utils";
import { todayIso } from "@/domain/work-orders/utils/wizard-date-utils";
import { pageSelectedDayGroups } from "@/domain/work-orders/utils/day-pagination";
import {
buildSelectedDayGroup,
@ -70,9 +71,7 @@ export function useWorkOrdersTableDayView({
});
const isEmptyWeek = !advActive && scheduled.length === 0 && !searchActive;
const todayKey =
filters.weekMonday === currentWeekMonday()
? deriveDayFields(new Date().toISOString().slice(0, 10)).dayGroup
: null;
filters.weekMonday === currentWeekMonday() ? deriveDayFields(todayIso()).dayGroup : null;
return {
scheduled,

View file

@ -1,4 +1,5 @@
import { Box } from "@mui/material";
import { Box, Link as MuiLink } from "@mui/material";
import { Link as RouterLink } from "react-router";
import { PageHeader } from "@/components/ui/page-header";
import { QueryErrorPanel } from "@/components/ui/query-error-panel";
import { WorkOrdersAdvancedFiltersBanner } from "@/app/(protected)/workorders/_components/list/work-orders-advanced-filters-banner";
@ -8,6 +9,7 @@ import { WorkOrdersListHeader } from "@/app/(protected)/workorders/_components/l
import { WorkOrdersListPagePanels } from "@/app/(protected)/workorders/_components/list/work-orders-list-page-panels";
import { WoTable } from "@/app/(protected)/workorders/_components/list/table/wo-table";
import { useWorkOrdersListPage } from "@/app/(protected)/workorders/_hooks/use-work-orders-list-page";
import { useDashboardDrilldownFilters } from "@/app/(protected)/workorders/_hooks/use-dashboard-drilldown-filters";
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
import { hasCompletionDocService } from "@/domain/work-orders/utils/completion-doc-service-gate";
import { useAuthContext } from "@/providers/auth-context";
@ -24,6 +26,7 @@ export default function WorkOrdersListPage() {
const { user } = useAuthContext();
const page = useWorkOrdersListPage();
const { filters, tableData } = page;
const fromDashboard = useDashboardDrilldownFilters(filters.applyAdvancedFilters);
return (
<Box className="flex h-full flex-col overflow-hidden p-2.5">
@ -54,6 +57,12 @@ export default function WorkOrdersListPage() {
}
/>
{fromDashboard && (
<MuiLink component={RouterLink} to="/" underline="hover" className="mb-2 self-start">
Back to Dashboard
</MuiLink>
)}
<WorkOrdersFilterBar
weekMonday={filters.weekMonday}
onWeekChange={filters.setWeekMonday}

View file

@ -1,7 +1,30 @@
import { API_PATHS } from "@/api/api-paths";
import { apiGet } from "@/api/api";
import { handleApiResponse } from "@/api/handle-api-response";
import type { DashboardStats } from "@/domain/dashboard/types/dashboard-stats";
import type { Options } from "ky";
import type { DashboardRangeParams } from "@/domain/dashboard/types/dashboard-range";
import type {
DashboardBreakdownRow,
DashboardStats,
DashboardStatusBucketRow,
} from "@/domain/dashboard/types/dashboard-stats";
import type { DispatcherPerformanceRow } from "@/domain/dashboard/types/dashboard-performance";
import {
DISPATCHER_PAGE_SIZE,
type DispatcherPage,
} from "@/domain/dashboard/types/dashboard-dispatcher-page";
import type { DashboardTrendParams } from "@/domain/dashboard/types/dashboard-trend";
import type {
DashboardTrend,
DashboardTrendGranularity,
} from "@/domain/dashboard/types/dashboard-trend";
import {
UNMAPPED_REGION_LABEL,
type RegionWorkOrdersRow,
} from "@/domain/dashboard/types/dashboard-regions";
import type { DispatcherWorkloadRow } from "@/domain/dashboard/types/dashboard-workload";
import type { VendorInsightRow } from "@/domain/dashboard/types/dashboard-vendor-insights";
import { WO_TYPES } from "@/domain/work-orders/types/work-order-wizard";
function toNumber(value: unknown): number {
if (typeof value === "number" && Number.isFinite(value)) {
@ -16,20 +39,383 @@ function toNumber(value: unknown): number {
return 0;
}
function mapDashboardStats(raw: unknown): DashboardStats {
const data = (typeof raw === "object" && raw !== null ? raw : {}) as Record<string, unknown>;
function asString(value: unknown): string {
if (typeof value === "string") {
return value.trim();
}
if (typeof value === "number" && Number.isFinite(value)) {
return String(value);
}
return "";
}
function asRecords(value: unknown): Record<string, unknown>[] {
if (!Array.isArray(value)) {
return [];
}
return value.filter(
(item): item is Record<string, unknown> =>
typeof item === "object" && item !== null && !Array.isArray(item),
);
}
function readField(data: Record<string, unknown>, key: string): unknown {
const capitalized = key.charAt(0).toUpperCase() + key.slice(1);
const value = data[key] ?? data[capitalized];
return value === null ? undefined : value;
}
function toRecord(value: unknown): Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value)
? (value as Record<string, unknown>)
: {};
}
function readCollection(value: unknown, key: string): Record<string, unknown>[] {
const data = toRecord(value);
const collection = key ? (readField(data, key) ?? value) : value;
return asRecords(collection);
}
/**
* 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;
}
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, 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 };
}
/**
* Reads the paging envelope the dispatcher endpoints return (`Page`, `PageSize`,
* `TotalDispatchers`). Falls back to the requested page, the default page size,
* and the item count for the legacy bare-array shape so older responses still
* render (with the pager hidden).
*/
function readDispatcherPageMeta(
raw: unknown,
requestedPage: number,
itemCount: number,
): { page: number; pageSize: number; totalDispatchers: number } {
const data = toRecord(raw);
const pageValue = readField(data, "page");
const pageSizeValue = readField(data, "pageSize");
const totalValue = readField(data, "totalDispatchers");
return {
open: toNumber(data.open ?? data.Open),
notDispatched: toNumber(data.notDispatched ?? data.NotDispatched),
completed: toNumber(data.completed ?? data.Completed),
page: pageValue == null ? requestedPage : toNumber(pageValue),
pageSize: pageSizeValue == null ? DISPATCHER_PAGE_SIZE : toNumber(pageSizeValue),
totalDispatchers: totalValue == null ? itemCount : toNumber(totalValue),
};
}
function trendSearchParams(params?: DashboardTrendParams): Options | undefined {
if (
!params ||
(!params.dateFrom && !params.dateTo && !params.granularity && !params.range && !params.year)
) {
return undefined;
}
const searchParams: Record<string, string | number> = {};
if (params.dateFrom) searchParams.dateFrom = params.dateFrom;
if (params.dateTo) searchParams.dateTo = params.dateTo;
if (params.granularity) searchParams.granularity = params.granularity;
if (params.range) searchParams.range = params.range;
if (params.year) searchParams.year = params.year;
return { searchParams };
}
const BREAKDOWN_TYPE_BY_LOWER = new Map<string, string>(
WO_TYPES.map((type) => [type.toLowerCase(), type]),
);
/**
* The backend breakdown object (backend#138) serializes its keys with no
* JsonPropertyName, so `PM`/`Emergency`/`Reactive`/`Overdue`/`Other` arrive
* lowercase. The card uses the row label as both display text and the
* `types` drill-down id, and `workOrderTypeDrilldownSearch` only accepts
* canonical `WOType` values — so map lowercase type keys back to their
* canonical labels. `other` is not a WOType: keep it as a labelled,
* non-drillable "Other" row. Unknown values (e.g. lifecycle labels from the
* legacy array shape) pass through unchanged.
*/
function normalizeBreakdownStatus(raw: string): string {
const trimmed = raw.trim();
const canonical = BREAKDOWN_TYPE_BY_LOWER.get(trimmed.toLowerCase());
if (canonical) return canonical;
if (trimmed.toLowerCase() === "other") return "Other";
return trimmed;
}
function mapBreakdownRow(raw: Record<string, unknown>): DashboardBreakdownRow {
const status = asString(
readField(raw, "status") ?? readField(raw, "name") ?? readField(raw, "label"),
);
return {
status: status ? normalizeBreakdownStatus(status) : UNMAPPED_REGION_LABEL,
count: toNumber(readField(raw, "count") ?? readField(raw, "value")),
};
}
function mapBreakdown(value: unknown): DashboardBreakdownRow[] {
if (Array.isArray(value)) {
return asRecords(value).map(mapBreakdownRow);
}
if (typeof value === "object" && value !== null) {
return Object.entries(value as Record<string, unknown>)
.map(([status, count]) => ({
status: normalizeBreakdownStatus(status),
count: toNumber(count),
}))
.filter((row) => row.status !== "");
}
return [];
}
function mapDispatcherIdentity(raw: Record<string, unknown>): {
dispatcherId: string;
dispatcherName: string;
} {
return {
dispatcherId: asString(
readField(raw, "dispatcherId") ?? readField(raw, "id") ?? readField(raw, "userId"),
),
dispatcherName: asString(readField(raw, "dispatcherName") ?? readField(raw, "name")),
};
}
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");
return {
total: toNumber(readField(data, "total")),
open: toNumber(readField(data, "open")),
notDispatched: toNumber(readField(data, "notDispatched")),
completed: toNumber(readField(data, "completed")),
dueCount: toNumber(readField(data, "dueCount")),
completedDueCount: toNumber(readField(data, "completedDueCount")),
completionRate: toNumber(readField(data, "completionRate")),
averageResolutionDays: average == null ? null : toNumber(average),
scheduledTomorrow: toNumber(readField(data, "scheduledTomorrow")),
pendingUplifts: toNumber(readField(data, "pendingUplifts")),
avetaPending: toNumber(readField(data, "avetaPending")),
breakdown: mapBreakdown(readField(data, "breakdown") ?? readField(data, "workOrderBreakdown")),
statusDistribution: mapStatusDistribution(readField(data, "statusDistribution")),
};
}
function mapDispatcherWorkload(raw: unknown): DispatcherWorkloadRow[] {
return readCollection(raw, "items").map((rawRow) => {
const identity = mapDispatcherIdentity(rawRow);
return {
...identity,
openWorkOrders: toNumber(
readField(rawRow, "openWorkOrders") ??
readField(rawRow, "openCount") ??
readField(rawRow, "open"),
),
totalWorkOrders: toNumber(
readField(rawRow, "totalWorkOrders") ??
readField(rawRow, "totalCount") ??
readField(rawRow, "scheduledWorkOrders") ??
readField(rawRow, "scheduled"),
),
};
});
}
function mapDispatcherPerformance(raw: unknown): DispatcherPerformanceRow[] {
return readCollection(raw, "items").map((rawRow) => {
const identity = mapDispatcherIdentity(rawRow);
return {
...identity,
completionRate: toNumber(
readField(rawRow, "completionRate") ?? readField(rawRow, "onTimeRate"),
),
averageResolutionDays:
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"),
),
};
});
}
function mapRegions(raw: unknown): RegionWorkOrdersRow[] {
return readCollection(raw, "items").map((rawRow) => {
const region =
asString(readField(rawRow, "region") ?? readField(rawRow, "name")) || UNMAPPED_REGION_LABEL;
return {
region,
workOrderCount: toNumber(
readField(rawRow, "workOrderCount") ??
readField(rawRow, "count") ??
readField(rawRow, "total"),
),
};
});
}
function mapVendorInsights(raw: unknown): VendorInsightRow[] {
return readCollection(raw, "items").map((rawRow) => ({
vendorId: asString(
readField(rawRow, "vendorId") ??
readField(rawRow, "vendorCompanyId") ??
readField(rawRow, "id"),
),
vendorName: asString(
readField(rawRow, "vendorName") ??
readField(rawRow, "vendorCompanyName") ??
readField(rawRow, "companyName") ??
readField(rawRow, "name"),
),
completionRate: toNumber(readField(rawRow, "completionRate")),
rescheduleRate: toNumber(readField(rawRow, "rescheduleRate")),
averageResolutionDays:
readField(rawRow, "averageResolutionDays") == null
? null
: toNumber(readField(rawRow, "averageResolutionDays")),
totalJobs: toNumber(readField(rawRow, "totalJobs")),
}));
}
function mapTrendGranularity(value: unknown): DashboardTrendGranularity | null {
const granularity = asString(value).toLowerCase();
return granularity === "day" || granularity === "week" || granularity === "month"
? granularity
: null;
}
function mapDashboardTrend(raw: unknown): DashboardTrend {
const data = toRecord(raw);
const buckets = readCollection(data, "buckets");
const legacyPoints = readCollection(data, "points");
const points = (buckets.length > 0 ? buckets : legacyPoints).map((rawPoint) => ({
date: asString(readField(rawPoint, "date")),
label: asString(
readField(rawPoint, "label") ?? readField(rawPoint, "period") ?? readField(rawPoint, "date"),
),
total: toNumber(
readField(rawPoint, "total") ?? readField(rawPoint, "count") ?? readField(rawPoint, "value"),
),
open: toNumber(readField(rawPoint, "open")),
completed: toNumber(readField(rawPoint, "completed")),
canceled: toNumber(readField(rawPoint, "canceled")),
overdue: toNumber(readField(rawPoint, "overdue")),
isCurrent: readField(rawPoint, "isCurrent") === true,
}));
return { granularity: mapTrendGranularity(readField(data, "granularity")), points };
}
export const dashboardApi = {
getStats: async (): Promise<DashboardStats> => {
const data = await apiGet<unknown>(API_PATHS.dashboard.stats);
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, dispatcherId),
);
const response = handleApiResponse<unknown>(data);
return mapDashboardStats(response);
const items = mapDispatcherWorkload(response);
return { items, ...readDispatcherPageMeta(response, page, items.length) };
},
getPerformance: async (
range?: DashboardRangeParams,
page = 1,
dispatcherId?: string,
): Promise<DispatcherPage<DispatcherPerformanceRow>> => {
const data = await apiGet<unknown>(
API_PATHS.dashboard.performance,
dispatcherPageSearchParams(range, page, dispatcherId),
);
const response = handleApiResponse<unknown>(data);
const items = mapDispatcherPerformance(response);
return { items, ...readDispatcherPageMeta(response, page, items.length) };
},
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[]> => {
const data = await apiGet<unknown>(API_PATHS.dashboard.vendorInsights);
return mapVendorInsights(handleApiResponse<unknown>(data));
},
getTrend: async (params?: DashboardTrendParams): Promise<DashboardTrend> => {
const data = await apiGet<unknown>(API_PATHS.dashboard.trend, trendSearchParams(params));
return mapDashboardTrend(handleApiResponse<unknown>(data));
},
};

View file

@ -0,0 +1,10 @@
/** Backend pages both dispatcher tables at 10 rows (backend#126/#127). */
export const DISPATCHER_PAGE_SIZE = 10;
/** A single page of dispatcher rows plus the paging metadata the tables need. */
export interface DispatcherPage<TRow> {
items: TRow[];
page: number;
pageSize: number;
totalDispatchers: number;
}

View file

@ -0,0 +1,8 @@
export interface DispatcherPerformanceRow {
dispatcherId: string;
dispatcherName: string;
completionRate: number;
averageResolutionDays: number | null;
assignedCount: number;
completedCount: number;
}

View file

@ -0,0 +1,10 @@
export const DASHBOARD_RANGES = ["this-week", "last-3-months", "today", "all-time"] as const;
export type DashboardRangeKey = (typeof DASHBOARD_RANGES)[number];
export const DEFAULT_DASHBOARD_RANGE: DashboardRangeKey = "this-week";
export interface DashboardRangeParams {
dateFrom?: string;
dateTo?: string;
}

View file

@ -0,0 +1,6 @@
export const UNMAPPED_REGION_LABEL = "Unmapped/Other";
export interface RegionWorkOrdersRow {
region: string;
workOrderCount: number;
}

View file

@ -1,5 +1,25 @@
export interface DashboardBreakdownRow {
status: string;
count: number;
}
export interface DashboardStatusBucketRow {
status: string;
count: number;
}
export interface DashboardStats {
total: number;
open: number;
notDispatched: number;
completed: number;
dueCount: number;
completedDueCount: number;
completionRate: number;
averageResolutionDays: number | null;
scheduledTomorrow: number;
pendingUplifts: number;
avetaPending: number;
breakdown: DashboardBreakdownRow[];
statusDistribution: DashboardStatusBucketRow[];
}

View file

@ -0,0 +1,25 @@
export type DashboardTrendGranularity = "day" | "week" | "month";
export interface DashboardTrendParams {
dateFrom?: string;
dateTo?: string;
granularity?: DashboardTrendGranularity;
range?: "3m";
year?: number;
}
export interface DashboardTrendPoint {
date: string;
label: string;
total: number;
open: number;
completed: number;
canceled: number;
overdue: number;
isCurrent: boolean;
}
export interface DashboardTrend {
granularity: DashboardTrendGranularity | null;
points: DashboardTrendPoint[];
}

View file

@ -0,0 +1,8 @@
export interface VendorInsightRow {
vendorId: string;
vendorName: string;
completionRate: number;
rescheduleRate: number;
averageResolutionDays: number | null;
totalJobs: number;
}

View file

@ -0,0 +1,6 @@
export interface DispatcherWorkloadRow {
dispatcherId: string;
dispatcherName: string;
openWorkOrders: number;
totalWorkOrders: number;
}

View file

@ -0,0 +1,22 @@
import { useQuery, type UseQueryResult } from "@tanstack/react-query";
import { dashboardApi } from "@/domain/dashboard/api/dashboard-api";
import type { DispatcherPage } from "@/domain/dashboard/types/dashboard-dispatcher-page";
import type { DispatcherPerformanceRow } from "@/domain/dashboard/types/dashboard-performance";
import type { DashboardRangeKey } from "@/domain/dashboard/types/dashboard-range";
import { dashboardRangeParams } from "@/domain/dashboard/utils/dashboard-range-utils";
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, dispatcherId),
queryFn: () =>
dashboardApi.getPerformance(dashboardRangeParams(range), page, dispatcherId ?? undefined),
staleTime: 60 * 1000,
enabled,
});
}

View file

@ -0,0 +1,17 @@
import { useQuery, type UseQueryResult } from "@tanstack/react-query";
import { dashboardApi } from "@/domain/dashboard/api/dashboard-api";
import type { RegionWorkOrdersRow } from "@/domain/dashboard/types/dashboard-regions";
import type { DashboardRangeKey } from "@/domain/dashboard/types/dashboard-range";
import { dashboardRangeParams } from "@/domain/dashboard/utils/dashboard-range-utils";
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, dispatcherId),
queryFn: () => dashboardApi.getRegions(dashboardRangeParams(range), dispatcherId ?? undefined),
staleTime: 60 * 1000,
});
}

View file

@ -1,12 +1,17 @@
import { useQuery, type UseQueryResult } from "@tanstack/react-query";
import { dashboardApi } from "@/domain/dashboard/api/dashboard-api";
import type { DashboardRangeKey } from "@/domain/dashboard/types/dashboard-range";
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(): UseQueryResult<DashboardStats, Error> {
export function useDashboardStats(
range: DashboardRangeKey,
dispatcherId: string | null = null,
): UseQueryResult<DashboardStats, Error> {
return useQuery({
queryKey: queryKeys.dashboard.stats(),
queryFn: () => dashboardApi.getStats(),
queryKey: queryKeys.dashboard.stats(range, dispatcherId),
queryFn: () => dashboardApi.getStats(dashboardRangeParams(range), dispatcherId ?? undefined),
staleTime: 60 * 1000,
});
}

View file

@ -0,0 +1,17 @@
import { useQuery, type UseQueryResult } from "@tanstack/react-query";
import { dashboardApi } from "@/domain/dashboard/api/dashboard-api";
import type {
DashboardTrend,
DashboardTrendParams,
} from "@/domain/dashboard/types/dashboard-trend";
import { queryKeys } from "@/infra/query-key/query-key";
export function useDashboardTrend(
params?: DashboardTrendParams,
): UseQueryResult<DashboardTrend, Error> {
return useQuery({
queryKey: queryKeys.dashboard.trend(params),
queryFn: () => dashboardApi.getTrend(params),
staleTime: 60 * 1000,
});
}

View file

@ -0,0 +1,12 @@
import { useQuery, type UseQueryResult } from "@tanstack/react-query";
import { dashboardApi } from "@/domain/dashboard/api/dashboard-api";
import type { VendorInsightRow } from "@/domain/dashboard/types/dashboard-vendor-insights";
import { queryKeys } from "@/infra/query-key/query-key";
export function useDashboardVendorInsights(): UseQueryResult<VendorInsightRow[], Error> {
return useQuery({
queryKey: queryKeys.dashboard.vendorInsights(),
queryFn: () => dashboardApi.getVendorInsights(),
staleTime: 60 * 1000,
});
}

View file

@ -0,0 +1,22 @@
import { useQuery, type UseQueryResult } from "@tanstack/react-query";
import { dashboardApi } from "@/domain/dashboard/api/dashboard-api";
import type { DispatcherPage } from "@/domain/dashboard/types/dashboard-dispatcher-page";
import type { DispatcherWorkloadRow } from "@/domain/dashboard/types/dashboard-workload";
import type { DashboardRangeKey } from "@/domain/dashboard/types/dashboard-range";
import { dashboardRangeParams } from "@/domain/dashboard/utils/dashboard-range-utils";
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, dispatcherId),
queryFn: () =>
dashboardApi.getWorkload(dashboardRangeParams(range), page, dispatcherId ?? undefined),
staleTime: 60 * 1000,
enabled,
});
}

View file

@ -0,0 +1,73 @@
import { DEFAULT_DASHBOARD_RANGE } from "@/domain/dashboard/types/dashboard-range";
import type {
DashboardRangeKey,
DashboardRangeParams,
} from "@/domain/dashboard/types/dashboard-range";
export const BUSINESS_TIME_ZONE = "America/New_York";
const ISO_DATE_PATTERN = /^\d{4}-\d{2}-\d{2}$/;
export function businessTodayIso(now: Date = new Date()): string {
return new Intl.DateTimeFormat("en-CA", {
timeZone: BUSINESS_TIME_ZONE,
year: "numeric",
month: "2-digit",
day: "2-digit",
}).format(now);
}
export function isIsoDate(value: string): boolean {
return ISO_DATE_PATTERN.test(value);
}
export function addDaysIso(isoDate: string, days: number): string {
const [year, month, day] = isoDate.split("-").map(Number);
const shifted = new Date(Date.UTC(year, month - 1, day + days));
return shifted.toISOString().slice(0, 10);
}
export function mondayOf(isoDate: string): string {
const [year, month, day] = isoDate.split("-").map(Number);
const weekday = new Date(Date.UTC(year, month - 1, day)).getUTCDay();
return addDaysIso(isoDate, -((weekday + 6) % 7));
}
export function monthStartIso(isoDate: string): string {
return `${isoDate.slice(0, 7)}-01`;
}
export function monthEndIso(isoDate: string): string {
const year = Number(isoDate.slice(0, 4));
const month = Number(isoDate.slice(5, 7));
const lastDay = new Date(Date.UTC(year, month, 0)).getUTCDate();
return `${isoDate.slice(0, 7)}-${String(lastDay).padStart(2, "0")}`;
}
function shiftMonthsIso(isoDate: string, months: number): string {
const year = Number(isoDate.slice(0, 4));
const month = Number(isoDate.slice(5, 7));
const shifted = new Date(Date.UTC(year, month - 1 + months, 1));
return `${shifted.getUTCFullYear()}-${String(shifted.getUTCMonth() + 1).padStart(2, "0")}-01`;
}
export function dashboardRangeParams(
range: DashboardRangeKey = DEFAULT_DASHBOARD_RANGE,
today: string = businessTodayIso(),
): DashboardRangeParams {
switch (range) {
case "today":
return { dateFrom: today, dateTo: today };
case "this-week": {
const monday = mondayOf(today);
return { dateFrom: monday, dateTo: addDaysIso(monday, 4) };
}
case "last-3-months":
return {
dateFrom: monthStartIso(shiftMonthsIso(today, -2)),
dateTo: monthEndIso(today),
};
case "all-time":
return {};
}
}

View file

@ -0,0 +1,96 @@
import { ALL_WIZARD_STATUSES } from "@/domain/work-orders/types/work-order-wizard";
import { WO_TYPES, type WOType } from "@/domain/work-orders/types/work-order-wizard";
import type { DashboardRangeKey } from "@/domain/dashboard/types/dashboard-range";
import {
addDaysIso,
businessTodayIso,
dashboardRangeParams,
} from "@/domain/dashboard/utils/dashboard-range-utils";
export const WORK_ORDERS_ROUTE = "/workorders";
export const UPLIFTS_ROUTE = "/uplifts";
export const OPEN_WIZARD_STATUSES: readonly string[] = ALL_WIZARD_STATUSES.filter(
(status) => status !== "Completed",
);
export function workOrderDrilldownUrl(search: URLSearchParams): string {
const query = search.toString();
return query ? `${WORK_ORDERS_ROUTE}?${query}` : WORK_ORDERS_ROUTE;
}
function setDateRange(search: URLSearchParams, range: DashboardRangeKey): void {
const params = dashboardRangeParams(range);
if (params.dateFrom && params.dateTo) {
search.set("dateFrom", params.dateFrom);
search.set("dateTo", params.dateTo);
}
}
export function workOrderRangeDrilldownSearch(range: DashboardRangeKey): URLSearchParams {
const search = new URLSearchParams();
setDateRange(search, range);
return search;
}
export function workOrderStatusDrilldownSearch(
status: string,
range: DashboardRangeKey,
): URLSearchParams {
const search = workOrderRangeDrilldownSearch(range);
search.set("statuses", status);
return search;
}
export function workOrderTypeDrilldownSearch(
type: string,
range: DashboardRangeKey,
): URLSearchParams | null {
if (!WO_TYPES.includes(type as WOType)) return null;
const search = workOrderRangeDrilldownSearch(range);
search.set("types", type);
return search;
}
export function workOrderDispatcherDrilldownSearch(
dispatcherId: string,
range: DashboardRangeKey,
): URLSearchParams {
const search = workOrderRangeDrilldownSearch(range);
search.set("dispatchers", dispatcherId);
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 {
const tomorrow = addDaysIso(today, 1);
const search = new URLSearchParams();
search.set("dateFrom", tomorrow);
search.set("dateTo", tomorrow);
return search;
}
export function avetaPendingDrilldownSearch(today: string = businessTodayIso()): URLSearchParams {
// The Aveta Pending count (backend#138 / SH-294) is: AvetaRequired, scheduled
// today or tomorrow, open, and missing its Aveta-category document. The board
// has no missing-Aveta-document filter, so the closest reachable set is the
// Aveta-required work in the two-day open window. `docs=No` is deliberately
// NOT sent: it filters on completion-document status, a different document
// from the Aveta clearance one, so it would narrow to the wrong rows.
const search = new URLSearchParams();
search.set("dateFrom", today);
search.set("dateTo", addDaysIso(today, 1));
search.set("aveta", "1");
search.set("statuses", OPEN_WIZARD_STATUSES.join(","));
return search;
}

View file

@ -2,7 +2,11 @@ import { HTTPError } from "ky";
import { API_PATHS } from "@/api/api-paths";
import { apiGet, apiPost, apiRequestRaw } from "@/api/api";
import { handleApiResponse } from "@/api/handle-api-response";
import { mapUpliftQueueItems, mapUpliftRequests } from "@/domain/uplifts/mappers/uplift-mapper";
import {
mapUpliftQueueExposureTotal,
mapUpliftQueueItems,
mapUpliftRequests,
} from "@/domain/uplifts/mappers/uplift-mapper";
import type { UpliftsQueueParams, UpliftsQueueResult } from "@/domain/uplifts/types/uplift";
import type { UpliftRequest } from "@/domain/uplifts/types/uplift";
@ -57,6 +61,7 @@ export const upliftsApi = {
totalCount: total,
page: params.page ?? 1,
pageSize: params.pageSize ?? 25,
pendingExposureTotal: mapUpliftQueueExposureTotal(response),
};
},
@ -81,10 +86,22 @@ export const upliftsApi = {
await apiPost<unknown>(`${API_PATHS.rest.uplifts}/${id}/reject`, { note });
},
canApprove: async (tier: number): Promise<boolean> => {
const data = await apiGet<unknown>(`${API_PATHS.rest.uplifts}/can-approve`, {
searchParams: { tier },
});
const response = handleApiResponse<{ canApprove?: unknown }>(data);
return response?.canApprove === true;
},
requestChanges: async (id: string | number, note: string): Promise<void> => {
await apiPost<unknown>(`${API_PATHS.rest.uplifts}/${id}/request-changes`, { note });
},
revoke: async (id: string | number, reason: string): Promise<void> => {
await apiPost<unknown>(`${API_PATHS.rest.uplifts}/${id}/revoke`, { note: reason });
},
downloadEvidence: async (
id: string | number,
fallbackFileName = "uplift-evidence",

View file

@ -39,6 +39,14 @@ function readOptionalId(
return null;
}
function readOptionalBool(record: Record<string, unknown>, ...keys: string[]): boolean | null {
for (const key of keys) {
const value = record[key];
if (typeof value === "boolean") return value;
}
return null;
}
export function mapUpliftRequest(raw: unknown): UpliftRequest {
const item = asRecord(raw);
return {
@ -49,7 +57,13 @@ export function mapUpliftRequest(raw: unknown): UpliftRequest {
delta: readNumber(item, "delta", "Delta") ?? 0,
vendorReason: readString(item, "vendorReason", "VendorReason"),
requestedAt: readString(item, "requestedAt", "RequestedAt"),
requestedByVendorName: readString(item, "requestedByVendorName", "RequestedByVendorName"),
requestedByVendorName: readString(
item,
"requestedByName",
"RequestedByName",
"requestedByVendorName",
"RequestedByVendorName",
),
decidedAt: readString(item, "decidedAt", "DecidedAt"),
decidedByName: readString(item, "decidedByName", "DecidedByName"),
decisionNote: readString(item, "decisionNote", "DecisionNote"),
@ -79,9 +93,86 @@ export function mapUpliftQueueItem(raw: unknown): UpliftQueueItem {
vendorCompanyName: readString(item, "vendorCompanyName", "VendorCompanyName"),
workOrderId: readOptionalId(item, "workOrderId", "WorkOrderId"),
dispatchId: readOptionalId(item, "dispatchId", "DispatchId"),
woNumber: readString(item, "woNumber", "WoNumber", "workOrderNumber", "WorkOrderNumber"),
site: readString(
item,
"workOrderSite",
"WorkOrderSite",
"site",
"Site",
"siteCode",
"SiteCode",
"locationName",
"LocationName",
),
serviceName: readString(
item,
"workOrderService",
"WorkOrderService",
"serviceName",
"ServiceName",
"service",
"Service",
"trade",
"Trade",
),
attachmentCount: readNumber(
item,
"attachmentCount",
"AttachmentCount",
"attachmentsCount",
"AttachmentsCount",
),
approvedOnWoAuto: readNumber(
item,
"workOrderAutoApprovedTotal",
"WorkOrderAutoApprovedTotal",
"approvedOnWoAuto",
"ApprovedOnWoAuto",
"autoApprovedTotal",
"AutoApprovedTotal",
),
approvedOnWoAdmin: readNumber(
item,
"workOrderAdminApprovedTotal",
"WorkOrderAdminApprovedTotal",
"approvedOnWoAdmin",
"ApprovedOnWoAdmin",
"adminApprovedTotal",
"AdminApprovedTotal",
),
approvedOnWoTotal: readNumber(
item,
"workOrderApprovedExposureTotal",
"WorkOrderApprovedExposureTotal",
"approvedOnWoTotal",
"ApprovedOnWoTotal",
"approvedOnWorkOrderTotal",
"ApprovedOnWorkOrderTotal",
),
workOrderClosed: readOptionalBool(
item,
"workOrderClosed",
"WorkOrderClosed",
"isWorkOrderClosed",
"IsWorkOrderClosed",
),
};
}
export function mapUpliftQueueItems(rawItems: unknown[]): UpliftQueueItem[] {
return rawItems.map(mapUpliftQueueItem);
}
export function mapUpliftQueueExposureTotal(raw: unknown): number | null {
const record = asRecord(raw);
return readNumber(
record,
"pendingExposureTotal",
"PendingExposureTotal",
"totalPendingExposure",
"TotalPendingExposure",
"exposureTotal",
"ExposureTotal",
);
}

View file

@ -43,6 +43,14 @@ export interface UpliftQueueItem extends UpliftRequest {
vendorCompanyName: string;
workOrderId: string | number | null;
dispatchId: string | number | null;
woNumber: string;
site: string;
serviceName: string;
attachmentCount: number | null;
approvedOnWoAuto: number | null;
approvedOnWoAdmin: number | null;
approvedOnWoTotal: number | null;
workOrderClosed: boolean | null;
}
export interface UpliftsQueueParams {
@ -57,4 +65,5 @@ export interface UpliftsQueueResult {
totalCount: number;
page: number;
pageSize: number;
pendingExposureTotal: number | null;
}

View file

@ -26,6 +26,9 @@ function invalidateUpliftQueries(
dispatchId?: string | number,
) {
void queryClient.invalidateQueries({ queryKey: queryKeys.uplifts.all });
// Queue decisions update the same uplift records rendered by Work Order views.
// Invalidate that shared cache even when the queue has no dispatch context.
void queryClient.invalidateQueries({ queryKey: queryKeys.workOrders.all });
if (dispatchId !== undefined && dispatchId !== "") {
void queryClient.invalidateQueries({ queryKey: queryKeys.uplifts.forDispatch(dispatchId) });
void queryClient.invalidateQueries({ queryKey: queryKeys.workOrders.dispatch(dispatchId) });
@ -33,14 +36,32 @@ function invalidateUpliftQueries(
}
}
function invalidateWorkOrderUpliftQueries(queryClient: ReturnType<typeof useQueryClient>) {
void queryClient.invalidateQueries({ queryKey: queryKeys.uplifts.all });
void queryClient.invalidateQueries({ queryKey: queryKeys.workOrders.all });
}
export interface ApproveUpliftInput {
id: string | number;
note?: string;
amount?: number;
woNumber?: string;
}
export interface DecisionUpliftInput {
id: string | number;
note: string;
amount?: number;
woNumber?: string;
}
function decisionAmount(amount: number | undefined): string {
if (amount == null || !Number.isFinite(amount)) return "the requested amount";
return new Intl.NumberFormat("en-US", { style: "currency", currency: "USD" }).format(amount);
}
function decisionWorkOrder(woNumber: string | undefined): string {
return woNumber?.trim() || "the work order";
}
export function useApproveUplift(
@ -49,11 +70,12 @@ export function useApproveUplift(
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ id, note }: { id: string | number; note?: string }) =>
upliftsApi.approve(id, note),
onSuccess: () => {
mutationFn: ({ id, note }: ApproveUpliftInput) => upliftsApi.approve(id, note),
onSuccess: (_, variables) => {
invalidateUpliftQueries(queryClient, dispatchId);
toast.success("Uplift approved — NTE updated");
toast.success(
`Uplift approved — ${decisionAmount(variables.amount)} on ${decisionWorkOrder(variables.woNumber)}`,
);
},
onError: (error: Error) => {
toast.error(error.message || "Failed to approve uplift");
@ -67,11 +89,12 @@ export function useRejectUplift(
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ id, note }: { id: string | number; note: string }) =>
upliftsApi.reject(id, note),
onSuccess: () => {
mutationFn: ({ id, note }: DecisionUpliftInput) => upliftsApi.reject(id, note),
onSuccess: (_, variables) => {
invalidateUpliftQueries(queryClient, dispatchId);
toast.success("Uplift rejected");
toast.success(
`Uplift rejected — ${decisionAmount(variables.amount)} on ${decisionWorkOrder(variables.woNumber)}`,
);
},
onError: (error: Error) => {
toast.error(error.message || "Failed to reject uplift");
@ -96,3 +119,27 @@ export function useRequestChangesUplift(
},
});
}
export interface RevokeUpliftInput {
upliftId: string | number;
reason: string;
amount?: number;
woNumber?: string;
}
export function useRevokeUplift(): UseMutationResult<void, Error, RevokeUpliftInput> {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ upliftId, reason }: RevokeUpliftInput) => upliftsApi.revoke(upliftId, reason),
onSuccess: (_, variables) => {
invalidateWorkOrderUpliftQueries(queryClient);
toast.success(
`Uplift revoked — ${decisionAmount(variables.amount)} on ${decisionWorkOrder(variables.woNumber)}`,
);
},
onError: (error: Error) => {
toast.error(error.message || "Failed to revoke uplift");
},
});
}

View file

@ -0,0 +1,75 @@
import { useState } from "react";
import type { UpliftQueueItem } from "@/domain/uplifts/types/uplift";
import {
useApproveUplift,
useRejectUplift,
useRevokeUplift,
} from "@/domain/uplifts/use-cases/use-uplift-actions";
export function useUpliftApprovalController() {
const [detailItem, setDetailItem] = useState<UpliftQueueItem | null>(null);
const [rejectTarget, setRejectTarget] = useState<UpliftQueueItem | null>(null);
const [revokeTarget, setRevokeTarget] = useState<UpliftQueueItem | null>(null);
const approveUplift = useApproveUplift();
const rejectUplift = useRejectUplift();
const revokeUplift = useRevokeUplift();
const handleApprove = (row: UpliftQueueItem) => {
approveUplift.mutate({ id: row.id, amount: row.delta, woNumber: row.woNumber });
setDetailItem(null);
};
const handleRejectRequest = (row: UpliftQueueItem) => {
setDetailItem(null);
setRejectTarget(row);
};
const handleRevokeRequest = (row: UpliftQueueItem) => {
setDetailItem(null);
setRevokeTarget(row);
};
const handleRejectConfirm = (reason: string) => {
if (!rejectTarget) return;
rejectUplift.mutate(
{
id: rejectTarget.id,
note: reason,
amount: rejectTarget.delta,
woNumber: rejectTarget.woNumber,
},
{ onSuccess: () => setRejectTarget(null) },
);
};
const handleRevokeConfirm = (reason: string) => {
if (!revokeTarget) return;
revokeUplift.mutate(
{
upliftId: revokeTarget.id,
reason,
amount: revokeTarget.delta,
woNumber: revokeTarget.woNumber,
},
{ onSuccess: () => setRevokeTarget(null) },
);
};
return {
detailItem,
rejectTarget,
revokeTarget,
approvePending: approveUplift.isPending,
rejectPending: rejectUplift.isPending,
revokePending: revokeUplift.isPending,
handleApprove,
handleRejectRequest,
handleRevokeRequest,
handleRejectConfirm,
handleRevokeConfirm,
closeDetail: () => setDetailItem(null),
closeReject: () => setRejectTarget(null),
closeRevoke: () => setRevokeTarget(null),
openDetail: setDetailItem,
};
}

View file

@ -0,0 +1,17 @@
import { useQuery, type UseQueryResult } from "@tanstack/react-query";
import { upliftsApi } from "@/domain/uplifts/api/uplifts-api";
import { queryKeys } from "@/infra/query-key/query-key";
/**
* Backend-authoritative answer to "may the current user decide uplifts at this
* tier?" (GET uplifts/can-approve). Combines with the per-row canDecide signal
* so decision controls stay disabled for roles the backend would refuse.
*/
export function useUpliftsCanApprove(tier: number): UseQueryResult<boolean, Error> {
return useQuery({
queryKey: queryKeys.uplifts.canApprove(tier),
queryFn: () => upliftsApi.canApprove(tier),
staleTime: 5 * 60 * 1000,
retry: 1,
});
}

View file

@ -0,0 +1,29 @@
import { useMutation, useQueryClient, type UseMutationResult } from "@tanstack/react-query";
import { toast } from "react-toastify";
import { vendorCompanyRosterApi } from "@/domain/vendors/api/vendor-company-roster-api";
import type { VendorCompanyRoster } from "@/domain/vendors/types/vendor";
import type { VendorCompanyNotesUpdate } from "@/domain/vendors/utils/build-company-notes-patch";
import { queryKeys } from "@/infra/query-key/query-key";
export const NOTES_SAVE_FAILED_MESSAGE =
"Work order created, but the vendor notes could not be saved.";
/** Writes company-level vendor notes and refreshes every vendor view that shows them. */
export function useSaveVendorCompanyNotes(): UseMutationResult<
VendorCompanyRoster,
unknown,
VendorCompanyNotesUpdate
> {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ companyId, patch }: VendorCompanyNotesUpdate) =>
vendorCompanyRosterApi.addTechnicians(companyId, patch),
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: queryKeys.vendors.all });
},
onError: () => {
toast.error(NOTES_SAVE_FAILED_MESSAGE);
},
});
}

View file

@ -0,0 +1,35 @@
import type { VendorCompanyRoster } from "@/domain/vendors/types/vendor";
import type { VendorRosterAdditivePatch } from "@/domain/vendors/mappers/vendor-roster-mapper";
export type VendorCompanyNotesBaseline = Pick<
VendorCompanyRoster,
"companyId" | "rowVersion" | "notes"
>;
export interface VendorCompanyNotesUpdate {
companyId: string | number;
patch: VendorRosterAdditivePatch;
}
/**
* Company-level notes edited outside the Vendors page (e.g. the Create WO wizard)
* are written through the additive roster PATCH, which only touches the fields sent.
* Returns null when nothing changed. Clearing notes is not expressible on that PATCH
* (blank company fields mean "unchanged"), so an emptied field is not sent.
*/
export function buildCompanyNotesUpdate(
baseline: VendorCompanyNotesBaseline | null | undefined,
editedNotes: string,
): VendorCompanyNotesUpdate | null {
if (!baseline || baseline.companyId === null || !baseline.rowVersion) {
return null;
}
const notes = editedNotes.trim();
if (!notes || notes === baseline.notes.trim()) {
return null;
}
return {
companyId: baseline.companyId,
patch: { rowVersion: baseline.rowVersion, addTechnicians: [], companyFields: { notes } },
};
}

View file

@ -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"

View file

@ -290,6 +290,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[];

View file

@ -13,19 +13,19 @@ import type { WorkOrderWizardDraft } from "@/domain/work-orders/types/work-order
import { queryKeys } from "@/infra/query-key/query-key";
export function useCreateWorkOrderFromWizard(
onSuccess?: () => void,
onSuccess?: (draft: WorkOrderWizardDraft) => void,
): UseMutationResult<WorkOrderDetail, Error, WorkOrderWizardDraft> {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (draft: WorkOrderWizardDraft) => workOrdersApi.createFromBoard(draft),
onSuccess: () => {
onSuccess: (_created, draft) => {
void queryClient.invalidateQueries({ queryKey: queryKeys.workOrders.all });
toast.success("Work Order created successfully!");
onSuccess?.();
onSuccess?.(draft);
},
onError: (error: Error) => {

View file

@ -27,11 +27,13 @@ export function useWorkOrderDetail(
export function useWorkOrderBoardDetail(
id: string | number | undefined,
enabled = true,
meta?: { suppressErrorToast?: boolean },
): UseQueryResult<WorkOrderBoardDetail, Error> {
return useQuery({
queryKey: queryKeys.workOrders.boardDetail(id ?? ""),
queryFn: () => workOrdersApi.getBoardDetail(requireQueryParam(id, "Work order id")),
enabled: enabled && id !== undefined && id !== "",
meta,
// Do not keepPreviousData — placeholder detail from another WO leaks into the slide-over.
});
}

View file

@ -24,12 +24,14 @@ function invalidateUpliftQueries(
export function useWorkOrderUplifts(
workOrderId: string | number | null | undefined,
meta?: { suppressErrorToast?: boolean },
): UseQueryResult<WorkOrderUplift[], Error> {
return useQuery({
queryKey: queryKeys.workOrders.uplifts(workOrderId ?? ""),
queryFn: () =>
workOrderUpliftsApi.list(requireQueryParam(workOrderId ?? undefined, "Work order id")),
enabled: workOrderId != null && workOrderId !== "",
meta,
});
}

View file

@ -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) {

View file

@ -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,

View file

@ -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),

View file

@ -0,0 +1,123 @@
import type { CompletionDocFilter } from "@/domain/work-orders/types/work-order-advanced-filters";
import type { WorkOrderAdvancedFilters } from "@/domain/work-orders/types/work-order-advanced-filters";
import { EMPTY_ADVANCED_FILTERS } from "@/domain/work-orders/types/work-order-advanced-filters";
import { ALL_WIZARD_STATUSES, WO_TYPES } from "@/domain/work-orders/types/work-order-wizard";
import type { WizardWOStatus, WOType } from "@/domain/work-orders/types/work-order-wizard";
import type { UpliftFilterStatus } from "@/domain/work-orders/types/work-order-uplift-filter";
import { cloneAdvancedFilters } from "@/domain/work-orders/utils/advanced-filters-utils";
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",
"auto_approved",
"rejected",
];
function parseList(raw: string | null): string[] {
if (!raw) {
return [];
}
return raw
.split(",")
.map((item) => item.trim())
.filter((item) => item !== "");
}
function parseStatuses(raw: string | null): WizardWOStatus[] {
const valid = new Set<string>(ALL_WIZARD_STATUSES);
return [...new Set(parseList(raw))].filter((status): status is WizardWOStatus =>
valid.has(status),
);
}
function parseTypes(raw: string | null): WOType[] {
const valid = new Set<string>(WO_TYPES);
return [...new Set(parseList(raw))].filter((type): type is WOType => valid.has(type));
}
function parseCompletionDocs(raw: string | null): CompletionDocFilter[] {
const valid = new Set<string>(COMPLETION_DOC_FILTERS);
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 =>
valid.has(value),
);
}
export function parseDashboardDrilldownParams(
params: URLSearchParams,
): WorkOrderAdvancedFilters | null {
const dateFrom = (params.get("dateFrom") ?? "").trim();
const dateTo = (params.get("dateTo") ?? "").trim();
const hasDateRange = ISO_DATE_PATTERN.test(dateFrom) && ISO_DATE_PATTERN.test(dateTo);
const avetaOnly = params.get("aveta") === "1";
const docs = parseCompletionDocs(params.get("docs"));
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";
const hasAny = [
hasDateRange,
avetaOnly,
docs.length,
statuses.length,
types.length,
dispatchers.length,
regions.length,
upliftStatuses.length,
hasUplift,
].some(Boolean);
if (!hasAny) {
return null;
}
const filters = cloneAdvancedFilters(EMPTY_ADVANCED_FILTERS);
if (hasDateRange) {
filters.dateRange = "custom";
filters.customFrom = dateFrom;
filters.customTo = dateTo;
}
filters.avetaOnly = avetaOnly;
filters.hasUplift = hasUplift || upliftStatuses.length > 0;
if (docs.length > 0) {
filters.docs = new Set(docs);
}
if (statuses.length > 0) {
filters.statuses = new Set(statuses);
}
if (types.length > 0) {
filters.types = new Set(types);
}
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);
}
return filters;
}

View file

@ -10,6 +10,7 @@ import { ADVANCED_FILTER_PREDICATES } from "@/domain/work-orders/utils/advanced-
import { isCarryOverDisplayRow } from "@/domain/work-orders/utils/carry-over-display";
import { isDateInWeek } from "@/domain/work-orders/utils/table-day-utils";
import { weekRange } from "@/domain/work-orders/utils/list-week-utils";
import { todayIso } from "@/domain/work-orders/utils/wizard-date-utils";
const TERMINAL_STATUSES = new Set(["Completed", "Canceled", "Done", "Cancelled"]);
@ -17,8 +18,10 @@ export function computeIsPastDue(row: WorkOrderTableRow): boolean {
if (!row.scheduledOn || TERMINAL_STATUSES.has(row.status)) {
return false;
}
const today = new Date().toISOString().slice(0, 10);
return row.scheduledOn.slice(0, 10) < today;
// scheduledOn is a calendar date, so compare it to the user's local calendar
// day. A UTC "today" rolls over hours early west of UTC and flags work
// scheduled for today as Past Due every evening.
return row.scheduledOn.slice(0, 10) < todayIso();
}
export function enrichTableRow(row: WorkOrderTableRow): WorkOrderTableRow {

View file

@ -1,3 +1,5 @@
import type { DashboardRangeKey } from "@/domain/dashboard/types/dashboard-range";
import type { DashboardTrendParams } from "@/domain/dashboard/types/dashboard-trend";
import type { WorkOrdersListParams } from "@/domain/work-orders/types/work-order-list";
import type {
WorkOrderBoardQueryParams,
@ -54,7 +56,17 @@ export const queryKeys = {
},
dashboard: {
all: ["dashboard"] as const,
stats: () => [...queryKeys.dashboard.all, "stats"] 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,
},
dropdownOptions: {
all: ["dropdownOptions"] as const,
@ -105,6 +117,7 @@ export const queryKeys = {
forDispatch: (dispatchId: string | number) =>
[...queryKeys.uplifts.all, "dispatch", dispatchId] as const,
evidence: (id: string | number) => [...queryKeys.uplifts.all, "evidence", id] as const,
canApprove: (tier: number) => [...queryKeys.uplifts.all, "can-approve", tier] as const,
},
vendorPos: {
all: ["vendorPos"] as const,

View file

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

View file

@ -1,33 +1,39 @@
import { MutationCache, QueryCache, QueryClient } from "@tanstack/react-query";
import { toast } from "react-toastify";
export const queryClient = new QueryClient({
queryCache: new QueryCache({
onError: (error) => {
console.error("Query error:", error);
const message =
error instanceof Error ? error.message : "An error occurred while fetching data";
toast.error(message);
},
}),
mutationCache: new MutationCache({
onError: (error, _variables, _context, mutation) => {
if (mutation.options.meta?.suppressErrorToast) return;
export function createAppQueryClient(): QueryClient {
return new QueryClient({
queryCache: new QueryCache({
onError: (error, query) => {
if (query.meta?.suppressErrorToast) return;
console.error("Mutation error:", error);
const message =
error instanceof Error ? error.message : "An error occurred while updating data";
toast.error(message);
console.error("Query error:", error);
const message =
error instanceof Error ? error.message : "An error occurred while fetching data";
toast.error(message);
},
}),
mutationCache: new MutationCache({
onError: (error, _variables, _context, mutation) => {
if (mutation.options.meta?.suppressErrorToast) return;
console.error("Mutation error:", error);
const message =
error instanceof Error ? error.message : "An error occurred while updating data";
toast.error(message);
},
}),
defaultOptions: {
queries: {
staleTime: 5 * 60 * 1000,
gcTime: 10 * 60 * 1000,
retry: 1,
refetchOnWindowFocus: false,
},
},
}),
defaultOptions: {
queries: {
staleTime: 5 * 60 * 1000,
gcTime: 10 * 60 * 1000,
retry: 1,
refetchOnWindowFocus: false,
},
},
});
});
}
export const queryClient = createAppQueryClient();
export default queryClient;

View file

@ -0,0 +1,265 @@
import { fireEvent, screen } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { DashboardRangeKey } from "@/domain/dashboard/types/dashboard-range";
import { renderWithProviders } from "@/test/test-utils";
const navigate = vi.fn();
const statsRange = vi.fn<(range: DashboardRangeKey) => void>();
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 };
vi.mock("react-router", async (importOriginal) => ({
...(await importOriginal<typeof import("react-router")>()),
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,
}));
vi.mock("@/domain/dashboard/use-cases/use-dashboard-performance", () => ({
useDashboardPerformance: performanceMock,
}));
vi.mock("@/domain/dashboard/use-cases/use-dashboard-regions", () => ({
useDashboardRegions: () => ({ data: [], isLoading: false, isFetching: false, error: null }),
}));
vi.mock("@/domain/dashboard/use-cases/use-dashboard-vendor-insights", () => ({
useDashboardVendorInsights: () => ({
data: [],
isLoading: false,
isFetching: false,
error: null,
}),
}));
vi.mock("@/domain/dashboard/use-cases/use-dashboard-trend", () => ({
useDashboardTrend: () => ({
data: { granularity: null, points: [] },
isLoading: false,
isFetching: false,
error: null,
}),
}));
const statsData = {
scheduledTomorrow: 3,
pendingUplifts: 5,
avetaPending: 0,
breakdown: [
{ status: "PM", count: 7 },
{ status: "Completed", count: 0 },
],
};
vi.mock("@/domain/dashboard/use-cases/use-dashboard-stats", () => ({
useDashboardStats: (range: DashboardRangeKey) => {
statsRange(range);
return { data: statsData, isLoading: false, isFetching: false, error: null };
},
}));
import DashboardPage from "@/app/(protected)/dashboard";
import {
avetaPendingDrilldownSearch,
scheduledTomorrowDrilldownSearch,
} from "@/domain/dashboard/utils/work-order-drilldown-links";
import { addDaysIso, businessTodayIso } from "@/domain/dashboard/utils/dashboard-range-utils";
function renderDashboard() {
return renderWithProviders(<DashboardPage />);
}
describe("DashboardPage", () => {
beforeEach(() => {
authUser.current = { id: "99", userRoles: "Admin" };
navigate.mockReset();
statsRange.mockReset();
workloadMock.mockReset();
performanceMock.mockReset();
workloadMock.mockReturnValue({
data: EMPTY_DISPATCHER_PAGE,
isLoading: false,
isFetching: false,
error: null,
});
performanceMock.mockReturnValue({
data: EMPTY_DISPATCHER_PAGE,
isLoading: false,
isFetching: false,
error: null,
});
});
it("renders the prototype title, range options, and card titles", () => {
renderDashboard();
expect(screen.getByRole("heading", { name: "Dashboard" })).toBeInTheDocument();
for (const label of ["This week", "Last 3 months", "Today", "All time"]) {
expect(screen.getByRole("button", { name: label })).toBeInTheDocument();
}
for (const title of [
"Scheduled Tomorrow",
"Pending Uplifts",
"Aveta Pending",
"Work Order Breakdown",
"Status Distribution",
"Dispatcher Workload",
"Dispatcher Performance",
"Work Orders by Region",
"Vendor Insights",
]) {
expect(screen.getByText(title)).toBeInTheDocument();
}
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();
const tomorrowCard = screen.getByRole("button", { name: /Scheduled Tomorrow/ });
expect(tomorrowCard).toBeInTheDocument();
fireEvent.click(tomorrowCard);
const today = businessTodayIso();
expect(navigate).toHaveBeenCalledWith(
`/workorders?${scheduledTomorrowDrilldownSearch(today).toString()}`,
);
fireEvent.click(screen.getByRole("button", { name: /Pending Uplifts/ }));
expect(navigate).toHaveBeenCalledWith("/uplifts");
});
it("keeps zero counts inert", () => {
renderDashboard();
expect(screen.queryByRole("button", { name: /Aveta Pending/ })).toBeNull();
fireEvent.click(screen.getByText("Aveta Pending"));
expect(navigate).not.toHaveBeenCalled();
const inertRow = screen.getByText("Completed");
fireEvent.click(inertRow);
expect(navigate).not.toHaveBeenCalled();
});
it("drills the breakdown status into filtered work orders", () => {
renderDashboard();
fireEvent.click(screen.getByText("PM"));
const calls = navigate.mock.calls;
const [url] = calls[calls.length - 1] ?? [];
expect(String(url)).toContain("/workorders?");
expect(String(url)).toContain("types=PM");
});
it("switches the stats range from the toggle", () => {
renderDashboard();
fireEvent.click(screen.getByRole("button", { name: "Today" }));
expect(statsRange).toHaveBeenCalledWith("today");
});
it("shows the documented empty states when datasets are empty", () => {
renderDashboard();
// 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();
});
it("pages the dispatcher tables at 10 rows and re-queries on page change", () => {
const rows = Array.from({ length: 10 }, (_, index) => ({
dispatcherId: String(index + 1),
dispatcherName: `Dispatcher ${index + 1}`,
openWorkOrders: 1,
totalWorkOrders: 2,
}));
workloadMock.mockReturnValue({
data: { items: rows, page: 1, pageSize: 10, totalDispatchers: 23 },
isLoading: false,
isFetching: false,
error: null,
});
renderDashboard();
// 23 dispatchers at 10/page = 3 pages; selecting page 2 re-queries with page 2.
fireEvent.click(screen.getByRole("button", { name: "Go to page 2" }));
const requestedPages = workloadMock.mock.calls.map((call) => call[1]);
expect(requestedPages).toContain(2);
});
it("resets the dispatcher page when the range changes", () => {
const rows = Array.from({ length: 10 }, (_, index) => ({
dispatcherId: String(index + 1),
dispatcherName: `Dispatcher ${index + 1}`,
openWorkOrders: 1,
totalWorkOrders: 2,
}));
workloadMock.mockReturnValue({
data: { items: rows, page: 2, pageSize: 10, totalDispatchers: 23 },
isLoading: false,
isFetching: false,
error: null,
});
renderDashboard();
fireEvent.click(screen.getByRole("button", { name: "Go to page 2" }));
fireEvent.click(screen.getByRole("button", { name: "Today" }));
const calls = workloadMock.mock.calls;
const lastRequestedPage = calls[calls.length - 1]?.[1];
expect(lastRequestedPage).toBe(1);
});
it("keeps drilldown dates deterministic on the business clock", () => {
const today = businessTodayIso();
expect(scheduledTomorrowDrilldownSearch(today).get("dateFrom")).toBe(addDaysIso(today, 1));
expect(avetaPendingDrilldownSearch(today).get("aveta")).toBe("1");
});
});

View file

@ -0,0 +1,39 @@
import { fireEvent, render, screen } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import { DispatcherTablePager } from "@/app/(protected)/dashboard/_components/dispatcher-table-pager";
describe("DispatcherTablePager", () => {
it("renders nothing when everything fits on one page", () => {
const { container } = render(
<DispatcherTablePager
page={1}
pageSize={10}
totalDispatchers={10}
onPageChange={vi.fn()}
label="pager"
/>,
);
expect(container).toBeEmptyDOMElement();
});
it("renders a page per 10 rows and reports the selected page", () => {
const onPageChange = vi.fn();
render(
<DispatcherTablePager
page={1}
pageSize={10}
totalDispatchers={23}
onPageChange={onPageChange}
label="pager"
/>,
);
// 23 rows at 10/page = 3 pages.
expect(screen.getByRole("button", { name: "Go to page 3" })).toBeInTheDocument();
expect(screen.queryByRole("button", { name: "Go to page 4" })).toBeNull();
fireEvent.click(screen.getByRole("button", { name: "Go to page 2" }));
expect(onPageChange).toHaveBeenCalledWith(2);
});
});

View file

@ -1,6 +1,9 @@
import { screen } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import PmSchedulesListPage from "@/app/(protected)/pmschedules/index";
// Static import: a dynamic import inside the test transforms the edit page's
// module graph against the 5s test budget, which times out under load.
import EditPage from "@/app/(protected)/pmschedules/edit/[id]";
import { renderWithProviders } from "@/test/test-utils";
const usePmSchedulesList = vi.fn();
@ -75,9 +78,7 @@ describe("PmSchedulesListPage actions", () => {
});
describe("PmScheduleEditUnavailablePage", () => {
it("shows unavailable message instead of edit form when canEdit is false", async () => {
const EditPage = (await import("@/app/(protected)/pmschedules/edit/[id]")).default;
it("shows unavailable message instead of edit form when canEdit is false", () => {
renderWithProviders(<EditPage />, {
route: "/pmschedules/edit/1",
routerProps: { initialEntries: ["/pmschedules/edit/1"] },

View file

@ -11,11 +11,14 @@ vi.mock("@/domain/team-members/use-cases/use-create-team-member", () => ({
describe("AddTeamMemberDialog", () => {
const mutate = vi.fn();
const reset = vi.fn();
beforeEach(() => {
mutate.mockReset();
reset.mockReset();
vi.mocked(useCreateTeamMember).mockReturnValue({
mutate,
reset,
isPending: false,
error: null,
} as unknown as ReturnType<typeof useCreateTeamMember>);
@ -62,4 +65,40 @@ describe("AddTeamMemberDialog", () => {
expect.any(Object),
);
}, 15000);
it("does not close while a member is being created", async () => {
const user = userEvent.setup();
const onClose = vi.fn();
vi.mocked(useCreateTeamMember).mockReturnValue({
mutate,
reset,
isPending: true,
error: null,
} as unknown as ReturnType<typeof useCreateTeamMember>);
renderWithProviders(<AddTeamMemberDialog open onClose={onClose} />, { withAuth: false });
await user.keyboard("{Escape}");
expect(onClose).not.toHaveBeenCalled();
});
it("clears a previous mutation error when reopened", () => {
const mutation = {
mutate,
reset,
isPending: false,
error: new Error("Email is already in use."),
} as unknown as ReturnType<typeof useCreateTeamMember>;
vi.mocked(useCreateTeamMember).mockReturnValue(mutation);
const { rerender } = renderWithProviders(<AddTeamMemberDialog open onClose={vi.fn()} />, {
withAuth: false,
});
expect(screen.getByText("Email is already in use.")).toBeInTheDocument();
mutation.error = null;
rerender(<AddTeamMemberDialog open={false} onClose={vi.fn()} />);
rerender(<AddTeamMemberDialog open onClose={vi.fn()} />);
expect(screen.queryByText("Email is already in use.")).not.toBeInTheDocument();
});
});

View file

@ -0,0 +1,178 @@
import { fireEvent, screen } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { UpliftQueueItem, UpliftsQueueResult } from "@/domain/uplifts/types/uplift";
import { renderWithProviders } from "@/test/test-utils";
const revokeMutate = vi.fn();
const queueState = vi.hoisted(() => ({
data: null as UpliftsQueueResult | null,
}));
const authState = vi.hoisted(() => ({
userRoles: "Admin",
}));
const approvedItem: UpliftQueueItem = {
id: 77,
status: "Approved",
currentNTE: 500,
requestedNTE: 900,
delta: 400,
vendorReason: "Additional scope",
requestedAt: "2026-01-10T08:00:00Z",
requestedByVendorName: "Gateway",
decidedAt: "2026-01-12T09:30:00Z",
decidedByName: "Admin User",
decisionNote: "",
requiredTier: 1,
canDecide: false,
expiresAt: "",
notificationStatus: "",
notificationError: "",
evidenceDocumentId: "doc-1",
evidenceFileName: "quote.pdf",
evidenceContentType: "application/pdf",
evidenceSizeBytes: 2048,
dispatchNumber: "DSP-77",
poNumber: "PO-77",
vendorCompanyName: "Gateway Plumbing",
workOrderId: 55,
dispatchId: 12,
woNumber: "WO-55",
site: "Site B",
serviceName: "HVAC service",
attachmentCount: 3,
approvedOnWoAuto: 100,
approvedOnWoAdmin: 400,
approvedOnWoTotal: 500,
workOrderClosed: true,
};
const approvedOpenItem: UpliftQueueItem = {
...approvedItem,
id: 78,
woNumber: "WO-56",
workOrderClosed: false,
};
vi.mock("@/domain/uplifts/use-cases/use-uplifts-queue", () => ({
useUpliftsQueue: () => ({
data: queueState.data,
isLoading: false,
isFetching: false,
error: null,
refetch: vi.fn(),
}),
}));
vi.mock("@/domain/uplifts/use-cases/use-uplift-actions", () => ({
useApproveUplift: () => ({ isPending: false, mutate: vi.fn() }),
useRejectUplift: () => ({ isPending: false, mutate: vi.fn() }),
useRevokeUplift: () => ({ isPending: false, mutate: revokeMutate }),
}));
vi.mock("@/providers/auth-context", async (importOriginal) => ({
...(await importOriginal<object>()),
useAuthContext: () => ({ user: { userRoles: authState.userRoles } }),
}));
vi.mock("@/domain/uplifts/use-cases/use-uplifts-can-approve", () => ({
useUpliftsCanApprove: () => ({ data: true }),
}));
describe("Uplift Approvals approved tab", () => {
beforeEach(() => {
revokeMutate.mockReset();
authState.userRoles = "Admin";
queueState.data = {
items: [approvedItem],
totalCount: 1,
page: 1,
pageSize: 25,
pendingExposureTotal: null,
};
});
it("disables Revoke with the closed work order tooltip", async () => {
const { default: UpliftQueuePage } = await import("@/app/(protected)/uplifts/index");
renderWithProviders(<UpliftQueuePage />);
fireEvent.click(screen.getByRole("tab", { name: "Approved" }));
expect(screen.getByRole("columnheader", { name: "Approved By" })).toBeInTheDocument();
expect(screen.getByRole("columnheader", { name: "Approved At" })).toBeInTheDocument();
expect(screen.queryByRole("columnheader", { name: "Waiting" })).not.toBeInTheDocument();
expect(screen.getByText("Admin User")).toBeInTheDocument();
const revoke = await screen.findByRole("button", { name: "Revoke" });
expect(revoke).toBeDisabled();
fireEvent.mouseOver(revoke);
expect(
await screen.findByText("This work order is closed. Uplifts can no longer be revoked."),
).toBeInTheDocument();
expect(revokeMutate).not.toHaveBeenCalled();
});
it("opens RevokeDialog for an approved uplift on an open work order", async () => {
queueState.data = {
items: [approvedOpenItem],
totalCount: 1,
page: 1,
pageSize: 25,
pendingExposureTotal: null,
};
const { default: UpliftQueuePage } = await import("@/app/(protected)/uplifts/index");
renderWithProviders(<UpliftQueuePage />);
fireEvent.click(screen.getByRole("tab", { name: "Approved" }));
const revoke = await screen.findByRole("button", { name: "Revoke" });
expect(revoke).toBeEnabled();
fireEvent.click(revoke);
expect(screen.getByRole("heading", { name: "Revoke this approval?" })).toBeInTheDocument();
expect(
screen.getByText(
"The approved uplift of $900.00 on WO WO-56 will be withdrawn. This does not recover money already spent — it records that the authorization was a mistake.",
),
).toBeInTheDocument();
});
it("disables Revoke with the admin-only tooltip for a Dispatcher and never opens the dialog", async () => {
authState.userRoles = "Dispatcher";
queueState.data = {
items: [approvedOpenItem],
totalCount: 1,
page: 1,
pageSize: 25,
pendingExposureTotal: null,
};
const { default: UpliftQueuePage } = await import("@/app/(protected)/uplifts/index");
renderWithProviders(<UpliftQueuePage />);
fireEvent.click(screen.getByRole("tab", { name: "Approved" }));
const revoke = await screen.findByRole("button", { name: "Revoke" });
expect(revoke).toBeDisabled();
fireEvent.mouseOver(revoke);
expect(await screen.findByText("Only admins can revoke uplifts")).toBeInTheDocument();
fireEvent.click(revoke);
expect(
screen.queryByRole("heading", { name: "Revoke this approval?" }),
).not.toBeInTheDocument();
expect(revokeMutate).not.toHaveBeenCalled();
});
it("shows the approved empty copy when nothing is archived", async () => {
queueState.data = {
items: [],
totalCount: 0,
page: 1,
pageSize: 25,
pendingExposureTotal: null,
};
const { default: UpliftQueuePage } = await import("@/app/(protected)/uplifts/index");
renderWithProviders(<UpliftQueuePage />);
fireEvent.click(screen.getByRole("tab", { name: "Approved" }));
expect(await screen.findByText("No approved uplifts yet")).toBeInTheDocument();
expect(
screen.getByText("Uplifts you approve are archived here for reference."),
).toBeInTheDocument();
});
});

View file

@ -0,0 +1,132 @@
import { screen, waitFor } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { UpliftDetailModal } from "@/app/(protected)/uplifts/_components/uplift-detail-modal";
import { ApiError } from "@/api/api-error";
import type { UpliftQueueItem } from "@/domain/uplifts/types/uplift";
import { createAppQueryClient } from "@/lib/query/query-client";
import { renderWithProviders } from "@/test/test-utils";
const toastMocks = vi.hoisted(() => ({ error: vi.fn() }));
const boardDetailMock = vi.hoisted(() => ({ calls: 0 }));
const woUpliftsMock = vi.hoisted(() => ({ calls: 0 }));
vi.mock("react-toastify", () => ({
toast: { error: toastMocks.error },
}));
vi.mock("@/domain/work-orders/api/work-orders-api", () => ({
workOrdersApi: {
getBoardDetail: vi.fn(() => {
boardDetailMock.calls += 1;
return Promise.reject(
new ApiError("You do not have permission to perform this action.", 403),
);
}),
},
}));
vi.mock("@/domain/work-orders/api/work-order-uplifts-api", () => ({
workOrderUpliftsApi: {
list: vi.fn(() => {
woUpliftsMock.calls += 1;
return Promise.reject(
new ApiError("You do not have permission to perform this action.", 403),
);
}),
},
}));
vi.mock("@/domain/auth/api/auth-api", async (importOriginal) => ({
...(await importOriginal<object>()),
getSession: vi.fn(() =>
Promise.resolve({
token: "t",
expiration: "",
email: "dispatch@seahaven.test",
userRoles: "Dispatcher",
phoneNumber: "",
fullname: "Dispatch User",
id: "u1",
}),
),
}));
const dispatcherItem: UpliftQueueItem = {
id: 41,
status: "Approved",
currentNTE: 100,
requestedNTE: 250,
delta: 150,
vendorReason: "",
requestedAt: "2026-01-15T10:00:00Z",
requestedByVendorName: "Gateway",
decidedAt: "2026-01-16T10:00:00Z",
decidedByName: "Admin User",
decisionNote: "",
requiredTier: 1,
canDecide: false,
expiresAt: "",
notificationStatus: "",
notificationError: "",
evidenceDocumentId: null,
evidenceFileName: "",
evidenceContentType: "",
evidenceSizeBytes: null,
dispatchNumber: "DSP-41",
poNumber: "PO-41",
vendorCompanyName: "Gateway Plumbing",
workOrderId: 99,
dispatchId: 7,
woNumber: "WO-99",
site: "Site A",
serviceName: "Plumbing repair",
attachmentCount: null,
approvedOnWoAuto: null,
approvedOnWoAdmin: null,
approvedOnWoTotal: null,
workOrderClosed: false,
};
function renderWithGovernedClient() {
const queryClient = createAppQueryClient();
queryClient.setDefaultOptions({ queries: { retry: false } });
return renderWithProviders(
<UpliftDetailModal
item={dispatcherItem}
canRevoke={false}
approvePending={false}
revokePending={false}
onClose={vi.fn()}
onApprove={vi.fn()}
onReject={vi.fn()}
onRevoke={vi.fn()}
onOpenAttachment={vi.fn()}
/>,
{ queryClient },
);
}
describe("UpliftDetailModal permission failures (Dispatcher 403)", () => {
beforeEach(() => {
toastMocks.error.mockReset();
boardDetailMock.calls = 0;
woUpliftsMock.calls = 0;
});
it("shows unavailable sections without an error toast and keeps Revoke admin-only", async () => {
renderWithGovernedClient();
await waitFor(() => {
expect(boardDetailMock.calls).toBeGreaterThan(0);
expect(woUpliftsMock.calls).toBeGreaterThan(0);
});
await waitFor(() => {
expect(screen.getAllByText("Unavailable").length).toBeGreaterThanOrEqual(3);
});
const revoke = screen.getByRole("button", { name: "Revoke" });
expect(revoke).toBeDisabled();
expect(toastMocks.error).not.toHaveBeenCalled();
});
});

View file

@ -0,0 +1,183 @@
import { fireEvent, screen } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { UpliftDetailModal } from "@/app/(protected)/uplifts/_components/uplift-detail-modal";
import type { UpliftQueueItem } from "@/domain/uplifts/types/uplift";
import { renderWithProviders } from "@/test/test-utils";
const boardDetail = vi.hoisted(() => ({ data: null as Record<string, unknown> | null }));
const woUplifts = vi.hoisted(() => ({ data: null as Array<Record<string, unknown>> | null }));
vi.mock("@/domain/work-orders/use-cases/use-work-order-detail", () => ({
useWorkOrderBoardDetail: () => ({ data: boardDetail.data, isLoading: false, error: null }),
}));
vi.mock("@/domain/work-orders/use-cases/use-work-order-uplifts", () => ({
useWorkOrderUplifts: () => ({ data: woUplifts.data, isLoading: false, error: null }),
}));
const canApproveState = vi.hoisted(() => ({ data: true as boolean | undefined }));
vi.mock("@/domain/uplifts/use-cases/use-uplifts-can-approve", () => ({
useUpliftsCanApprove: () => ({ data: canApproveState.data }),
}));
const baseItem: UpliftQueueItem = {
id: 41,
status: "Pending",
currentNTE: 100,
requestedNTE: 250,
delta: 150,
vendorReason: "",
requestedAt: "2026-01-15T10:00:00Z",
requestedByVendorName: "Gateway",
decidedAt: "",
decidedByName: "",
decisionNote: "",
requiredTier: 1,
canDecide: true,
expiresAt: "",
notificationStatus: "",
notificationError: "",
evidenceDocumentId: null,
evidenceFileName: "",
evidenceContentType: "",
evidenceSizeBytes: null,
dispatchNumber: "DSP-41",
poNumber: "PO-41",
vendorCompanyName: "Gateway Plumbing",
workOrderId: 99,
dispatchId: 7,
woNumber: "WO-99",
site: "Site A",
serviceName: "Plumbing repair",
attachmentCount: null,
approvedOnWoAuto: null,
approvedOnWoAdmin: null,
approvedOnWoTotal: null,
workOrderClosed: null,
};
function renderModal(overrides: Partial<UpliftQueueItem> = {}, canRevoke = true) {
const onApprove = vi.fn();
const onReject = vi.fn();
const onRevoke = vi.fn();
const onOpenAttachment = vi.fn();
renderWithProviders(
<UpliftDetailModal
item={{ ...baseItem, ...overrides }}
canRevoke={canRevoke}
approvePending={false}
revokePending={false}
onClose={vi.fn()}
onApprove={onApprove}
onReject={onReject}
onRevoke={onRevoke}
onOpenAttachment={onOpenAttachment}
/>,
);
return { onApprove, onReject, onRevoke, onOpenAttachment };
}
describe("UpliftDetailModal", () => {
beforeEach(() => {
canApproveState.data = true;
boardDetail.data = {
info: { woNumber: "WO-99", site: "Site A", status: "Open", dueDate: "" },
};
woUplifts.data = [
{ id: 1, status: "auto_approved", amount: 100 },
{ id: 2, status: "approved", amount: 250 },
];
});
it("shows work order and request placeholders for a pending uplift", () => {
renderModal();
expect(screen.getByText("Unscheduled")).toBeInTheDocument();
expect(screen.getByText("No justification provided.")).toBeInTheDocument();
expect(screen.getByText("No attachments")).toBeInTheDocument();
});
it("opens an evidence attachment through the provided callback", () => {
const { onOpenAttachment } = renderModal({
evidenceDocumentId: "document-1",
evidenceFileName: "quote.pdf",
attachmentCount: 1,
});
fireEvent.click(screen.getByText("quote.pdf"));
expect(onOpenAttachment).toHaveBeenCalledWith(
expect.objectContaining({ evidenceDocumentId: "document-1" }),
);
});
it("falls back to the work order uplifts for the approved-on-WO breakdown", () => {
renderModal();
expect(screen.getByText("Auto-approved")).toBeInTheDocument();
expect(screen.getByText("Admin-approved")).toBeInTheDocument();
expect(screen.getByText("$100.00")).toBeInTheDocument();
expect(screen.getByText("$250.00")).toBeInTheDocument();
expect(screen.getByText("$350.00")).toBeInTheDocument();
});
it("offers status-specific actions for a pending uplift", () => {
const { onApprove, onReject } = renderModal();
fireEvent.click(screen.getByRole("button", { name: "Approve" }));
expect(onApprove).toHaveBeenCalled();
expect(screen.queryByRole("button", { name: "Revoke" })).not.toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: "Reject" }));
expect(onReject).toHaveBeenCalled();
});
it("disables Approve and Reject in the modal for a Dispatcher the backend refuses", async () => {
canApproveState.data = false;
const { onApprove, onReject } = renderModal();
const approve = screen.getByRole("button", { name: "Approve" });
const reject = screen.getByRole("button", { name: "Reject" });
expect(approve).toBeDisabled();
expect(reject).toBeDisabled();
fireEvent.mouseOver(approve);
expect(await screen.findByText("Requires Tier 1 role")).toBeInTheDocument();
fireEvent.click(approve);
fireEvent.click(reject);
expect(onApprove).not.toHaveBeenCalled();
expect(onReject).not.toHaveBeenCalled();
});
it("offers Revoke for an approved uplift and disables it on a closed work order", () => {
const { onRevoke } = renderModal({ status: "Approved", workOrderClosed: true });
expect(screen.queryByRole("button", { name: "Approve" })).not.toBeInTheDocument();
const revoke = screen.getByRole("button", { name: "Revoke" });
expect(revoke).toBeDisabled();
fireEvent.click(revoke);
expect(onRevoke).not.toHaveBeenCalled();
});
it("disables Revoke with the admin-only tooltip for non-admin users", async () => {
const { onRevoke } = renderModal({ status: "Approved", workOrderClosed: false }, false);
const revoke = screen.getByRole("button", { name: "Revoke" });
expect(revoke).toBeDisabled();
fireEvent.mouseOver(revoke);
expect(await screen.findByText("Only admins can revoke uplifts")).toBeInTheDocument();
fireEvent.click(revoke);
expect(onRevoke).not.toHaveBeenCalled();
});
it("does not render a heading nested inside the dialog title heading", () => {
renderModal();
const dialog = screen.getByRole("dialog");
const headings = dialog.querySelectorAll("h1, h2, h3, h4, h5, h6");
expect(headings.length).toBeGreaterThan(0);
const nestedHeadings = [...headings].filter((heading) =>
heading.querySelector("h1, h2, h3, h4, h5, h6"),
);
expect(nestedHeadings).toEqual([]);
});
});

View file

@ -1,13 +1,18 @@
import { fireEvent, screen } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { UpliftQueueItem } from "@/domain/uplifts/types/uplift";
import type { UpliftQueueItem, UpliftsQueueResult } from "@/domain/uplifts/types/uplift";
import { renderWithProviders } from "@/test/test-utils";
const rejectMutate = vi.fn();
const approveMutate = vi.fn();
const requestChangesMutate = vi.fn();
const revokeMutate = vi.fn();
const refetch = vi.fn();
const queueState = vi.hoisted(() => ({
data: null as UpliftsQueueResult | null,
}));
const canApproveState = vi.hoisted(() => ({ data: true as boolean | undefined }));
const pendingItem: UpliftQueueItem = {
id: 41,
status: "Pending",
@ -34,11 +39,19 @@ const pendingItem: UpliftQueueItem = {
vendorCompanyName: "Gateway Plumbing",
workOrderId: 99,
dispatchId: 7,
woNumber: "WO-99",
site: "Site A",
serviceName: "Plumbing repair",
attachmentCount: null,
approvedOnWoAuto: null,
approvedOnWoAdmin: null,
approvedOnWoTotal: 100,
workOrderClosed: false,
};
vi.mock("@/domain/uplifts/use-cases/use-uplifts-queue", () => ({
useUpliftsQueue: () => ({
data: { items: [pendingItem], totalCount: 1, page: 1, pageSize: 25 },
data: queueState.data,
isLoading: false,
isFetching: false,
error: null,
@ -49,33 +62,102 @@ vi.mock("@/domain/uplifts/use-cases/use-uplifts-queue", () => ({
vi.mock("@/domain/uplifts/use-cases/use-uplift-actions", () => ({
useApproveUplift: () => ({ isPending: false, mutate: approveMutate }),
useRejectUplift: () => ({ isPending: false, mutate: rejectMutate }),
useRequestChangesUplift: () => ({ isPending: false, mutate: requestChangesMutate }),
useRevokeUplift: () => ({ isPending: false, mutate: revokeMutate }),
}));
describe("Uplift queue decision dialogs", () => {
vi.mock("@/providers/auth-context", async (importOriginal) => ({
...(await importOriginal<object>()),
useAuthContext: () => ({ user: { userRoles: "Admin" } }),
}));
vi.mock("@/domain/uplifts/use-cases/use-uplifts-can-approve", () => ({
useUpliftsCanApprove: () => ({ data: canApproveState.data }),
}));
describe("Uplift Approvals decision flow", () => {
beforeEach(() => {
rejectMutate.mockReset();
approveMutate.mockReset();
requestChangesMutate.mockReset();
revokeMutate.mockReset();
refetch.mockReset();
canApproveState.data = true;
queueState.data = {
items: [pendingItem],
totalCount: 1,
page: 1,
pageSize: 25,
pendingExposureTotal: 250,
};
});
it("opens RejectDialog from Reject and does not reject on Cancel", async () => {
it("shows the Uplift Approvals heading, count pill and exposure total", async () => {
const { default: UpliftQueuePage } = await import("@/app/(protected)/uplifts/index");
renderWithProviders(<UpliftQueuePage />);
expect(screen.getByRole("heading", { name: "Uplift Approvals" })).toBeInTheDocument();
expect(screen.getByLabelText("1 pending uplifts")).toBeInTheDocument();
expect(screen.getByText("Pending exposure: $250.00")).toBeInTheDocument();
});
it("approves in one click without a confirmation dialog", async () => {
const { default: UpliftQueuePage } = await import("@/app/(protected)/uplifts/index");
renderWithProviders(<UpliftQueuePage />);
fireEvent.click(screen.getByRole("button", { name: "Approve" }));
expect(approveMutate).toHaveBeenCalledWith({ id: 41, amount: 150, woNumber: "WO-99" });
expect(screen.queryByRole("button", { name: "Confirm" })).not.toBeInTheDocument();
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
});
it("opens RejectDialog with the dispatcher copy and does not reject on Cancel", async () => {
const { default: UpliftQueuePage } = await import("@/app/(protected)/uplifts/index");
renderWithProviders(<UpliftQueuePage />);
fireEvent.click(screen.getByRole("button", { name: "Reject" }));
expect(screen.getByRole("heading", { name: /reject uplift of \$250/i })).toBeInTheDocument();
expect(screen.getByText("The dispatcher sees this reason on WO #WO-99.")).toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: "Cancel" }));
expect(rejectMutate).not.toHaveBeenCalled();
});
it("keeps Approve on the generic confirm dialog", async () => {
it("shows the pending empty copy when nothing is waiting", async () => {
queueState.data = {
items: [],
totalCount: 0,
page: 1,
pageSize: 25,
pendingExposureTotal: null,
};
const { default: UpliftQueuePage } = await import("@/app/(protected)/uplifts/index");
renderWithProviders(<UpliftQueuePage />);
fireEvent.click(screen.getByRole("button", { name: "Approve" }));
expect(screen.getByRole("button", { name: "Confirm" })).toBeInTheDocument();
expect(screen.queryByRole("button", { name: "Reject uplift" })).not.toBeInTheDocument();
expect(screen.getByText("No uplifts waiting for approval.")).toBeInTheDocument();
});
it("disables Approve and Reject for a Dispatcher the backend refuses, without dialogs or toasts", async () => {
canApproveState.data = false;
const { default: UpliftQueuePage } = await import("@/app/(protected)/uplifts/index");
renderWithProviders(<UpliftQueuePage />);
const approve = screen.getByRole("button", { name: "Approve" });
const reject = screen.getByRole("button", { name: "Reject" });
expect(approve).toBeDisabled();
expect(reject).toBeDisabled();
fireEvent.mouseOver(approve);
expect(await screen.findByText("Requires Tier 1 role")).toBeInTheDocument();
fireEvent.click(approve);
fireEvent.click(reject);
expect(approveMutate).not.toHaveBeenCalled();
expect(rejectMutate).not.toHaveBeenCalled();
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
expect(screen.queryByRole("heading", { name: /reject uplift of/i })).not.toBeInTheDocument();
});
it("keeps Approve and Reject enabled when the backend allows the tier", async () => {
const { default: UpliftQueuePage } = await import("@/app/(protected)/uplifts/index");
renderWithProviders(<UpliftQueuePage />);
expect(screen.getByRole("button", { name: "Approve" })).toBeEnabled();
expect(screen.getByRole("button", { name: "Reject" })).toBeEnabled();
});
});

View file

@ -2,7 +2,7 @@ import { fireEvent, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { describe, expect, it, vi } from "vitest";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { VendorCreateModal } from "@/app/(protected)/vendors/_components/vendor-create-modal";
import { VendorRosterFormFields } from "@/app/(protected)/vendors/_components/vendor-roster-form-fields";
import {
@ -49,6 +49,10 @@ function renderCreateModal() {
});
}
// delay: null skips user-event's per-keystroke setTimeout yields, which only
// add real-timer latency here.
let user = userEvent.setup({ delay: null });
async function fillCompanyNameAndSubmit(name = "Gateway Plumbing") {
fireEvent.change(screen.getByRole("combobox", { name: "Company (required)" }), {
target: { value: name },
@ -57,7 +61,7 @@ async function fillCompanyNameAndSubmit(name = "Gateway Plumbing") {
// phone-or-email rule, so the address is filled to keep the form's only
// outstanding problem the one under test.
fillRequiredAddress();
await userEvent.click(screen.getByRole("button", { name: "Add Vendor" }));
await user.click(screen.getByRole("button", { name: "Add Vendor" }));
}
function fillRequiredAddress({
@ -74,7 +78,15 @@ function fillRequiredAddress({
pickVendorState(state);
}
describe("VendorCreateModal validation", () => {
// Each test mounts the full MUI dialog (Autocompletes, 50-option State list)
// and costs ~1s of CPU when idle; the first also pays JIT warm-up. Under
// parallel agent load the 5s default is exceeded by CPU starvation alone,
// matching the 10s budgets already used by vendors-list.test.tsx.
describe("VendorCreateModal validation", { timeout: 15_000 }, () => {
beforeEach(() => {
user = userEvent.setup({ delay: null });
});
it("explains the company phone-or-email requirement after submission", async () => {
renderCreateModal();
await fillCompanyNameAndSubmit();
@ -138,8 +150,8 @@ describe("VendorCreateModal validation", () => {
const phone = screen.getByRole("textbox", { name: "Company Phone (optional)" });
const email = screen.getByRole("textbox", { name: "Email (optional)" });
await userEvent.type(phone, "314");
await userEvent.click(screen.getByRole("button", { name: "Add Vendor" }));
await user.type(phone, "314");
await user.click(screen.getByRole("button", { name: "Add Vendor" }));
expect(await screen.findByText("Enter a 10-digit phone number")).toBeInTheDocument();
expect(phone).toHaveAttribute("aria-invalid", "true");
@ -153,8 +165,8 @@ describe("VendorCreateModal validation", () => {
const phone = screen.getByRole("textbox", { name: "Company Phone (optional)" });
const email = screen.getByRole("textbox", { name: "Email (optional)" });
await userEvent.type(email, "not-an-email");
await userEvent.click(screen.getByRole("button", { name: "Add Vendor" }));
await user.type(email, "not-an-email");
await user.click(screen.getByRole("button", { name: "Add Vendor" }));
expect(await screen.findByText("Invalid email")).toBeInTheDocument();
expect(email).toHaveAttribute("aria-invalid", "true");
@ -169,7 +181,7 @@ describe("VendorCreateModal validation", () => {
expect(await screen.findByText(VENDOR_COMPANY_CONTACT_REQUIRED_MESSAGE)).toBeInTheDocument();
await userEvent.type(phone, "3145550100");
await user.type(phone, "3145550100");
await waitFor(() =>
expect(screen.queryByText(VENDOR_COMPANY_CONTACT_REQUIRED_MESSAGE)).not.toBeInTheDocument(),
);

View file

@ -68,11 +68,19 @@ function rosterWith(technicians: Array<Record<string, unknown>>) {
};
}
// delay: null skips user-event's per-keystroke setTimeout yields, which only
// add real-timer latency here.
let user = userEvent.setup({ delay: null });
beforeEach(() => {
saveMutate.mockReset();
user = userEvent.setup({ delay: null });
});
describe("VendorDetailDrawer selected-technician display", () => {
// The edit-mode drawer is a heavy MUI render and the first test pays JIT
// warm-up; under parallel agent load the 5s default is exceeded by CPU
// starvation alone, matching the 10s budgets in vendors-list.test.tsx.
describe("VendorDetailDrawer selected-technician display", { timeout: 15_000 }, () => {
it.each([
{ removedPosition: 1, remainingId: 2, remainingName: "Beth" },
{ removedPosition: 2, remainingId: 1, remainingName: "Adam" },
@ -107,10 +115,10 @@ describe("VendorDetailDrawer selected-technician display", () => {
{ route: "/vendors", withAuth: false },
);
await userEvent.click(
await user.click(
screen.getByRole("button", { name: `Remove technician ${removedPosition}` }),
);
await userEvent.click(screen.getByRole("button", { name: "Save changes" }));
await user.click(screen.getByRole("button", { name: "Save changes" }));
await waitFor(() => expect(saveMutate).toHaveBeenCalledTimes(1));
expect(saveMutate).toHaveBeenCalledWith(
@ -161,12 +169,12 @@ describe("VendorDetailDrawer selected-technician display", () => {
{ route: "/vendors", withAuth: false },
);
await userEvent.click(screen.getByRole("button", { name: "Remove technician 2" }));
await user.click(screen.getByRole("button", { name: "Remove technician 2" }));
const save = screen.getByRole("button", { name: "Save changes" });
expect(save).toBeEnabled();
await userEvent.click(save);
await user.click(save);
expect(
await screen.findByText("Provide a company phone or email (at least one required)"),
@ -444,10 +452,10 @@ describe("VendorDetailDrawer deactivation prompt", () => {
);
const toggle = screen.getByRole("switch", { name: "Active status" });
await userEvent.click(toggle);
await user.click(toggle);
expect(toggle).toBeChecked();
await userEvent.click(toggle);
await user.click(toggle);
expect(toggle).not.toBeChecked();
expect(onRequestDeactivation).not.toHaveBeenCalled();
@ -472,7 +480,7 @@ describe("VendorDetailDrawer deactivation prompt", () => {
{ route: "/vendors", withAuth: false },
);
await userEvent.click(screen.getByRole("switch", { name: "Active status" }));
await user.click(screen.getByRole("switch", { name: "Active status" }));
expect(onRequestDeactivation).toHaveBeenCalledWith(vendor);
});

View file

@ -0,0 +1,222 @@
import { act, renderHook, waitFor } from "@testing-library/react";
import { QueryClientProvider } from "@tanstack/react-query";
import { useState, type ReactNode } from "react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { useWizardVendorCompanyNotes } from "@/app/(protected)/workorders/_components/wizard/use-wizard-vendor-company-notes";
import { NOTES_SAVE_FAILED_MESSAGE } from "@/domain/vendors/use-cases/use-save-vendor-company-notes";
import {
EMPTY_WIZARD_DRAFT,
type WorkOrderWizardDraft,
} from "@/domain/work-orders/types/work-order-wizard";
import { toast } from "react-toastify";
import { createTestQueryClient } from "@/test/test-utils";
const rosterGet = vi.fn();
const addTechnicians = vi.fn();
vi.mock("@/domain/vendors/api/vendor-company-roster-api", () => ({
vendorCompanyRosterApi: {
get: (...args: unknown[]) => rosterGet(...args),
addTechnicians: (...args: unknown[]) => addTechnicians(...args),
},
}));
vi.mock("react-toastify", () => ({ toast: { success: vi.fn(), error: vi.fn() } }));
type Roster = ReturnType<typeof roster>;
function roster(companyId: number, notes: string) {
return {
companyId,
rowVersion: `rv-${companyId}`,
name: `Company ${companyId}`,
companyPhone: "",
email: "",
address: "",
city: "",
state: "",
zip: "",
googleMapsUrl: "",
notes,
technicians: [],
};
}
function deferredRoster() {
let resolve!: (value: Roster) => void;
const pending = new Promise<Roster>((res) => {
resolve = res;
});
return {
promise: () => pending,
resolveAll: (value: Roster) => resolve(value),
};
}
function useHarness() {
const [draft, setDraft] = useState<WorkOrderWizardDraft>({ ...EMPTY_WIZARD_DRAFT });
const notes = useWizardVendorCompanyNotes(true, draft.vendorId, setDraft);
return { draft, setDraft, ...notes };
}
function renderHarness() {
const client = createTestQueryClient();
const wrapper = ({ children }: { children: ReactNode }) => (
<QueryClientProvider client={client}>{children}</QueryClientProvider>
);
return renderHook(() => useHarness(), { wrapper });
}
describe("useWizardVendorCompanyNotes", () => {
beforeEach(() => {
rosterGet.mockReset();
addTechnicians.mockReset();
vi.mocked(toast.error).mockClear();
rosterGet.mockImplementation(({ vendorId }: { vendorId: string }) =>
Promise.resolve(
vendorId === "31" ? roster(9, "Other company notes") : roster(7, "Call before arrival"),
),
);
addTechnicians.mockResolvedValue(roster(7, "Gate code 4411"));
});
it("pre-fills the Notes field with the picked technician's company notes", async () => {
const { result } = renderHarness();
act(() => result.current.setDraft((d) => ({ ...d, vendorId: "21" })));
await waitFor(() => expect(result.current.draft.vendorNotes).toBe("Call before arrival"));
expect(rosterGet).toHaveBeenCalledWith({ vendorId: "21", companyId: undefined });
});
it("keeps edits when switching technicians within the same company", async () => {
rosterGet.mockResolvedValue(roster(7, "Call before arrival"));
const { result } = renderHarness();
act(() => result.current.setDraft((d) => ({ ...d, vendorId: "21" })));
await waitFor(() => expect(result.current.draft.vendorNotes).toBe("Call before arrival"));
act(() => result.current.setDraft((d) => ({ ...d, vendorNotes: "Gate code 4411" })));
act(() => result.current.setDraft((d) => ({ ...d, vendorId: "22" })));
await waitFor(() =>
expect(rosterGet).toHaveBeenCalledWith({ vendorId: "22", companyId: undefined }),
);
expect(result.current.draft.vendorNotes).toBe("Gate code 4411");
});
it("re-seeds notes when the technician belongs to a different company", async () => {
const { result } = renderHarness();
act(() => result.current.setDraft((d) => ({ ...d, vendorId: "21" })));
await waitFor(() => expect(result.current.draft.vendorNotes).toBe("Call before arrival"));
act(() => result.current.setDraft((d) => ({ ...d, vendorId: "31" })));
await waitFor(() => expect(result.current.draft.vendorNotes).toBe("Other company notes"));
});
it("keeps notes typed before the roster resolves and saves them for that company", async () => {
const lateRoster = deferredRoster();
rosterGet.mockImplementation(({ vendorId }: { vendorId: string }) =>
vendorId === "21" ? lateRoster.promise() : Promise.resolve(roster(7, "Call before arrival")),
);
const { result } = renderHarness();
act(() => result.current.setDraft((d) => ({ ...d, vendorId: "21" })));
act(() => result.current.setDraft((d) => ({ ...d, vendorNotes: "Gate code 4411" })));
await act(async () => lateRoster.resolveAll(roster(7, "Call before arrival")));
expect(result.current.draft.vendorNotes).toBe("Gate code 4411");
await act(async () => {
await result.current.saveCompanyNotes({
...result.current.draft,
vendorNotes: "Gate code 4411",
});
});
expect(addTechnicians).toHaveBeenCalledWith(7, {
rowVersion: "rv-7",
addTechnicians: [],
companyFields: { notes: "Gate code 4411" },
});
});
it("targets the newly selected company at save time while its roster is still loading", async () => {
const companyB = deferredRoster();
rosterGet.mockImplementation(({ vendorId }: { vendorId: string }) =>
vendorId === "21" ? Promise.resolve(roster(7, "Call before arrival")) : companyB.promise(),
);
const { result } = renderHarness();
act(() => result.current.setDraft((d) => ({ ...d, vendorId: "21" })));
await waitFor(() => expect(result.current.draft.vendorNotes).toBe("Call before arrival"));
act(() => result.current.setDraft((d) => ({ ...d, vendorId: "31" })));
act(() => result.current.setDraft((d) => ({ ...d, vendorNotes: "Gate code 4411" })));
let savePromise!: Promise<void>;
act(() => {
savePromise = result.current
.saveCompanyNotes({ ...result.current.draft, vendorNotes: "Gate code 4411" })
.then(() => undefined);
});
await act(async () => companyB.resolveAll(roster(9, "Other company notes")));
await act(async () => {
await savePromise;
});
expect(addTechnicians).toHaveBeenCalledWith(9, {
rowVersion: "rv-9",
addTechnicians: [],
companyFields: { notes: "Gate code 4411" },
});
expect(addTechnicians).not.toHaveBeenCalledWith(7, expect.anything());
expect(result.current.draft.vendorNotes).toBe("Gate code 4411");
});
it("warns without writing when the save-time roster read fails", async () => {
rosterGet.mockRejectedValue(new Error("roster down"));
const { result } = renderHarness();
act(() => result.current.setDraft((d) => ({ ...d, vendorId: "21" })));
act(() => result.current.setDraft((d) => ({ ...d, vendorNotes: "Gate code 4411" })));
await act(async () => {
await result.current.saveCompanyNotes({
...result.current.draft,
vendorNotes: "Gate code 4411",
});
});
expect(toast.error).toHaveBeenCalledWith(NOTES_SAVE_FAILED_MESSAGE);
expect(addTechnicians).not.toHaveBeenCalled();
});
it("writes edited notes to the vendor company when the work order is created", async () => {
const { result } = renderHarness();
act(() => result.current.setDraft((d) => ({ ...d, vendorId: "21" })));
await waitFor(() => expect(result.current.draft.vendorNotes).toBe("Call before arrival"));
await act(async () => {
await result.current.saveCompanyNotes({
...result.current.draft,
vendorNotes: "Gate code 4411",
});
});
expect(addTechnicians).toHaveBeenCalledWith(7, {
rowVersion: "rv-7",
addTechnicians: [],
companyFields: { notes: "Gate code 4411" },
});
});
it("does not write when the notes were left as the company's existing notes", async () => {
const { result } = renderHarness();
act(() => result.current.setDraft((d) => ({ ...d, vendorId: "21" })));
await waitFor(() => expect(result.current.draft.vendorNotes).toBe("Call before arrival"));
await act(async () => {
await result.current.saveCompanyNotes(result.current.draft);
});
expect(addTechnicians).not.toHaveBeenCalled();
});
});

View file

@ -15,6 +15,7 @@ describe("WizardStepLocationServiceSelect — vendor clear on service change", (
vendorId: "9",
vendorName: "Old Vendor",
techPhone: "555-0100",
vendorNotes: "Old company notes",
};
render(
@ -43,6 +44,7 @@ describe("WizardStepLocationServiceSelect — vendor clear on service change", (
vendorId: "",
vendorName: "",
techPhone: "",
vendorNotes: "",
}),
);
});
@ -56,6 +58,7 @@ describe("WizardStepLocationServiceSelect — vendor clear on service change", (
vendorId: "9",
vendorName: "Old Vendor",
techPhone: "555-0100",
vendorNotes: "Old company notes",
};
render(
@ -84,6 +87,7 @@ describe("WizardStepLocationServiceSelect — vendor clear on service change", (
vendorId: "",
vendorName: "",
techPhone: "",
vendorNotes: "",
});
});
});

View file

@ -1,4 +1,4 @@
import { fireEvent, screen, waitFor } from "@testing-library/react";
import { fireEvent, screen, waitFor, within } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { WorkOrderBoardSearchQueryParams } from "@/domain/work-orders/types/work-order-board";
import { EMPTY_ADVANCED_FILTERS } from "@/domain/work-orders/types/work-order-advanced-filters";
@ -120,6 +120,12 @@ function pagedResult(rows: WorkOrderTableRow[], page: number, pageSize: number)
};
}
// Role and text queries over the whole board scan every row cell; scoping them
// to the pagination footer keeps each waitFor poll cheap on a loaded machine.
function pagination() {
return within(screen.getByTestId("wo-day-pagination"));
}
function pagesAfter(callCount: number): number[] {
return searchBoard.mock.calls.slice(callCount).map((call) => {
const params = call[0] as WorkOrderBoardSearchQueryParams;
@ -213,14 +219,14 @@ describe("advanced search page sync after mutation", () => {
);
});
expect(screen.getByText("WO-0")).toBeInTheDocument();
expect(screen.getByText("Page 1 of 2")).toBeInTheDocument();
expect(pagination().getByText("Page 1 of 2")).toBeInTheDocument();
expect(screen.queryByText("WO-25")).not.toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: "Next page" }));
fireEvent.click(pagination().getByRole("button", { name: "Next page" }));
await waitFor(() => {
expect(screen.getByTestId("harness-state")).toHaveTextContent("page=1");
expect(screen.getByText("WO-25")).toBeInTheDocument();
expect(screen.getByText("Page 2 of 2")).toBeInTheDocument();
expect(pagination().getByText("Page 2 of 2")).toBeInTheDocument();
});
const callsBeforeMutation = searchBoard.mock.calls.length;
@ -236,12 +242,12 @@ describe("advanced search page sync after mutation", () => {
expect(screen.getByTestId("harness-state")).toHaveTextContent(
"adv=true rows=20 total=20 page=0",
);
expect(screen.getByText("Page 1 of 1")).toBeInTheDocument();
expect(pagination().getByText("Page 1 of 1")).toBeInTheDocument();
});
expect(screen.getByText("WO-0")).toBeInTheDocument();
expect(screen.queryByText("WO-25")).not.toBeInTheDocument();
expect(screen.getByText("RESULTS · 20 items")).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Previous page" })).toBeDisabled();
expect(screen.getByRole("button", { name: "Next page" })).toBeDisabled();
expect(pagination().getByRole("button", { name: "Previous page" })).toBeDisabled();
expect(pagination().getByRole("button", { name: "Next page" })).toBeDisabled();
}, 15000);
});

View file

@ -0,0 +1,248 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const apiGet = vi.fn();
vi.mock("@/api/api", () => ({
apiGet: (...args: unknown[]) => apiGet(...args),
}));
import { dashboardApi } from "@/domain/dashboard/api/dashboard-api";
describe("dashboardApi", () => {
beforeEach(() => {
apiGet.mockReset();
});
it("maps stats tolerantly and sends the range params", async () => {
apiGet.mockResolvedValue({
Data: {
Total: 12,
Open: 8,
NotDispatched: 2,
Completed: 4,
DueCount: 5,
CompletedDueCount: 3,
CompletionRate: 60,
AverageResolutionDays: 4.5,
ScheduledTomorrow: "3",
PendingUplifts: 2,
AvetaPending: "1",
Breakdown: [
{ Status: "Scheduled", Count: "7" },
{ Status: "Completed", Count: 4 },
],
StatusDistribution: [
{ Status: "Scheduled", Count: "7" },
{ Status: "Unknown", Count: 1 },
],
},
});
const stats = await dashboardApi.getStats({ dateFrom: "2026-09-14", dateTo: "2026-09-18" });
expect(stats).toEqual({
total: 12,
open: 8,
notDispatched: 2,
completed: 4,
dueCount: 5,
completedDueCount: 3,
completionRate: 60,
averageResolutionDays: 4.5,
scheduledTomorrow: 3,
pendingUplifts: 2,
avetaPending: 1,
breakdown: [
{ 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" },
});
});
it("maps the object breakdown shape to canonical WOType labels", async () => {
apiGet.mockResolvedValue({
Data: {
Breakdown: { pm: 3, emergency: 1, reactive: "2", overdue: 4, other: 5 },
},
});
const stats = await dashboardApi.getStats();
expect(stats.breakdown).toEqual([
{ status: "PM", count: 3 },
{ status: "Emergency", count: 1 },
{ status: "Reactive", count: 2 },
{ status: "Overdue", count: 4 },
{ status: "Other", count: 5 },
]);
});
it("omits search params for the all-time range", async () => {
apiGet.mockResolvedValue({ scheduledTomorrow: 0, pendingUplifts: 0, avetaPending: 0 });
await dashboardApi.getStats({});
expect(apiGet).toHaveBeenCalledWith("Dashboard/Stats", undefined);
});
it("maps workload rows and returns the paging envelope", async () => {
apiGet.mockResolvedValue({
Items: [{ DispatcherId: 12, DispatcherName: "Dana Reyes", OpenCount: "4", TotalCount: 6 }],
Page: 1,
PageSize: 10,
TotalDispatchers: 23,
});
await expect(dashboardApi.getWorkload()).resolves.toEqual({
items: [
{
dispatcherId: "12",
dispatcherName: "Dana Reyes",
openWorkOrders: 4,
totalWorkOrders: 6,
},
],
page: 1,
pageSize: 10,
totalDispatchers: 23,
});
expect(apiGet).toHaveBeenCalledWith("Dashboard/Workload", { searchParams: { page: 1 } });
});
it("sends the requested page and range and maps performance rows", async () => {
apiGet.mockResolvedValue({
Items: [
{
DispatcherId: 12,
DispatcherName: "Dana Reyes",
CompletionRate: 96.5,
AverageResolutionDays: 3.25,
AssignedCount: 40,
CompletedCount: "38",
},
],
Page: 2,
PageSize: 10,
TotalDispatchers: 14,
});
await expect(
dashboardApi.getPerformance({ dateFrom: "2026-09-14", dateTo: "2026-09-18" }, 2),
).resolves.toEqual({
items: [
{
dispatcherId: "12",
dispatcherName: "Dana Reyes",
completionRate: 96.5,
averageResolutionDays: 3.25,
assignedCount: 40,
completedCount: 38,
},
],
page: 2,
pageSize: 10,
totalDispatchers: 14,
});
expect(apiGet).toHaveBeenCalledWith("Dashboard/Performance", {
searchParams: { dateFrom: "2026-09-14", dateTo: "2026-09-18", page: 2 },
});
});
it("falls back to the requested page for the legacy bare-array shape", async () => {
apiGet.mockResolvedValue([
{ DispatcherId: 7, DispatcherName: "Lee Ash", OpenCount: 1, TotalCount: 2 },
]);
await expect(dashboardApi.getWorkload({}, 1)).resolves.toEqual({
items: [
{ dispatcherId: "7", dispatcherName: "Lee Ash", openWorkOrders: 1, totalWorkOrders: 2 },
],
page: 1,
pageSize: 10,
totalDispatchers: 1,
});
});
it("maps unknown regions to Unmapped", async () => {
apiGet.mockResolvedValue({
Items: [{ Region: "North", Count: 5 }, { Name: " ", WorkOrderCount: 2 }, {}],
});
await expect(dashboardApi.getRegions()).resolves.toEqual([
{ region: "North", workOrderCount: 5 },
{ region: "Unmapped/Other", workOrderCount: 2 },
{ region: "Unmapped/Other", workOrderCount: 0 },
]);
});
it("maps vendor insights with company-name fallback", async () => {
apiGet.mockResolvedValue({
Items: [
{
VendorCompanyId: 3,
VendorCompanyName: "Gateway Plumbing",
CompletionRate: 82,
RescheduleRate: 4,
AverageResolutionDays: 3,
TotalJobs: 11,
},
],
});
await expect(dashboardApi.getVendorInsights()).resolves.toEqual([
{
vendorId: "3",
vendorName: "Gateway Plumbing",
completionRate: 82,
rescheduleRate: 4,
averageResolutionDays: 3,
totalJobs: 11,
},
]);
});
it("maps the trend contract and sends date, granularity, and year params", async () => {
apiGet.mockResolvedValue({
Granularity: "Week",
Buckets: [{ Date: "2026-09-07", Label: "2026-W37", Total: "9", IsCurrent: true }],
});
await expect(
dashboardApi.getTrend({
dateFrom: "2026-09-01",
dateTo: "2026-09-30",
granularity: "month",
year: 2026,
}),
).resolves.toEqual({
granularity: "week",
points: [
{
date: "2026-09-07",
label: "2026-W37",
total: 9,
open: 0,
completed: 0,
canceled: 0,
overdue: 0,
isCurrent: true,
},
],
});
expect(apiGet).toHaveBeenCalledWith("Dashboard/Trend", {
searchParams: {
dateFrom: "2026-09-01",
dateTo: "2026-09-30",
granularity: "month",
year: 2026,
},
});
});
});

View file

@ -0,0 +1,68 @@
import { describe, expect, it } from "vitest";
import {
addDaysIso,
businessTodayIso,
dashboardRangeParams,
mondayOf,
monthEndIso,
monthStartIso,
} from "@/domain/dashboard/utils/dashboard-range-utils";
describe("businessTodayIso", () => {
it("formats the America/New_York calendar date regardless of UTC time", () => {
expect(businessTodayIso(new Date("2026-09-16T23:30:00Z"))).toBe("2026-09-16");
expect(businessTodayIso(new Date("2026-09-17T02:30:00Z"))).toBe("2026-09-16");
expect(businessTodayIso(new Date("2026-01-01T04:30:00Z"))).toBe("2025-12-31");
});
});
describe("addDaysIso", () => {
it("rolls over month and year boundaries", () => {
expect(addDaysIso("2026-01-31", 1)).toBe("2026-02-01");
expect(addDaysIso("2026-12-31", 1)).toBe("2027-01-01");
expect(addDaysIso("2026-09-14", -7)).toBe("2026-09-07");
});
});
describe("mondayOf", () => {
it("returns the Monday of the ISO week", () => {
expect(mondayOf("2026-09-16")).toBe("2026-09-14");
expect(mondayOf("2026-09-14")).toBe("2026-09-14");
expect(mondayOf("2026-09-13")).toBe("2026-09-07");
});
});
describe("monthStartIso / monthEndIso", () => {
it("returns month bounds", () => {
expect(monthStartIso("2026-09-16")).toBe("2026-09-01");
expect(monthEndIso("2026-09-16")).toBe("2026-09-30");
expect(monthEndIso("2026-02-16")).toBe("2026-02-28");
});
});
describe("dashboardRangeParams", () => {
it("maps today to the business date", () => {
expect(dashboardRangeParams("today", "2026-09-16")).toEqual({
dateFrom: "2026-09-16",
dateTo: "2026-09-16",
});
});
it("maps this week to the Monday-Friday business week", () => {
expect(dashboardRangeParams("this-week", "2026-09-16")).toEqual({
dateFrom: "2026-09-14",
dateTo: "2026-09-18",
});
});
it("maps last 3 months to the trailing month span", () => {
expect(dashboardRangeParams("last-3-months", "2026-09-16")).toEqual({
dateFrom: "2026-07-01",
dateTo: "2026-09-30",
});
});
it("maps all time to unbounded params", () => {
expect(dashboardRangeParams("all-time", "2026-09-16")).toEqual({});
});
});

View file

@ -0,0 +1,59 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import {
avetaPendingDrilldownSearch,
scheduledTomorrowDrilldownSearch,
workOrderDrilldownUrl,
workOrderRangeDrilldownSearch,
workOrderStatusDrilldownSearch,
} 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");
expect(search.get("dateFrom")).toBe("2026-09-17");
expect(search.get("dateTo")).toBe("2026-09-17");
});
it("builds the Aveta Pending drilldown per SH-294", () => {
const search = avetaPendingDrilldownSearch("2026-09-16");
const statuses = (search.get("statuses") ?? "").split(",");
expect(search.get("dateFrom")).toBe("2026-09-16");
expect(search.get("dateTo")).toBe("2026-09-17");
expect(search.get("aveta")).toBe("1");
// `docs=No` is the completion-document status, not the Aveta document, so it
// must not be part of the Aveta Pending drilldown (SH-294 / backend#138).
expect(search.get("docs")).toBeNull();
expect(statuses).toContain("Scheduled");
expect(statuses).not.toContain("Completed");
});
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");
expect(search.get("dateTo")).toBe("2026-09-16");
expect(search.get("statuses")).toBe("Scheduled");
});
it("omits dates for the all-time range", () => {
expect(workOrderRangeDrilldownSearch("all-time").toString()).toBe("");
});
it("renders plain or query-bearing work order URLs", () => {
expect(workOrderDrilldownUrl(new URLSearchParams())).toBe("/workorders");
expect(workOrderDrilldownUrl(new URLSearchParams({ statuses: "Scheduled" }))).toBe(
"/workorders?statuses=Scheduled",
);
});
});

View file

@ -0,0 +1,45 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const apiGet = vi.fn();
const apiPost = vi.fn();
const apiRequestRaw = vi.fn();
vi.mock("@/api/api", () => ({
apiGet: (...args: unknown[]) => apiGet(...args),
apiPost: (...args: unknown[]) => apiPost(...args),
apiRequestRaw: (...args: unknown[]) => apiRequestRaw(...args),
}));
import { upliftsApi } from "@/domain/uplifts/api/uplifts-api";
describe("upliftsApi", () => {
beforeEach(() => {
apiGet.mockReset();
apiPost.mockReset();
apiRequestRaw.mockReset();
});
it("posts admin revoke decisions to the uplift decision endpoint", async () => {
apiPost.mockResolvedValueOnce(undefined);
await upliftsApi.revoke(42, "Scope was already covered");
expect(apiPost).toHaveBeenCalledWith("uplifts/42/revoke", {
note: "Scope was already covered",
});
});
it("reads the backend tier decision permission", async () => {
apiGet.mockResolvedValueOnce({ status: "Success", data: { canApprove: true } });
await expect(upliftsApi.canApprove(1)).resolves.toBe(true);
expect(apiGet).toHaveBeenCalledWith("uplifts/can-approve", { searchParams: { tier: 1 } });
});
it("treats a missing tier permission as false", async () => {
apiGet.mockResolvedValueOnce({ status: "Success", data: {} });
await expect(upliftsApi.canApprove(2)).resolves.toBe(false);
});
});

View file

@ -1,5 +1,8 @@
import { describe, expect, it } from "vitest";
import { mapUpliftQueueItem } from "@/domain/uplifts/mappers/uplift-mapper";
import {
mapUpliftQueueExposureTotal,
mapUpliftQueueItem,
} from "@/domain/uplifts/mappers/uplift-mapper";
describe("mapUpliftQueueItem", () => {
it("maps ASP.NET PascalCase payload with queue fields", () => {
@ -56,4 +59,71 @@ describe("mapUpliftQueueItem", () => {
expect(result.status).toBe("Approved");
expect(result.dispatchNumber).toBe("D-1");
});
it("maps queue display fields in PascalCase", () => {
const result = mapUpliftQueueItem({
id: 1,
WoNumber: "WO-9",
WorkOrderSite: "Site A",
WorkOrderService: "Plumbing",
RequestedByName: "Pat Approver",
AttachmentCount: 3,
WorkOrderAutoApprovedTotal: 100,
WorkOrderAdminApprovedTotal: 250,
WorkOrderApprovedExposureTotal: 350,
WorkOrderClosed: true,
});
expect(result.woNumber).toBe("WO-9");
expect(result.site).toBe("Site A");
expect(result.serviceName).toBe("Plumbing");
expect(result.requestedByVendorName).toBe("Pat Approver");
expect(result.attachmentCount).toBe(3);
expect(result.approvedOnWoAuto).toBe(100);
expect(result.approvedOnWoAdmin).toBe(250);
expect(result.approvedOnWoTotal).toBe(350);
expect(result.workOrderClosed).toBe(true);
});
it("maps queue display fields in camelCase", () => {
const result = mapUpliftQueueItem({
id: 2,
woNumber: "WO-10",
siteCode: "S-2",
trade: "HVAC",
attachmentCount: 2,
autoApprovedTotal: 50,
adminApprovedTotal: 75,
approvedOnWorkOrderTotal: 125,
isWorkOrderClosed: false,
});
expect(result.woNumber).toBe("WO-10");
expect(result.site).toBe("S-2");
expect(result.serviceName).toBe("HVAC");
expect(result.attachmentCount).toBe(2);
expect(result.approvedOnWoAuto).toBe(50);
expect(result.approvedOnWoAdmin).toBe(75);
expect(result.approvedOnWoTotal).toBe(125);
expect(result.workOrderClosed).toBe(false);
});
it("tolerates absent optional queue fields", () => {
const result = mapUpliftQueueItem({ id: 3 });
expect(result.woNumber).toBe("");
expect(result.site).toBe("");
expect(result.serviceName).toBe("");
expect(result.attachmentCount).toBeNull();
expect(result.approvedOnWoAuto).toBeNull();
expect(result.approvedOnWoAdmin).toBeNull();
expect(result.approvedOnWoTotal).toBeNull();
expect(result.workOrderClosed).toBeNull();
});
it("maps pending exposure total from the queue payload", () => {
expect(mapUpliftQueueExposureTotal({ PendingExposureTotal: 1250 })).toBe(1250);
expect(mapUpliftQueueExposureTotal({ pendingExposureTotal: "900" })).toBe(900);
expect(mapUpliftQueueExposureTotal({})).toBeNull();
});
});

View file

@ -0,0 +1,48 @@
import type { ReactNode } from "react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { act, renderHook, waitFor } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
const approve = vi.fn();
vi.mock("@/domain/uplifts/api/uplifts-api", () => ({
upliftsApi: {
approve: (...args: unknown[]) => approve(...args),
},
}));
vi.mock("react-toastify", () => ({
toast: { error: vi.fn(), success: vi.fn(), warning: vi.fn() },
}));
import { useApproveUplift } from "@/domain/uplifts/use-cases/use-uplift-actions";
import { queryKeys } from "@/infra/query-key/query-key";
function makeWrapper(client: QueryClient) {
return function Wrapper({ children }: { children: ReactNode }) {
return <QueryClientProvider client={client}>{children}</QueryClientProvider>;
};
}
describe("useApproveUplift", () => {
it("refreshes work-order views after a queue decision", async () => {
approve.mockResolvedValueOnce(undefined);
const client = new QueryClient({
defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
});
const invalidateSpy = vi.spyOn(client, "invalidateQueries");
const { result } = renderHook(() => useApproveUplift(), {
wrapper: makeWrapper(client),
});
act(() => {
result.current.mutate({ id: 17 });
});
await waitFor(() => expect(result.current.isSuccess).toBe(true));
expect(approve).toHaveBeenCalledWith(17, undefined);
expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: queryKeys.uplifts.all });
expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: queryKeys.workOrders.all });
});
});

View file

@ -0,0 +1,31 @@
import { describe, expect, it } from "vitest";
import { buildCompanyNotesUpdate } from "@/domain/vendors/utils/build-company-notes-patch";
const baseline = { companyId: 12, rowVersion: "AAAAAAAAB9E=", notes: "Call before arrival" };
describe("buildCompanyNotesUpdate", () => {
it("builds a notes-only additive patch when the notes changed", () => {
expect(buildCompanyNotesUpdate(baseline, " Gate code 4411 ")).toEqual({
companyId: 12,
patch: {
rowVersion: "AAAAAAAAB9E=",
addTechnicians: [],
companyFields: { notes: "Gate code 4411" },
},
});
});
it("skips the write when the notes are unchanged apart from whitespace", () => {
expect(buildCompanyNotesUpdate(baseline, "Call before arrival ")).toBeNull();
});
it("skips the write without a loaded company baseline", () => {
expect(buildCompanyNotesUpdate(null, "New notes")).toBeNull();
expect(buildCompanyNotesUpdate({ ...baseline, companyId: null }, "New notes")).toBeNull();
expect(buildCompanyNotesUpdate({ ...baseline, rowVersion: "" }, "New notes")).toBeNull();
});
it("does not send an emptied field, which the additive patch treats as unchanged", () => {
expect(buildCompanyNotesUpdate(baseline, " ")).toBeNull();
});
});

View file

@ -0,0 +1,70 @@
import { describe, expect, it } from "vitest";
import { parseDashboardDrilldownParams } from "@/domain/work-orders/utils/dashboard-drilldown-params";
import { avetaPendingDrilldownSearch } from "@/domain/dashboard/utils/work-order-drilldown-links";
describe("parseDashboardDrilldownParams", () => {
it("returns null when no recognized param is present", () => {
expect(parseDashboardDrilldownParams(new URLSearchParams())).toBeNull();
expect(
parseDashboardDrilldownParams(new URLSearchParams({ dateFrom: "2026-09-16" })),
).toBeNull();
expect(parseDashboardDrilldownParams(new URLSearchParams({ aveta: "0" }))).toBeNull();
});
it("maps dates to a custom advanced-filter range", () => {
const filters = parseDashboardDrilldownParams(
new URLSearchParams({ dateFrom: "2026-09-14", dateTo: "2026-09-18" }),
);
expect(filters).not.toBeNull();
expect(filters?.dateRange).toBe("custom");
expect(filters?.customFrom).toBe("2026-09-14");
expect(filters?.customTo).toBe("2026-09-18");
});
it("parses the Aveta Pending drilldown and drops unknown statuses", () => {
const params = avetaPendingDrilldownSearch("2026-09-16");
params.append("statuses", "NotAStatus");
const filters = parseDashboardDrilldownParams(params);
expect(filters).not.toBeNull();
expect(filters?.avetaOnly).toBe(true);
// The Aveta drilldown no longer sends `docs=No` (completion-doc status), so
// no completion-document filter is applied.
expect(filters?.docs.size).toBe(0);
expect(filters?.statuses).not.toBeNull();
expect([...(filters?.statuses ?? [])]).not.toContain("Completed");
expect([...(filters?.statuses ?? [])]).not.toContain("NotAStatus");
expect(filters?.statuses.has("Scheduled")).toBe(true);
expect(filters?.statuses.has("Pending Quote")).toBe(true);
});
it("parses dispatcher and uplift params", () => {
const filters = parseDashboardDrilldownParams(
new URLSearchParams({
dispatchers: " 12 , 13",
upliftStatuses: "pending,bogus",
}),
);
expect(filters?.dispatchers).toEqual(new Set(["12", "13"]));
expect(filters?.hasUplift).toBe(true);
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(
new URLSearchParams({ dateFrom: "2026-9-16", dateTo: "2026-09-18" }),
),
).toBeNull();
});
});

View file

@ -1,4 +1,4 @@
import { describe, expect, it } from "vitest";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { EMPTY_UPLIFT_SUMMARY } from "@/domain/work-orders/types/work-order-uplift";
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
import { EMPTY_ADVANCED_FILTERS } from "@/domain/work-orders/types/work-order-advanced-filters";
@ -526,6 +526,41 @@ describe("computeIsPastDue", () => {
});
});
describe("computeIsPastDue across the local/UTC day boundary", () => {
beforeEach(() => {
// UTC-3 with no DST: local evening is already the next day in UTC.
vi.stubEnv("TZ", "America/Sao_Paulo");
vi.useFakeTimers({ toFake: ["Date"] });
});
afterEach(() => {
vi.useRealTimers();
vi.unstubAllEnvs();
});
it("does not flag today's work as Past Due at 23:30 local (02:30 UTC next day)", () => {
vi.setSystemTime(new Date("2026-09-17T02:30:00.000Z"));
expect(computeIsPastDue(makeRow({ scheduledOn: "2026-09-16", status: "Scheduled" }))).toBe(
false,
);
expect(computeIsPastDue(makeRow({ scheduledOn: "2026-09-15", status: "Scheduled" }))).toBe(
true,
);
});
it("flags yesterday's work as Past Due at 00:30 local", () => {
vi.setSystemTime(new Date("2026-09-17T03:30:00.000Z"));
expect(computeIsPastDue(makeRow({ scheduledOn: "2026-09-16", status: "Scheduled" }))).toBe(
true,
);
expect(computeIsPastDue(makeRow({ scheduledOn: "2026-09-17", status: "Scheduled" }))).toBe(
false,
);
});
});
describe("enrichTableRow", () => {
it("recomputes isPastDue from scheduledOn and ignores sticky API true", () => {
const enriched = enrichTableRow(

View file

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

Some files were not shown because too many files have changed in this diff Show more