diff --git a/e2e/vendors/vendor-uplift-workflow.spec.ts b/e2e/vendors/vendor-uplift-workflow.spec.ts index 18938fd4..69205a2e 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,95 @@ 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; } + if (path.endsWith("/uplifts/can-approve")) { + await fulfillJson(route, { canApprove: true }); + return; + } + + const requestedStatus = new URL(request.url()).searchParams.get("status"); + const items = [ + { + id: 41, + dispatchId: 7, + workOrderId: 501, + dispatchNumber: "DSP-41", + poNumber: "PO-41", + vendorCompanyName: "Gateway Plumbing", + 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: { note: "Approval was made in error." }, + }, ]); }); 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 new file mode 100644 index 00000000..fb40d7c1 --- /dev/null +++ b/src/app/(protected)/uplifts/_components/uplift-approvals-table.tsx @@ -0,0 +1,371 @@ +import { + Box, + Button, + Chip, + CircularProgress, + Paper, + Table, + TableBody, + TableCell, + TableContainer, + TableHead, + TableRow, + Tooltip, +} from "@mui/material"; +import { formatUpliftMoney } from "@/app/(protected)/uplifts/_components/format-uplift-money"; +import { Text } from "@/components/ui/text"; +import { useUpliftsCanApprove } from "@/domain/uplifts/use-cases/use-uplifts-can-approve"; +import type { UpliftQueueItem } from "@/domain/uplifts/types/uplift"; +import { formatDateTime, getWaitTimeTextClass, timeSince, waitTimeColor } from "@/lib/time-utils"; + +export type UpliftApprovalTab = "pending" | "approved"; + +const CLOSED_WO_TOOLTIP = "This work order is closed. Uplifts can no longer be revoked."; +const ADMIN_ONLY_REVOKE_TOOLTIP = "Only admins can revoke uplifts"; + +function workOrderLabel(row: UpliftQueueItem): string { + return row.woNumber || row.dispatchNumber || "—"; +} + +function WaitingCell({ row }: { row: UpliftQueueItem }) { + if (!row.requestedAt) { + return ( + + — + + ); + } + const color = waitTimeColor(row.requestedAt); + const className = getWaitTimeTextClass(color); + return ( + + {timeSince(row.requestedAt)} + + ); +} + +function AttachmentsCell({ + row, + onOpenAttachment, +}: { + row: UpliftQueueItem; + onOpenAttachment: (row: UpliftQueueItem) => void; +}) { + if (row.evidenceDocumentId == null && !row.evidenceFileName) { + return ( + + — + + ); + } + const extraCount = + typeof row.attachmentCount === "number" && row.attachmentCount > 1 + ? row.attachmentCount - 1 + : 0; + return ( + + onOpenAttachment(row) : undefined} + clickable={row.evidenceDocumentId != null} + /> + {extraCount > 0 && } + + ); +} + +function PendingRowActions({ + row, + onApprove, + onReject, + isDecisionPending, +}: { + row: UpliftQueueItem; + onApprove: (row: UpliftQueueItem) => void; + onReject: (row: UpliftQueueItem) => void; + isDecisionPending: boolean; +}) { + const canApproveTier = useUpliftsCanApprove(row.requiredTier); + const canDecide = row.canDecide && canApproveTier.data === true; + const tooltip = canDecide ? "" : `Requires Tier ${row.requiredTier} role`; + return ( + e.stopPropagation()}> + + + + + + + + + + + + ); +} + +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()}> + + + + + + + ); +} + +function UpliftApprovalRow({ + row, + tab, + canRevoke, + onOpenDetail, + onApprove, + onReject, + onRevoke, + onOpenAttachment, + isDecisionPending, +}: { + row: UpliftQueueItem; + tab: UpliftApprovalTab; + canRevoke: boolean; + onOpenDetail: (row: UpliftQueueItem) => void; + onApprove: (row: UpliftQueueItem) => void; + onReject: (row: UpliftQueueItem) => void; + onRevoke: (row: UpliftQueueItem) => void; + onOpenAttachment: (row: UpliftQueueItem) => void; + isDecisionPending: boolean; +}) { + return ( + 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" ? ( + + + + ) : ( + <> + {row.decidedByName || "—"} + {row.decidedAt ? formatDateTime(row.decidedAt) : "—"} + + )} + + {tab === "pending" ? ( + + ) : ( + + )} + + + ); +} + +const PENDING_HEADER_CELLS = [ + "Work Order", + "Service / Vendor", + "Amount", + "Approved on WO", + "Notes", + "Attachments", + "Requested By", + "Waiting", + "Actions", +] as const; + +const APPROVED_HEADER_CELLS = [ + "Work Order", + "Service / Vendor", + "Amount", + "Approved on WO", + "Notes", + "Attachments", + "Requested By", + "Approved By", + "Approved At", + "Actions", +] as const; + +export function UpliftApprovalsTable({ + tab, + isLoading, + rows, + canRevoke, + onOpenDetail, + onApprove, + onReject, + onRevoke, + onOpenAttachment, + isDecisionPending, +}: { + tab: UpliftApprovalTab; + isLoading: boolean; + rows: UpliftQueueItem[]; + canRevoke: boolean; + onOpenDetail: (row: UpliftQueueItem) => void; + onApprove: (row: UpliftQueueItem) => void; + onReject: (row: UpliftQueueItem) => void; + onRevoke: (row: UpliftQueueItem) => void; + onOpenAttachment: (row: UpliftQueueItem) => void; + isDecisionPending: boolean; +}) { + const headerCells = tab === "pending" ? PENDING_HEADER_CELLS : APPROVED_HEADER_CELLS; + return ( + + + + + {headerCells.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-decision-dialogs.tsx b/src/app/(protected)/uplifts/_components/uplift-decision-dialogs.tsx new file mode 100644 index 00000000..7cc8537b --- /dev/null +++ b/src/app/(protected)/uplifts/_components/uplift-decision-dialogs.tsx @@ -0,0 +1,42 @@ +import { RejectDialog } from "@/app/(protected)/uplifts/_components/reject-dialog"; +import { RevokeDialog } from "@/app/(protected)/uplifts/_components/revoke-dialog"; +import type { useUpliftApprovalController } from "@/domain/uplifts/use-cases/use-uplift-approval-controller"; + +export type UpliftApprovalController = ReturnType; + +function woFallbackNumber(target: UpliftApprovalController["rejectTarget"]): string { + return target?.woNumber || String(target?.workOrderId ?? ""); +} + +export function UpliftDecisionDialogs({ controller }: { controller: UpliftApprovalController }) { + const { + rejectTarget, + revokeTarget, + rejectPending, + revokePending, + handleRejectConfirm, + handleRevokeConfirm, + closeReject, + closeRevoke, + } = controller; + return ( + <> + + + + ); +} 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..55422e2e --- /dev/null +++ b/src/app/(protected)/uplifts/_components/uplift-detail-modal.tsx @@ -0,0 +1,418 @@ +import { useMemo, type ReactNode } from "react"; +import { + Box, + Button, + Chip, + Dialog, + DialogActions, + DialogContent, + DialogTitle, + Tooltip, +} from "@mui/material"; +import { formatUpliftMoney } from "@/app/(protected)/uplifts/_components/format-uplift-money"; +import { Text } from "@/components/ui/text"; +import { useWorkOrderBoardDetail } from "@/domain/work-orders/use-cases/use-work-order-detail"; +import { useWorkOrderUplifts } from "@/domain/work-orders/use-cases/use-work-order-uplifts"; +import { useUpliftsCanApprove } from "@/domain/uplifts/use-cases/use-uplifts-can-approve"; +import { isWorkOrderUpliftsReadOnly } from "@/domain/work-orders/utils/uplift-display-utils"; +import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row"; +import type { UpliftQueueItem } from "@/domain/uplifts/types/uplift"; +import { getWaitTimeTextClass, timeSince, waitTimeColor } from "@/lib/time-utils"; + +const CLOSED_WO_TOOLTIP = "This work order is closed. Uplifts can no longer be revoked."; +const ADMIN_ONLY_REVOKE_TOOLTIP = "Only admins can revoke uplifts"; +const UNAVAILABLE_LABEL = "Unavailable"; + +type ModalCallbacks = { + onClose: () => void; + onApprove: (row: UpliftQueueItem) => void; + onReject: (row: UpliftQueueItem) => void; + onRevoke: (row: UpliftQueueItem) => void; + onOpenAttachment: (row: UpliftQueueItem) => void; +}; + +function DetailField({ label, children }: { label: string; children: ReactNode }) { + return ( + + + {label} + + {children} + + ); +} + +function DetailSection({ title, children }: { title: string; children: ReactNode }) { + return ( + + + {title} + + {children} + + ); +} + +function UpliftAttachments({ + item, + onOpenAttachment, +}: { + item: UpliftQueueItem; + onOpenAttachment: (row: UpliftQueueItem) => void; +}) { + if (item.evidenceDocumentId == null && !item.evidenceFileName) { + return ( + + No attachments + + ); + } + const extraCount = + typeof item.attachmentCount === "number" && item.attachmentCount > 1 + ? item.attachmentCount - 1 + : 0; + return ( + + onOpenAttachment(item) : undefined} + clickable={item.evidenceDocumentId != null} + /> + {extraCount > 0 && } + + ); +} + +function WorkOrderSection({ + item, + info, + unavailable, +}: { + item: UpliftQueueItem; + info?: WorkOrderTableRow; + unavailable: boolean; +}) { + const statusFallback = unavailable ? UNAVAILABLE_LABEL : "—"; + return ( + + + {item.woNumber || "—"} + + + {info?.site || item.site || "—"} + + + {info?.status || statusFallback} + + + + {info?.scheduledOn || (unavailable ? UNAVAILABLE_LABEL : "Unscheduled")} + + + + {item.vendorCompanyName || "—"} + + + ); +} + +function RequestSection({ + item, + onOpenAttachment, +}: { + item: UpliftQueueItem; + onOpenAttachment: (row: UpliftQueueItem) => void; +}) { + 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, + unavailable, +}: { + auto: number | null; + admin: number | null; + total: number | null; + unavailable: boolean; +}) { + const rows: Array<{ label: string; value: number | null; strong: boolean }> = [ + { label: "Auto-approved", value: auto, strong: false }, + { label: "Admin-approved", value: admin, strong: false }, + { label: "Total", value: total, strong: true }, + ]; + return ( + + {rows.map((row) => ( + + ))} + + ); +} + +function BreakdownRow({ + label, + value, + strong, + unavailable, +}: { + label: string; + value: number | null; + strong: boolean; + unavailable: boolean; +}) { + return ( + + + {label} + + + {value != null ? formatUpliftMoney(value) : unavailable ? UNAVAILABLE_LABEL : "—"} + + + ); +} + +function PendingModalActions({ + item, + approvePending, + onApprove, + onReject, +}: { + item: UpliftQueueItem; + approvePending: boolean; + onApprove: (row: UpliftQueueItem) => void; + onReject: (row: UpliftQueueItem) => void; +}) { + const canApproveTier = useUpliftsCanApprove(item.requiredTier); + const canDecide = item.canDecide && canApproveTier.data === true; + const tooltip = canDecide ? "" : `Requires Tier ${item.requiredTier} role`; + return ( + <> + + + + + + + + + + + + ); +} + +function UpliftModalActions({ + item, + closed, + canRevoke, + approvePending, + revokePending, + onApprove, + onReject, + onRevoke, +}: { + item: UpliftQueueItem; + closed: boolean; + canRevoke: boolean; + approvePending: boolean; + revokePending: boolean; + onApprove: (row: UpliftQueueItem) => void; + onReject: (row: UpliftQueueItem) => void; + onRevoke: (row: UpliftQueueItem) => void; +}) { + const revokeTooltip = !canRevoke ? ADMIN_ONLY_REVOKE_TOOLTIP : closed ? CLOSED_WO_TOOLTIP : ""; + return ( + <> + {item.status === "Pending" && ( + + )} + {item.status === "Approved" && ( + + + + + + )} + + ); +} + +export function UpliftDetailModal({ + item, + canRevoke, + approvePending, + revokePending, + onClose, + onApprove, + onReject, + onRevoke, + onOpenAttachment, +}: { + item: UpliftQueueItem | null; + canRevoke: boolean; + approvePending: boolean; + revokePending: boolean; +} & ModalCallbacks) { + const open = item != null; + // Permission failures (e.g. 403 for account-scoped users) are handled quietly + // in this modal: dependent sections fall back to "Unavailable" instead of toasting. + const quietMeta = { suppressErrorToast: true } as const; + const boardDetail = useWorkOrderBoardDetail( + item != null && item.workOrderId != null ? item.workOrderId : undefined, + true, + quietMeta, + ); + const woUpliftsQuery = useWorkOrderUplifts( + item != null && item.workOrderId != null ? item.workOrderId : null, + quietMeta, + ); + + const info = boardDetail.data?.info; + const closed = useMemo(() => { + if (item?.workOrderClosed === true) return true; + return info ? isWorkOrderUpliftsReadOnly(info.status) : false; + }, [item?.workOrderClosed, info]); + + const breakdown = useMemo(() => { + const woUplifts = woUpliftsQuery.data ?? []; + const autoFallback = sumUpliftAmounts(woUplifts, "auto_approved"); + const adminFallback = sumUpliftAmounts(woUplifts, "approved"); + const hasWoData = woUplifts.length > 0; + return { + auto: item?.approvedOnWoAuto ?? (hasWoData ? autoFallback : null), + admin: item?.approvedOnWoAdmin ?? (hasWoData ? adminFallback : null), + total: item?.approvedOnWoTotal ?? (hasWoData ? autoFallback + adminFallback : null), + }; + }, [ + item?.approvedOnWoAuto, + item?.approvedOnWoAdmin, + item?.approvedOnWoTotal, + woUpliftsQuery.data, + ]); + + if (!open) { + return null; + } + + return ( + + + + 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..eb891ab7 100644 --- a/src/app/(protected)/uplifts/index.tsx +++ b/src/app/(protected)/uplifts/index.tsx @@ -1,473 +1,190 @@ -import { useMemo, useState } from "react"; -import { useNavigate } from "react-router"; +import { useState } from "react"; +import { Alert, Box, Chip, Stack, Tab, Tabs, TablePagination } from "@mui/material"; import { - Alert, - Box, - Button, - Chip, - CircularProgress, - Dialog, - DialogActions, - DialogContent, - DialogTitle, - FormControl, - InputLabel, - MenuItem, - Paper, - Select, - Stack, - Table, - TableBody, - TableCell, - TableContainer, - TableHead, - TablePagination, - TableRow, - TextField, - Tooltip, - Typography, -} from "@mui/material"; -import { upliftsApi } from "@/domain/uplifts/api/uplifts-api"; -import type { UpliftQueueItem } from "@/domain/uplifts/types/uplift"; -import { UPLIFT_STATUSES } from "@/domain/uplifts/types/uplift"; -import { - useApproveUplift, - useRejectUplift, - useRequestChangesUplift, -} from "@/domain/uplifts/use-cases/use-uplift-actions"; + UpliftApprovalsTable, + type UpliftApprovalTab, +} from "@/app/(protected)/uplifts/_components/uplift-approvals-table"; +import { UpliftDecisionDialogs } from "@/app/(protected)/uplifts/_components/uplift-decision-dialogs"; +import { UpliftDetailModal } from "@/app/(protected)/uplifts/_components/uplift-detail-modal"; +import { openUpliftEvidence } from "@/app/(protected)/uplifts/_components/open-uplift-evidence"; +import { formatUpliftMoney } from "@/app/(protected)/uplifts/_components/format-uplift-money"; +import { isAdminUser } from "@/lib/auth/user-utils"; +import { useAuthContext } from "@/providers/auth-context"; +import { useUpliftApprovalController } from "@/domain/uplifts/use-cases/use-uplift-approval-controller"; import { useUpliftsQueue } from "@/domain/uplifts/use-cases/use-uplifts-queue"; -import { formatDateTime } from "@/lib/time-utils"; -import { toast } from "react-toastify"; -import { RejectDialog } from "@/app/(protected)/uplifts/_components/reject-dialog"; +import { Text } from "@/components/ui/text"; -const STATUS_OPTIONS = ["", ...UPLIFT_STATUSES]; +const PAGE_SIZE = 25; -type UpliftActionType = "approve" | "reject" | "request-changes"; - -type UpliftDecision = { type: UpliftActionType; item: UpliftQueueItem }; - -function formatMoney(value: number | null | undefined): string { - return `$${Number(value ?? 0).toFixed(2)}`; -} - -function formatBytes(value: number | null | undefined): string { - if (value == null) return ""; - if (value < 1024) return `${value} B`; - if (value < 1024 * 1024) return `${(value / 1024).toFixed(0)} KB`; - return `${(value / (1024 * 1024)).toFixed(1)} MB`; -} - -function UpliftQueueHeader({ - totalCount, - isFetching, - isLoading, -}: { - totalCount: number; - isFetching: boolean; - isLoading: boolean; -}) { +function UpliftApprovalsHeader({ pendingExposureTotal }: { pendingExposureTotal: number | null }) { return ( - - - Uplift Queue - - - {totalCount} total - - {isFetching && !isLoading && } - + + + Uplift Approvals + + + Requests to increase the approved spend on a work order, above its remaining auto-approval + allowance. + + + Pending exposure:{" "} + {pendingExposureTotal && pendingExposureTotal > 0 + ? formatUpliftMoney(pendingExposureTotal) + : "—"} + + ); } -function UpliftQueueFilters({ - status, - tier, - onStatusChange, - onTierChange, +function UpliftApprovalsTabs({ + tab, + pendingCount, + onChange, }: { - status: string; - tier: string; - onStatusChange: (value: string) => void; - onTierChange: (value: string) => void; + tab: UpliftApprovalTab; + pendingCount: number; + onChange: (_event: unknown, value: string) => void; }) { return ( - - - 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)} - - )} - + + + Pending + - ) : ( - - — - - )} - - {row.requestedAt ? formatDateTime(row.requestedAt) : ""} - - - {Boolean(row.workOrderId) && ( - - )} - {decisionable && ( - <> - - - - - - - - - - - - - - - - - )} - - - + } + /> + + ); } -function UpliftQueueTable({ - isLoading, - rows, - onViewWorkOrder, - onDownloadEvidence, - onDecide, +function UpliftApprovalsPagination({ + tab, + count, + pendingPage, + approvedPage, + onPendingPageChange, + onApprovedPageChange, }: { - isLoading: boolean; - rows: UpliftQueueItem[]; - onViewWorkOrder: (row: UpliftQueueItem) => void; - onDownloadEvidence: (row: UpliftQueueItem) => void; - onDecide: (type: UpliftActionType, row: UpliftQueueItem) => void; + tab: UpliftApprovalTab; + count: number; + pendingPage: number; + approvedPage: number; + onPendingPageChange: (page: number) => void; + onApprovedPageChange: (page: number) => 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) => ( - - )) - )} - -
-
+ + tab === "pending" ? onPendingPageChange(nextPage) : onApprovedPageChange(nextPage) + } + rowsPerPage={PAGE_SIZE} + rowsPerPageOptions={[PAGE_SIZE]} + /> ); } -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; -}) { +function UpliftQueueError({ error }: { error: unknown }) { + if (!error) return null; 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)} - /> - - - - - - + + {error instanceof Error ? error.message : "Failed to load uplift queue"} + ); } 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 listParams = useMemo( - () => ({ status, tier, page: page + 1, pageSize }), - [status, tier, page, pageSize], - ); + const { user } = useAuthContext(); + const canRevokeUplifts = isAdminUser(user?.userRoles); - const { data, isLoading, isFetching, error, refetch } = useUpliftsQueue(listParams); - const approveUplift = useApproveUplift(); - const rejectUplift = useRejectUplift(); - const requestChangesUplift = useRequestChangesUplift(); + const pendingQuery = useUpliftsQueue({ + status: "Pending", + page: pendingPage + 1, + pageSize: PAGE_SIZE, + }); + const approvedQuery = useUpliftsQueue({ + status: "Approved", + page: approvedPage + 1, + pageSize: PAGE_SIZE, + }); - const rows = data?.items ?? []; - const totalCount = data?.totalCount ?? 0; - const genericAction = upliftAction?.type === "reject" ? null : upliftAction; - const noteRequired = genericAction?.type === "request-changes"; - const decisionPending = approveUplift.isPending || requestChangesUplift.isPending; + const controller = useUpliftApprovalController(); + const { + detailItem, + approvePending, + rejectPending, + revokePending, + handleApprove, + handleRejectRequest, + handleRevokeRequest, + closeDetail, + openDetail, + } = controller; - const handleStatusChange = (value: string) => { - setStatus(value); - setPage(0); - }; - const handleTierChange = (value: string) => { - setTier(value); - setPage(0); - }; + const activeQuery = tab === "pending" ? pendingQuery : approvedQuery; + const rows = activeQuery.data?.items ?? []; + const pendingCount = pendingQuery.data?.totalCount ?? 0; + const pendingExposureTotal = pendingQuery.data?.pendingExposureTotal ?? null; - const closeDialog = () => { - setUpliftAction(null); - setNote(""); - }; - - const handleConfirm = () => { - if (!upliftAction) return; - if (noteRequired && !note.trim()) return; - const id = upliftAction.item.id; - const onDone = () => { - closeDialog(); - void refetch(); - }; - if (upliftAction.type === "approve") { - approveUplift.mutate({ id, note: note || undefined }, { onSuccess: onDone }); - return; + const handleTabChange = (_event: unknown, value: string) => { + if (value === "pending" || value === "approved") { + setTab(value); } - if (upliftAction.type === "request-changes") { - requestChangesUplift.mutate({ id, note: note.trim() }, { onSuccess: onDone }); - } - }; - - const handleRejectConfirm = (reason: string) => { - if (upliftAction?.type !== "reject") return; - rejectUplift.mutate( - { id: upliftAction.item.id, note: reason }, - { - onSuccess: () => { - closeDialog(); - void refetch(); - }, - }, - ); - }; - - const downloadEvidence = async (item: UpliftQueueItem) => { - try { - await upliftsApi.downloadEvidence(item.id, item.evidenceFileName || "uplift-evidence"); - } catch (err) { - toast.error(err instanceof Error ? err.message : "Unable to download evidence."); - } - }; - - const handleDecide = (type: UpliftActionType, item: UpliftQueueItem) => { - setUpliftAction({ type, item }); - setNote(""); }; return ( - - - - - {Boolean(error) && ( - - {error instanceof Error ? error.message : "Failed to load uplift queue"} - - )} - + + + navigate(`/workorders/${r.workOrderId}`)} - onDownloadEvidence={(r) => void downloadEvidence(r)} - onDecide={handleDecide} + canRevoke={canRevokeUplifts} + onOpenDetail={openDetail} + onApprove={handleApprove} + onReject={handleRejectRequest} + onRevoke={handleRevokeRequest} + onOpenAttachment={openUpliftEvidence} + isDecisionPending={approvePending || rejectPending || revokePending} /> - setPage(nextPage)} - rowsPerPage={pageSize} - rowsPerPageOptions={[25]} - /> - - + {detailItem != null && ( + + )} + ); } diff --git a/src/domain/uplifts/api/uplifts-api.ts b/src/domain/uplifts/api/uplifts-api.ts index 890d7116..21937764 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), }; }, @@ -81,10 +86,22 @@ export const upliftsApi = { await apiPost(`${API_PATHS.rest.uplifts}/${id}/reject`, { note }); }, + canApprove: async (tier: number): Promise => { + const data = await apiGet(`${API_PATHS.rest.uplifts}/can-approve`, { + searchParams: { tier }, + }); + const response = handleApiResponse<{ canApprove?: unknown }>(data); + return response?.canApprove === true; + }, + requestChanges: async (id: string | number, note: string): Promise => { await apiPost(`${API_PATHS.rest.uplifts}/${id}/request-changes`, { note }); }, + revoke: async (id: string | number, reason: string): Promise => { + await apiPost(`${API_PATHS.rest.uplifts}/${id}/revoke`, { note: 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..300a6877 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 { @@ -49,7 +57,13 @@ export function mapUpliftRequest(raw: unknown): UpliftRequest { delta: readNumber(item, "delta", "Delta") ?? 0, vendorReason: readString(item, "vendorReason", "VendorReason"), requestedAt: readString(item, "requestedAt", "RequestedAt"), - requestedByVendorName: readString(item, "requestedByVendorName", "RequestedByVendorName"), + requestedByVendorName: readString( + item, + "requestedByName", + "RequestedByName", + "requestedByVendorName", + "RequestedByVendorName", + ), decidedAt: readString(item, "decidedAt", "DecidedAt"), decidedByName: readString(item, "decidedByName", "DecidedByName"), decisionNote: readString(item, "decisionNote", "DecisionNote"), @@ -79,9 +93,86 @@ export function mapUpliftQueueItem(raw: unknown): UpliftQueueItem { vendorCompanyName: readString(item, "vendorCompanyName", "VendorCompanyName"), workOrderId: readOptionalId(item, "workOrderId", "WorkOrderId"), dispatchId: readOptionalId(item, "dispatchId", "DispatchId"), + woNumber: readString(item, "woNumber", "WoNumber", "workOrderNumber", "WorkOrderNumber"), + site: readString( + item, + "workOrderSite", + "WorkOrderSite", + "site", + "Site", + "siteCode", + "SiteCode", + "locationName", + "LocationName", + ), + serviceName: readString( + item, + "workOrderService", + "WorkOrderService", + "serviceName", + "ServiceName", + "service", + "Service", + "trade", + "Trade", + ), + attachmentCount: readNumber( + item, + "attachmentCount", + "AttachmentCount", + "attachmentsCount", + "AttachmentsCount", + ), + approvedOnWoAuto: readNumber( + item, + "workOrderAutoApprovedTotal", + "WorkOrderAutoApprovedTotal", + "approvedOnWoAuto", + "ApprovedOnWoAuto", + "autoApprovedTotal", + "AutoApprovedTotal", + ), + approvedOnWoAdmin: readNumber( + item, + "workOrderAdminApprovedTotal", + "WorkOrderAdminApprovedTotal", + "approvedOnWoAdmin", + "ApprovedOnWoAdmin", + "adminApprovedTotal", + "AdminApprovedTotal", + ), + approvedOnWoTotal: readNumber( + item, + "workOrderApprovedExposureTotal", + "WorkOrderApprovedExposureTotal", + "approvedOnWoTotal", + "ApprovedOnWoTotal", + "approvedOnWorkOrderTotal", + "ApprovedOnWorkOrderTotal", + ), + workOrderClosed: readOptionalBool( + item, + "workOrderClosed", + "WorkOrderClosed", + "isWorkOrderClosed", + "IsWorkOrderClosed", + ), }; } export function mapUpliftQueueItems(rawItems: unknown[]): UpliftQueueItem[] { return rawItems.map(mapUpliftQueueItem); } + +export function mapUpliftQueueExposureTotal(raw: unknown): number | null { + const record = asRecord(raw); + return readNumber( + record, + "pendingExposureTotal", + "PendingExposureTotal", + "totalPendingExposure", + "TotalPendingExposure", + "exposureTotal", + "ExposureTotal", + ); +} 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..1de4d83b 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) }); @@ -33,14 +36,32 @@ 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; + amount?: number; + woNumber?: string; } export interface DecisionUpliftInput { id: string | number; note: string; + amount?: number; + woNumber?: string; +} + +function decisionAmount(amount: number | undefined): string { + if (amount == null || !Number.isFinite(amount)) return "the requested amount"; + return new Intl.NumberFormat("en-US", { style: "currency", currency: "USD" }).format(amount); +} + +function decisionWorkOrder(woNumber: string | undefined): string { + return woNumber?.trim() || "the work order"; } export function useApproveUplift( @@ -49,11 +70,12 @@ export function useApproveUplift( const queryClient = useQueryClient(); return useMutation({ - mutationFn: ({ id, note }: { id: string | number; note?: string }) => - upliftsApi.approve(id, note), - onSuccess: () => { + mutationFn: ({ id, note }: ApproveUpliftInput) => upliftsApi.approve(id, note), + onSuccess: (_, variables) => { invalidateUpliftQueries(queryClient, dispatchId); - toast.success("Uplift approved — NTE updated"); + toast.success( + `Uplift approved — ${decisionAmount(variables.amount)} on ${decisionWorkOrder(variables.woNumber)}`, + ); }, onError: (error: Error) => { toast.error(error.message || "Failed to approve uplift"); @@ -67,11 +89,12 @@ export function useRejectUplift( const queryClient = useQueryClient(); return useMutation({ - mutationFn: ({ id, note }: { id: string | number; note: string }) => - upliftsApi.reject(id, note), - onSuccess: () => { + mutationFn: ({ id, note }: DecisionUpliftInput) => upliftsApi.reject(id, note), + onSuccess: (_, variables) => { invalidateUpliftQueries(queryClient, dispatchId); - toast.success("Uplift rejected"); + toast.success( + `Uplift rejected — ${decisionAmount(variables.amount)} on ${decisionWorkOrder(variables.woNumber)}`, + ); }, onError: (error: Error) => { toast.error(error.message || "Failed to reject uplift"); @@ -96,3 +119,27 @@ export function useRequestChangesUplift( }, }); } + +export interface RevokeUpliftInput { + upliftId: string | number; + reason: string; + amount?: number; + woNumber?: string; +} + +export function useRevokeUplift(): UseMutationResult { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: ({ upliftId, reason }: RevokeUpliftInput) => upliftsApi.revoke(upliftId, reason), + onSuccess: (_, variables) => { + invalidateWorkOrderUpliftQueries(queryClient); + toast.success( + `Uplift revoked — ${decisionAmount(variables.amount)} on ${decisionWorkOrder(variables.woNumber)}`, + ); + }, + onError: (error: Error) => { + toast.error(error.message || "Failed to revoke uplift"); + }, + }); +} 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..f02e7664 --- /dev/null +++ b/src/domain/uplifts/use-cases/use-uplift-approval-controller.ts @@ -0,0 +1,75 @@ +import { useState } from "react"; +import type { UpliftQueueItem } from "@/domain/uplifts/types/uplift"; +import { + useApproveUplift, + useRejectUplift, + useRevokeUplift, +} from "@/domain/uplifts/use-cases/use-uplift-actions"; + +export function useUpliftApprovalController() { + const [detailItem, setDetailItem] = useState(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, amount: row.delta, woNumber: row.woNumber }); + setDetailItem(null); + }; + + const handleRejectRequest = (row: UpliftQueueItem) => { + setDetailItem(null); + setRejectTarget(row); + }; + + const handleRevokeRequest = (row: UpliftQueueItem) => { + setDetailItem(null); + setRevokeTarget(row); + }; + + const handleRejectConfirm = (reason: string) => { + if (!rejectTarget) return; + rejectUplift.mutate( + { + id: rejectTarget.id, + note: reason, + amount: rejectTarget.delta, + woNumber: rejectTarget.woNumber, + }, + { onSuccess: () => setRejectTarget(null) }, + ); + }; + + const handleRevokeConfirm = (reason: string) => { + if (!revokeTarget) return; + revokeUplift.mutate( + { + upliftId: revokeTarget.id, + reason, + amount: revokeTarget.delta, + woNumber: revokeTarget.woNumber, + }, + { onSuccess: () => setRevokeTarget(null) }, + ); + }; + + return { + detailItem, + rejectTarget, + revokeTarget, + approvePending: approveUplift.isPending, + rejectPending: rejectUplift.isPending, + revokePending: revokeUplift.isPending, + handleApprove, + handleRejectRequest, + handleRevokeRequest, + handleRejectConfirm, + handleRevokeConfirm, + closeDetail: () => setDetailItem(null), + closeReject: () => setRejectTarget(null), + closeRevoke: () => setRevokeTarget(null), + openDetail: setDetailItem, + }; +} diff --git a/src/domain/uplifts/use-cases/use-uplifts-can-approve.ts b/src/domain/uplifts/use-cases/use-uplifts-can-approve.ts new file mode 100644 index 00000000..1b9dd971 --- /dev/null +++ b/src/domain/uplifts/use-cases/use-uplifts-can-approve.ts @@ -0,0 +1,17 @@ +import { useQuery, type UseQueryResult } from "@tanstack/react-query"; +import { upliftsApi } from "@/domain/uplifts/api/uplifts-api"; +import { queryKeys } from "@/infra/query-key/query-key"; + +/** + * Backend-authoritative answer to "may the current user decide uplifts at this + * tier?" (GET uplifts/can-approve). Combines with the per-row canDecide signal + * so decision controls stay disabled for roles the backend would refuse. + */ +export function useUpliftsCanApprove(tier: number): UseQueryResult { + return useQuery({ + queryKey: queryKeys.uplifts.canApprove(tier), + queryFn: () => upliftsApi.canApprove(tier), + staleTime: 5 * 60 * 1000, + retry: 1, + }); +} diff --git a/src/domain/work-orders/use-cases/use-work-order-detail.ts b/src/domain/work-orders/use-cases/use-work-order-detail.ts index a8b3ef3f..fc28b8fd 100644 --- a/src/domain/work-orders/use-cases/use-work-order-detail.ts +++ b/src/domain/work-orders/use-cases/use-work-order-detail.ts @@ -27,11 +27,13 @@ export function useWorkOrderDetail( export function useWorkOrderBoardDetail( id: string | number | undefined, enabled = true, + meta?: { suppressErrorToast?: boolean }, ): UseQueryResult { return useQuery({ queryKey: queryKeys.workOrders.boardDetail(id ?? ""), queryFn: () => workOrdersApi.getBoardDetail(requireQueryParam(id, "Work order id")), enabled: enabled && id !== undefined && id !== "", + meta, // Do not keepPreviousData — placeholder detail from another WO leaks into the slide-over. }); } diff --git a/src/domain/work-orders/use-cases/use-work-order-uplifts.ts b/src/domain/work-orders/use-cases/use-work-order-uplifts.ts index ec9cd3cc..3a94e733 100644 --- a/src/domain/work-orders/use-cases/use-work-order-uplifts.ts +++ b/src/domain/work-orders/use-cases/use-work-order-uplifts.ts @@ -24,12 +24,14 @@ function invalidateUpliftQueries( export function useWorkOrderUplifts( workOrderId: string | number | null | undefined, + meta?: { suppressErrorToast?: boolean }, ): UseQueryResult { return useQuery({ queryKey: queryKeys.workOrders.uplifts(workOrderId ?? ""), queryFn: () => workOrderUpliftsApi.list(requireQueryParam(workOrderId ?? undefined, "Work order id")), enabled: workOrderId != null && workOrderId !== "", + meta, }); } diff --git a/src/infra/query-key/query-key.ts b/src/infra/query-key/query-key.ts index 25fd0c82..693f5a51 100644 --- a/src/infra/query-key/query-key.ts +++ b/src/infra/query-key/query-key.ts @@ -106,6 +106,7 @@ export const queryKeys = { forDispatch: (dispatchId: string | number) => [...queryKeys.uplifts.all, "dispatch", dispatchId] as const, evidence: (id: string | number) => [...queryKeys.uplifts.all, "evidence", id] as const, + canApprove: (tier: number) => [...queryKeys.uplifts.all, "can-approve", tier] as const, }, vendorPos: { all: ["vendorPos"] as const, diff --git a/src/lib/query/query-client.ts b/src/lib/query/query-client.ts index 1223fc2b..6c321caf 100644 --- a/src/lib/query/query-client.ts +++ b/src/lib/query/query-client.ts @@ -1,33 +1,39 @@ import { MutationCache, QueryCache, QueryClient } from "@tanstack/react-query"; import { toast } from "react-toastify"; -export const queryClient = new QueryClient({ - queryCache: new QueryCache({ - onError: (error) => { - console.error("Query error:", error); - const message = - error instanceof Error ? error.message : "An error occurred while fetching data"; - toast.error(message); - }, - }), - mutationCache: new MutationCache({ - onError: (error, _variables, _context, mutation) => { - if (mutation.options.meta?.suppressErrorToast) return; +export function createAppQueryClient(): QueryClient { + return new QueryClient({ + queryCache: new QueryCache({ + onError: (error, query) => { + if (query.meta?.suppressErrorToast) return; - console.error("Mutation error:", error); - const message = - error instanceof Error ? error.message : "An error occurred while updating data"; - toast.error(message); + console.error("Query error:", error); + const message = + error instanceof Error ? error.message : "An error occurred while fetching data"; + toast.error(message); + }, + }), + mutationCache: new MutationCache({ + onError: (error, _variables, _context, mutation) => { + if (mutation.options.meta?.suppressErrorToast) return; + + console.error("Mutation error:", error); + const message = + error instanceof Error ? error.message : "An error occurred while updating data"; + toast.error(message); + }, + }), + defaultOptions: { + queries: { + staleTime: 5 * 60 * 1000, + gcTime: 10 * 60 * 1000, + retry: 1, + refetchOnWindowFocus: false, + }, }, - }), - defaultOptions: { - queries: { - staleTime: 5 * 60 * 1000, - gcTime: 10 * 60 * 1000, - retry: 1, - refetchOnWindowFocus: false, - }, - }, -}); + }); +} + +export const queryClient = createAppQueryClient(); export default queryClient; 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..7b3456a8 --- /dev/null +++ b/src/test/app/(protected)/uplifts/uplift-approvals-approved-tab.test.tsx @@ -0,0 +1,178 @@ +import { fireEvent, screen } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { UpliftQueueItem, UpliftsQueueResult } from "@/domain/uplifts/types/uplift"; +import { renderWithProviders } from "@/test/test-utils"; + +const revokeMutate = vi.fn(); + +const queueState = vi.hoisted(() => ({ + data: null as UpliftsQueueResult | null, +})); +const authState = vi.hoisted(() => ({ + userRoles: "Admin", +})); + +const approvedItem: UpliftQueueItem = { + id: 77, + status: "Approved", + currentNTE: 500, + requestedNTE: 900, + delta: 400, + vendorReason: "Additional scope", + requestedAt: "2026-01-10T08:00:00Z", + requestedByVendorName: "Gateway", + decidedAt: "2026-01-12T09:30:00Z", + decidedByName: "Admin User", + decisionNote: "", + requiredTier: 1, + canDecide: false, + expiresAt: "", + notificationStatus: "", + notificationError: "", + evidenceDocumentId: "doc-1", + evidenceFileName: "quote.pdf", + evidenceContentType: "application/pdf", + evidenceSizeBytes: 2048, + dispatchNumber: "DSP-77", + poNumber: "PO-77", + vendorCompanyName: "Gateway Plumbing", + workOrderId: 55, + dispatchId: 12, + woNumber: "WO-55", + site: "Site B", + serviceName: "HVAC service", + attachmentCount: 3, + approvedOnWoAuto: 100, + approvedOnWoAdmin: 400, + approvedOnWoTotal: 500, + workOrderClosed: true, +}; + +const approvedOpenItem: UpliftQueueItem = { + ...approvedItem, + id: 78, + woNumber: "WO-56", + workOrderClosed: false, +}; + +vi.mock("@/domain/uplifts/use-cases/use-uplifts-queue", () => ({ + useUpliftsQueue: () => ({ + data: queueState.data, + isLoading: false, + isFetching: false, + error: null, + refetch: vi.fn(), + }), +})); + +vi.mock("@/domain/uplifts/use-cases/use-uplift-actions", () => ({ + useApproveUplift: () => ({ isPending: false, mutate: vi.fn() }), + useRejectUplift: () => ({ isPending: false, mutate: vi.fn() }), + useRevokeUplift: () => ({ isPending: false, mutate: revokeMutate }), +})); + +vi.mock("@/providers/auth-context", async (importOriginal) => ({ + ...(await importOriginal()), + useAuthContext: () => ({ user: { userRoles: authState.userRoles } }), +})); + +vi.mock("@/domain/uplifts/use-cases/use-uplifts-can-approve", () => ({ + useUpliftsCanApprove: () => ({ data: true }), +})); + +describe("Uplift Approvals approved tab", () => { + beforeEach(() => { + revokeMutate.mockReset(); + authState.userRoles = "Admin"; + queueState.data = { + items: [approvedItem], + totalCount: 1, + page: 1, + pageSize: 25, + pendingExposureTotal: null, + }; + }); + + it("disables Revoke with the closed work order tooltip", async () => { + const { default: UpliftQueuePage } = await import("@/app/(protected)/uplifts/index"); + renderWithProviders(); + + fireEvent.click(screen.getByRole("tab", { name: "Approved" })); + expect(screen.getByRole("columnheader", { name: "Approved By" })).toBeInTheDocument(); + expect(screen.getByRole("columnheader", { name: "Approved At" })).toBeInTheDocument(); + expect(screen.queryByRole("columnheader", { name: "Waiting" })).not.toBeInTheDocument(); + expect(screen.getByText("Admin User")).toBeInTheDocument(); + const revoke = await screen.findByRole("button", { name: "Revoke" }); + expect(revoke).toBeDisabled(); + fireEvent.mouseOver(revoke); + expect( + await screen.findByText("This work order is closed. Uplifts can no longer be revoked."), + ).toBeInTheDocument(); + expect(revokeMutate).not.toHaveBeenCalled(); + }); + + it("opens RevokeDialog for an approved uplift on an open work order", async () => { + queueState.data = { + items: [approvedOpenItem], + totalCount: 1, + page: 1, + pageSize: 25, + pendingExposureTotal: null, + }; + const { default: UpliftQueuePage } = await import("@/app/(protected)/uplifts/index"); + renderWithProviders(); + + fireEvent.click(screen.getByRole("tab", { name: "Approved" })); + const revoke = await screen.findByRole("button", { name: "Revoke" }); + expect(revoke).toBeEnabled(); + fireEvent.click(revoke); + expect(screen.getByRole("heading", { name: "Revoke this approval?" })).toBeInTheDocument(); + expect( + screen.getByText( + "The approved uplift of $900.00 on WO WO-56 will be withdrawn. This does not recover money already spent — it records that the authorization was a mistake.", + ), + ).toBeInTheDocument(); + }); + + it("disables Revoke with the admin-only tooltip for a Dispatcher and never opens the dialog", async () => { + authState.userRoles = "Dispatcher"; + queueState.data = { + items: [approvedOpenItem], + totalCount: 1, + page: 1, + pageSize: 25, + pendingExposureTotal: null, + }; + const { default: UpliftQueuePage } = await import("@/app/(protected)/uplifts/index"); + renderWithProviders(); + + fireEvent.click(screen.getByRole("tab", { name: "Approved" })); + const revoke = await screen.findByRole("button", { name: "Revoke" }); + expect(revoke).toBeDisabled(); + fireEvent.mouseOver(revoke); + expect(await screen.findByText("Only admins can revoke uplifts")).toBeInTheDocument(); + fireEvent.click(revoke); + expect( + screen.queryByRole("heading", { name: "Revoke this approval?" }), + ).not.toBeInTheDocument(); + expect(revokeMutate).not.toHaveBeenCalled(); + }); + + it("shows the approved empty copy when nothing is archived", async () => { + queueState.data = { + items: [], + totalCount: 0, + page: 1, + pageSize: 25, + pendingExposureTotal: null, + }; + const { default: UpliftQueuePage } = await import("@/app/(protected)/uplifts/index"); + renderWithProviders(); + + 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-permissions.test.tsx b/src/test/app/(protected)/uplifts/uplift-detail-modal-permissions.test.tsx new file mode 100644 index 00000000..2b65b7ca --- /dev/null +++ b/src/test/app/(protected)/uplifts/uplift-detail-modal-permissions.test.tsx @@ -0,0 +1,132 @@ +import { screen, waitFor } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { UpliftDetailModal } from "@/app/(protected)/uplifts/_components/uplift-detail-modal"; +import { ApiError } from "@/api/api-error"; +import type { UpliftQueueItem } from "@/domain/uplifts/types/uplift"; +import { createAppQueryClient } from "@/lib/query/query-client"; +import { renderWithProviders } from "@/test/test-utils"; + +const toastMocks = vi.hoisted(() => ({ error: vi.fn() })); +const boardDetailMock = vi.hoisted(() => ({ calls: 0 })); +const woUpliftsMock = vi.hoisted(() => ({ calls: 0 })); + +vi.mock("react-toastify", () => ({ + toast: { error: toastMocks.error }, +})); + +vi.mock("@/domain/work-orders/api/work-orders-api", () => ({ + workOrdersApi: { + getBoardDetail: vi.fn(() => { + boardDetailMock.calls += 1; + return Promise.reject( + new ApiError("You do not have permission to perform this action.", 403), + ); + }), + }, +})); + +vi.mock("@/domain/work-orders/api/work-order-uplifts-api", () => ({ + workOrderUpliftsApi: { + list: vi.fn(() => { + woUpliftsMock.calls += 1; + return Promise.reject( + new ApiError("You do not have permission to perform this action.", 403), + ); + }), + }, +})); + +vi.mock("@/domain/auth/api/auth-api", async (importOriginal) => ({ + ...(await importOriginal()), + getSession: vi.fn(() => + Promise.resolve({ + token: "t", + expiration: "", + email: "dispatch@seahaven.test", + userRoles: "Dispatcher", + phoneNumber: "", + fullname: "Dispatch User", + id: "u1", + }), + ), +})); + +const dispatcherItem: UpliftQueueItem = { + id: 41, + status: "Approved", + currentNTE: 100, + requestedNTE: 250, + delta: 150, + vendorReason: "", + requestedAt: "2026-01-15T10:00:00Z", + requestedByVendorName: "Gateway", + decidedAt: "2026-01-16T10:00:00Z", + decidedByName: "Admin User", + decisionNote: "", + requiredTier: 1, + canDecide: false, + expiresAt: "", + notificationStatus: "", + notificationError: "", + evidenceDocumentId: null, + evidenceFileName: "", + evidenceContentType: "", + evidenceSizeBytes: null, + dispatchNumber: "DSP-41", + poNumber: "PO-41", + vendorCompanyName: "Gateway Plumbing", + workOrderId: 99, + dispatchId: 7, + woNumber: "WO-99", + site: "Site A", + serviceName: "Plumbing repair", + attachmentCount: null, + approvedOnWoAuto: null, + approvedOnWoAdmin: null, + approvedOnWoTotal: null, + workOrderClosed: false, +}; + +function renderWithGovernedClient() { + const queryClient = createAppQueryClient(); + queryClient.setDefaultOptions({ queries: { retry: false } }); + return renderWithProviders( + , + { queryClient }, + ); +} + +describe("UpliftDetailModal permission failures (Dispatcher 403)", () => { + beforeEach(() => { + toastMocks.error.mockReset(); + boardDetailMock.calls = 0; + woUpliftsMock.calls = 0; + }); + + it("shows unavailable sections without an error toast and keeps Revoke admin-only", async () => { + renderWithGovernedClient(); + + await waitFor(() => { + expect(boardDetailMock.calls).toBeGreaterThan(0); + expect(woUpliftsMock.calls).toBeGreaterThan(0); + }); + + await waitFor(() => { + expect(screen.getAllByText("Unavailable").length).toBeGreaterThanOrEqual(3); + }); + + const revoke = screen.getByRole("button", { name: "Revoke" }); + expect(revoke).toBeDisabled(); + expect(toastMocks.error).not.toHaveBeenCalled(); + }); +}); 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..ea42e5ae --- /dev/null +++ b/src/test/app/(protected)/uplifts/uplift-detail-modal.test.tsx @@ -0,0 +1,183 @@ +import { fireEvent, screen } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { UpliftDetailModal } from "@/app/(protected)/uplifts/_components/uplift-detail-modal"; +import type { UpliftQueueItem } from "@/domain/uplifts/types/uplift"; +import { renderWithProviders } from "@/test/test-utils"; + +const boardDetail = vi.hoisted(() => ({ data: null as Record | 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 canApproveState = vi.hoisted(() => ({ data: true as boolean | undefined })); + +vi.mock("@/domain/uplifts/use-cases/use-uplifts-can-approve", () => ({ + useUpliftsCanApprove: () => ({ data: canApproveState.data }), +})); + +const baseItem: UpliftQueueItem = { + id: 41, + status: "Pending", + currentNTE: 100, + requestedNTE: 250, + delta: 150, + vendorReason: "", + requestedAt: "2026-01-15T10:00:00Z", + requestedByVendorName: "Gateway", + decidedAt: "", + decidedByName: "", + decisionNote: "", + requiredTier: 1, + canDecide: true, + expiresAt: "", + notificationStatus: "", + notificationError: "", + evidenceDocumentId: null, + evidenceFileName: "", + evidenceContentType: "", + evidenceSizeBytes: null, + dispatchNumber: "DSP-41", + poNumber: "PO-41", + vendorCompanyName: "Gateway Plumbing", + workOrderId: 99, + dispatchId: 7, + woNumber: "WO-99", + site: "Site A", + serviceName: "Plumbing repair", + attachmentCount: null, + approvedOnWoAuto: null, + approvedOnWoAdmin: null, + approvedOnWoTotal: null, + workOrderClosed: null, +}; + +function renderModal(overrides: Partial = {}, canRevoke = true) { + const onApprove = vi.fn(); + const onReject = vi.fn(); + const onRevoke = vi.fn(); + const onOpenAttachment = vi.fn(); + renderWithProviders( + , + ); + return { onApprove, onReject, onRevoke, onOpenAttachment }; +} + +describe("UpliftDetailModal", () => { + beforeEach(() => { + canApproveState.data = true; + boardDetail.data = { + info: { woNumber: "WO-99", site: "Site A", status: "Open", dueDate: "" }, + }; + woUplifts.data = [ + { id: 1, status: "auto_approved", amount: 100 }, + { id: 2, status: "approved", amount: 250 }, + ]; + }); + + it("shows work order and request placeholders for a pending uplift", () => { + renderModal(); + + expect(screen.getByText("Unscheduled")).toBeInTheDocument(); + expect(screen.getByText("No justification provided.")).toBeInTheDocument(); + expect(screen.getByText("No attachments")).toBeInTheDocument(); + }); + + it("opens an evidence attachment through the provided callback", () => { + const { onOpenAttachment } = renderModal({ + evidenceDocumentId: "document-1", + evidenceFileName: "quote.pdf", + attachmentCount: 1, + }); + + fireEvent.click(screen.getByText("quote.pdf")); + expect(onOpenAttachment).toHaveBeenCalledWith( + expect.objectContaining({ evidenceDocumentId: "document-1" }), + ); + }); + + it("falls back to the work order uplifts for the approved-on-WO breakdown", () => { + renderModal(); + + expect(screen.getByText("Auto-approved")).toBeInTheDocument(); + expect(screen.getByText("Admin-approved")).toBeInTheDocument(); + expect(screen.getByText("$100.00")).toBeInTheDocument(); + expect(screen.getByText("$250.00")).toBeInTheDocument(); + expect(screen.getByText("$350.00")).toBeInTheDocument(); + }); + + it("offers status-specific actions for a pending uplift", () => { + const { onApprove, onReject } = renderModal(); + + fireEvent.click(screen.getByRole("button", { name: "Approve" })); + expect(onApprove).toHaveBeenCalled(); + expect(screen.queryByRole("button", { name: "Revoke" })).not.toBeInTheDocument(); + + fireEvent.click(screen.getByRole("button", { name: "Reject" })); + expect(onReject).toHaveBeenCalled(); + }); + + it("disables Approve and Reject in the modal for a Dispatcher the backend refuses", async () => { + canApproveState.data = false; + const { onApprove, onReject } = renderModal(); + + const approve = screen.getByRole("button", { name: "Approve" }); + const reject = screen.getByRole("button", { name: "Reject" }); + expect(approve).toBeDisabled(); + expect(reject).toBeDisabled(); + fireEvent.mouseOver(approve); + expect(await screen.findByText("Requires Tier 1 role")).toBeInTheDocument(); + fireEvent.click(approve); + fireEvent.click(reject); + expect(onApprove).not.toHaveBeenCalled(); + expect(onReject).not.toHaveBeenCalled(); + }); + + it("offers Revoke for an approved uplift and disables it on a closed work order", () => { + const { onRevoke } = renderModal({ status: "Approved", workOrderClosed: true }); + + expect(screen.queryByRole("button", { name: "Approve" })).not.toBeInTheDocument(); + const revoke = screen.getByRole("button", { name: "Revoke" }); + expect(revoke).toBeDisabled(); + fireEvent.click(revoke); + expect(onRevoke).not.toHaveBeenCalled(); + }); + + it("disables Revoke with the admin-only tooltip for non-admin users", async () => { + const { onRevoke } = renderModal({ status: "Approved", workOrderClosed: false }, false); + + const revoke = screen.getByRole("button", { name: "Revoke" }); + expect(revoke).toBeDisabled(); + fireEvent.mouseOver(revoke); + expect(await screen.findByText("Only admins can revoke uplifts")).toBeInTheDocument(); + fireEvent.click(revoke); + expect(onRevoke).not.toHaveBeenCalled(); + }); + + it("does not render a heading nested inside the dialog title heading", () => { + renderModal(); + + const dialog = screen.getByRole("dialog"); + const headings = dialog.querySelectorAll("h1, h2, h3, h4, h5, h6"); + expect(headings.length).toBeGreaterThan(0); + const nestedHeadings = [...headings].filter((heading) => + heading.querySelector("h1, h2, h3, h4, h5, h6"), + ); + expect(nestedHeadings).toEqual([]); + }); +}); 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..23a4d3ec 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,18 @@ import { fireEvent, screen } from "@testing-library/react"; import { beforeEach, describe, expect, it, vi } from "vitest"; -import type { UpliftQueueItem } from "@/domain/uplifts/types/uplift"; +import type { UpliftQueueItem, UpliftsQueueResult } from "@/domain/uplifts/types/uplift"; import { renderWithProviders } from "@/test/test-utils"; const rejectMutate = vi.fn(); const approveMutate = vi.fn(); -const requestChangesMutate = vi.fn(); +const revokeMutate = vi.fn(); const refetch = vi.fn(); +const queueState = vi.hoisted(() => ({ + data: null as UpliftsQueueResult | null, +})); +const canApproveState = vi.hoisted(() => ({ data: true as boolean | undefined })); + const pendingItem: UpliftQueueItem = { id: 41, status: "Pending", @@ -34,11 +39,19 @@ const pendingItem: UpliftQueueItem = { vendorCompanyName: "Gateway Plumbing", workOrderId: 99, dispatchId: 7, + woNumber: "WO-99", + site: "Site A", + serviceName: "Plumbing repair", + attachmentCount: null, + approvedOnWoAuto: null, + approvedOnWoAdmin: null, + approvedOnWoTotal: 100, + workOrderClosed: false, }; vi.mock("@/domain/uplifts/use-cases/use-uplifts-queue", () => ({ useUpliftsQueue: () => ({ - data: { items: [pendingItem], totalCount: 1, page: 1, pageSize: 25 }, + data: queueState.data, isLoading: false, isFetching: false, error: null, @@ -49,33 +62,102 @@ vi.mock("@/domain/uplifts/use-cases/use-uplifts-queue", () => ({ vi.mock("@/domain/uplifts/use-cases/use-uplift-actions", () => ({ useApproveUplift: () => ({ isPending: false, mutate: approveMutate }), useRejectUplift: () => ({ isPending: false, mutate: rejectMutate }), - useRequestChangesUplift: () => ({ isPending: false, mutate: requestChangesMutate }), + useRevokeUplift: () => ({ isPending: false, mutate: revokeMutate }), })); -describe("Uplift queue decision dialogs", () => { +vi.mock("@/providers/auth-context", async (importOriginal) => ({ + ...(await importOriginal()), + useAuthContext: () => ({ user: { userRoles: "Admin" } }), +})); + +vi.mock("@/domain/uplifts/use-cases/use-uplifts-can-approve", () => ({ + useUpliftsCanApprove: () => ({ data: canApproveState.data }), +})); + +describe("Uplift Approvals decision flow", () => { beforeEach(() => { rejectMutate.mockReset(); approveMutate.mockReset(); - requestChangesMutate.mockReset(); + revokeMutate.mockReset(); refetch.mockReset(); + canApproveState.data = true; + queueState.data = { + items: [pendingItem], + totalCount: 1, + page: 1, + pageSize: 25, + pendingExposureTotal: 250, + }; }); - it("opens RejectDialog from Reject and does not reject on Cancel", async () => { + it("shows the Uplift Approvals heading, count pill and exposure total", async () => { + const { default: UpliftQueuePage } = await import("@/app/(protected)/uplifts/index"); + renderWithProviders(); + + 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, amount: 150, woNumber: "WO-99" }); + expect(screen.queryByRole("button", { name: "Confirm" })).not.toBeInTheDocument(); + expect(screen.queryByRole("dialog")).not.toBeInTheDocument(); + }); + + it("opens RejectDialog with the dispatcher copy and does not reject on Cancel", async () => { const { default: UpliftQueuePage } = await import("@/app/(protected)/uplifts/index"); renderWithProviders(); 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(); + }); + + it("disables Approve and Reject for a Dispatcher the backend refuses, without dialogs or toasts", async () => { + canApproveState.data = false; + const { default: UpliftQueuePage } = await import("@/app/(protected)/uplifts/index"); + renderWithProviders(); + + const approve = screen.getByRole("button", { name: "Approve" }); + const reject = screen.getByRole("button", { name: "Reject" }); + expect(approve).toBeDisabled(); + expect(reject).toBeDisabled(); + fireEvent.mouseOver(approve); + expect(await screen.findByText("Requires Tier 1 role")).toBeInTheDocument(); + fireEvent.click(approve); + fireEvent.click(reject); + expect(approveMutate).not.toHaveBeenCalled(); + expect(rejectMutate).not.toHaveBeenCalled(); + expect(screen.queryByRole("dialog")).not.toBeInTheDocument(); + expect(screen.queryByRole("heading", { name: /reject uplift of/i })).not.toBeInTheDocument(); + }); + + it("keeps Approve and Reject enabled when the backend allows the tier", async () => { + const { default: UpliftQueuePage } = await import("@/app/(protected)/uplifts/index"); + renderWithProviders(); + + expect(screen.getByRole("button", { name: "Approve" })).toBeEnabled(); + expect(screen.getByRole("button", { name: "Reject" })).toBeEnabled(); }); }); 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..f43c33ad --- /dev/null +++ b/src/test/domain/uplifts/api/uplifts-api.test.ts @@ -0,0 +1,45 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const apiGet = vi.fn(); +const apiPost = vi.fn(); +const apiRequestRaw = vi.fn(); + +vi.mock("@/api/api", () => ({ + apiGet: (...args: unknown[]) => apiGet(...args), + apiPost: (...args: unknown[]) => apiPost(...args), + apiRequestRaw: (...args: unknown[]) => apiRequestRaw(...args), +})); + +import { upliftsApi } from "@/domain/uplifts/api/uplifts-api"; + +describe("upliftsApi", () => { + beforeEach(() => { + apiGet.mockReset(); + apiPost.mockReset(); + apiRequestRaw.mockReset(); + }); + + it("posts admin revoke decisions to the uplift decision endpoint", async () => { + apiPost.mockResolvedValueOnce(undefined); + + await upliftsApi.revoke(42, "Scope was already covered"); + + expect(apiPost).toHaveBeenCalledWith("uplifts/42/revoke", { + note: "Scope was already covered", + }); + }); + + it("reads the backend tier decision permission", async () => { + apiGet.mockResolvedValueOnce({ status: "Success", data: { canApprove: true } }); + + await expect(upliftsApi.canApprove(1)).resolves.toBe(true); + + expect(apiGet).toHaveBeenCalledWith("uplifts/can-approve", { searchParams: { tier: 1 } }); + }); + + it("treats a missing tier permission as false", async () => { + apiGet.mockResolvedValueOnce({ status: "Success", data: {} }); + + await expect(upliftsApi.canApprove(2)).resolves.toBe(false); + }); +}); diff --git a/src/test/domain/uplifts/mappers/uplift-mapper.test.ts b/src/test/domain/uplifts/mappers/uplift-mapper.test.ts index 75c5cfde..702b7cae 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,71 @@ describe("mapUpliftQueueItem", () => { expect(result.status).toBe("Approved"); expect(result.dispatchNumber).toBe("D-1"); }); + + it("maps queue display fields in PascalCase", () => { + const result = mapUpliftQueueItem({ + id: 1, + WoNumber: "WO-9", + WorkOrderSite: "Site A", + WorkOrderService: "Plumbing", + RequestedByName: "Pat Approver", + AttachmentCount: 3, + WorkOrderAutoApprovedTotal: 100, + WorkOrderAdminApprovedTotal: 250, + WorkOrderApprovedExposureTotal: 350, + WorkOrderClosed: true, + }); + + expect(result.woNumber).toBe("WO-9"); + expect(result.site).toBe("Site A"); + expect(result.serviceName).toBe("Plumbing"); + expect(result.requestedByVendorName).toBe("Pat Approver"); + expect(result.attachmentCount).toBe(3); + expect(result.approvedOnWoAuto).toBe(100); + expect(result.approvedOnWoAdmin).toBe(250); + expect(result.approvedOnWoTotal).toBe(350); + expect(result.workOrderClosed).toBe(true); + }); + + it("maps queue display fields in camelCase", () => { + const result = mapUpliftQueueItem({ + id: 2, + woNumber: "WO-10", + siteCode: "S-2", + trade: "HVAC", + attachmentCount: 2, + autoApprovedTotal: 50, + adminApprovedTotal: 75, + approvedOnWorkOrderTotal: 125, + isWorkOrderClosed: false, + }); + + expect(result.woNumber).toBe("WO-10"); + expect(result.site).toBe("S-2"); + expect(result.serviceName).toBe("HVAC"); + expect(result.attachmentCount).toBe(2); + expect(result.approvedOnWoAuto).toBe(50); + expect(result.approvedOnWoAdmin).toBe(75); + expect(result.approvedOnWoTotal).toBe(125); + expect(result.workOrderClosed).toBe(false); + }); + + it("tolerates absent optional queue fields", () => { + const result = mapUpliftQueueItem({ id: 3 }); + + expect(result.woNumber).toBe(""); + expect(result.site).toBe(""); + expect(result.serviceName).toBe(""); + expect(result.attachmentCount).toBeNull(); + expect(result.approvedOnWoAuto).toBeNull(); + expect(result.approvedOnWoAdmin).toBeNull(); + expect(result.approvedOnWoTotal).toBeNull(); + expect(result.workOrderClosed).toBeNull(); + }); + + it("maps pending exposure total from the queue payload", () => { + expect(mapUpliftQueueExposureTotal({ PendingExposureTotal: 1250 })).toBe(1250); + expect(mapUpliftQueueExposureTotal({ pendingExposureTotal: "900" })).toBe(900); + expect(mapUpliftQueueExposureTotal({})).toBeNull(); + }); }); 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 }); + }); +}); diff --git a/src/test/lib/query/query-client.test.ts b/src/test/lib/query/query-client.test.ts new file mode 100644 index 00000000..430d1415 --- /dev/null +++ b/src/test/lib/query/query-client.test.ts @@ -0,0 +1,42 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { createAppQueryClient } from "@/lib/query/query-client"; + +const toastMocks = vi.hoisted(() => ({ error: vi.fn() })); + +vi.mock("react-toastify", () => ({ + toast: { error: toastMocks.error }, +})); + +describe("createAppQueryClient query cache", () => { + beforeEach(() => { + toastMocks.error.mockReset(); + }); + + it("does not toast for queries that suppress the error toast", async () => { + const queryClient = createAppQueryClient(); + + await expect( + queryClient.fetchQuery({ + queryKey: ["suppressed-403"], + queryFn: () => + Promise.reject(new Error("You do not have permission to perform this action.")), + meta: { suppressErrorToast: true }, + }), + ).rejects.toThrow("You do not have permission"); + + expect(toastMocks.error).not.toHaveBeenCalled(); + }); + + it("still toasts for queries without suppression", async () => { + const queryClient = createAppQueryClient(); + + await expect( + queryClient.fetchQuery({ + queryKey: ["unsuppressed-failure"], + queryFn: () => Promise.reject(new Error("A server error occurred.")), + }), + ).rejects.toThrow("A server error occurred."); + + expect(toastMocks.error).toHaveBeenCalledWith("A server error occurred."); + }); +});