mirror of
https://github.com/Sea-Haven-Industries/shoc-frontend-new.git
synced 2026-10-04 00:53:14 +00:00
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:
parent
aec11782b3
commit
28e30f8edb
9 changed files with 223 additions and 19 deletions
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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} />
|
||||
|
|
|
|||
|
|
@ -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) },
|
||||
|
|
|
|||
11
src/domain/uplifts/utils/uplift-amount.ts
Normal file
11
src/domain/uplifts/utils/uplift-amount.ts
Normal 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;
|
||||
}
|
||||
|
|
@ -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();
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue