From 3f98ac380f63093c13884f63ad3eaacfd8970d67 Mon Sep 17 00:00:00 2001 From: Codex Review Integration Date: Wed, 16 Sep 2026 20:43:12 -0300 Subject: [PATCH] feat(uplifts): deliver approval queue experience (SH-207) --- .../_components/uplift-approvals-table.tsx | 303 ++++++++++ .../_components/uplift-detail-modal.tsx | 364 ++++++++++++ src/app/(protected)/uplifts/index.tsx | 560 +++++------------- src/domain/uplifts/api/uplifts-api.ts | 15 +- src/domain/uplifts/mappers/uplift-mapper.ts | 67 +++ src/domain/uplifts/types/uplift.ts | 9 + .../uplifts/use-cases/use-uplift-actions.ts | 27 + .../uplift-approvals-approved-tab.test.tsx | 137 +++++ .../uplifts/uplift-detail-modal.test.tsx | 121 ++++ .../uplift-queue-decision-flow.test.tsx | 66 ++- .../uplifts/mappers/uplift-mapper.test.ts | 70 ++- 11 files changed, 1306 insertions(+), 433 deletions(-) create mode 100644 src/app/(protected)/uplifts/_components/uplift-approvals-table.tsx create mode 100644 src/app/(protected)/uplifts/_components/uplift-detail-modal.tsx create mode 100644 src/test/app/(protected)/uplifts/uplift-approvals-approved-tab.test.tsx create mode 100644 src/test/app/(protected)/uplifts/uplift-detail-modal.test.tsx 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 ( + + + Uplift request + + {item.woNumber || item.dispatchNumber || "Work order"} · {item.status} + + + + + + + + + + + + + + + + + ); +} + +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 ( - - - {action?.type === "approve" - ? `Approve uplift to ${formatMoney(action.item.requestedNTE)}?` - : "Request Changes"} - - - {action?.type === "approve" && ( - - {formatMoney(action.item.currentNTE)} → {formatMoney(action.item.requestedNTE)} - - )} - onNoteChange(e.target.value)} - /> - - - - - - - ); +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(); + }); });