Compare commits

...

16 commits

Author SHA1 Message Date
Alexandre Brandizzi
4b77e4f0f9
Merge b91682f53c into 52cbb87126 2026-09-25 06:02:31 +00:00
Alexandre Brandizzi
b91682f53c Merge remote-tracking branch 'origin/main' into lane/pr261 2026-09-25 03:02:25 -03:00
Alexandre Brandizzi
52cbb87126
Merge pull request #259 from Sea-Haven-Industries/fix/ab/sh-391-adv-filter-date-range
fix(workorders): make the Advanced Filters date range narrow results (SH-391)
2026-09-25 05:53:39 +00:00
Alexandre Brandizzi
7c665464f8
Merge pull request #263 from Sea-Haven-Industries/fix/ab/sh-398-uplift-amount
Some checks are pending
Frontend checks / static (push) Waiting to run
Frontend checks / build (push) Waiting to run
Frontend checks / unit (push) Waiting to run
Frontend checks / Visual regression (push) Waiting to run
Frontend checks / browser-smoke (push) Waiting to run
Frontend checks / governance (push) Waiting to run
Frontend checks / ci-complete (push) Blocked by required conditions
Deploy Web / Resolve target (push) Waiting to run
Deploy Web / Deploy SPA to (push) Blocked by required conditions
fix(uplifts): show one uplift amount on every approvals surface
2026-09-25 05:02:57 +00:00
Alexandre Brandizzi
56ca5b0c39 test(uplifts): assert the uplift amount on the approver e2e buttons 2026-09-25 01:58:47 -03:00
Alexandre Brandizzi
3d10ba8a0c style(uplifts): apply prettier to uplift approval tests 2026-09-25 01:53:05 -03:00
Alexandre Brandizzi
28e30f8edb fix(uplifts): show the requested increase as the one uplift amount
The queue Amount column, the detail modal and the reject/revoke dialogs
showed requestedNTE (the resulting NTE total) while the decision toasts
showed delta (the increase), so a work order with an existing NTE named
two different figures for one request. Route every surface through one
upliftAmount helper that returns the increase, matching the prototype's
single uplift amount, and show it on both Approve buttons.
2026-09-25 01:49:24 -03:00
Alexandre Brandizzi
aec11782b3
Merge pull request #262 from Sea-Haven-Industries/fix/ab/sh-394-exposure-dash
Some checks are pending
Frontend checks / static (push) Waiting to run
Frontend checks / build (push) Waiting to run
Frontend checks / unit (push) Waiting to run
Frontend checks / Visual regression (push) Waiting to run
Frontend checks / browser-smoke (push) Waiting to run
Frontend checks / governance (push) Waiting to run
Frontend checks / ci-complete (push) Blocked by required conditions
Deploy Web / Resolve target (push) Waiting to run
Deploy Web / Deploy SPA to (push) Blocked by required conditions
fix(uplifts): show a dash for Approved on WO when nothing is granted (SH-394)
2026-09-25 02:31:59 +00:00
Alexandre Brandizzi
5387a7b1c9
Merge pull request #252 from Sea-Haven-Industries/albrand/renovate-npm-minor-and-patch
chore(deps): update npm minor and patch
2026-09-25 02:12:28 +00:00
Alexandre Brandizzi
eb01c59471 test(uplifts): drop the ticket key from the Approved on WO test name 2026-09-24 23:03:42 -03:00
Alexandre Brandizzi
b8d24d75a3 test(uplifts): cover an unknown row total with nothing granted on the WO (SH-394) 2026-09-24 23:00:27 -03:00
Alexandre Brandizzi
ec3f97dcf6 fix(uplifts): show a dash for Approved on WO when nothing is granted (SH-394)
A work order with a zero granted total rendered $0.00 in the Pending
table's Approved on WO column and in the detail modal's Total line.
Both now share isGrantedAmount, so zero or unknown totals render a dash
and the column always matches the modal Total.
2026-09-24 22:54:39 -03:00
Alexandre Brandizzi
ffff03fcc2 test(visual): refresh work-order baselines for lucide-react icon updates 2026-09-24 22:48:18 -03:00
Alexandre Brandizzi
62e38e4b81 chore(workorders): keep ticket keys out of test names
The review contract bars Jira keys in source comments and test names;
ticket identity lives in the PR, commit and branch. The previous commit
cleaned the source files; this strips the six (SH-391) suffixes the
branch added to test names and a test comment, keeping the behavioural
wording.
2026-09-24 22:41:05 -03:00
Alexandre Brandizzi
ec83829098 Merge remote-tracking branch 'origin/main' into lane/renovate
# Conflicts:
#	e2e/__screenshots__/work-orders/work-orders.visual.spec.ts/wo-filters.png
#	e2e/__screenshots__/work-orders/work-orders.visual.spec.ts/wo-list.png
#	e2e/__screenshots__/work-orders/work-orders.visual.spec.ts/wo-new.png
#	package-lock.json
#	package.json
2026-09-24 22:35:01 -03:00
Alexandre Brandizzi
77db507e4e fix(deps): update npm minor and patch 2026-09-23 02:08:53 -03:00
21 changed files with 1515 additions and 1025 deletions

Binary file not shown.

Before

Width:  |  Height:  |  Size: 154 KiB

After

Width:  |  Height:  |  Size: 154 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 124 KiB

After

Width:  |  Height:  |  Size: 124 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 195 KiB

After

Width:  |  Height:  |  Size: 195 KiB

View file

@ -349,7 +349,7 @@ test("internal approver can approve, reject, and revoke with audited notes", asy
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 approvalRow.getByRole("button", { name: "Approve $25.00" }).click();
const changesRow = page.getByRole("row").filter({ hasText: "DSP-42" });
await changesRow.getByRole("button", { name: "Reject" }).click();
@ -360,7 +360,7 @@ test("internal approver can approve, reject, and revoke with audited notes", asy
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 page.getByRole("button", { name: "Revoke $25.00" }).click();
await expect
.poll(() => decisions)

2026
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -50,63 +50,63 @@
"dependencies": {
"@emotion/react": "^11.14.0",
"@emotion/styled": "^11.14.1",
"@fontsource/dm-sans": "^5.2.8",
"@fontsource/jetbrains-mono": "^5.2.8",
"@fontsource/montserrat": "^5.2.8",
"@fontsource/dm-sans": "^5.3.0",
"@fontsource/jetbrains-mono": "^5.3.0",
"@fontsource/montserrat": "^5.3.0",
"@fullcalendar/daygrid": "^6.1.21",
"@fullcalendar/interaction": "^6.1.21",
"@fullcalendar/react": "^6.1.21",
"@fullcalendar/timegrid": "^6.1.21",
"@generouted/react-router": "^1.20.2",
"@hookform/resolvers": "^5.4.0",
"@mui/icons-material": "^9.2.0",
"@mui/material": "^9.2.0",
"@sentry/react": "10.73.0",
"@hookform/resolvers": "^5.9.1",
"@mui/icons-material": "9.2.0",
"@mui/material": "9.2.0",
"@sentry/react": "10.75.0",
"@tanstack/query-broadcast-client-experimental": "5.103.1",
"@tanstack/react-query": "5.103.1",
"@tanstack/react-query-devtools": "5.103.1",
"@testing-library/dom": "^10.4.1",
"@testing-library/dom": "^10.4.2",
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.2",
"@testing-library/user-event": "^14.6.1",
"@testing-library/react": "^16.3.3",
"@testing-library/user-event": "^14.6.7",
"clsx": "^2.1.1",
"ky": "^2.0.2",
"lucide-react": "^1.24.0",
"react": "^19.2.7",
"react-dom": "^19.2.7",
"react-hook-form": "^7.81.0",
"ky": "^2.1.0",
"lucide-react": "^1.47.0",
"react": "^19.3.0",
"react-dom": "^19.3.0",
"react-hook-form": "^7.88.0",
"react-icons": "^5.7.0",
"react-router": "^8.2.0",
"react-router": "^8.4.0",
"react-toastify": "^11.1.0",
"tailwind-merge": "^3.6.0",
"zod": "^4.4.3"
"tailwind-merge": "^3.7.0",
"zod": "^4.6.5"
},
"devDependencies": {
"@commitlint/cli": "^21.2.1",
"@commitlint/config-conventional": "^21.2.0",
"@commitlint/cli": "^21.2.3",
"@commitlint/config-conventional": "^21.2.3",
"@eslint/js": "^10.0.1",
"@playwright/test": "^1.61.1",
"@sentry/cli": "3.7.0",
"@tailwindcss/vite": "^4.3.2",
"@types/node": "^24.13.3",
"@types/react": "^19.2.17",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^6.0.3",
"@vitest/coverage-v8": "^4.1.10",
"eslint": "^10.7.0",
"@sentry/cli": "3.8.0",
"@tailwindcss/vite": "^4.3.3",
"@types/node": "^24.13.6",
"@types/react": "^19.3.0",
"@types/react-dom": "^19.3.0",
"@vitejs/plugin-react": "^6.1.1",
"@vitest/coverage-v8": "^4.1.11",
"eslint": "^10.11.0",
"eslint-config-prettier": "^10.1.8",
"eslint-plugin-react-hooks": "^7.1.1",
"eslint-plugin-react-refresh": "^0.5.3",
"globals": "^17.7.0",
"eslint-plugin-react-refresh": "^0.5.7",
"globals": "^17.12.0",
"husky": "^9.1.7",
"jsdom": "^29.1.1",
"lint-staged": "^17.0.8",
"prettier": "^3.9.5",
"tailwindcss": "^4.3.2",
"lint-staged": "^17.5.1",
"prettier": "^3.9.8",
"tailwindcss": "^4.3.3",
"typescript": "~6.0.3",
"typescript-eslint": "^8.63.0",
"vite": "^8.1.4",
"vitest": "^4.1.10"
"typescript-eslint": "^8.70.0",
"vite": "^8.3.0",
"vitest": "^4.1.11"
},
"engines": {
"node": ">=22.22.1"

View file

@ -4,3 +4,7 @@ export function formatUpliftMoney(value: number): string {
maximumFractionDigits: 2,
})}`;
}
export function isGrantedAmount(value: number | null): value is number {
return value != null && value > 0;
}

View file

@ -12,9 +12,13 @@ import {
TableRow,
Tooltip,
} from "@mui/material";
import { formatUpliftMoney } from "@/app/(protected)/uplifts/_components/format-uplift-money";
import {
formatUpliftMoney,
isGrantedAmount,
} 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 { upliftAmount } from "@/domain/uplifts/utils/uplift-amount";
import type { UpliftQueueItem } from "@/domain/uplifts/types/uplift";
import { formatDateTime, getWaitTimeTextClass, timeSince, waitTimeColor } from "@/lib/time-utils";
@ -100,7 +104,7 @@ function PendingRowActions({
disabled={!canDecide || isDecisionPending}
onClick={() => onApprove(row)}
>
Approve
Approve {formatUpliftMoney(upliftAmount(row))}
</Button>
</span>
</Tooltip>
@ -211,11 +215,11 @@ function UpliftApprovalRow({
</TableCell>
<TableCell>
<Text variant="body" className="font-medium">
{formatUpliftMoney(row.requestedNTE)}
{formatUpliftMoney(upliftAmount(row))}
</Text>
</TableCell>
<TableCell>
{row.approvedOnWoTotal != null ? (
{isGrantedAmount(row.approvedOnWoTotal) ? (
<Text variant="body">{formatUpliftMoney(row.approvedOnWoTotal)}</Text>
) : (
<Text variant="caption" tone="muted">

View file

@ -1,5 +1,6 @@
import { RejectDialog } from "@/app/(protected)/uplifts/_components/reject-dialog";
import { RevokeDialog } from "@/app/(protected)/uplifts/_components/revoke-dialog";
import { upliftAmount } from "@/domain/uplifts/utils/uplift-amount";
import type { useUpliftApprovalController } from "@/domain/uplifts/use-cases/use-uplift-approval-controller";
export type UpliftApprovalController = ReturnType<typeof useUpliftApprovalController>;
@ -23,7 +24,7 @@ export function UpliftDecisionDialogs({ controller }: { controller: UpliftApprov
<>
<RejectDialog
open={rejectTarget != null}
amount={rejectTarget?.requestedNTE}
amount={rejectTarget ? upliftAmount(rejectTarget) : undefined}
woNumber={woFallbackNumber(rejectTarget)}
onClose={closeReject}
onConfirm={handleRejectConfirm}
@ -31,7 +32,7 @@ export function UpliftDecisionDialogs({ controller }: { controller: UpliftApprov
/>
<RevokeDialog
open={revokeTarget != null}
amount={revokeTarget?.requestedNTE}
amount={revokeTarget ? upliftAmount(revokeTarget) : undefined}
woNumber={woFallbackNumber(revokeTarget)}
onClose={closeRevoke}
onConfirm={handleRevokeConfirm}

View file

@ -16,10 +16,14 @@ import {
formatUpliftCalendarDate,
formatUpliftDateTime,
} from "@/app/(protected)/uplifts/_components/format-uplift-dates";
import { formatUpliftMoney } from "@/app/(protected)/uplifts/_components/format-uplift-money";
import {
formatUpliftMoney,
isGrantedAmount,
} from "@/app/(protected)/uplifts/_components/format-uplift-money";
import { Text } from "@/components/ui/text";
import { useWorkOrderUplifts } from "@/domain/work-orders/use-cases/use-work-order-uplifts";
import { useUpliftsCanApprove } from "@/domain/uplifts/use-cases/use-uplifts-can-approve";
import { upliftAmount } from "@/domain/uplifts/utils/uplift-amount";
import type { UpliftQueueItem } from "@/domain/uplifts/types/uplift";
const CLOSED_WO_TOOLTIP = "This work order is closed. Uplifts can no longer be revoked.";
@ -142,6 +146,7 @@ function ApprovedOnWoBreakdown({
breakdown: ExposureBreakdown;
unavailable: boolean;
}) {
const grantedTotal = isGrantedAmount(breakdown.total) ? breakdown.total : null;
return (
<Box sx={BORDERED_BLOCK_SX}>
<BreakdownRow
@ -156,7 +161,12 @@ function ApprovedOnWoBreakdown({
strong={false}
unavailable={unavailable}
/>
<BreakdownRow label="Total" value={breakdown.total} strong unavailable={unavailable} />
<BreakdownRow
label="Total"
value={grantedTotal}
strong
unavailable={unavailable && breakdown.total == null}
/>
</Box>
);
}
@ -272,7 +282,7 @@ function PendingModalActions({
disabled={!canDecide || approvePending}
onClick={() => onApprove(item)}
>
Approve
Approve {formatUpliftMoney(upliftAmount(item))}
</Button>
</span>
</Tooltip>
@ -385,7 +395,7 @@ function UpliftDetailContent({
Amount
</Text>
<Text variant="heading" as="span" tone="warning">
{formatUpliftMoney(item.requestedNTE)}
{formatUpliftMoney(upliftAmount(item))}
</Text>
</Box>
<WorkOrderSection item={item} />

View file

@ -5,6 +5,7 @@ import {
useRejectUplift,
useRevokeUplift,
} from "@/domain/uplifts/use-cases/use-uplift-actions";
import { upliftAmount } from "@/domain/uplifts/utils/uplift-amount";
export function useUpliftApprovalController() {
const [detailItem, setDetailItem] = useState<UpliftQueueItem | null>(null);
@ -15,7 +16,7 @@ export function useUpliftApprovalController() {
const revokeUplift = useRevokeUplift();
const handleApprove = (row: UpliftQueueItem) => {
approveUplift.mutate({ id: row.id, amount: row.delta, woNumber: row.woNumber });
approveUplift.mutate({ id: row.id, amount: upliftAmount(row), woNumber: row.woNumber });
setDetailItem(null);
};
@ -35,7 +36,7 @@ export function useUpliftApprovalController() {
{
id: rejectTarget.id,
note: reason,
amount: rejectTarget.delta,
amount: upliftAmount(rejectTarget),
woNumber: rejectTarget.woNumber,
},
{ onSuccess: () => setRejectTarget(null) },
@ -48,7 +49,7 @@ export function useUpliftApprovalController() {
{
upliftId: revokeTarget.id,
reason,
amount: revokeTarget.delta,
amount: upliftAmount(revokeTarget),
woNumber: revokeTarget.woNumber,
},
{ onSuccess: () => setRevokeTarget(null) },

View file

@ -0,0 +1,11 @@
import type { UpliftRequest } from "@/domain/uplifts/types/uplift";
/**
* The amount of an uplift: the increase being requested on top of the work
* order's current NTE. Every surface that names "the uplift" (queue Amount,
* Approve label, detail modal, reject/revoke dialogs and decision toasts)
* shows this figure; `requestedNTE` is the resulting NTE total, not the uplift.
*/
export function upliftAmount(request: Pick<UpliftRequest, "delta">): number {
return request.delta;
}

View file

@ -132,7 +132,7 @@ describe("Uplift Approvals approved tab", () => {
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.",
"The approved uplift of $400.00 on WO WO-56 will be withdrawn. This does not recover money already spent — it records that the authorization was a mistake.",
),
).toBeInTheDocument();
});

View file

@ -0,0 +1,189 @@
import { fireEvent, screen, waitFor, within } 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";
// A work order that already carries an NTE: the uplift (the increase) and the
// requested NTE total differ, so any surface reading the wrong field shows up.
const UPLIFT = "$4,999,997,000.00";
const REQUESTED_NTE_TOTAL = "$5,000,000,000.00";
const api = vi.hoisted(() => ({
approve: vi.fn(),
reject: vi.fn(),
revoke: vi.fn(),
}));
const toastSuccess = vi.hoisted(() => vi.fn());
const queueState = vi.hoisted(() => ({
data: null as UpliftsQueueResult | null,
}));
vi.mock("@/domain/uplifts/api/uplifts-api", () => ({
upliftsApi: {
approve: (...args: unknown[]) => api.approve(...args),
reject: (...args: unknown[]) => api.reject(...args),
revoke: (...args: unknown[]) => api.revoke(...args),
},
}));
vi.mock("react-toastify", async (importOriginal) => ({
...(await importOriginal<object>()),
toast: { error: vi.fn(), success: toastSuccess, warning: vi.fn() },
}));
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/work-orders/use-cases/use-work-order-uplifts", () => ({
useWorkOrderUplifts: () => ({ data: [], isLoading: false, isError: false }),
}));
vi.mock("@/domain/uplifts/use-cases/use-uplifts-can-approve", () => ({
useUpliftsCanApprove: () => ({ data: true }),
}));
vi.mock("@/providers/auth-context", async (importOriginal) => ({
...(await importOriginal<object>()),
useAuthContext: () => ({ user: { userRoles: "Admin" } }),
}));
const pendingItem: UpliftQueueItem = {
id: 41,
status: "Pending",
currentNTE: 3000,
requestedNTE: 5_000_000_000,
delta: 4_999_997_000,
vendorReason: "Full replacement",
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",
technicianName: "",
workOrderDispatcherName: "",
workOrderScheduledDate: "",
attachmentCount: null,
approvedOnWoAuto: null,
approvedOnWoAdmin: null,
approvedOnWoTotal: null,
workOrderClosed: false,
};
const approvedItem: UpliftQueueItem = {
...pendingItem,
id: 42,
status: "Approved",
decidedAt: "2026-01-16T10:00:00Z",
decidedByName: "Admin User",
canDecide: false,
};
function queueOf(item: UpliftQueueItem): UpliftsQueueResult {
return { items: [item], totalCount: 1, page: 1, pageSize: 25, pendingExposureTotal: null };
}
async function renderQueue() {
const { default: UpliftQueuePage } = await import("@/app/(protected)/uplifts/index");
renderWithProviders(<UpliftQueuePage />);
}
function amountCell(): HTMLElement {
const row = screen.getByLabelText(/open uplift details for/i);
const header = screen.getByRole("columnheader", { name: "Amount" });
const index = Array.from(header.parentElement?.children ?? []).indexOf(header);
return row.querySelectorAll("td")[index] as HTMLElement;
}
describe("Uplift Approvals shows one amount per request", () => {
beforeEach(() => {
api.approve.mockReset().mockResolvedValue(undefined);
api.reject.mockReset().mockResolvedValue(undefined);
api.revoke.mockReset().mockResolvedValue(undefined);
toastSuccess.mockReset();
});
it("uses the uplift amount in the pending row, the detail modal and the approve toast", async () => {
queueState.data = queueOf(pendingItem);
await renderQueue();
expect(amountCell()).toHaveTextContent(UPLIFT);
expect(screen.getByRole("button", { name: `Approve ${UPLIFT}` })).toBeInTheDocument();
fireEvent.click(screen.getByLabelText(/open uplift details for/i));
const modal = screen.getByRole("dialog");
expect(within(modal).getByText(UPLIFT)).toBeInTheDocument();
fireEvent.click(within(modal).getByRole("button", { name: `Approve ${UPLIFT}` }));
await waitFor(() =>
expect(toastSuccess).toHaveBeenCalledWith(`Uplift approved — ${UPLIFT} on WO-99`),
);
expect(screen.queryByText(REQUESTED_NTE_TOTAL)).not.toBeInTheDocument();
});
it("uses the uplift amount in the reject dialog title and the reject toast", async () => {
queueState.data = queueOf(pendingItem);
await renderQueue();
fireEvent.click(screen.getByRole("button", { name: "Reject" }));
expect(
screen.getByRole("heading", { name: `Reject uplift of ${UPLIFT}?` }),
).toBeInTheDocument();
fireEvent.change(screen.getByLabelText(/reason for rejection/i), {
target: { value: "Out of scope" },
});
fireEvent.click(screen.getByRole("button", { name: "Reject uplift" }));
await waitFor(() =>
expect(toastSuccess).toHaveBeenCalledWith(`Uplift rejected — ${UPLIFT} on WO-99`),
);
expect(screen.queryByText(REQUESTED_NTE_TOTAL)).not.toBeInTheDocument();
});
it("uses the uplift amount in the approved row, the revoke dialog and the revoke toast", async () => {
queueState.data = queueOf(approvedItem);
await renderQueue();
fireEvent.click(screen.getByRole("tab", { name: "Approved" }));
expect(amountCell()).toHaveTextContent(UPLIFT);
fireEvent.click(screen.getByRole("button", { name: "Revoke" }));
expect(
screen.getByText(
`The approved uplift of ${UPLIFT} on WO WO-99 will be withdrawn. This does not recover money already spent — it records that the authorization was a mistake.`,
),
).toBeInTheDocument();
fireEvent.change(screen.getByLabelText(/reason for revoking/i), {
target: { value: "Approved in error" },
});
fireEvent.click(screen.getByRole("button", { name: `Revoke ${UPLIFT}` }));
await waitFor(() =>
expect(toastSuccess).toHaveBeenCalledWith(`Uplift revoked — ${UPLIFT} on WO-99`),
);
expect(screen.queryByText(REQUESTED_NTE_TOTAL)).not.toBeInTheDocument();
});
});

View file

@ -0,0 +1,160 @@
import { fireEvent, screen, within } 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 queueState = vi.hoisted(() => ({ data: null as UpliftsQueueResult | null }));
const woUplifts = vi.hoisted(() => ({
data: [] as Array<Record<string, unknown>> | null,
isError: 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: vi.fn() }),
}));
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: true }),
}));
vi.mock("@/domain/work-orders/use-cases/use-work-order-uplifts", () => ({
useWorkOrderUplifts: () => ({
data: woUplifts.data,
isLoading: false,
isError: woUplifts.isError,
}),
}));
const pendingItem: UpliftQueueItem = {
id: 41,
status: "Pending",
currentNTE: 100,
requestedNTE: 250,
delta: 150,
vendorReason: "Extra parts",
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",
technicianName: "",
workOrderDispatcherName: "",
workOrderScheduledDate: "",
attachmentCount: null,
approvedOnWoAuto: null,
approvedOnWoAdmin: null,
approvedOnWoTotal: null,
workOrderClosed: false,
};
function seedQueue(overrides: Partial<UpliftQueueItem>) {
queueState.data = {
items: [{ ...pendingItem, ...overrides }],
totalCount: 1,
page: 1,
pageSize: 25,
pendingExposureTotal: 250,
};
}
async function renderQueuePage() {
const { default: UpliftQueuePage } = await import("@/app/(protected)/uplifts/index");
renderWithProviders(<UpliftQueuePage />);
}
function approvedOnWoColumnText(): string {
const table = screen.getByRole("table", { name: "pending uplift requests" });
const headers = within(table).getAllByRole("columnheader");
const columnIndex = headers.findIndex((header) => header.textContent === "Approved on WO");
const row = within(table).getByRole("row", { name: /Open uplift details for WO-99/ });
return within(row).getAllByRole("cell")[columnIndex]?.textContent ?? "";
}
function modalTotalText(): string {
fireEvent.click(screen.getByRole("row", { name: /Open uplift details for WO-99/ }));
const dialog = screen.getByRole("dialog");
const label = within(dialog).getByText("Total");
return label.parentElement?.lastElementChild?.textContent ?? "";
}
describe("Uplift Approvals: Approved on WO", () => {
beforeEach(() => {
woUplifts.data = [];
woUplifts.isError = false;
});
it.each([
{ total: 0, expected: "—" },
{ total: null, expected: "—" },
{ total: 125, expected: "$125.00" },
])(
"renders $expected in the column and the modal Total for total $total",
async ({ total, expected }) => {
seedQueue({ approvedOnWoTotal: total });
await renderQueuePage();
const column = approvedOnWoColumnText();
const modalTotal = modalTotalText();
expect(column).toBe(expected);
expect(modalTotal).toBe(column);
},
);
it("shows a dash in both places when the row total is unknown and the WO has nothing granted", async () => {
woUplifts.data = [
{ id: 1, status: "pending", amount: 250 },
{ id: 2, status: "rejected", amount: 80 },
{ id: 3, status: "revoked", amount: 40 },
];
seedQueue({ approvedOnWoTotal: null });
await renderQueuePage();
expect(approvedOnWoColumnText()).toBe("—");
expect(modalTotalText()).toBe("—");
});
it("keeps the modal Total as a dash when the WO uplift lookup fails but the row reports zero", async () => {
woUplifts.data = null;
woUplifts.isError = true;
seedQueue({ approvedOnWoAuto: 0, approvedOnWoAdmin: 0, approvedOnWoTotal: 0 });
await renderQueuePage();
expect(approvedOnWoColumnText()).toBe("—");
expect(modalTotalText()).toBe("—");
});
});

View file

@ -195,10 +195,10 @@ describe("UpliftDetailModal", () => {
const footerButtons = screen
.getAllByRole("button")
.map((button) => button.textContent)
.filter((label) => label === "Reject" || label === "Approve" || label === "Revoke");
expect(footerButtons).toEqual(["Reject", "Approve"]);
.filter((label) => label === "Reject" || label === "Approve $150.00" || label === "Revoke");
expect(footerButtons).toEqual(["Reject", "Approve $150.00"]);
fireEvent.click(screen.getByRole("button", { name: "Approve" }));
fireEvent.click(screen.getByRole("button", { name: "Approve $150.00" }));
expect(onApprove).toHaveBeenCalled();
fireEvent.click(screen.getByRole("button", { name: "Reject" }));
expect(onReject).toHaveBeenCalled();
@ -208,7 +208,7 @@ describe("UpliftDetailModal", () => {
canApproveState.data = false;
const { onApprove, onReject } = renderModal();
const approve = screen.getByRole("button", { name: "Approve" });
const approve = screen.getByRole("button", { name: "Approve $150.00" });
const reject = screen.getByRole("button", { name: "Reject" });
expect(approve).toBeDisabled();
expect(reject).toBeDisabled();
@ -223,7 +223,7 @@ describe("UpliftDetailModal", () => {
it("offers Revoke for an approved uplift and disables it with a tooltip on a closed work order", async () => {
const { onRevoke } = renderModal({ status: "Approved", workOrderClosed: true });
expect(screen.queryByRole("button", { name: "Approve" })).not.toBeInTheDocument();
expect(screen.queryByRole("button", { name: /^Approve/ })).not.toBeInTheDocument();
expect(screen.queryByRole("button", { name: "Reject" })).not.toBeInTheDocument();
const revoke = screen.getByRole("button", { name: "Revoke" });
expect(revoke).toBeDisabled();

View file

@ -106,7 +106,7 @@ describe("Uplift Approvals decision flow", () => {
const { default: UpliftQueuePage } = await import("@/app/(protected)/uplifts/index");
renderWithProviders(<UpliftQueuePage />);
fireEvent.click(screen.getByRole("button", { name: "Approve" }));
fireEvent.click(screen.getByRole("button", { name: "Approve $150.00" }));
expect(approveMutate).toHaveBeenCalledWith({ id: 41, amount: 150, woNumber: "WO-99" });
expect(screen.queryByRole("button", { name: "Confirm" })).not.toBeInTheDocument();
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
@ -117,7 +117,7 @@ describe("Uplift Approvals decision flow", () => {
renderWithProviders(<UpliftQueuePage />);
fireEvent.click(screen.getByRole("button", { name: "Reject" }));
expect(screen.getByRole("heading", { name: /reject uplift of \$250/i })).toBeInTheDocument();
expect(screen.getByRole("heading", { name: "Reject uplift of $150.00?" })).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();
@ -142,7 +142,7 @@ describe("Uplift Approvals decision flow", () => {
const { default: UpliftQueuePage } = await import("@/app/(protected)/uplifts/index");
renderWithProviders(<UpliftQueuePage />);
const approve = screen.getByRole("button", { name: "Approve" });
const approve = screen.getByRole("button", { name: "Approve $150.00" });
const reject = screen.getByRole("button", { name: "Reject" });
expect(approve).toBeDisabled();
expect(reject).toBeDisabled();
@ -160,7 +160,7 @@ describe("Uplift Approvals decision flow", () => {
const { default: UpliftQueuePage } = await import("@/app/(protected)/uplifts/index");
renderWithProviders(<UpliftQueuePage />);
expect(screen.getByRole("button", { name: "Approve" })).toBeEnabled();
expect(screen.getByRole("button", { name: "Approve $150.00" })).toBeEnabled();
expect(screen.getByRole("button", { name: "Reject" })).toBeEnabled();
});
});

View file

@ -33,7 +33,7 @@ function openSection() {
}
}
describe("Advanced Filters date range (SH-391)", () => {
describe("Advanced Filters date range", () => {
it("starts with no range selected, so the search spans every week", () => {
openSection();

View file

@ -420,7 +420,7 @@ describe("workOrdersApi.searchBoard", () => {
});
});
/** A WO# lookup spans every week and undated rows (SH-391). */
/** A WO# lookup spans every week and undated rows. */
const ALL_WEEKS_SEARCH = {
datePreset: "Custom",
dateFrom: "2000-01-01",

View file

@ -135,7 +135,7 @@ describe("toBoardSearchQueryParams", () => {
expect(thisWeek.dateTo).toBeUndefined();
});
it("narrows strictly for every selected range, This week included (SH-391)", () => {
it("narrows strictly for every selected range, This week included", () => {
for (const dateRange of ["this-week", "last-week", "last-3-months", "custom"] as const) {
const params = toBoardSearchQueryParams({
filters: {
@ -151,7 +151,7 @@ describe("toBoardSearchQueryParams", () => {
}
});
it("searches every week, undated rows included, when no range is selected (SH-391)", () => {
it("searches every week, undated rows included, when no range is selected", () => {
const params = toBoardSearchQueryParams({
filters: { ...EMPTY_ADVANCED_FILTERS, dispatchers: new Set(["__unassigned__"]) },
});
@ -165,7 +165,7 @@ describe("toBoardSearchQueryParams", () => {
});
});
it("keeps undated rows in the pinned Unassigned queue (SH-391)", () => {
it("keeps undated rows in the pinned Unassigned queue", () => {
expect(toUnassignedQueueQueryParams().includeDateless).toBe(true);
});

View file

@ -455,7 +455,7 @@ describe("matchesAdvancedFilters", () => {
expect(matchesAdvancedFilters(undatedAssigned, filters)).toBe(true);
});
it("matches a range the same way the board search does (SH-391)", () => {
it("matches a range the same way the board search does", () => {
const filters = {
...EMPTY_ADVANCED_FILTERS,
dateRange: "custom" as const,