fix(uplifts): align approval queue contracts (SH-207)

This commit is contained in:
Codex Review Integration 2026-09-16 22:40:48 -03:00
parent 6b84db18ca
commit 6f8b502116
10 changed files with 135 additions and 33 deletions

View file

@ -367,7 +367,7 @@ test("internal approver can approve, reject, and revoke with audited notes", asy
}, },
{ {
path: "/api/uplifts/41/revoke", path: "/api/uplifts/41/revoke",
body: { reason: "Approval was made in error." }, body: { note: "Approval was made in error." },
}, },
]); ]);
}); });

View file

@ -42,7 +42,13 @@ function WaitingCell({ row }: { row: UpliftQueueItem }) {
); );
} }
function AttachmentsCell({ row }: { row: UpliftQueueItem }) { function AttachmentsCell({
row,
onOpenAttachment,
}: {
row: UpliftQueueItem;
onOpenAttachment: (row: UpliftQueueItem) => void;
}) {
if (row.evidenceDocumentId == null && !row.evidenceFileName) { if (row.evidenceDocumentId == null && !row.evidenceFileName) {
return ( return (
<Text variant="caption" tone="muted"> <Text variant="caption" tone="muted">
@ -56,7 +62,12 @@ function AttachmentsCell({ row }: { row: UpliftQueueItem }) {
: 0; : 0;
return ( return (
<Box sx={{ display: "flex", alignItems: "center", gap: 0.5 }}> <Box sx={{ display: "flex", alignItems: "center", gap: 0.5 }}>
<Chip size="small" label={row.evidenceFileName || "Attachment"} /> <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}`} />} {extraCount > 0 && <Chip size="small" label={`+${extraCount}`} />}
</Box> </Box>
); );
@ -66,10 +77,12 @@ function PendingRowActions({
row, row,
onApprove, onApprove,
onReject, onReject,
isDecisionPending,
}: { }: {
row: UpliftQueueItem; row: UpliftQueueItem;
onApprove: (row: UpliftQueueItem) => void; onApprove: (row: UpliftQueueItem) => void;
onReject: (row: UpliftQueueItem) => void; onReject: (row: UpliftQueueItem) => void;
isDecisionPending: boolean;
}) { }) {
const tooltip = row.canDecide ? "" : `Requires Tier ${row.requiredTier} role`; const tooltip = row.canDecide ? "" : `Requires Tier ${row.requiredTier} role`;
return ( return (
@ -80,7 +93,7 @@ function PendingRowActions({
size="small" size="small"
variant="contained" variant="contained"
color="success" color="success"
disabled={!row.canDecide} disabled={!row.canDecide || isDecisionPending}
onClick={() => onApprove(row)} onClick={() => onApprove(row)}
> >
Approve Approve
@ -93,7 +106,7 @@ function PendingRowActions({
size="small" size="small"
variant="contained" variant="contained"
color="error" color="error"
disabled={!row.canDecide} disabled={!row.canDecide || isDecisionPending}
onClick={() => onReject(row)} onClick={() => onReject(row)}
> >
Reject Reject
@ -107,9 +120,11 @@ function PendingRowActions({
function ApprovedRowActions({ function ApprovedRowActions({
row, row,
onRevoke, onRevoke,
isDecisionPending,
}: { }: {
row: UpliftQueueItem; row: UpliftQueueItem;
onRevoke: (row: UpliftQueueItem) => void; onRevoke: (row: UpliftQueueItem) => void;
isDecisionPending: boolean;
}) { }) {
const closed = row.workOrderClosed === true; const closed = row.workOrderClosed === true;
return ( return (
@ -120,7 +135,7 @@ function ApprovedRowActions({
size="small" size="small"
variant="outlined" variant="outlined"
color="error" color="error"
disabled={closed || row.workOrderId == null} disabled={isDecisionPending || closed || row.workOrderId == null}
onClick={() => onRevoke(row)} onClick={() => onRevoke(row)}
> >
Revoke Revoke
@ -138,6 +153,8 @@ function UpliftApprovalRow({
onApprove, onApprove,
onReject, onReject,
onRevoke, onRevoke,
onOpenAttachment,
isDecisionPending,
}: { }: {
row: UpliftQueueItem; row: UpliftQueueItem;
tab: UpliftApprovalTab; tab: UpliftApprovalTab;
@ -145,6 +162,8 @@ function UpliftApprovalRow({
onApprove: (row: UpliftQueueItem) => void; onApprove: (row: UpliftQueueItem) => void;
onReject: (row: UpliftQueueItem) => void; onReject: (row: UpliftQueueItem) => void;
onRevoke: (row: UpliftQueueItem) => void; onRevoke: (row: UpliftQueueItem) => void;
onOpenAttachment: (row: UpliftQueueItem) => void;
isDecisionPending: boolean;
}) { }) {
return ( return (
<TableRow <TableRow
@ -201,7 +220,7 @@ function UpliftApprovalRow({
</Text> </Text>
</TableCell> </TableCell>
<TableCell> <TableCell>
<AttachmentsCell row={row} /> <AttachmentsCell row={row} onOpenAttachment={onOpenAttachment} />
</TableCell> </TableCell>
<TableCell>{row.requestedByVendorName || "—"}</TableCell> <TableCell>{row.requestedByVendorName || "—"}</TableCell>
<TableCell> <TableCell>
@ -209,9 +228,14 @@ function UpliftApprovalRow({
</TableCell> </TableCell>
<TableCell> <TableCell>
{tab === "pending" ? ( {tab === "pending" ? (
<PendingRowActions row={row} onApprove={onApprove} onReject={onReject} /> <PendingRowActions
row={row}
onApprove={onApprove}
onReject={onReject}
isDecisionPending={isDecisionPending}
/>
) : ( ) : (
<ApprovedRowActions row={row} onRevoke={onRevoke} /> <ApprovedRowActions row={row} onRevoke={onRevoke} isDecisionPending={isDecisionPending} />
)} )}
</TableCell> </TableCell>
</TableRow> </TableRow>
@ -238,6 +262,8 @@ export function UpliftApprovalsTable({
onApprove, onApprove,
onReject, onReject,
onRevoke, onRevoke,
onOpenAttachment,
isDecisionPending,
}: { }: {
tab: UpliftApprovalTab; tab: UpliftApprovalTab;
isLoading: boolean; isLoading: boolean;
@ -246,6 +272,8 @@ export function UpliftApprovalsTable({
onApprove: (row: UpliftQueueItem) => void; onApprove: (row: UpliftQueueItem) => void;
onReject: (row: UpliftQueueItem) => void; onReject: (row: UpliftQueueItem) => void;
onRevoke: (row: UpliftQueueItem) => void; onRevoke: (row: UpliftQueueItem) => void;
onOpenAttachment: (row: UpliftQueueItem) => void;
isDecisionPending: boolean;
}) { }) {
return ( return (
<TableContainer component={Paper} variant="outlined"> <TableContainer component={Paper} variant="outlined">
@ -293,6 +321,8 @@ export function UpliftApprovalsTable({
onApprove={onApprove} onApprove={onApprove}
onReject={onReject} onReject={onReject}
onRevoke={onRevoke} onRevoke={onRevoke}
onOpenAttachment={onOpenAttachment}
isDecisionPending={isDecisionPending}
/> />
)) ))
)} )}

View file

@ -1,5 +1,6 @@
import { useMemo, useState } from "react"; import { useMemo, useState } from "react";
import { Alert, Box, Chip, Stack, Tab, Tabs, TablePagination } from "@mui/material"; import { Alert, Box, Chip, Stack, Tab, Tabs, TablePagination } from "@mui/material";
import { toast } from "react-toastify";
import { RejectDialog } from "@/app/(protected)/uplifts/_components/reject-dialog"; import { RejectDialog } from "@/app/(protected)/uplifts/_components/reject-dialog";
import { RevokeDialog } from "@/app/(protected)/uplifts/_components/revoke-dialog"; import { RevokeDialog } from "@/app/(protected)/uplifts/_components/revoke-dialog";
import { import {
@ -11,6 +12,7 @@ import { formatUpliftMoney } from "@/app/(protected)/uplifts/_components/format-
import type { UpliftQueueItem } from "@/domain/uplifts/types/uplift"; import type { UpliftQueueItem } from "@/domain/uplifts/types/uplift";
import { useUpliftApprovalController } from "@/domain/uplifts/use-cases/use-uplift-approval-controller"; import { useUpliftApprovalController } from "@/domain/uplifts/use-cases/use-uplift-approval-controller";
import { useUpliftsQueue } from "@/domain/uplifts/use-cases/use-uplifts-queue"; import { useUpliftsQueue } from "@/domain/uplifts/use-cases/use-uplifts-queue";
import { upliftsApi } from "@/domain/uplifts/api/uplifts-api";
import { Text } from "@/components/ui/text"; import { Text } from "@/components/ui/text";
const PAGE_SIZE = 25; const PAGE_SIZE = 25;
@ -38,7 +40,10 @@ function UpliftApprovalsHeader({ pendingExposureTotal }: { pendingExposureTotal:
allowance. allowance.
</Text> </Text>
<Text when={pendingExposureTotal != null} variant="caption" tone="muted"> <Text when={pendingExposureTotal != null} variant="caption" tone="muted">
Pending exposure: {formatUpliftMoney(pendingExposureTotal ?? 0)} Pending exposure:{" "}
{pendingExposureTotal && pendingExposureTotal > 0
? formatUpliftMoney(pendingExposureTotal)
: "—"}
</Text> </Text>
</Box> </Box>
); );
@ -156,6 +161,16 @@ export default function UpliftQueuePage() {
} }
}; };
const handleOpenAttachment = (row: UpliftQueueItem) => {
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.",
);
});
};
return ( return (
<Box className="flex flex-col gap-4 p-4"> <Box className="flex flex-col gap-4 p-4">
<UpliftApprovalsHeader pendingExposureTotal={pendingExposureTotal} /> <UpliftApprovalsHeader pendingExposureTotal={pendingExposureTotal} />
@ -169,6 +184,8 @@ export default function UpliftQueuePage() {
onApprove={handleApprove} onApprove={handleApprove}
onReject={handleRejectRequest} onReject={handleRejectRequest}
onRevoke={handleRevokeRequest} onRevoke={handleRevokeRequest}
onOpenAttachment={handleOpenAttachment}
isDecisionPending={approvePending || rejectPending || revokePending}
/> />
<UpliftApprovalsPagination <UpliftApprovalsPagination
tab={tab} tab={tab}

View file

@ -91,7 +91,7 @@ export const upliftsApi = {
}, },
revoke: async (id: string | number, reason: string): Promise<void> => { revoke: async (id: string | number, reason: string): Promise<void> => {
await apiPost<unknown>(`${API_PATHS.rest.uplifts}/${id}/revoke`, { reason }); await apiPost<unknown>(`${API_PATHS.rest.uplifts}/${id}/revoke`, { note: reason });
}, },
downloadEvidence: async ( downloadEvidence: async (

View file

@ -57,7 +57,13 @@ export function mapUpliftRequest(raw: unknown): UpliftRequest {
delta: readNumber(item, "delta", "Delta") ?? 0, delta: readNumber(item, "delta", "Delta") ?? 0,
vendorReason: readString(item, "vendorReason", "VendorReason"), vendorReason: readString(item, "vendorReason", "VendorReason"),
requestedAt: readString(item, "requestedAt", "RequestedAt"), requestedAt: readString(item, "requestedAt", "RequestedAt"),
requestedByVendorName: readString(item, "requestedByVendorName", "RequestedByVendorName"), requestedByVendorName: readString(
item,
"requestedByName",
"RequestedByName",
"requestedByVendorName",
"RequestedByVendorName",
),
decidedAt: readString(item, "decidedAt", "DecidedAt"), decidedAt: readString(item, "decidedAt", "DecidedAt"),
decidedByName: readString(item, "decidedByName", "DecidedByName"), decidedByName: readString(item, "decidedByName", "DecidedByName"),
decisionNote: readString(item, "decisionNote", "DecisionNote"), decisionNote: readString(item, "decisionNote", "DecisionNote"),
@ -88,9 +94,21 @@ export function mapUpliftQueueItem(raw: unknown): UpliftQueueItem {
workOrderId: readOptionalId(item, "workOrderId", "WorkOrderId"), workOrderId: readOptionalId(item, "workOrderId", "WorkOrderId"),
dispatchId: readOptionalId(item, "dispatchId", "DispatchId"), dispatchId: readOptionalId(item, "dispatchId", "DispatchId"),
woNumber: readString(item, "woNumber", "WoNumber", "workOrderNumber", "WorkOrderNumber"), woNumber: readString(item, "woNumber", "WoNumber", "workOrderNumber", "WorkOrderNumber"),
site: readString(item, "site", "Site", "siteCode", "SiteCode", "locationName", "LocationName"), site: readString(
item,
"workOrderSite",
"WorkOrderSite",
"site",
"Site",
"siteCode",
"SiteCode",
"locationName",
"LocationName",
),
serviceName: readString( serviceName: readString(
item, item,
"workOrderService",
"WorkOrderService",
"serviceName", "serviceName",
"ServiceName", "ServiceName",
"service", "service",
@ -107,6 +125,8 @@ export function mapUpliftQueueItem(raw: unknown): UpliftQueueItem {
), ),
approvedOnWoAuto: readNumber( approvedOnWoAuto: readNumber(
item, item,
"workOrderAutoApprovedTotal",
"WorkOrderAutoApprovedTotal",
"approvedOnWoAuto", "approvedOnWoAuto",
"ApprovedOnWoAuto", "ApprovedOnWoAuto",
"autoApprovedTotal", "autoApprovedTotal",
@ -114,6 +134,8 @@ export function mapUpliftQueueItem(raw: unknown): UpliftQueueItem {
), ),
approvedOnWoAdmin: readNumber( approvedOnWoAdmin: readNumber(
item, item,
"workOrderAdminApprovedTotal",
"WorkOrderAdminApprovedTotal",
"approvedOnWoAdmin", "approvedOnWoAdmin",
"ApprovedOnWoAdmin", "ApprovedOnWoAdmin",
"adminApprovedTotal", "adminApprovedTotal",
@ -121,6 +143,8 @@ export function mapUpliftQueueItem(raw: unknown): UpliftQueueItem {
), ),
approvedOnWoTotal: readNumber( approvedOnWoTotal: readNumber(
item, item,
"workOrderApprovedExposureTotal",
"WorkOrderApprovedExposureTotal",
"approvedOnWoTotal", "approvedOnWoTotal",
"ApprovedOnWoTotal", "ApprovedOnWoTotal",
"approvedOnWorkOrderTotal", "approvedOnWorkOrderTotal",

View file

@ -44,11 +44,24 @@ function invalidateWorkOrderUpliftQueries(queryClient: ReturnType<typeof useQuer
export interface ApproveUpliftInput { export interface ApproveUpliftInput {
id: string | number; id: string | number;
note?: string; note?: string;
amount?: number;
woNumber?: string;
} }
export interface DecisionUpliftInput { export interface DecisionUpliftInput {
id: string | number; id: string | number;
note: string; 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( export function useApproveUplift(
@ -57,11 +70,12 @@ export function useApproveUplift(
const queryClient = useQueryClient(); const queryClient = useQueryClient();
return useMutation({ return useMutation({
mutationFn: ({ id, note }: { id: string | number; note?: string }) => mutationFn: ({ id, note }: ApproveUpliftInput) => upliftsApi.approve(id, note),
upliftsApi.approve(id, note), onSuccess: (_, variables) => {
onSuccess: () => {
invalidateUpliftQueries(queryClient, dispatchId); invalidateUpliftQueries(queryClient, dispatchId);
toast.success("Uplift approved — NTE updated"); toast.success(
`Uplift approved — ${decisionAmount(variables.amount)} on ${decisionWorkOrder(variables.woNumber)}`,
);
}, },
onError: (error: Error) => { onError: (error: Error) => {
toast.error(error.message || "Failed to approve uplift"); toast.error(error.message || "Failed to approve uplift");
@ -75,11 +89,12 @@ export function useRejectUplift(
const queryClient = useQueryClient(); const queryClient = useQueryClient();
return useMutation({ return useMutation({
mutationFn: ({ id, note }: { id: string | number; note: string }) => mutationFn: ({ id, note }: DecisionUpliftInput) => upliftsApi.reject(id, note),
upliftsApi.reject(id, note), onSuccess: (_, variables) => {
onSuccess: () => {
invalidateUpliftQueries(queryClient, dispatchId); invalidateUpliftQueries(queryClient, dispatchId);
toast.success("Uplift rejected"); toast.success(
`Uplift rejected — ${decisionAmount(variables.amount)} on ${decisionWorkOrder(variables.woNumber)}`,
);
}, },
onError: (error: Error) => { onError: (error: Error) => {
toast.error(error.message || "Failed to reject uplift"); toast.error(error.message || "Failed to reject uplift");
@ -108,6 +123,8 @@ export function useRequestChangesUplift(
export interface RevokeUpliftInput { export interface RevokeUpliftInput {
upliftId: string | number; upliftId: string | number;
reason: string; reason: string;
amount?: number;
woNumber?: string;
} }
export function useRevokeUplift(): UseMutationResult<void, Error, RevokeUpliftInput> { export function useRevokeUplift(): UseMutationResult<void, Error, RevokeUpliftInput> {
@ -115,9 +132,11 @@ export function useRevokeUplift(): UseMutationResult<void, Error, RevokeUpliftIn
return useMutation({ return useMutation({
mutationFn: ({ upliftId, reason }: RevokeUpliftInput) => upliftsApi.revoke(upliftId, reason), mutationFn: ({ upliftId, reason }: RevokeUpliftInput) => upliftsApi.revoke(upliftId, reason),
onSuccess: () => { onSuccess: (_, variables) => {
invalidateWorkOrderUpliftQueries(queryClient); invalidateWorkOrderUpliftQueries(queryClient);
toast.success("Uplift revoked"); toast.success(
`Uplift revoked — ${decisionAmount(variables.amount)} on ${decisionWorkOrder(variables.woNumber)}`,
);
}, },
onError: (error: Error) => { onError: (error: Error) => {
toast.error(error.message || "Failed to revoke uplift"); toast.error(error.message || "Failed to revoke uplift");

View file

@ -15,7 +15,7 @@ export function useUpliftApprovalController() {
const revokeUplift = useRevokeUplift(); const revokeUplift = useRevokeUplift();
const handleApprove = (row: UpliftQueueItem) => { const handleApprove = (row: UpliftQueueItem) => {
approveUplift.mutate({ id: row.id }); approveUplift.mutate({ id: row.id, amount: row.delta, woNumber: row.woNumber });
setDetailItem(null); setDetailItem(null);
}; };
@ -32,7 +32,12 @@ export function useUpliftApprovalController() {
const handleRejectConfirm = (reason: string) => { const handleRejectConfirm = (reason: string) => {
if (!rejectTarget) return; if (!rejectTarget) return;
rejectUplift.mutate( rejectUplift.mutate(
{ id: rejectTarget.id, note: reason }, {
id: rejectTarget.id,
note: reason,
amount: rejectTarget.delta,
woNumber: rejectTarget.woNumber,
},
{ onSuccess: () => setRejectTarget(null) }, { onSuccess: () => setRejectTarget(null) },
); );
}; };
@ -40,7 +45,12 @@ export function useUpliftApprovalController() {
const handleRevokeConfirm = (reason: string) => { const handleRevokeConfirm = (reason: string) => {
if (!revokeTarget) return; if (!revokeTarget) return;
revokeUplift.mutate( revokeUplift.mutate(
{ upliftId: revokeTarget.id, reason }, {
upliftId: revokeTarget.id,
reason,
amount: revokeTarget.delta,
woNumber: revokeTarget.woNumber,
},
{ onSuccess: () => setRevokeTarget(null) }, { onSuccess: () => setRevokeTarget(null) },
); );
}; };

View file

@ -93,7 +93,7 @@ describe("Uplift Approvals decision flow", () => {
renderWithProviders(<UpliftQueuePage />); renderWithProviders(<UpliftQueuePage />);
fireEvent.click(screen.getByRole("button", { name: "Approve" })); fireEvent.click(screen.getByRole("button", { name: "Approve" }));
expect(approveMutate).toHaveBeenCalledWith({ id: 41 }); expect(approveMutate).toHaveBeenCalledWith({ id: 41, amount: 150, woNumber: "WO-99" });
expect(screen.queryByRole("button", { name: "Confirm" })).not.toBeInTheDocument(); expect(screen.queryByRole("button", { name: "Confirm" })).not.toBeInTheDocument();
expect(screen.queryByRole("dialog")).not.toBeInTheDocument(); expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
}); });

View file

@ -25,7 +25,7 @@ describe("upliftsApi", () => {
await upliftsApi.revoke(42, "Scope was already covered"); await upliftsApi.revoke(42, "Scope was already covered");
expect(apiPost).toHaveBeenCalledWith("uplifts/42/revoke", { expect(apiPost).toHaveBeenCalledWith("uplifts/42/revoke", {
reason: "Scope was already covered", note: "Scope was already covered",
}); });
}); });
}); });

View file

@ -64,18 +64,20 @@ describe("mapUpliftQueueItem", () => {
const result = mapUpliftQueueItem({ const result = mapUpliftQueueItem({
id: 1, id: 1,
WoNumber: "WO-9", WoNumber: "WO-9",
Site: "Site A", WorkOrderSite: "Site A",
ServiceName: "Plumbing", WorkOrderService: "Plumbing",
RequestedByName: "Pat Approver",
AttachmentCount: 3, AttachmentCount: 3,
ApprovedOnWoAuto: 100, WorkOrderAutoApprovedTotal: 100,
ApprovedOnWoAdmin: 250, WorkOrderAdminApprovedTotal: 250,
ApprovedOnWoTotal: 350, WorkOrderApprovedExposureTotal: 350,
WorkOrderClosed: true, WorkOrderClosed: true,
}); });
expect(result.woNumber).toBe("WO-9"); expect(result.woNumber).toBe("WO-9");
expect(result.site).toBe("Site A"); expect(result.site).toBe("Site A");
expect(result.serviceName).toBe("Plumbing"); expect(result.serviceName).toBe("Plumbing");
expect(result.requestedByVendorName).toBe("Pat Approver");
expect(result.attachmentCount).toBe(3); expect(result.attachmentCount).toBe(3);
expect(result.approvedOnWoAuto).toBe(100); expect(result.approvedOnWoAuto).toBe(100);
expect(result.approvedOnWoAdmin).toBe(250); expect(result.approvedOnWoAdmin).toBe(250);