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.
This commit is contained in:
Alexandre Brandizzi 2026-09-25 01:49:24 -03:00
parent aec11782b3
commit 28e30f8edb
9 changed files with 223 additions and 19 deletions

View file

@ -18,6 +18,7 @@ import {
} 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";
@ -103,7 +104,7 @@ function PendingRowActions({
disabled={!canDecide || isDecisionPending}
onClick={() => onApprove(row)}
>
Approve
Approve {formatUpliftMoney(upliftAmount(row))}
</Button>
</span>
</Tooltip>
@ -214,7 +215,7 @@ function UpliftApprovalRow({
</TableCell>
<TableCell>
<Text variant="body" className="font-medium">
{formatUpliftMoney(row.requestedNTE)}
{formatUpliftMoney(upliftAmount(row))}
</Text>
</TableCell>
<TableCell>

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

@ -23,6 +23,7 @@ import {
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.";
@ -281,7 +282,7 @@ function PendingModalActions({
disabled={!canDecide || approvePending}
onClick={() => onApprove(item)}
>
Approve
Approve {formatUpliftMoney(upliftAmount(item))}
</Button>
</span>
</Tooltip>
@ -394,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,187 @@
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

@ -195,10 +195,12 @@ 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 +210,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 +225,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();
});
});