From 3f98ac380f63093c13884f63ad3eaacfd8970d67 Mon Sep 17 00:00:00 2001 From: Codex Review Integration Date: Wed, 16 Sep 2026 20:43:12 -0300 Subject: [PATCH 01/12] 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(); + }); }); From 85024b40f3de3c70482acc7f411fb26cabc67055 Mon Sep 17 00:00:00 2001 From: Codex Review Integration Date: Wed, 16 Sep 2026 20:54:33 -0300 Subject: [PATCH 02/12] fix(uplifts): use admin revoke contract (SH-212) --- src/app/(protected)/uplifts/index.tsx | 215 ++++++++++-------- src/domain/uplifts/api/uplifts-api.ts | 8 +- .../uplifts/use-cases/use-uplift-actions.ts | 4 +- .../use-uplift-approval-controller.ts | 65 ++++++ .../domain/uplifts/api/uplifts-api.test.ts | 31 +++ 5 files changed, 215 insertions(+), 108 deletions(-) create mode 100644 src/domain/uplifts/use-cases/use-uplift-approval-controller.ts create mode 100644 src/test/domain/uplifts/api/uplifts-api.test.ts diff --git a/src/app/(protected)/uplifts/index.tsx b/src/app/(protected)/uplifts/index.tsx index 3c133e7e..8f2f418b 100644 --- a/src/app/(protected)/uplifts/index.tsx +++ b/src/app/(protected)/uplifts/index.tsx @@ -9,11 +9,7 @@ import { 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 { - useApproveUplift, - useRejectUplift, - useRevokeUplift, -} from "@/domain/uplifts/use-cases/use-uplift-actions"; +import { useUpliftApprovalController } from "@/domain/uplifts/use-cases/use-uplift-approval-controller"; import { useUpliftsQueue } from "@/domain/uplifts/use-cases/use-uplifts-queue"; import { Text } from "@/components/ui/text"; @@ -31,13 +27,94 @@ function sortRows(rows: UpliftQueueItem[], tab: UpliftApprovalTab): UpliftQueueI return copy; } +function UpliftApprovalsHeader({ pendingExposureTotal }: { pendingExposureTotal: number | null }) { + return ( + + + Uplift Approvals + + + Requests to increase the approved spend on a work order, above its remaining auto-approval + allowance. + + + Pending exposure: {formatUpliftMoney(pendingExposureTotal ?? 0)} + + + ); +} + +function UpliftApprovalsTabs({ + tab, + pendingCount, + onChange, +}: { + tab: UpliftApprovalTab; + pendingCount: number; + onChange: (_event: unknown, value: string) => void; +}) { + return ( + + + Pending + + + } + /> + + + ); +} + +function UpliftApprovalsPagination({ + tab, + count, + pendingPage, + approvedPage, + onPendingPageChange, + onApprovedPageChange, +}: { + tab: UpliftApprovalTab; + count: number; + pendingPage: number; + approvedPage: number; + onPendingPageChange: (page: number) => void; + onApprovedPageChange: (page: number) => void; +}) { + return ( + + tab === "pending" ? onPendingPageChange(nextPage) : onApprovedPageChange(nextPage) + } + rowsPerPage={PAGE_SIZE} + rowsPerPageOptions={[PAGE_SIZE]} + /> + ); +} + +function UpliftQueueError({ error }: { error: unknown }) { + if (!error) return null; + return ( + + {error instanceof Error ? error.message : "Failed to load uplift queue"} + + ); +} + export default function UpliftQueuePage() { 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 pendingQuery = useUpliftsQueue({ status: "Pending", @@ -50,55 +127,29 @@ export default function UpliftQueuePage() { pageSize: PAGE_SIZE, }); - const approveUplift = useApproveUplift(); - const rejectUplift = useRejectUplift(); - const revokeUplift = useRevokeUplift(); + const { + detailItem, + rejectTarget, + revokeTarget, + approvePending, + rejectPending, + revokePending, + handleApprove, + handleRejectRequest, + handleRevokeRequest, + handleRejectConfirm, + handleRevokeConfirm, + closeDetail, + closeReject, + closeRevoke, + openDetail, + } = useUpliftApprovalController(); 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 closeDetail = () => setDetailItem(null); - - const handleApprove = (row: UpliftQueueItem) => { - approveUplift.mutate({ id: row.id }); - setDetailItem(null); - }; - - const handleRejectRequest = (row: UpliftQueueItem) => { - setDetailItem(null); - setRejectTarget(row); - }; - - const handleRevokeRequest = (row: UpliftQueueItem) => { - setDetailItem(null); - setRevokeTarget(row); - }; - - const closeReject = () => setRejectTarget(null); - const closeRevoke = () => setRevokeTarget(null); - - const handleRejectConfirm = (reason: string) => { - if (!rejectTarget) return; - rejectUplift.mutate( - { id: rejectTarget.id, note: reason }, - { onSuccess: () => setRejectTarget(null) }, - ); - }; - - const handleRevokeConfirm = (reason: string) => { - if (!revokeTarget?.workOrderId) return; - revokeUplift.mutate( - { - workOrderId: revokeTarget.workOrderId, - upliftId: revokeTarget.id, - reason, - }, - { onSuccess: () => setRevokeTarget(null) }, - ); - }; - const handleTabChange = (_event: unknown, value: string) => { if (value === "pending" || value === "approved") { setTab(value); @@ -107,65 +158,31 @@ export default function UpliftQueuePage() { 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(activeQuery.error) && ( - - {activeQuery.error instanceof Error - ? activeQuery.error.message - : "Failed to load uplift queue"} - - )} + + + - - tab === "pending" ? setPendingPage(nextPage) : setApprovedPage(nextPage) - } - rowsPerPage={PAGE_SIZE} - rowsPerPageOptions={[PAGE_SIZE]} + pendingPage={pendingPage} + approvedPage={approvedPage} + onPendingPageChange={setPendingPage} + onApprovedPageChange={setApprovedPage} /> {detailItem != null && ( ); diff --git a/src/domain/uplifts/api/uplifts-api.ts b/src/domain/uplifts/api/uplifts-api.ts index 7acd5252..6a93bdf5 100644 --- a/src/domain/uplifts/api/uplifts-api.ts +++ b/src/domain/uplifts/api/uplifts-api.ts @@ -90,12 +90,8 @@ 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 }); + revoke: async (id: string | number, reason: string): Promise => { + await apiPost(`${API_PATHS.rest.uplifts}/${id}/revoke`, { reason }); }, downloadEvidence: async ( diff --git a/src/domain/uplifts/use-cases/use-uplift-actions.ts b/src/domain/uplifts/use-cases/use-uplift-actions.ts index 7ecdc4f1..3ffedc21 100644 --- a/src/domain/uplifts/use-cases/use-uplift-actions.ts +++ b/src/domain/uplifts/use-cases/use-uplift-actions.ts @@ -103,7 +103,6 @@ export function useRequestChangesUplift( } export interface RevokeUpliftInput { - workOrderId: string | number; upliftId: string | number; reason: string; } @@ -112,8 +111,7 @@ export function useRevokeUplift(): UseMutationResult - upliftsApi.revoke(workOrderId, upliftId, reason), + mutationFn: ({ upliftId, reason }: RevokeUpliftInput) => upliftsApi.revoke(upliftId, reason), onSuccess: () => { invalidateWorkOrderUpliftQueries(queryClient); toast.success("Uplift revoked"); diff --git a/src/domain/uplifts/use-cases/use-uplift-approval-controller.ts b/src/domain/uplifts/use-cases/use-uplift-approval-controller.ts new file mode 100644 index 00000000..190ed24f --- /dev/null +++ b/src/domain/uplifts/use-cases/use-uplift-approval-controller.ts @@ -0,0 +1,65 @@ +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(null); + const [rejectTarget, setRejectTarget] = useState(null); + const [revokeTarget, setRevokeTarget] = useState(null); + const approveUplift = useApproveUplift(); + const rejectUplift = useRejectUplift(); + const revokeUplift = useRevokeUplift(); + + const handleApprove = (row: UpliftQueueItem) => { + approveUplift.mutate({ id: row.id }); + 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 }, + { onSuccess: () => setRejectTarget(null) }, + ); + }; + + const handleRevokeConfirm = (reason: string) => { + if (!revokeTarget) return; + revokeUplift.mutate( + { upliftId: revokeTarget.id, reason }, + { 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, + }; +} diff --git a/src/test/domain/uplifts/api/uplifts-api.test.ts b/src/test/domain/uplifts/api/uplifts-api.test.ts new file mode 100644 index 00000000..a1424bc8 --- /dev/null +++ b/src/test/domain/uplifts/api/uplifts-api.test.ts @@ -0,0 +1,31 @@ +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", { + reason: "Scope was already covered", + }); + }); +}); From 111b813e9eee2c3b19629020ce7465a03ae80adc Mon Sep 17 00:00:00 2001 From: Codex Review Integration Date: Wed, 16 Sep 2026 21:02:21 -0300 Subject: [PATCH 03/12] fix(uplifts): show scheduled work order date (SH-209) --- src/app/(protected)/uplifts/_components/uplift-detail-modal.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/app/(protected)/uplifts/_components/uplift-detail-modal.tsx b/src/app/(protected)/uplifts/_components/uplift-detail-modal.tsx index 22939a72..8fb24905 100644 --- a/src/app/(protected)/uplifts/_components/uplift-detail-modal.tsx +++ b/src/app/(protected)/uplifts/_components/uplift-detail-modal.tsx @@ -82,7 +82,7 @@ function WorkOrderSection({ item, info }: { item: UpliftQueueItem; info?: WorkOr {info?.status || "—"} - {info?.dueDate || "Unscheduled"} + {info?.scheduledOn || "Unscheduled"} {item.vendorCompanyName || "—"} From 0d95a320711eae3d7a46e126124ec51367b70cc3 Mon Sep 17 00:00:00 2001 From: Codex Review Integration Date: Wed, 16 Sep 2026 21:12:22 -0300 Subject: [PATCH 04/12] feat(uplifts): refresh work-order views after decisions (SH-214) --- .../uplifts/use-cases/use-uplift-actions.ts | 3 ++ .../uplifts/use-uplift-actions.test.tsx | 48 +++++++++++++++++++ 2 files changed, 51 insertions(+) create mode 100644 src/test/domain/uplifts/use-uplift-actions.test.tsx diff --git a/src/domain/uplifts/use-cases/use-uplift-actions.ts b/src/domain/uplifts/use-cases/use-uplift-actions.ts index 3ffedc21..9dda66a7 100644 --- a/src/domain/uplifts/use-cases/use-uplift-actions.ts +++ b/src/domain/uplifts/use-cases/use-uplift-actions.ts @@ -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) }); diff --git a/src/test/domain/uplifts/use-uplift-actions.test.tsx b/src/test/domain/uplifts/use-uplift-actions.test.tsx new file mode 100644 index 00000000..ac37f8bf --- /dev/null +++ b/src/test/domain/uplifts/use-uplift-actions.test.tsx @@ -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 {children}; + }; +} + +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 }); + }); +}); From 6b84db18ca89db707fd49d80e138857c107c43bc Mon Sep 17 00:00:00 2001 From: Codex Review Integration Date: Wed, 16 Sep 2026 22:07:54 -0300 Subject: [PATCH 05/12] fix(uplifts): align approval journey test (SH-207) --- e2e/vendors/vendor-uplift-workflow.spec.ts | 105 ++++++++++++--------- 1 file changed, 61 insertions(+), 44 deletions(-) diff --git a/e2e/vendors/vendor-uplift-workflow.spec.ts b/e2e/vendors/vendor-uplift-workflow.spec.ts index 18938fd4..7900f0a0 100644 --- a/e2e/vendors/vendor-uplift-workflow.spec.ts +++ b/e2e/vendors/vendor-uplift-workflow.spec.ts @@ -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([ [41, "Pending"], @@ -284,73 +284,90 @@ 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; } + 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", + currentNTE: 100, + requestedNTE: 125, + delta: 25, + requiredTier: 1, + status: statuses.get(41), + canDecide: true, + evidenceDocumentId: 22, + evidenceFileName: "estimate.pdf", + evidenceContentType: "application/pdf", + evidenceSizeBytes: 2048, + }, + { + id: 42, + dispatchId: 8, + workOrderId: 502, + dispatchNumber: "DSP-42", + poNumber: "PO-42", + vendorCompanyName: "Metro HVAC", + currentNTE: 200, + requestedNTE: 260, + delta: 60, + requiredTier: 2, + status: statuses.get(42), + canDecide: true, + evidenceDocumentId: 23, + evidenceFileName: "quote.pdf", + }, + ].filter((item) => requestedStatus == null || item.status === requestedStatus); + await fulfillJson(route, { - total: 2, - items: [ - { - id: 41, - dispatchId: 7, - dispatchNumber: "DSP-41", - poNumber: "PO-41", - vendorCompanyName: "Gateway Plumbing", - currentNTE: 100, - requestedNTE: 125, - delta: 25, - requiredTier: 1, - status: statuses.get(41), - canDecide: true, - evidenceDocumentId: 22, - evidenceFileName: "estimate.pdf", - evidenceContentType: "application/pdf", - evidenceSizeBytes: 2048, - }, - { - id: 42, - dispatchId: 8, - dispatchNumber: "DSP-42", - poNumber: "PO-42", - vendorCompanyName: "Metro HVAC", - currentNTE: 200, - requestedNTE: 260, - delta: 60, - requiredTier: 2, - status: statuses.get(42), - canDecide: true, - evidenceDocumentId: 23, - evidenceFileName: "quote.pdf", - }, - ], + 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: { reason: "Approval was made in error." }, + }, ]); }); From 6f8b50211610f66ea4fe352250ffff6e503a6818 Mon Sep 17 00:00:00 2001 From: Codex Review Integration Date: Wed, 16 Sep 2026 22:40:48 -0300 Subject: [PATCH 06/12] fix(uplifts): align approval queue contracts (SH-207) --- e2e/vendors/vendor-uplift-workflow.spec.ts | 2 +- .../_components/uplift-approvals-table.tsx | 46 +++++++++++++++---- src/app/(protected)/uplifts/index.tsx | 19 +++++++- src/domain/uplifts/api/uplifts-api.ts | 2 +- src/domain/uplifts/mappers/uplift-mapper.ts | 28 ++++++++++- .../uplifts/use-cases/use-uplift-actions.ts | 39 ++++++++++++---- .../use-uplift-approval-controller.ts | 16 +++++-- .../uplift-queue-decision-flow.test.tsx | 2 +- .../domain/uplifts/api/uplifts-api.test.ts | 2 +- .../uplifts/mappers/uplift-mapper.test.ts | 12 +++-- 10 files changed, 135 insertions(+), 33 deletions(-) diff --git a/e2e/vendors/vendor-uplift-workflow.spec.ts b/e2e/vendors/vendor-uplift-workflow.spec.ts index 7900f0a0..e0ca3300 100644 --- a/e2e/vendors/vendor-uplift-workflow.spec.ts +++ b/e2e/vendors/vendor-uplift-workflow.spec.ts @@ -367,7 +367,7 @@ test("internal approver can approve, reject, and revoke with audited notes", asy }, { path: "/api/uplifts/41/revoke", - body: { reason: "Approval was made in error." }, + body: { note: "Approval was made in error." }, }, ]); }); diff --git a/src/app/(protected)/uplifts/_components/uplift-approvals-table.tsx b/src/app/(protected)/uplifts/_components/uplift-approvals-table.tsx index 7eed68c7..8aa2ec8c 100644 --- a/src/app/(protected)/uplifts/_components/uplift-approvals-table.tsx +++ b/src/app/(protected)/uplifts/_components/uplift-approvals-table.tsx @@ -42,7 +42,13 @@ function WaitingCell({ row }: { row: UpliftQueueItem }) { ); } -function AttachmentsCell({ row }: { row: UpliftQueueItem }) { +function AttachmentsCell({ + row, + onOpenAttachment, +}: { + row: UpliftQueueItem; + onOpenAttachment: (row: UpliftQueueItem) => void; +}) { if (row.evidenceDocumentId == null && !row.evidenceFileName) { return ( @@ -56,7 +62,12 @@ function AttachmentsCell({ row }: { row: UpliftQueueItem }) { : 0; return ( - + onOpenAttachment(row) : undefined} + clickable={row.evidenceDocumentId != null} + /> {extraCount > 0 && } ); @@ -66,10 +77,12 @@ function PendingRowActions({ row, onApprove, onReject, + isDecisionPending, }: { row: UpliftQueueItem; onApprove: (row: UpliftQueueItem) => void; onReject: (row: UpliftQueueItem) => void; + isDecisionPending: boolean; }) { const tooltip = row.canDecide ? "" : `Requires Tier ${row.requiredTier} role`; return ( @@ -80,7 +93,7 @@ function PendingRowActions({ size="small" variant="contained" color="success" - disabled={!row.canDecide} + disabled={!row.canDecide || isDecisionPending} onClick={() => onApprove(row)} > Approve @@ -93,7 +106,7 @@ function PendingRowActions({ size="small" variant="contained" color="error" - disabled={!row.canDecide} + disabled={!row.canDecide || isDecisionPending} onClick={() => onReject(row)} > Reject @@ -107,9 +120,11 @@ function PendingRowActions({ function ApprovedRowActions({ row, onRevoke, + isDecisionPending, }: { row: UpliftQueueItem; onRevoke: (row: UpliftQueueItem) => void; + isDecisionPending: boolean; }) { const closed = row.workOrderClosed === true; return ( @@ -120,7 +135,7 @@ function ApprovedRowActions({ size="small" variant="outlined" color="error" - disabled={closed || row.workOrderId == null} + disabled={isDecisionPending || closed || row.workOrderId == null} onClick={() => onRevoke(row)} > Revoke @@ -138,6 +153,8 @@ function UpliftApprovalRow({ onApprove, onReject, onRevoke, + onOpenAttachment, + isDecisionPending, }: { row: UpliftQueueItem; tab: UpliftApprovalTab; @@ -145,6 +162,8 @@ function UpliftApprovalRow({ onApprove: (row: UpliftQueueItem) => void; onReject: (row: UpliftQueueItem) => void; onRevoke: (row: UpliftQueueItem) => void; + onOpenAttachment: (row: UpliftQueueItem) => void; + isDecisionPending: boolean; }) { return ( - + {row.requestedByVendorName || "—"} @@ -209,9 +228,14 @@ function UpliftApprovalRow({ {tab === "pending" ? ( - + ) : ( - + )} @@ -238,6 +262,8 @@ export function UpliftApprovalsTable({ onApprove, onReject, onRevoke, + onOpenAttachment, + isDecisionPending, }: { tab: UpliftApprovalTab; isLoading: boolean; @@ -246,6 +272,8 @@ export function UpliftApprovalsTable({ onApprove: (row: UpliftQueueItem) => void; onReject: (row: UpliftQueueItem) => void; onRevoke: (row: UpliftQueueItem) => void; + onOpenAttachment: (row: UpliftQueueItem) => void; + isDecisionPending: boolean; }) { return ( @@ -293,6 +321,8 @@ export function UpliftApprovalsTable({ onApprove={onApprove} onReject={onReject} onRevoke={onRevoke} + onOpenAttachment={onOpenAttachment} + isDecisionPending={isDecisionPending} /> )) )} diff --git a/src/app/(protected)/uplifts/index.tsx b/src/app/(protected)/uplifts/index.tsx index 8f2f418b..d2ad8a23 100644 --- a/src/app/(protected)/uplifts/index.tsx +++ b/src/app/(protected)/uplifts/index.tsx @@ -1,5 +1,6 @@ import { useMemo, useState } from "react"; import { Alert, Box, Chip, Stack, Tab, Tabs, TablePagination } from "@mui/material"; +import { toast } from "react-toastify"; import { RejectDialog } from "@/app/(protected)/uplifts/_components/reject-dialog"; import { RevokeDialog } from "@/app/(protected)/uplifts/_components/revoke-dialog"; import { @@ -11,6 +12,7 @@ import { formatUpliftMoney } from "@/app/(protected)/uplifts/_components/format- import type { UpliftQueueItem } from "@/domain/uplifts/types/uplift"; import { useUpliftApprovalController } from "@/domain/uplifts/use-cases/use-uplift-approval-controller"; import { useUpliftsQueue } from "@/domain/uplifts/use-cases/use-uplifts-queue"; +import { upliftsApi } from "@/domain/uplifts/api/uplifts-api"; import { Text } from "@/components/ui/text"; const PAGE_SIZE = 25; @@ -38,7 +40,10 @@ function UpliftApprovalsHeader({ pendingExposureTotal }: { pendingExposureTotal: allowance. - Pending exposure: {formatUpliftMoney(pendingExposureTotal ?? 0)} + Pending exposure:{" "} + {pendingExposureTotal && pendingExposureTotal > 0 + ? formatUpliftMoney(pendingExposureTotal) + : "—"} ); @@ -156,6 +161,16 @@ export default function UpliftQueuePage() { } }; + const handleOpenAttachment = (row: UpliftQueueItem) => { + void upliftsApi + .downloadEvidence(row.id, row.evidenceFileName || "uplift-evidence") + .catch((error: unknown) => { + toast.error( + error instanceof Error ? error.message : "Unable to download evidence right now.", + ); + }); + }; + return ( @@ -169,6 +184,8 @@ export default function UpliftQueuePage() { onApprove={handleApprove} onReject={handleRejectRequest} onRevoke={handleRevokeRequest} + onOpenAttachment={handleOpenAttachment} + isDecisionPending={approvePending || rejectPending || revokePending} /> => { - await apiPost(`${API_PATHS.rest.uplifts}/${id}/revoke`, { reason }); + await apiPost(`${API_PATHS.rest.uplifts}/${id}/revoke`, { note: reason }); }, downloadEvidence: async ( diff --git a/src/domain/uplifts/mappers/uplift-mapper.ts b/src/domain/uplifts/mappers/uplift-mapper.ts index 5eaca683..300a6877 100644 --- a/src/domain/uplifts/mappers/uplift-mapper.ts +++ b/src/domain/uplifts/mappers/uplift-mapper.ts @@ -57,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"), @@ -88,9 +94,21 @@ export function mapUpliftQueueItem(raw: unknown): UpliftQueueItem { 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"), + site: readString( + item, + "workOrderSite", + "WorkOrderSite", + "site", + "Site", + "siteCode", + "SiteCode", + "locationName", + "LocationName", + ), serviceName: readString( item, + "workOrderService", + "WorkOrderService", "serviceName", "ServiceName", "service", @@ -107,6 +125,8 @@ export function mapUpliftQueueItem(raw: unknown): UpliftQueueItem { ), approvedOnWoAuto: readNumber( item, + "workOrderAutoApprovedTotal", + "WorkOrderAutoApprovedTotal", "approvedOnWoAuto", "ApprovedOnWoAuto", "autoApprovedTotal", @@ -114,6 +134,8 @@ export function mapUpliftQueueItem(raw: unknown): UpliftQueueItem { ), approvedOnWoAdmin: readNumber( item, + "workOrderAdminApprovedTotal", + "WorkOrderAdminApprovedTotal", "approvedOnWoAdmin", "ApprovedOnWoAdmin", "adminApprovedTotal", @@ -121,6 +143,8 @@ export function mapUpliftQueueItem(raw: unknown): UpliftQueueItem { ), approvedOnWoTotal: readNumber( item, + "workOrderApprovedExposureTotal", + "WorkOrderApprovedExposureTotal", "approvedOnWoTotal", "ApprovedOnWoTotal", "approvedOnWorkOrderTotal", diff --git a/src/domain/uplifts/use-cases/use-uplift-actions.ts b/src/domain/uplifts/use-cases/use-uplift-actions.ts index 9dda66a7..1de4d83b 100644 --- a/src/domain/uplifts/use-cases/use-uplift-actions.ts +++ b/src/domain/uplifts/use-cases/use-uplift-actions.ts @@ -44,11 +44,24 @@ function invalidateWorkOrderUpliftQueries(queryClient: ReturnType - 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"); @@ -75,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"); @@ -108,6 +123,8 @@ export function useRequestChangesUplift( export interface RevokeUpliftInput { upliftId: string | number; reason: string; + amount?: number; + woNumber?: string; } export function useRevokeUplift(): UseMutationResult { @@ -115,9 +132,11 @@ export function useRevokeUplift(): UseMutationResult upliftsApi.revoke(upliftId, reason), - onSuccess: () => { + onSuccess: (_, variables) => { invalidateWorkOrderUpliftQueries(queryClient); - toast.success("Uplift revoked"); + toast.success( + `Uplift revoked — ${decisionAmount(variables.amount)} on ${decisionWorkOrder(variables.woNumber)}`, + ); }, onError: (error: Error) => { toast.error(error.message || "Failed to revoke uplift"); diff --git a/src/domain/uplifts/use-cases/use-uplift-approval-controller.ts b/src/domain/uplifts/use-cases/use-uplift-approval-controller.ts index 190ed24f..f02e7664 100644 --- a/src/domain/uplifts/use-cases/use-uplift-approval-controller.ts +++ b/src/domain/uplifts/use-cases/use-uplift-approval-controller.ts @@ -15,7 +15,7 @@ export function useUpliftApprovalController() { const revokeUplift = useRevokeUplift(); const handleApprove = (row: UpliftQueueItem) => { - approveUplift.mutate({ id: row.id }); + approveUplift.mutate({ id: row.id, amount: row.delta, woNumber: row.woNumber }); setDetailItem(null); }; @@ -32,7 +32,12 @@ export function useUpliftApprovalController() { const handleRejectConfirm = (reason: string) => { if (!rejectTarget) return; rejectUplift.mutate( - { id: rejectTarget.id, note: reason }, + { + id: rejectTarget.id, + note: reason, + amount: rejectTarget.delta, + woNumber: rejectTarget.woNumber, + }, { onSuccess: () => setRejectTarget(null) }, ); }; @@ -40,7 +45,12 @@ export function useUpliftApprovalController() { const handleRevokeConfirm = (reason: string) => { if (!revokeTarget) return; revokeUplift.mutate( - { upliftId: revokeTarget.id, reason }, + { + upliftId: revokeTarget.id, + reason, + amount: revokeTarget.delta, + woNumber: revokeTarget.woNumber, + }, { onSuccess: () => setRevokeTarget(null) }, ); }; 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 d90aeed9..65d89594 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 @@ -93,7 +93,7 @@ describe("Uplift Approvals decision flow", () => { renderWithProviders(); fireEvent.click(screen.getByRole("button", { name: "Approve" })); - expect(approveMutate).toHaveBeenCalledWith({ id: 41 }); + expect(approveMutate).toHaveBeenCalledWith({ id: 41, amount: 150, woNumber: "WO-99" }); expect(screen.queryByRole("button", { name: "Confirm" })).not.toBeInTheDocument(); expect(screen.queryByRole("dialog")).not.toBeInTheDocument(); }); diff --git a/src/test/domain/uplifts/api/uplifts-api.test.ts b/src/test/domain/uplifts/api/uplifts-api.test.ts index a1424bc8..4c4d2494 100644 --- a/src/test/domain/uplifts/api/uplifts-api.test.ts +++ b/src/test/domain/uplifts/api/uplifts-api.test.ts @@ -25,7 +25,7 @@ describe("upliftsApi", () => { await upliftsApi.revoke(42, "Scope was already covered"); expect(apiPost).toHaveBeenCalledWith("uplifts/42/revoke", { - reason: "Scope was already covered", + note: "Scope was already covered", }); }); }); diff --git a/src/test/domain/uplifts/mappers/uplift-mapper.test.ts b/src/test/domain/uplifts/mappers/uplift-mapper.test.ts index ece11ec2..702b7cae 100644 --- a/src/test/domain/uplifts/mappers/uplift-mapper.test.ts +++ b/src/test/domain/uplifts/mappers/uplift-mapper.test.ts @@ -64,18 +64,20 @@ describe("mapUpliftQueueItem", () => { const result = mapUpliftQueueItem({ id: 1, WoNumber: "WO-9", - Site: "Site A", - ServiceName: "Plumbing", + WorkOrderSite: "Site A", + WorkOrderService: "Plumbing", + RequestedByName: "Pat Approver", AttachmentCount: 3, - ApprovedOnWoAuto: 100, - ApprovedOnWoAdmin: 250, - ApprovedOnWoTotal: 350, + 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); From 5aed31392cfa1315c2e71ca60d0c2275f87d1248 Mon Sep 17 00:00:00 2001 From: Codex Review Integration Date: Wed, 16 Sep 2026 22:44:37 -0300 Subject: [PATCH 07/12] fix(uplifts): complete approval queue review fixes (SH-208) --- .../_components/uplift-approvals-table.tsx | 37 +++++++++++++++---- .../_components/uplift-detail-modal.tsx | 29 ++++++++++++--- src/app/(protected)/uplifts/index.tsx | 17 ++------- .../uplift-approvals-approved-tab.test.tsx | 4 ++ 4 files changed, 60 insertions(+), 27 deletions(-) diff --git a/src/app/(protected)/uplifts/_components/uplift-approvals-table.tsx b/src/app/(protected)/uplifts/_components/uplift-approvals-table.tsx index 8aa2ec8c..2ab99236 100644 --- a/src/app/(protected)/uplifts/_components/uplift-approvals-table.tsx +++ b/src/app/(protected)/uplifts/_components/uplift-approvals-table.tsx @@ -15,7 +15,7 @@ import { 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"; +import { formatDateTime, getWaitTimeTextClass, timeSince, waitTimeColor } from "@/lib/time-utils"; export type UpliftApprovalTab = "pending" | "approved"; @@ -223,9 +223,16 @@ function UpliftApprovalRow({ {row.requestedByVendorName || "—"} - - - + {tab === "pending" ? ( + + + + ) : ( + <> + {row.decidedByName || "—"} + {row.decidedAt ? formatDateTime(row.decidedAt) : "—"} + + )} {tab === "pending" ? ( void; isDecisionPending: boolean; }) { + const headerCells = tab === "pending" ? PENDING_HEADER_CELLS : APPROVED_HEADER_CELLS; return ( - {HEADER_CELLS.map((label) => ( + {headerCells.map((label) => ( {label} @@ -290,13 +311,13 @@ export function UpliftApprovalsTable({ {isLoading ? ( - + ) : rows.length === 0 ? ( - + {tab === "pending" ? ( No uplifts waiting for approval. diff --git a/src/app/(protected)/uplifts/_components/uplift-detail-modal.tsx b/src/app/(protected)/uplifts/_components/uplift-detail-modal.tsx index 8fb24905..04ac0b56 100644 --- a/src/app/(protected)/uplifts/_components/uplift-detail-modal.tsx +++ b/src/app/(protected)/uplifts/_components/uplift-detail-modal.tsx @@ -25,6 +25,7 @@ type ModalCallbacks = { onApprove: (row: UpliftQueueItem) => void; onReject: (row: UpliftQueueItem) => void; onRevoke: (row: UpliftQueueItem) => void; + onOpenAttachment: (row: UpliftQueueItem) => void; }; function DetailField({ label, children }: { label: string; children: ReactNode }) { @@ -49,7 +50,13 @@ function DetailSection({ title, children }: { title: string; children: ReactNode ); } -function UpliftAttachments({ item }: { item: UpliftQueueItem }) { +function UpliftAttachments({ + item, + onOpenAttachment, +}: { + item: UpliftQueueItem; + onOpenAttachment: (row: UpliftQueueItem) => void; +}) { if (item.evidenceDocumentId == null && !item.evidenceFileName) { return ( @@ -63,7 +70,12 @@ function UpliftAttachments({ item }: { item: UpliftQueueItem }) { : 0; return ( - + onOpenAttachment(item) : undefined} + clickable={item.evidenceDocumentId != null} + /> {extraCount > 0 && } ); @@ -91,7 +103,13 @@ function WorkOrderSection({ item, info }: { item: UpliftQueueItem; info?: WorkOr ); } -function RequestSection({ item }: { item: UpliftQueueItem }) { +function RequestSection({ + item, + onOpenAttachment, +}: { + item: UpliftQueueItem; + onOpenAttachment: (row: UpliftQueueItem) => void; +}) { const waitingClass = item.requestedAt ? getWaitTimeTextClass(waitTimeColor(item.requestedAt)) : undefined; @@ -114,7 +132,7 @@ function RequestSection({ item }: { item: UpliftQueueItem }) { {item.vendorReason || "No justification provided."} - + ); @@ -269,6 +287,7 @@ export function UpliftDetailModal({ onApprove, onReject, onRevoke, + onOpenAttachment, }: { item: UpliftQueueItem | null; approvePending: boolean; @@ -326,7 +345,7 @@ export function UpliftDetailModal({ - + - 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; -} - function UpliftApprovalsHeader({ pendingExposureTotal }: { pendingExposureTotal: number | null }) { return ( @@ -151,7 +139,7 @@ export default function UpliftQueuePage() { } = useUpliftApprovalController(); const activeQuery = tab === "pending" ? pendingQuery : approvedQuery; - const rows = useMemo(() => sortRows(activeQuery.data?.items ?? [], tab), [activeQuery.data, tab]); + const rows = activeQuery.data?.items ?? []; const pendingCount = pendingQuery.data?.totalCount ?? 0; const pendingExposureTotal = pendingQuery.data?.pendingExposureTotal ?? null; @@ -204,6 +192,7 @@ export default function UpliftQueuePage() { onApprove={handleApprove} onReject={handleRejectRequest} onRevoke={handleRevokeRequest} + onOpenAttachment={handleOpenAttachment} /> )} { renderWithProviders(); 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); From 6a2b9eb80c47cbdc82326049022bb8dcba96749a Mon Sep 17 00:00:00 2001 From: Codex Review Integration Date: Wed, 16 Sep 2026 22:50:40 -0300 Subject: [PATCH 08/12] test(uplifts): cover detail evidence callback (SH-209) --- .../uplifts/uplift-detail-modal.test.tsx | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/src/test/app/(protected)/uplifts/uplift-detail-modal.test.tsx b/src/test/app/(protected)/uplifts/uplift-detail-modal.test.tsx index fda63056..60654846 100644 --- a/src/test/app/(protected)/uplifts/uplift-detail-modal.test.tsx +++ b/src/test/app/(protected)/uplifts/uplift-detail-modal.test.tsx @@ -55,6 +55,7 @@ function renderModal(overrides: Partial = {}) { const onApprove = vi.fn(); const onReject = vi.fn(); const onRevoke = vi.fn(); + const onOpenAttachment = vi.fn(); renderWithProviders( = {}) { onApprove={onApprove} onReject={onReject} onRevoke={onRevoke} + onOpenAttachment={onOpenAttachment} />, ); - return { onApprove, onReject, onRevoke }; + return { onApprove, onReject, onRevoke, onOpenAttachment }; } describe("UpliftDetailModal", () => { @@ -88,6 +90,19 @@ describe("UpliftDetailModal", () => { 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(); From 4bffdac1752a5295c6904cc7c01b6e201e40aa8a Mon Sep 17 00:00:00 2001 From: Codex Review Integration Date: Thu, 17 Sep 2026 00:13:54 -0300 Subject: [PATCH 09/12] fix(uplifts): restrict revoke to admins and quiet permission failures (SH-207) - Gate the Revoke action on the approved queue row and the detail modal to admin users; non-admins get a disabled button with a plain 'Only admins can revoke uplifts' tooltip and the dialog cannot open. - Stop nesting an h2 inside DialogTitle in the uplift detail modal (hydration error) by rendering the title text as a non-heading. - Handle work-order detail/uplift query 403s quietly in the detail modal: sections fall back to 'Unavailable' and the queries suppress the global error toast (QueryCache now honors suppressErrorToast). - Extract UpliftDecisionDialogs and openUpliftEvidence from the page to meet the changed-file maintainability gate (complexity 23 -> pass). --- .../_components/open-uplift-evidence.ts | 13 ++ .../_components/uplift-approvals-table.tsx | 20 ++- .../_components/uplift-decision-dialogs.tsx | 42 ++++++ .../_components/uplift-detail-modal.tsx | 62 ++++++-- src/app/(protected)/uplifts/index.tsx | 54 ++----- .../use-cases/use-work-order-detail.ts | 2 + .../use-cases/use-work-order-uplifts.ts | 2 + src/lib/query/query-client.ts | 58 ++++---- .../uplift-approvals-approved-tab.test.tsx | 33 +++++ .../uplift-detail-modal-permissions.test.tsx | 132 ++++++++++++++++++ .../uplifts/uplift-detail-modal.test.tsx | 26 +++- .../uplift-queue-decision-flow.test.tsx | 5 + src/test/lib/query/query-client.test.ts | 42 ++++++ 13 files changed, 412 insertions(+), 79 deletions(-) create mode 100644 src/app/(protected)/uplifts/_components/open-uplift-evidence.ts create mode 100644 src/app/(protected)/uplifts/_components/uplift-decision-dialogs.tsx create mode 100644 src/test/app/(protected)/uplifts/uplift-detail-modal-permissions.test.tsx create mode 100644 src/test/lib/query/query-client.test.ts diff --git a/src/app/(protected)/uplifts/_components/open-uplift-evidence.ts b/src/app/(protected)/uplifts/_components/open-uplift-evidence.ts new file mode 100644 index 00000000..2654fedd --- /dev/null +++ b/src/app/(protected)/uplifts/_components/open-uplift-evidence.ts @@ -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.", + ); + }); +} diff --git a/src/app/(protected)/uplifts/_components/uplift-approvals-table.tsx b/src/app/(protected)/uplifts/_components/uplift-approvals-table.tsx index 2ab99236..3c0f3e20 100644 --- a/src/app/(protected)/uplifts/_components/uplift-approvals-table.tsx +++ b/src/app/(protected)/uplifts/_components/uplift-approvals-table.tsx @@ -20,6 +20,7 @@ import { formatDateTime, getWaitTimeTextClass, timeSince, waitTimeColor } from " 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 || "—"; @@ -119,23 +120,26 @@ function PendingRowActions({ 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 ( e.stopPropagation()}> - +