Merge branch 'dev' into fix/ab/sh-321-wizard-vendor-notes

This commit is contained in:
Alexandre Brandizzi 2026-09-17 14:48:19 -03:00 • committed by GitHub
commit 318859fb1b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
24 changed files with 2153 additions and 523 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,21 +284,29 @@ 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;
}
await fulfillJson(route, {
total: 2,
items: [
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",
@ -316,6 +324,7 @@ test("internal approver can approve and request changes with audited notes", asy
{
id: 42,
dispatchId: 8,
workOrderId: 502,
dispatchNumber: "DSP-42",
poNumber: "PO-42",
vendorCompanyName: "Metro HVAC",
@ -328,29 +337,42 @@ test("internal approver can approve and request changes with audited notes", asy
evidenceDocumentId: 23,
evidenceFileName: "quote.pdf",
},
],
].filter((item) => requestedStatus == null || item.status === requestedStatus);
await fulfillJson(route, {
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,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>
</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
<Tabs value={tab} onChange={onChange}>
<Tab
value="pending"
label={
<Stack direction="row" spacing={1} sx={{ alignItems: "center" }}>
<span>Pending</span>
<Chip
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>
);
}
function UpliftQueueTable({
isLoading,
rows,
onViewWorkOrder,
onDownloadEvidence,
onDecide,
}: {
isLoading: boolean;
rows: UpliftQueueItem[];
onViewWorkOrder: (row: UpliftQueueItem) => void;
onDownloadEvidence: (row: UpliftQueueItem) => void;
onDecide: (type: UpliftActionType, row: UpliftQueueItem) => 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}
label={pendingCount}
aria-label={`${pendingCount} pending uplifts`}
/>
))
)}
</TableBody>
</Table>
</TableContainer>
</Stack>
}
/>
<Tab value="approved" label="Approved" />
</Tabs>
);
}
function UpliftDecisionDialog({
action,
note,
noteRequired,
decisionPending,
onNoteChange,
onClose,
onConfirm,
function UpliftApprovalsPagination({
tab,
count,
pendingPage,
approvedPage,
onPendingPageChange,
onApprovedPageChange,
}: {
action: UpliftDecision | null;
note: string;
noteRequired: boolean;
decisionPending: boolean;
onNoteChange: (value: string) => void;
onClose: () => void;
onConfirm: () => void;
tab: UpliftApprovalTab;
count: number;
pendingPage: number;
approvedPage: number;
onPendingPageChange: (page: number) => void;
onApprovedPageChange: (page: number) => void;
}) {
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)}
<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]}
/>
</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>
);
}
function UpliftQueueError({ error }: { error: unknown }) {
if (!error) return null;
return (
<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]}
<UpliftApprovalsPagination
tab={tab}
count={activeQuery.data?.totalCount ?? 0}
pendingPage={pendingPage}
approvedPage={approvedPage}
onPendingPageChange={setPendingPage}
onApprovedPageChange={setApprovedPage}
/>
<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}
{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

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

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

@ -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,9 +1,12 @@
import { MutationCache, QueryCache, QueryClient } from "@tanstack/react-query";
import { toast } from "react-toastify";
export const queryClient = new QueryClient({
export function createAppQueryClient(): QueryClient {
return new QueryClient({
queryCache: new QueryCache({
onError: (error) => {
onError: (error, query) => {
if (query.meta?.suppressErrorToast) return;
console.error("Query error:", error);
const message =
error instanceof Error ? error.message : "An error occurred while fetching data";
@ -29,5 +32,8 @@ export const queryClient = new QueryClient({
},
},
});
}
export const queryClient = createAppQueryClient();
export default queryClient;

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

@ -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,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.");
});
});