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/14] 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/14] 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/14] 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/14] 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 58fc323ea254477d2a40531aa10f577debddf07f Mon Sep 17 00:00:00 2001 From: Codex Review Integration Date: Wed, 16 Sep 2026 22:06:33 -0300 Subject: [PATCH 05/14] fix(work-orders): use local calendar day for Past Due and stabilize slow suites computeIsPastDue and the day view's todayKey derived today from toISOString() (UTC). West of UTC that rolls over hours early, so work scheduled for today showed as Past Due every evening. Both now use the local todayIso() helper the board already uses; regression tests pin 23:30 and 00:30 in America/Sao_Paulo with fake Date. Test stability: - adv-search page sync: scope pagination queries with within() instead of scanning ~330 buttons / 3000 nodes on every waitFor poll - pm-schedules-list-actions: static import of the edit page so module transform does not count against the 5s test budget - vendor create modal / detail drawer: userEvent.setup({ delay: null }); 15s describe budget for full MUI dialog renders under parallel load --- .../_hooks/use-work-orders-table-day-view.ts | 3 +- .../work-orders/utils/table-filter-utils.ts | 7 +++- .../pm-schedules-list-actions.test.tsx | 7 ++-- .../vendors/vendor-create-modal.test.tsx | 28 ++++++++++---- .../vendors/vendor-detail-drawer.test.tsx | 24 ++++++++---- ...-adv-search-page-sync.integration.test.tsx | 20 ++++++---- .../utils/table-filter-utils.test.ts | 37 ++++++++++++++++++- 7 files changed, 96 insertions(+), 30 deletions(-) diff --git a/src/app/(protected)/workorders/_hooks/use-work-orders-table-day-view.ts b/src/app/(protected)/workorders/_hooks/use-work-orders-table-day-view.ts index 3f15b3cb..07e2ce06 100644 --- a/src/app/(protected)/workorders/_hooks/use-work-orders-table-day-view.ts +++ b/src/app/(protected)/workorders/_hooks/use-work-orders-table-day-view.ts @@ -4,6 +4,7 @@ import { useWorkOrdersDayPagination } from "@/app/(protected)/workorders/_hooks/ import { currentWeekMonday } from "@/domain/work-orders/utils/list-week-utils"; import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row"; import { deriveDayFields } from "@/domain/work-orders/utils/table-day-utils"; +import { todayIso } from "@/domain/work-orders/utils/wizard-date-utils"; import { pageSelectedDayGroups } from "@/domain/work-orders/utils/day-pagination"; import { buildSelectedDayGroup, @@ -71,7 +72,7 @@ export function useWorkOrdersTableDayView({ const isEmptyWeek = !advActive && scheduled.length === 0 && !searchActive; const todayKey = filters.weekMonday === currentWeekMonday() - ? deriveDayFields(new Date().toISOString().slice(0, 10)).dayGroup + ? deriveDayFields(todayIso()).dayGroup : null; return { diff --git a/src/domain/work-orders/utils/table-filter-utils.ts b/src/domain/work-orders/utils/table-filter-utils.ts index 4f2a045b..3d8eb132 100644 --- a/src/domain/work-orders/utils/table-filter-utils.ts +++ b/src/domain/work-orders/utils/table-filter-utils.ts @@ -10,6 +10,7 @@ import { ADVANCED_FILTER_PREDICATES } from "@/domain/work-orders/utils/advanced- import { isCarryOverDisplayRow } from "@/domain/work-orders/utils/carry-over-display"; import { isDateInWeek } from "@/domain/work-orders/utils/table-day-utils"; import { weekRange } from "@/domain/work-orders/utils/list-week-utils"; +import { todayIso } from "@/domain/work-orders/utils/wizard-date-utils"; const TERMINAL_STATUSES = new Set(["Completed", "Canceled", "Done", "Cancelled"]); @@ -17,8 +18,10 @@ export function computeIsPastDue(row: WorkOrderTableRow): boolean { if (!row.scheduledOn || TERMINAL_STATUSES.has(row.status)) { return false; } - const today = new Date().toISOString().slice(0, 10); - return row.scheduledOn.slice(0, 10) < today; + // scheduledOn is a calendar date, so compare it to the user's local calendar + // day. A UTC "today" rolls over hours early west of UTC and flags work + // scheduled for today as Past Due every evening. + return row.scheduledOn.slice(0, 10) < todayIso(); } export function enrichTableRow(row: WorkOrderTableRow): WorkOrderTableRow { diff --git a/src/test/app/(protected)/pmschedules/pm-schedules-list-actions.test.tsx b/src/test/app/(protected)/pmschedules/pm-schedules-list-actions.test.tsx index f26b9ea5..efb56316 100644 --- a/src/test/app/(protected)/pmschedules/pm-schedules-list-actions.test.tsx +++ b/src/test/app/(protected)/pmschedules/pm-schedules-list-actions.test.tsx @@ -1,6 +1,9 @@ import { screen } from "@testing-library/react"; import { describe, expect, it, vi } from "vitest"; import PmSchedulesListPage from "@/app/(protected)/pmschedules/index"; +// Static import: a dynamic import inside the test transforms the edit page's +// module graph against the 5s test budget, which times out under load. +import EditPage from "@/app/(protected)/pmschedules/edit/[id]"; import { renderWithProviders } from "@/test/test-utils"; const usePmSchedulesList = vi.fn(); @@ -75,9 +78,7 @@ describe("PmSchedulesListPage actions", () => { }); describe("PmScheduleEditUnavailablePage", () => { - it("shows unavailable message instead of edit form when canEdit is false", async () => { - const EditPage = (await import("@/app/(protected)/pmschedules/edit/[id]")).default; - + it("shows unavailable message instead of edit form when canEdit is false", () => { renderWithProviders(, { route: "/pmschedules/edit/1", routerProps: { initialEntries: ["/pmschedules/edit/1"] }, diff --git a/src/test/app/(protected)/vendors/vendor-create-modal.test.tsx b/src/test/app/(protected)/vendors/vendor-create-modal.test.tsx index 355f99f1..644fc329 100644 --- a/src/test/app/(protected)/vendors/vendor-create-modal.test.tsx +++ b/src/test/app/(protected)/vendors/vendor-create-modal.test.tsx @@ -2,7 +2,7 @@ import { fireEvent, screen, waitFor } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { useForm } from "react-hook-form"; import { zodResolver } from "@hookform/resolvers/zod"; -import { describe, expect, it, vi } from "vitest"; +import { beforeEach, describe, expect, it, vi } from "vitest"; import { VendorCreateModal } from "@/app/(protected)/vendors/_components/vendor-create-modal"; import { VendorRosterFormFields } from "@/app/(protected)/vendors/_components/vendor-roster-form-fields"; import { @@ -49,6 +49,10 @@ function renderCreateModal() { }); } +// delay: null skips user-event's per-keystroke setTimeout yields, which only +// add real-timer latency here. +let user = userEvent.setup({ delay: null }); + async function fillCompanyNameAndSubmit(name = "Gateway Plumbing") { fireEvent.change(screen.getByRole("combobox", { name: "Company (required)" }), { target: { value: name }, @@ -57,7 +61,7 @@ async function fillCompanyNameAndSubmit(name = "Gateway Plumbing") { // phone-or-email rule, so the address is filled to keep the form's only // outstanding problem the one under test. fillRequiredAddress(); - await userEvent.click(screen.getByRole("button", { name: "Add Vendor" })); + await user.click(screen.getByRole("button", { name: "Add Vendor" })); } function fillRequiredAddress({ @@ -74,7 +78,15 @@ function fillRequiredAddress({ pickVendorState(state); } -describe("VendorCreateModal validation", () => { +// Each test mounts the full MUI dialog (Autocompletes, 50-option State list) +// and costs ~1s of CPU when idle; the first also pays JIT warm-up. Under +// parallel agent load the 5s default is exceeded by CPU starvation alone, +// matching the 10s budgets already used by vendors-list.test.tsx. +describe("VendorCreateModal validation", { timeout: 15_000 }, () => { + beforeEach(() => { + user = userEvent.setup({ delay: null }); + }); + it("explains the company phone-or-email requirement after submission", async () => { renderCreateModal(); await fillCompanyNameAndSubmit(); @@ -138,8 +150,8 @@ describe("VendorCreateModal validation", () => { const phone = screen.getByRole("textbox", { name: "Company Phone (optional)" }); const email = screen.getByRole("textbox", { name: "Email (optional)" }); - await userEvent.type(phone, "314"); - await userEvent.click(screen.getByRole("button", { name: "Add Vendor" })); + await user.type(phone, "314"); + await user.click(screen.getByRole("button", { name: "Add Vendor" })); expect(await screen.findByText("Enter a 10-digit phone number")).toBeInTheDocument(); expect(phone).toHaveAttribute("aria-invalid", "true"); @@ -153,8 +165,8 @@ describe("VendorCreateModal validation", () => { const phone = screen.getByRole("textbox", { name: "Company Phone (optional)" }); const email = screen.getByRole("textbox", { name: "Email (optional)" }); - await userEvent.type(email, "not-an-email"); - await userEvent.click(screen.getByRole("button", { name: "Add Vendor" })); + await user.type(email, "not-an-email"); + await user.click(screen.getByRole("button", { name: "Add Vendor" })); expect(await screen.findByText("Invalid email")).toBeInTheDocument(); expect(email).toHaveAttribute("aria-invalid", "true"); @@ -169,7 +181,7 @@ describe("VendorCreateModal validation", () => { expect(await screen.findByText(VENDOR_COMPANY_CONTACT_REQUIRED_MESSAGE)).toBeInTheDocument(); - await userEvent.type(phone, "3145550100"); + await user.type(phone, "3145550100"); await waitFor(() => expect(screen.queryByText(VENDOR_COMPANY_CONTACT_REQUIRED_MESSAGE)).not.toBeInTheDocument(), ); diff --git a/src/test/app/(protected)/vendors/vendor-detail-drawer.test.tsx b/src/test/app/(protected)/vendors/vendor-detail-drawer.test.tsx index 0da2e959..0e5ea3eb 100644 --- a/src/test/app/(protected)/vendors/vendor-detail-drawer.test.tsx +++ b/src/test/app/(protected)/vendors/vendor-detail-drawer.test.tsx @@ -68,11 +68,19 @@ function rosterWith(technicians: Array>) { }; } +// delay: null skips user-event's per-keystroke setTimeout yields, which only +// add real-timer latency here. +let user = userEvent.setup({ delay: null }); + beforeEach(() => { saveMutate.mockReset(); + user = userEvent.setup({ delay: null }); }); -describe("VendorDetailDrawer selected-technician display", () => { +// The edit-mode drawer is a heavy MUI render and the first test pays JIT +// warm-up; under parallel agent load the 5s default is exceeded by CPU +// starvation alone, matching the 10s budgets in vendors-list.test.tsx. +describe("VendorDetailDrawer selected-technician display", { timeout: 15_000 }, () => { it.each([ { removedPosition: 1, remainingId: 2, remainingName: "Beth" }, { removedPosition: 2, remainingId: 1, remainingName: "Adam" }, @@ -107,10 +115,10 @@ describe("VendorDetailDrawer selected-technician display", () => { { route: "/vendors", withAuth: false }, ); - await userEvent.click( + await user.click( screen.getByRole("button", { name: `Remove technician ${removedPosition}` }), ); - await userEvent.click(screen.getByRole("button", { name: "Save changes" })); + await user.click(screen.getByRole("button", { name: "Save changes" })); await waitFor(() => expect(saveMutate).toHaveBeenCalledTimes(1)); expect(saveMutate).toHaveBeenCalledWith( @@ -161,12 +169,12 @@ describe("VendorDetailDrawer selected-technician display", () => { { route: "/vendors", withAuth: false }, ); - await userEvent.click(screen.getByRole("button", { name: "Remove technician 2" })); + await user.click(screen.getByRole("button", { name: "Remove technician 2" })); const save = screen.getByRole("button", { name: "Save changes" }); expect(save).toBeEnabled(); - await userEvent.click(save); + await user.click(save); expect( await screen.findByText("Provide a company phone or email (at least one required)"), @@ -444,10 +452,10 @@ describe("VendorDetailDrawer deactivation prompt", () => { ); const toggle = screen.getByRole("switch", { name: "Active status" }); - await userEvent.click(toggle); + await user.click(toggle); expect(toggle).toBeChecked(); - await userEvent.click(toggle); + await user.click(toggle); expect(toggle).not.toBeChecked(); expect(onRequestDeactivation).not.toHaveBeenCalled(); @@ -472,7 +480,7 @@ describe("VendorDetailDrawer deactivation prompt", () => { { route: "/vendors", withAuth: false }, ); - await userEvent.click(screen.getByRole("switch", { name: "Active status" })); + await user.click(screen.getByRole("switch", { name: "Active status" })); expect(onRequestDeactivation).toHaveBeenCalledWith(vendor); }); diff --git a/src/test/app/(protected)/workorders/wo-table-adv-search-page-sync.integration.test.tsx b/src/test/app/(protected)/workorders/wo-table-adv-search-page-sync.integration.test.tsx index 1ef0734c..4ca3e8d8 100644 --- a/src/test/app/(protected)/workorders/wo-table-adv-search-page-sync.integration.test.tsx +++ b/src/test/app/(protected)/workorders/wo-table-adv-search-page-sync.integration.test.tsx @@ -1,4 +1,4 @@ -import { fireEvent, screen, waitFor } from "@testing-library/react"; +import { fireEvent, screen, waitFor, within } from "@testing-library/react"; import { beforeEach, describe, expect, it, vi } from "vitest"; import type { WorkOrderBoardSearchQueryParams } from "@/domain/work-orders/types/work-order-board"; import { EMPTY_ADVANCED_FILTERS } from "@/domain/work-orders/types/work-order-advanced-filters"; @@ -120,6 +120,12 @@ function pagedResult(rows: WorkOrderTableRow[], page: number, pageSize: number) }; } +// Role and text queries over the whole board scan every row cell; scoping them +// to the pagination footer keeps each waitFor poll cheap on a loaded machine. +function pagination() { + return within(screen.getByTestId("wo-day-pagination")); +} + function pagesAfter(callCount: number): number[] { return searchBoard.mock.calls.slice(callCount).map((call) => { const params = call[0] as WorkOrderBoardSearchQueryParams; @@ -213,14 +219,14 @@ describe("advanced search page sync after mutation", () => { ); }); expect(screen.getByText("WO-0")).toBeInTheDocument(); - expect(screen.getByText("Page 1 of 2")).toBeInTheDocument(); + expect(pagination().getByText("Page 1 of 2")).toBeInTheDocument(); expect(screen.queryByText("WO-25")).not.toBeInTheDocument(); - fireEvent.click(screen.getByRole("button", { name: "Next page" })); + fireEvent.click(pagination().getByRole("button", { name: "Next page" })); await waitFor(() => { expect(screen.getByTestId("harness-state")).toHaveTextContent("page=1"); expect(screen.getByText("WO-25")).toBeInTheDocument(); - expect(screen.getByText("Page 2 of 2")).toBeInTheDocument(); + expect(pagination().getByText("Page 2 of 2")).toBeInTheDocument(); }); const callsBeforeMutation = searchBoard.mock.calls.length; @@ -236,12 +242,12 @@ describe("advanced search page sync after mutation", () => { expect(screen.getByTestId("harness-state")).toHaveTextContent( "adv=true rows=20 total=20 page=0", ); - expect(screen.getByText("Page 1 of 1")).toBeInTheDocument(); + expect(pagination().getByText("Page 1 of 1")).toBeInTheDocument(); }); expect(screen.getByText("WO-0")).toBeInTheDocument(); expect(screen.queryByText("WO-25")).not.toBeInTheDocument(); expect(screen.getByText("RESULTS · 20 items")).toBeInTheDocument(); - expect(screen.getByRole("button", { name: "Previous page" })).toBeDisabled(); - expect(screen.getByRole("button", { name: "Next page" })).toBeDisabled(); + expect(pagination().getByRole("button", { name: "Previous page" })).toBeDisabled(); + expect(pagination().getByRole("button", { name: "Next page" })).toBeDisabled(); }, 15000); }); diff --git a/src/test/domain/work-orders/utils/table-filter-utils.test.ts b/src/test/domain/work-orders/utils/table-filter-utils.test.ts index a2e1f2eb..022d36fe 100644 --- a/src/test/domain/work-orders/utils/table-filter-utils.test.ts +++ b/src/test/domain/work-orders/utils/table-filter-utils.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { EMPTY_UPLIFT_SUMMARY } from "@/domain/work-orders/types/work-order-uplift"; import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row"; import { EMPTY_ADVANCED_FILTERS } from "@/domain/work-orders/types/work-order-advanced-filters"; @@ -526,6 +526,41 @@ describe("computeIsPastDue", () => { }); }); +describe("computeIsPastDue across the local/UTC day boundary", () => { + beforeEach(() => { + // UTC-3 with no DST: local evening is already the next day in UTC. + vi.stubEnv("TZ", "America/Sao_Paulo"); + vi.useFakeTimers({ toFake: ["Date"] }); + }); + + afterEach(() => { + vi.useRealTimers(); + vi.unstubAllEnvs(); + }); + + it("does not flag today's work as Past Due at 23:30 local (02:30 UTC next day)", () => { + vi.setSystemTime(new Date("2026-09-17T02:30:00.000Z")); + + expect(computeIsPastDue(makeRow({ scheduledOn: "2026-09-16", status: "Scheduled" }))).toBe( + false, + ); + expect(computeIsPastDue(makeRow({ scheduledOn: "2026-09-15", status: "Scheduled" }))).toBe( + true, + ); + }); + + it("flags yesterday's work as Past Due at 00:30 local", () => { + vi.setSystemTime(new Date("2026-09-17T03:30:00.000Z")); + + expect(computeIsPastDue(makeRow({ scheduledOn: "2026-09-16", status: "Scheduled" }))).toBe( + true, + ); + expect(computeIsPastDue(makeRow({ scheduledOn: "2026-09-17", status: "Scheduled" }))).toBe( + false, + ); + }); +}); + describe("enrichTableRow", () => { it("recomputes isPastDue from scheduledOn and ignores sticky API true", () => { const enriched = enrichTableRow( From 6b84db18ca89db707fd49d80e138857c107c43bc Mon Sep 17 00:00:00 2001 From: Codex Review Integration Date: Wed, 16 Sep 2026 22:07:54 -0300 Subject: [PATCH 06/14] 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 d1b36ed1c02815bbd34157ba1272062050e90917 Mon Sep 17 00:00:00 2001 From: Codex Review Integration Date: Wed, 16 Sep 2026 22:21:17 -0300 Subject: [PATCH 07/14] style(work-orders): apply prettier to day-view hook --- .../workorders/_hooks/use-work-orders-table-day-view.ts | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/app/(protected)/workorders/_hooks/use-work-orders-table-day-view.ts b/src/app/(protected)/workorders/_hooks/use-work-orders-table-day-view.ts index 07e2ce06..42b3bde9 100644 --- a/src/app/(protected)/workorders/_hooks/use-work-orders-table-day-view.ts +++ b/src/app/(protected)/workorders/_hooks/use-work-orders-table-day-view.ts @@ -71,9 +71,7 @@ export function useWorkOrdersTableDayView({ }); const isEmptyWeek = !advActive && scheduled.length === 0 && !searchActive; const todayKey = - filters.weekMonday === currentWeekMonday() - ? deriveDayFields(todayIso()).dayGroup - : null; + filters.weekMonday === currentWeekMonday() ? deriveDayFields(todayIso()).dayGroup : null; return { scheduled, From 6f8b50211610f66ea4fe352250ffff6e503a6818 Mon Sep 17 00:00:00 2001 From: Codex Review Integration Date: Wed, 16 Sep 2026 22:40:48 -0300 Subject: [PATCH 08/14] 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 09/14] 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 10/14] 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 11/14] 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()}> - +