diff --git a/src/app/(protected)/uplifts/_components/uplift-approvals-table.tsx b/src/app/(protected)/uplifts/_components/uplift-approvals-table.tsx
new file mode 100644
index 00000000..7eed68c7
--- /dev/null
+++ b/src/app/(protected)/uplifts/_components/uplift-approvals-table.tsx
@@ -0,0 +1,303 @@
+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 type { UpliftQueueItem } from "@/domain/uplifts/types/uplift";
+import { 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.";
+
+function workOrderLabel(row: UpliftQueueItem): string {
+ return row.woNumber || row.dispatchNumber || "—";
+}
+
+function WaitingCell({ row }: { row: UpliftQueueItem }) {
+ if (!row.requestedAt) {
+ return (
+
+ —
+
+ );
+ }
+ const color = waitTimeColor(row.requestedAt);
+ const className = getWaitTimeTextClass(color);
+ return (
+
+ {timeSince(row.requestedAt)}
+
+ );
+}
+
+function AttachmentsCell({ row }: { row: UpliftQueueItem }) {
+ if (row.evidenceDocumentId == null && !row.evidenceFileName) {
+ return (
+
+ —
+
+ );
+ }
+ const extraCount =
+ typeof row.attachmentCount === "number" && row.attachmentCount > 1
+ ? row.attachmentCount - 1
+ : 0;
+ return (
+
+
+ {extraCount > 0 && }
+
+ );
+}
+
+function PendingRowActions({
+ row,
+ onApprove,
+ onReject,
+}: {
+ row: UpliftQueueItem;
+ onApprove: (row: UpliftQueueItem) => void;
+ onReject: (row: UpliftQueueItem) => void;
+}) {
+ const tooltip = row.canDecide ? "" : `Requires Tier ${row.requiredTier} role`;
+ return (
+ e.stopPropagation()}>
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
+
+function ApprovedRowActions({
+ row,
+ onRevoke,
+}: {
+ row: UpliftQueueItem;
+ onRevoke: (row: UpliftQueueItem) => void;
+}) {
+ const closed = row.workOrderClosed === true;
+ return (
+ e.stopPropagation()}>
+
+
+
+
+
+
+ );
+}
+
+function UpliftApprovalRow({
+ row,
+ tab,
+ onOpenDetail,
+ onApprove,
+ onReject,
+ onRevoke,
+}: {
+ row: UpliftQueueItem;
+ tab: UpliftApprovalTab;
+ onOpenDetail: (row: UpliftQueueItem) => void;
+ onApprove: (row: UpliftQueueItem) => void;
+ onReject: (row: UpliftQueueItem) => void;
+ onRevoke: (row: UpliftQueueItem) => void;
+}) {
+ return (
+ onOpenDetail(row)}
+ onKeyDown={(e) => {
+ if (e.key === "Enter") onOpenDetail(row);
+ }}
+ tabIndex={0}
+ aria-label={`Open uplift details for ${workOrderLabel(row)}`}
+ >
+
+
+
+ {workOrderLabel(row)}
+
+ {Boolean(row.site) && (
+
+ {row.site}
+
+ )}
+
+
+
+
+ {Boolean(row.serviceName) && (
+
+ {row.serviceName}
+
+ )}
+
+ {row.vendorCompanyName || "—"}
+
+
+
+
+
+ {formatUpliftMoney(row.requestedNTE)}
+
+
+
+ {row.approvedOnWoTotal != null ? (
+ {formatUpliftMoney(row.approvedOnWoTotal)}
+ ) : (
+
+ —
+
+ )}
+
+
+
+ {row.vendorReason}
+
+
+
+
+
+ {row.requestedByVendorName || "—"}
+
+
+
+
+ {tab === "pending" ? (
+
+ ) : (
+
+ )}
+
+
+ );
+}
+
+const HEADER_CELLS = [
+ "Work Order",
+ "Service / Vendor",
+ "Amount",
+ "Approved on WO",
+ "Notes",
+ "Attachments",
+ "Requested By",
+ "Waiting",
+ "Actions",
+] as const;
+
+export function UpliftApprovalsTable({
+ tab,
+ isLoading,
+ rows,
+ onOpenDetail,
+ onApprove,
+ onReject,
+ onRevoke,
+}: {
+ tab: UpliftApprovalTab;
+ isLoading: boolean;
+ rows: UpliftQueueItem[];
+ onOpenDetail: (row: UpliftQueueItem) => void;
+ onApprove: (row: UpliftQueueItem) => void;
+ onReject: (row: UpliftQueueItem) => void;
+ onRevoke: (row: UpliftQueueItem) => void;
+}) {
+ return (
+
+
+
+
+ {HEADER_CELLS.map((label) => (
+
+ {label}
+
+ ))}
+
+
+
+ {isLoading ? (
+
+
+
+
+
+ ) : rows.length === 0 ? (
+
+
+ {tab === "pending" ? (
+
+ No uplifts waiting for approval.
+
+ ) : (
+
+ No approved uplifts yet
+
+ Uplifts you approve are archived here for reference.
+
+
+ )}
+
+
+ ) : (
+ rows.map((row) => (
+
+ ))
+ )}
+
+
+
+ );
+}
diff --git a/src/app/(protected)/uplifts/_components/uplift-detail-modal.tsx b/src/app/(protected)/uplifts/_components/uplift-detail-modal.tsx
new file mode 100644
index 00000000..22939a72
--- /dev/null
+++ b/src/app/(protected)/uplifts/_components/uplift-detail-modal.tsx
@@ -0,0 +1,364 @@
+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 { 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.";
+
+type ModalCallbacks = {
+ onClose: () => void;
+ onApprove: (row: UpliftQueueItem) => void;
+ onReject: (row: UpliftQueueItem) => void;
+ onRevoke: (row: UpliftQueueItem) => void;
+};
+
+function DetailField({ label, children }: { label: string; children: ReactNode }) {
+ return (
+
+
+ {label}
+
+ {children}
+
+ );
+}
+
+function DetailSection({ title, children }: { title: string; children: ReactNode }) {
+ return (
+
+
+ {title}
+
+ {children}
+
+ );
+}
+
+function UpliftAttachments({ item }: { item: UpliftQueueItem }) {
+ if (item.evidenceDocumentId == null && !item.evidenceFileName) {
+ return (
+
+ No attachments
+
+ );
+ }
+ const extraCount =
+ typeof item.attachmentCount === "number" && item.attachmentCount > 1
+ ? item.attachmentCount - 1
+ : 0;
+ return (
+
+
+ {extraCount > 0 && }
+
+ );
+}
+
+function WorkOrderSection({ item, info }: { item: UpliftQueueItem; info?: WorkOrderTableRow }) {
+ return (
+
+
+ {item.woNumber || "—"}
+
+
+ {info?.site || item.site || "—"}
+
+
+ {info?.status || "—"}
+
+
+ {info?.dueDate || "Unscheduled"}
+
+
+ {item.vendorCompanyName || "—"}
+
+
+ );
+}
+
+function RequestSection({ item }: { item: UpliftQueueItem }) {
+ const waitingClass = item.requestedAt
+ ? getWaitTimeTextClass(waitTimeColor(item.requestedAt))
+ : undefined;
+ return (
+
+
+
+ {formatUpliftMoney(item.currentNTE ?? 0)} → {formatUpliftMoney(item.requestedNTE)}
+
+
+
+ {item.requestedByVendorName || "—"}
+
+
+
+ {item.requestedAt ? timeSince(item.requestedAt) : "—"}
+
+
+
+ {item.vendorReason || "No justification provided."}
+
+
+
+
+
+ );
+}
+
+function ApprovedOnWoBreakdown({
+ auto,
+ admin,
+ total,
+}: {
+ auto: number | null;
+ admin: number | null;
+ total: number | null;
+}) {
+ 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 (
+
+ {rows.map((row) => (
+
+ ))}
+
+ );
+}
+
+function BreakdownRow({
+ label,
+ value,
+ strong,
+}: {
+ label: string;
+ value: number | null;
+ strong: boolean;
+}) {
+ return (
+
+
+ {label}
+
+
+ {value != null ? formatUpliftMoney(value) : "—"}
+
+
+ );
+}
+
+function PendingModalActions({
+ item,
+ approvePending,
+ onApprove,
+ onReject,
+}: {
+ item: UpliftQueueItem;
+ approvePending: boolean;
+ onApprove: (row: UpliftQueueItem) => void;
+ onReject: (row: UpliftQueueItem) => void;
+}) {
+ const tooltip = item.canDecide ? "" : `Requires Tier ${item.requiredTier} role`;
+ return (
+ <>
+
+
+
+
+
+
+
+
+
+
+ >
+ );
+}
+
+function UpliftModalActions({
+ item,
+ closed,
+ approvePending,
+ revokePending,
+ onApprove,
+ onReject,
+ onRevoke,
+}: {
+ item: UpliftQueueItem;
+ closed: boolean;
+ approvePending: boolean;
+ revokePending: boolean;
+ onApprove: (row: UpliftQueueItem) => void;
+ onReject: (row: UpliftQueueItem) => void;
+ onRevoke: (row: UpliftQueueItem) => void;
+}) {
+ return (
+ <>
+ {item.status === "Pending" && (
+
+ )}
+ {item.status === "Approved" && (
+
+
+
+
+
+ )}
+ >
+ );
+}
+
+export function UpliftDetailModal({
+ item,
+ approvePending,
+ revokePending,
+ onClose,
+ onApprove,
+ onReject,
+ onRevoke,
+}: {
+ item: UpliftQueueItem | null;
+ approvePending: boolean;
+ revokePending: boolean;
+} & ModalCallbacks) {
+ const open = item != null;
+ const boardDetail = useWorkOrderBoardDetail(
+ item != null && item.workOrderId != null ? item.workOrderId : undefined,
+ );
+ const woUpliftsQuery = useWorkOrderUplifts(
+ item != null && item.workOrderId != null ? item.workOrderId : null,
+ );
+
+ 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 (
+
+ );
+}
+
+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);
+}
diff --git a/src/app/(protected)/uplifts/index.tsx b/src/app/(protected)/uplifts/index.tsx
index 382214d2..3c133e7e 100644
--- a/src/app/(protected)/uplifts/index.tsx
+++ b/src/app/(protected)/uplifts/index.tsx
@@ -1,472 +1,192 @@
import { useMemo, useState } from "react";
-import { useNavigate } from "react-router";
+import { Alert, Box, Chip, Stack, Tab, Tabs, TablePagination } from "@mui/material";
+import { RejectDialog } from "@/app/(protected)/uplifts/_components/reject-dialog";
+import { RevokeDialog } from "@/app/(protected)/uplifts/_components/revoke-dialog";
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";
+ UpliftApprovalsTable,
+ type UpliftApprovalTab,
+} from "@/app/(protected)/uplifts/_components/uplift-approvals-table";
+import { UpliftDetailModal } from "@/app/(protected)/uplifts/_components/uplift-detail-modal";
+import { formatUpliftMoney } from "@/app/(protected)/uplifts/_components/format-uplift-money";
import type { UpliftQueueItem } from "@/domain/uplifts/types/uplift";
-import { UPLIFT_STATUSES } from "@/domain/uplifts/types/uplift";
import {
useApproveUplift,
useRejectUplift,
- useRequestChangesUplift,
+ useRevokeUplift,
} from "@/domain/uplifts/use-cases/use-uplift-actions";
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;
-}) {
- return (
-
-
- Uplift Queue
-
-
- {totalCount} total
-
- {isFetching && !isLoading && }
-
- );
-}
-
-function UpliftQueueFilters({
- status,
- tier,
- onStatusChange,
- onTierChange,
-}: {
- status: string;
- tier: string;
- onStatusChange: (value: string) => void;
- onTierChange: (value: string) => void;
-}) {
- return (
-
-
- Status
-
-
-
- Tier
-
-
-
- );
-}
-
-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 (
-
- {row.dispatchNumber}
- {row.poNumber}
- {row.vendorCompanyName}
- {formatMoney(row.currentNTE)}
- {formatMoney(row.requestedNTE)}
-
-
-
-
-
-
-
- {row.evidenceDocumentId != null ? (
-
- {row.evidenceFileName}
- {Boolean(row.evidenceContentType) && (
-
- {row.evidenceContentType} · {formatBytes(row.evidenceSizeBytes)}
-
- )}
-
-
- ) : (
-
- —
-
- )}
-
- {row.requestedAt ? formatDateTime(row.requestedAt) : ""}
-
-
- {Boolean(row.workOrderId) && (
-
- )}
- {decisionable && (
- <>
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- >
- )}
-
-
-
- );
-}
-
-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 (
-
-
-
-
- Dispatch
- PO
- Vendor
- Current NTE
- Requested
- Tier
- Status
- Evidence
- Requested At
- Actions
-
-
-
- {isLoading ? (
-
-
-
-
-
- ) : rows.length === 0 ? (
-
-
-
- No uplift requests match these filters.
-
-
-
- ) : (
- rows.map((row) => (
-
- ))
- )}
-
-
-
- );
-}
-
-function UpliftDecisionDialog({
- action,
- note,
- noteRequired,
- decisionPending,
- onNoteChange,
- onClose,
- onConfirm,
-}: {
- action: UpliftDecision | null;
- note: string;
- noteRequired: boolean;
- decisionPending: boolean;
- onNoteChange: (value: string) => void;
- onClose: () => void;
- onConfirm: () => void;
-}) {
- return (
-
- );
+function sortRows(rows: UpliftQueueItem[], tab: UpliftApprovalTab): UpliftQueueItem[] {
+ const copy = [...rows];
+ if (tab === "pending") {
+ copy.sort((a, b) =>
+ a.requestedAt < b.requestedAt ? -1 : a.requestedAt > b.requestedAt ? 1 : 0,
+ );
+ } else {
+ copy.sort((a, b) => (a.decidedAt > b.decidedAt ? -1 : a.decidedAt < b.decidedAt ? 1 : 0));
+ }
+ return copy;
}
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(null);
- const [note, setNote] = useState("");
+ const [tab, setTab] = useState("pending");
+ const [pendingPage, setPendingPage] = useState(0);
+ const [approvedPage, setApprovedPage] = useState(0);
+ const [detailItem, setDetailItem] = useState(null);
+ const [rejectTarget, setRejectTarget] = useState(null);
+ const [revokeTarget, setRevokeTarget] = useState(null);
- const listParams = useMemo(
- () => ({ status, tier, page: page + 1, pageSize }),
- [status, tier, page, pageSize],
- );
+ const pendingQuery = useUpliftsQueue({
+ status: "Pending",
+ page: pendingPage + 1,
+ pageSize: PAGE_SIZE,
+ });
+ const approvedQuery = useUpliftsQueue({
+ status: "Approved",
+ page: approvedPage + 1,
+ pageSize: PAGE_SIZE,
+ });
- const { data, isLoading, isFetching, error, refetch } = useUpliftsQueue(listParams);
const approveUplift = useApproveUplift();
const rejectUplift = useRejectUplift();
- const requestChangesUplift = useRequestChangesUplift();
+ const revokeUplift = useRevokeUplift();
- 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 activeQuery = tab === "pending" ? pendingQuery : approvedQuery;
+ const rows = useMemo(() => sortRows(activeQuery.data?.items ?? [], tab), [activeQuery.data, tab]);
+ const pendingCount = pendingQuery.data?.totalCount ?? 0;
+ const pendingExposureTotal = pendingQuery.data?.pendingExposureTotal ?? null;
- const handleStatusChange = (value: string) => {
- setStatus(value);
- setPage(0);
- };
- const handleTierChange = (value: string) => {
- setTier(value);
- setPage(0);
+ const closeDetail = () => setDetailItem(null);
+
+ const handleApprove = (row: UpliftQueueItem) => {
+ approveUplift.mutate({ id: row.id });
+ setDetailItem(null);
};
- const closeDialog = () => {
- setUpliftAction(null);
- setNote("");
+ const handleRejectRequest = (row: UpliftQueueItem) => {
+ setDetailItem(null);
+ setRejectTarget(row);
};
- 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;
- }
- if (upliftAction.type === "request-changes") {
- requestChangesUplift.mutate({ id, note: note.trim() }, { onSuccess: onDone });
- }
+ const handleRevokeRequest = (row: UpliftQueueItem) => {
+ setDetailItem(null);
+ setRevokeTarget(row);
};
+ const closeReject = () => setRejectTarget(null);
+ const closeRevoke = () => setRevokeTarget(null);
+
const handleRejectConfirm = (reason: string) => {
- if (upliftAction?.type !== "reject") return;
+ if (!rejectTarget) return;
rejectUplift.mutate(
- { id: upliftAction.item.id, note: reason },
- {
- onSuccess: () => {
- closeDialog();
- void refetch();
- },
- },
+ { id: rejectTarget.id, note: reason },
+ { onSuccess: () => setRejectTarget(null) },
);
};
- 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 handleRevokeConfirm = (reason: string) => {
+ if (!revokeTarget?.workOrderId) return;
+ revokeUplift.mutate(
+ {
+ workOrderId: revokeTarget.workOrderId,
+ upliftId: revokeTarget.id,
+ reason,
+ },
+ { onSuccess: () => setRevokeTarget(null) },
+ );
};
- const handleDecide = (type: UpliftActionType, item: UpliftQueueItem) => {
- setUpliftAction({ type, item });
- setNote("");
+ const handleTabChange = (_event: unknown, value: string) => {
+ if (value === "pending" || value === "approved") {
+ setTab(value);
+ }
};
return (
-
-
-
+
+ Uplift Approvals
+
+
+ Requests to increase the approved spend on a work order, above its remaining auto-approval
+ allowance.
+
+
+ Pending exposure: {formatUpliftMoney(pendingExposureTotal ?? 0)}
+
+
+
+
+ Pending
+
+
+ }
/>
-
- {Boolean(error) && (
+
+
+ {Boolean(activeQuery.error) && (
- {error instanceof Error ? error.message : "Failed to load uplift queue"}
+ {activeQuery.error instanceof Error
+ ? activeQuery.error.message
+ : "Failed to load uplift queue"}
)}
- navigate(`/workorders/${r.workOrderId}`)}
- onDownloadEvidence={(r) => void downloadEvidence(r)}
- onDecide={handleDecide}
+ onOpenDetail={setDetailItem}
+ onApprove={handleApprove}
+ onReject={handleRejectRequest}
+ onRevoke={handleRevokeRequest}
/>
setPage(nextPage)}
- rowsPerPage={pageSize}
- rowsPerPageOptions={[25]}
+ count={activeQuery.data?.totalCount ?? 0}
+ page={tab === "pending" ? pendingPage : approvedPage}
+ onPageChange={(_event, nextPage) =>
+ tab === "pending" ? setPendingPage(nextPage) : setApprovedPage(nextPage)
+ }
+ rowsPerPage={PAGE_SIZE}
+ rowsPerPageOptions={[PAGE_SIZE]}
/>
+ {detailItem != null && (
+
+ )}
-
);
diff --git a/src/domain/uplifts/api/uplifts-api.ts b/src/domain/uplifts/api/uplifts-api.ts
index 890d7116..7acd5252 100644
--- a/src/domain/uplifts/api/uplifts-api.ts
+++ b/src/domain/uplifts/api/uplifts-api.ts
@@ -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),
};
},
@@ -85,6 +90,14 @@ export const upliftsApi = {
await apiPost(`${API_PATHS.rest.uplifts}/${id}/request-changes`, { note });
},
+ revoke: async (
+ workOrderId: string | number,
+ upliftId: string | number,
+ reason?: string,
+ ): Promise => {
+ await apiPost(API_PATHS.workOrder.upliftRevoke(workOrderId, upliftId), { reason });
+ },
+
downloadEvidence: async (
id: string | number,
fallbackFileName = "uplift-evidence",
diff --git a/src/domain/uplifts/mappers/uplift-mapper.ts b/src/domain/uplifts/mappers/uplift-mapper.ts
index 2753ef92..5eaca683 100644
--- a/src/domain/uplifts/mappers/uplift-mapper.ts
+++ b/src/domain/uplifts/mappers/uplift-mapper.ts
@@ -39,6 +39,14 @@ function readOptionalId(
return null;
}
+function readOptionalBool(record: Record, ...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 {
@@ -79,9 +87,68 @@ 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, "site", "Site", "siteCode", "SiteCode", "locationName", "LocationName"),
+ serviceName: readString(
+ item,
+ "serviceName",
+ "ServiceName",
+ "service",
+ "Service",
+ "trade",
+ "Trade",
+ ),
+ attachmentCount: readNumber(
+ item,
+ "attachmentCount",
+ "AttachmentCount",
+ "attachmentsCount",
+ "AttachmentsCount",
+ ),
+ approvedOnWoAuto: readNumber(
+ item,
+ "approvedOnWoAuto",
+ "ApprovedOnWoAuto",
+ "autoApprovedTotal",
+ "AutoApprovedTotal",
+ ),
+ approvedOnWoAdmin: readNumber(
+ item,
+ "approvedOnWoAdmin",
+ "ApprovedOnWoAdmin",
+ "adminApprovedTotal",
+ "AdminApprovedTotal",
+ ),
+ approvedOnWoTotal: readNumber(
+ item,
+ "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",
+ );
+}
diff --git a/src/domain/uplifts/types/uplift.ts b/src/domain/uplifts/types/uplift.ts
index 2bd5406e..2e46d805 100644
--- a/src/domain/uplifts/types/uplift.ts
+++ b/src/domain/uplifts/types/uplift.ts
@@ -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;
}
diff --git a/src/domain/uplifts/use-cases/use-uplift-actions.ts b/src/domain/uplifts/use-cases/use-uplift-actions.ts
index 78a2326e..7ecdc4f1 100644
--- a/src/domain/uplifts/use-cases/use-uplift-actions.ts
+++ b/src/domain/uplifts/use-cases/use-uplift-actions.ts
@@ -33,6 +33,11 @@ function invalidateUpliftQueries(
}
}
+function invalidateWorkOrderUpliftQueries(queryClient: ReturnType) {
+ void queryClient.invalidateQueries({ queryKey: queryKeys.uplifts.all });
+ void queryClient.invalidateQueries({ queryKey: queryKeys.workOrders.all });
+}
+
export interface ApproveUpliftInput {
id: string | number;
note?: string;
@@ -96,3 +101,25 @@ export function useRequestChangesUplift(
},
});
}
+
+export interface RevokeUpliftInput {
+ workOrderId: string | number;
+ upliftId: string | number;
+ reason: string;
+}
+
+export function useRevokeUplift(): UseMutationResult {
+ const queryClient = useQueryClient();
+
+ return useMutation({
+ mutationFn: ({ workOrderId, upliftId, reason }: RevokeUpliftInput) =>
+ upliftsApi.revoke(workOrderId, upliftId, reason),
+ onSuccess: () => {
+ invalidateWorkOrderUpliftQueries(queryClient);
+ toast.success("Uplift revoked");
+ },
+ onError: (error: Error) => {
+ toast.error(error.message || "Failed to revoke uplift");
+ },
+ });
+}
diff --git a/src/test/app/(protected)/uplifts/uplift-approvals-approved-tab.test.tsx b/src/test/app/(protected)/uplifts/uplift-approvals-approved-tab.test.tsx
new file mode 100644
index 00000000..c47750fc
--- /dev/null
+++ b/src/test/app/(protected)/uplifts/uplift-approvals-approved-tab.test.tsx
@@ -0,0 +1,137 @@
+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 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 }),
+}));
+
+describe("Uplift Approvals approved tab", () => {
+ beforeEach(() => {
+ revokeMutate.mockReset();
+ 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();
+
+ 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("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();
+
+ 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("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();
+
+ 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();
+ });
+});
diff --git a/src/test/app/(protected)/uplifts/uplift-detail-modal.test.tsx b/src/test/app/(protected)/uplifts/uplift-detail-modal.test.tsx
new file mode 100644
index 00000000..fda63056
--- /dev/null
+++ b/src/test/app/(protected)/uplifts/uplift-detail-modal.test.tsx
@@ -0,0 +1,121 @@
+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 | null }));
+const woUplifts = vi.hoisted(() => ({ data: null as Array> | 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 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 = {}) {
+ const onApprove = vi.fn();
+ const onReject = vi.fn();
+ const onRevoke = vi.fn();
+ renderWithProviders(
+ ,
+ );
+ return { onApprove, onReject, onRevoke };
+}
+
+describe("UpliftDetailModal", () => {
+ beforeEach(() => {
+ 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("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("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();
+ });
+});
diff --git a/src/test/app/(protected)/uplifts/uplift-queue-decision-flow.test.tsx b/src/test/app/(protected)/uplifts/uplift-queue-decision-flow.test.tsx
index afc17983..d90aeed9 100644
--- a/src/test/app/(protected)/uplifts/uplift-queue-decision-flow.test.tsx
+++ b/src/test/app/(protected)/uplifts/uplift-queue-decision-flow.test.tsx
@@ -1,13 +1,17 @@
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 pendingItem: UpliftQueueItem = {
id: 41,
status: "Pending",
@@ -34,11 +38,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 +61,65 @@ 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", () => {
+describe("Uplift Approvals decision flow", () => {
beforeEach(() => {
rejectMutate.mockReset();
approveMutate.mockReset();
- requestChangesMutate.mockReset();
+ revokeMutate.mockReset();
refetch.mockReset();
+ 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();
+
+ 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();
+
+ fireEvent.click(screen.getByRole("button", { name: "Approve" }));
+ expect(approveMutate).toHaveBeenCalledWith({ id: 41 });
+ 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();
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();
- 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();
});
});
diff --git a/src/test/domain/uplifts/mappers/uplift-mapper.test.ts b/src/test/domain/uplifts/mappers/uplift-mapper.test.ts
index 75c5cfde..ece11ec2 100644
--- a/src/test/domain/uplifts/mappers/uplift-mapper.test.ts
+++ b/src/test/domain/uplifts/mappers/uplift-mapper.test.ts
@@ -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,69 @@ 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",
+ Site: "Site A",
+ ServiceName: "Plumbing",
+ AttachmentCount: 3,
+ ApprovedOnWoAuto: 100,
+ ApprovedOnWoAdmin: 250,
+ ApprovedOnWoTotal: 350,
+ WorkOrderClosed: true,
+ });
+
+ expect(result.woNumber).toBe("WO-9");
+ expect(result.site).toBe("Site A");
+ expect(result.serviceName).toBe("Plumbing");
+ 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();
+ });
});