Compare commits

...

36 commits

Author SHA1 Message Date
Codex Review Integration
8a0a134238 style: format wizard-vendor-company-fields with prettier 2026-09-17 15:31:15 -03:00
Codex Review Integration
abe28a3a7f Merge remote-tracking branch 'origin/dev' into fix/ab/sh-364-inline-technician-registration
# Conflicts:
#	src/app/(protected)/workorders/_components/wizard/use-new-wo-wizard-controller.ts
#	src/app/(protected)/workorders/_components/wizard/wizard-step-location-service-select.tsx
#	src/app/(protected)/workorders/_components/wizard/wizard-step-vendor-time.tsx
#	src/test/app/(protected)/workorders/wizard-service-clears-vendor.test.tsx
2026-09-17 15:27:39 -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
534d7ce4d6
Merge branch 'dev' into fix/ab/sh-364-inline-technician-registration 2026-09-17 15:02: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
65d55d7c9f
Merge branch 'dev' into fix/ab/sh-364-inline-technician-registration 2026-09-17 14:48:26 -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
Alexandre Brandizzi
b48d1a24be
Merge branch 'dev' into fix/ab/sh-321-wizard-vendor-notes 2026-09-17 14:34:36 -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
e5df5b46c0
Merge branch 'dev' into fix/ab/sh-321-wizard-vendor-notes 2026-09-17 14:26:33 -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
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
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
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
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
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
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
43 changed files with 2902 additions and 562 deletions

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,174 @@
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 company/ }).click();
await page
.getByRole("button", { name: /Gateway Plumbing/ })
.first()
.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

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

@ -15,6 +15,7 @@ import { useNewWoWizardDraft } from "@/app/(protected)/workorders/_components/wi
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 { useWizardCreateWithTechnicians } from "@/app/(protected)/workorders/_components/wizard/use-wizard-create-with-technicians";
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 = {
@ -50,6 +51,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) {
@ -57,7 +64,15 @@ export function useNewWoWizardController({
}
};
const createWorkOrder = useCreateWorkOrderFromWizard(() => onOpenChange(false));
const createWorkOrder = 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 createMutation = useWizardCreateWithTechnicians({
vendors,
patchDraft: draftState.patchDraft,

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

@ -30,7 +30,14 @@ export function WizardStepLocationServiceSelect({
}: WizardStepLocationServiceSelectProps) {
const clearVendorIfPrimaryChanged = (nextPm: string): Partial<WorkOrderWizardDraft> =>
nextPm !== draft.pm
? { vendorId: "", vendorName: "", techPhone: "", vendorCompany: "", newTechnicians: [] }
? {
vendorId: "",
vendorName: "",
techPhone: "",
vendorCompany: "",
vendorNotes: "",
newTechnicians: [],
}
: {};
const selectSvc = (p: string) => {

View file

@ -64,6 +64,7 @@ export function WizardVendorCompanyFields({
vendorId: "",
vendorName: "",
techPhone: "",
vendorNotes: "",
newTechnicians: [],
})
}
@ -80,7 +81,13 @@ export function WizardVendorCompanyFields({
onPatch({
vendorCompany: nextCompany,
...(nextCompany !== company
? { vendorId: "", vendorName: "", techPhone: "", newTechnicians: [] }
? {
vendorId: "",
vendorName: "",
techPhone: "",
vendorNotes: "",
newTechnicians: [],
}
: {}),
})
}
@ -92,7 +99,7 @@ export function WizardVendorCompanyFields({
optional
onClear={
draft.vendorId !== ""
? () => onPatch({ vendorId: "", vendorName: "", techPhone: "" })
? () => onPatch({ vendorId: "", vendorName: "", techPhone: "", vendorNotes: "" })
: undefined
}
>

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

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

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

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

@ -104,6 +104,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

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

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

@ -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(
@ -44,6 +45,7 @@ describe("WizardStepLocationServiceSelect — vendor clear on service change", (
vendorName: "",
techPhone: "",
vendorCompany: "",
vendorNotes: "",
newTechnicians: [],
}),
);
@ -58,6 +60,7 @@ describe("WizardStepLocationServiceSelect — vendor clear on service change", (
vendorId: "9",
vendorName: "Old Vendor",
techPhone: "555-0100",
vendorNotes: "Old company notes",
};
render(
@ -87,6 +90,7 @@ describe("WizardStepLocationServiceSelect — vendor clear on service change", (
vendorName: "",
techPhone: "",
vendorCompany: "",
vendorNotes: "",
newTechnicians: [],
});
});
@ -127,6 +131,7 @@ describe("WizardStepLocationServiceSelect — vendor clear on service change", (
vendorName: "",
techPhone: "",
vendorCompany: "",
vendorNotes: "",
newTechnicians: [],
});
});

View file

@ -113,6 +113,7 @@ describe("WizardStepVendorTime inline technician (SH-364)", () => {
vendorId: "",
vendorName: "",
techPhone: "",
vendorNotes: "",
newTechnicians: [],
});
});

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

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

@ -0,0 +1,42 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { createAppQueryClient } from "@/lib/query/query-client";
const toastMocks = vi.hoisted(() => ({ error: vi.fn() }));
vi.mock("react-toastify", () => ({
toast: { error: toastMocks.error },
}));
describe("createAppQueryClient query cache", () => {
beforeEach(() => {
toastMocks.error.mockReset();
});
it("does not toast for queries that suppress the error toast", async () => {
const queryClient = createAppQueryClient();
await expect(
queryClient.fetchQuery({
queryKey: ["suppressed-403"],
queryFn: () =>
Promise.reject(new Error("You do not have permission to perform this action.")),
meta: { suppressErrorToast: true },
}),
).rejects.toThrow("You do not have permission");
expect(toastMocks.error).not.toHaveBeenCalled();
});
it("still toasts for queries without suppression", async () => {
const queryClient = createAppQueryClient();
await expect(
queryClient.fetchQuery({
queryKey: ["unsuppressed-failure"],
queryFn: () => Promise.reject(new Error("A server error occurred.")),
}),
).rejects.toThrow("A server error occurred.");
expect(toastMocks.error).toHaveBeenCalledWith("A server error occurred.");
});
});