diff --git a/e2e/vendors/vendor-uplift-workflow.spec.ts b/e2e/vendors/vendor-uplift-workflow.spec.ts new file mode 100644 index 00000000..18938fd4 --- /dev/null +++ b/e2e/vendors/vendor-uplift-workflow.spec.ts @@ -0,0 +1,356 @@ +import { expect, test, type Route } from "@playwright/test"; + +const INTERNAL_USER = { + token: "uplift-approver-token", + expiration: new Date(Date.now() + 60 * 60 * 1000).toISOString(), + email: "approver@seahavenind.com", + userRoles: "Admin,Tier2Approver", + fullname: "Uplift Approver", + id: "1", +}; + +async function fulfillJson(route: Route, data: unknown, status = 200) { + await route.fulfill({ + status, + contentType: "application/json", + body: JSON.stringify({ status: status < 400 ? "Success" : "Error", data }), + }); +} + +test("vendor uploads scanned evidence and submits a dispatch-scoped uplift", async ({ page }) => { + let requestBody: Record | undefined; + let uploadPurpose = ""; + let created = false; + + await page.route("**/api/vendor-portal/**", async (route) => { + const request = route.request(); + const path = new URL(request.url()).pathname; + + if (path.endsWith("/session")) { + await fulfillJson(route, { id: 9, companyName: "Gateway Plumbing" }); + return; + } + if (path.endsWith("/dispatches/7/documents") && request.method() === "POST") { + uploadPurpose = request.postData() ?? ""; + await fulfillJson(route, { + id: 22, + originalFileName: "estimate.pdf", + scanStatus: "Pending", + reviewStatus: "Processing", + purpose: "UpliftEvidence", + }); + return; + } + if (path.endsWith("/dispatches/7/documents/22/status")) { + await fulfillJson(route, { + id: 22, + originalFileName: "estimate.pdf", + scanStatus: "Passed", + reviewStatus: "Approved", + purpose: "UpliftEvidence", + }); + return; + } + if (path.endsWith("/dispatches/7/uplift-request") && request.method() === "POST") { + requestBody = request.postDataJSON(); + created = true; + await fulfillJson(route, { + id: 31, + status: "Pending", + noApprovalRequired: false, + }); + return; + } + if (path.endsWith("/dispatches/7")) { + await fulfillJson(route, { + id: 7, + dispatchNumber: "DSP-7", + poNumber: "PO-7", + status: "In Progress", + nteAmount: 100, + checklist: [], + comments: [], + signoffs: [], + documents: [], + upliftRequests: created + ? [ + { + id: 31, + status: "Pending", + currentNTE: 100, + requestedNTE: 125, + vendorReason: "Replacement compressor", + evidenceDocumentId: 22, + evidenceFileName: "estimate.pdf", + notificationStatus: "Sent", + }, + ] + : [], + }); + return; + } + await fulfillJson(route, []); + }); + + await page.goto("/v/portal-token/dispatch/7"); + await expect(page.getByRole("heading", { name: /DSP-7|PO-7/ })).toBeVisible(); + + await page.getByLabel("New total NTE").fill("125"); + await page.getByLabel("Reason").fill("Replacement compressor"); + await page.getByLabel("Evidence (PDF/JPG/PNG)").setInputFiles({ + name: "estimate.pdf", + mimeType: "application/pdf", + buffer: Buffer.from("%PDF-1.4\nmock estimate"), + }); + await page.getByRole("button", { name: "Request uplift" }).click(); + + await expect(page.getByText("Pending", { exact: true })).toBeVisible(); + expect(uploadPurpose).toContain('name="purpose"'); + expect(uploadPurpose).toContain("UpliftEvidence"); + expect(requestBody).toMatchObject({ + requestedNTE: 125, + reason: "Replacement compressor", + evidenceDocumentId: 22, + }); + expect(requestBody?.requestKey).toMatch(/^[0-9a-f-]{36}$/i); +}); + +test("vendor can withdraw a pending uplift from the dispatch", async ({ page }) => { + let withdrawnPath = ""; + let pending = true; + + await page.route("**/api/vendor-portal/**", async (route) => { + const request = route.request(); + const path = new URL(request.url()).pathname; + if (path.endsWith("/session")) { + await fulfillJson(route, { id: 9, companyName: "Gateway Plumbing" }); + return; + } + if (path.endsWith("/dispatches/7/uplift-request/31/withdraw")) { + withdrawnPath = path; + pending = false; + await fulfillJson(route, { id: 31, status: "Withdrawn" }); + return; + } + if (path.endsWith("/dispatches/7")) { + await fulfillJson(route, { + id: 7, + dispatchNumber: "DSP-7", + status: "In Progress", + nteAmount: 100, + checklist: [], + comments: [], + signoffs: [], + documents: [], + upliftRequests: [ + { + id: 31, + status: pending ? "Pending" : "Withdrawn", + currentNTE: 100, + requestedNTE: 125, + vendorReason: "Parts", + }, + ], + }); + return; + } + await fulfillJson(route, []); + }); + + page.on("dialog", (dialog) => void dialog.accept()); + await page.goto("/v/portal-token/dispatch/7"); + await page.getByRole("button", { name: "Withdraw request" }).click(); + + await expect(page.getByText("Withdrawn", { exact: true })).toBeVisible(); + expect(withdrawnPath).toBe("/api/vendor-portal/dispatches/7/uplift-request/31/withdraw"); +}); + +test("vendor receives the no-approval result for a request within the current NTE", async ({ + page, +}) => { + let requestBody: Record | undefined; + await page.route("**/api/vendor-portal/**", async (route) => { + const request = route.request(); + const path = new URL(request.url()).pathname; + if (path.endsWith("/session")) { + await fulfillJson(route, { id: 9, companyName: "Gateway Plumbing" }); + return; + } + if (path.endsWith("/dispatches/7/uplift-request") && request.method() === "POST") { + requestBody = request.postDataJSON(); + await fulfillJson(route, { noApprovalRequired: true, status: "Approved" }); + return; + } + if (path.endsWith("/dispatches/7")) { + await fulfillJson(route, { + id: 7, + dispatchNumber: "DSP-7", + status: "In Progress", + nteAmount: 100, + checklist: [], + comments: [], + signoffs: [], + documents: [], + upliftRequests: [], + }); + return; + } + await fulfillJson(route, []); + }); + + await page.goto("/v/portal-token/dispatch/7"); + await page.getByLabel("New total NTE").fill("90"); + await expect(page.getByLabel("Reason")).toHaveCount(0); + await page.getByRole("button", { name: "Request uplift" }).click(); + + await expect( + page.getByText("No approval required — the existing NTE already covers this amount."), + ).toBeVisible(); + expect(requestBody).toMatchObject({ requestedNTE: 90, reason: "" }); + expect(requestBody).not.toHaveProperty("evidenceDocumentId"); +}); + +test("vendor revises a changes-requested uplift using its existing evidence", async ({ page }) => { + let revisionBody: Record | undefined; + let revised = false; + await page.route("**/api/vendor-portal/**", async (route) => { + const request = route.request(); + const path = new URL(request.url()).pathname; + if (path.endsWith("/session")) { + await fulfillJson(route, { id: 9, companyName: "Gateway Plumbing" }); + return; + } + if (path.endsWith("/dispatches/7/uplift-request/31/revise")) { + revisionBody = request.postDataJSON(); + revised = true; + await fulfillJson(route, { id: 31, status: "Pending" }); + return; + } + if (path.endsWith("/dispatches/7")) { + await fulfillJson(route, { + id: 7, + dispatchNumber: "DSP-7", + status: "In Progress", + nteAmount: 100, + checklist: [], + comments: [], + signoffs: [], + documents: [], + upliftRequests: [ + { + id: 31, + status: revised ? "Pending" : "ChangesRequested", + currentNTE: 100, + requestedNTE: 125, + vendorReason: "Parts", + decisionNote: "Itemize the estimate", + evidenceDocumentId: 22, + evidenceFileName: "estimate.pdf", + }, + ], + }); + return; + } + await fulfillJson(route, []); + }); + + await page.goto("/v/portal-token/dispatch/7"); + await page.getByRole("button", { name: "Revise" }).click(); + await page.getByLabel("New total NTE").fill("120"); + await page.getByLabel("Reason").fill("Parts itemized in the attached estimate"); + await page.getByRole("button", { name: "Submit revision" }).click(); + + await expect(page.getByText("Pending", { exact: true })).toBeVisible(); + expect(revisionBody).toEqual({ + requestedNTE: 120, + reason: "Parts itemized in the attached estimate", + evidenceDocumentId: 22, + }); +}); + +test("internal approver can approve and request changes with audited notes", async ({ page }) => { + const decisions: Array<{ path: string; body: unknown }> = []; + const statuses = new Map([ + [41, "Pending"], + [42, "Pending"], + ]); + + await page.addInitScript( + (user) => localStorage.setItem("auth", JSON.stringify(user)), + INTERNAL_USER, + ); + await page.route("**/api/Dashboard/Stats", (route) => fulfillJson(route, {})); + await page.route("**/api/User*", (route) => fulfillJson(route, [])); + 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)$/); + if (action && request.method() === "POST") { + const id = Number(action[1]); + statuses.set(id, action[2] === "approve" ? "Approved" : "ChangesRequested"); + decisions.push({ path, body: request.postDataJSON() }); + await fulfillJson(route, { id, status: statuses.get(id) }); + return; + } + + 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", + }, + ], + }); + }); + + await page.goto("/uplifts"); + await expect(page.getByRole("heading", { name: "Uplift Queue" })).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 expect + .poll(() => decisions) + .toEqual([ + { path: "/api/uplifts/41/approve", body: {} }, + { + path: "/api/uplifts/42/request-changes", + body: { note: "Please itemize labor and parts." }, + }, + ]); +}); diff --git a/src/app/(protected)/uplifts/index.tsx b/src/app/(protected)/uplifts/index.tsx index e78ac07f..8648831f 100644 --- a/src/app/(protected)/uplifts/index.tsx +++ b/src/app/(protected)/uplifts/index.tsx @@ -27,28 +27,319 @@ import { Tooltip, Typography, } from "@mui/material"; +import { upliftsApi } from "@/domain/uplifts/api/uplifts-api"; import type { UpliftQueueItem } from "@/domain/uplifts/types/uplift"; -import { useApproveUplift, useDenyUplift } from "@/domain/uplifts/use-cases/use-uplift-actions"; +import { UPLIFT_STATUSES } from "@/domain/uplifts/types/uplift"; +import { + useApproveUplift, + useRejectUplift, + useRequestChangesUplift, +} 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"; -const STATUS_OPTIONS = ["Pending", "Approved", "Denied", "Cancelled", ""]; +const STATUS_OPTIONS = ["", ...UPLIFT_STATUSES]; + +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)}?` + : action?.type === "reject" + ? "Reject Uplift" + : "Request Changes"} + + + {action?.type === "approve" && ( + + {formatMoney(action.item.currentNTE)} → {formatMoney(action.item.requestedNTE)} + + )} + onNoteChange(e.target.value)} + /> + + + + + + + ); +} + 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<{ - type: "approve" | "deny"; - item: UpliftQueueItem; - } | null>(null); + const [upliftAction, setUpliftAction] = useState(null); const [note, setNote] = useState(""); const listParams = useMemo( @@ -58,39 +349,59 @@ export default function UpliftQueuePage() { const { data, isLoading, isFetching, error, refetch } = useUpliftsQueue(listParams); const approveUplift = useApproveUplift(); - const denyUplift = useDenyUplift(); + const rejectUplift = useRejectUplift(); + const requestChangesUplift = useRequestChangesUplift(); const rows = data?.items ?? []; const totalCount = data?.totalCount ?? 0; + const noteRequired = upliftAction?.type === "reject" || upliftAction?.type === "request-changes"; + const decisionPending = + approveUplift.isPending || rejectUplift.isPending || requestChangesUplift.isPending; + + const handleStatusChange = (value: string) => { + setStatus(value); + setPage(0); + }; + const handleTierChange = (value: string) => { + setTier(value); + setPage(0); + }; + + 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: upliftAction.item.id, note: note || undefined }, - { - onSuccess: () => { - setUpliftAction(null); - setNote(""); - void refetch(); - }, - }, - ); + approveUplift.mutate({ id, note: note || undefined }, { onSuccess: onDone }); + } else if (upliftAction.type === "reject") { + rejectUplift.mutate({ id, note: note.trim() }, { onSuccess: onDone }); } else { - if (!note.trim()) return; - denyUplift.mutate( - { id: upliftAction.item.id, note: note.trim() }, - { - onSuccess: () => { - setUpliftAction(null); - setNote(""); - void refetch(); - }, - }, - ); + requestChangesUplift.mutate({ id, note: note.trim() }, { onSuccess: onDone }); } }; + 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 ( - - - Uplift Queue - - - {totalCount} total - - {isFetching && !isLoading && } - - - - - Status - - - - Tier - - - + + {Boolean(error) && ( {error instanceof Error ? error.message : "Failed to load uplift queue"} )} - - - - - Dispatch - PO - Vendor - Current NTE - Requested - Delta - Tier - Status - Requested At - Actions - - - - {isLoading ? ( - - - - - - ) : rows.length === 0 ? ( - - - - No uplift requests match these filters. - - - - ) : ( - rows.map((row) => ( - - {row.dispatchNumber} - {row.poNumber} - {row.vendorCompanyName} - {formatMoney(row.currentNTE)} - {formatMoney(row.requestedNTE)} - {formatMoney(row.delta)} - - - - - - - {row.requestedAt ? formatDateTime(row.requestedAt) : ""} - - - {Boolean(row.workOrderId) && ( - - )} - {row.status === "Pending" && ( - <> - - - - - - - - - - - - )} - - - - )) - )} - -
-
+ navigate(`/workorders/${r.workOrderId}`)} + onDownloadEvidence={(r) => void downloadEvidence(r)} + onDecide={handleDecide} + /> - setUpliftAction(null)}> - - {upliftAction?.type === "approve" - ? `Approve uplift to ${formatMoney(upliftAction.item.requestedNTE)}?` - : "Deny Uplift"} - - - {upliftAction?.type === "approve" && ( - - {formatMoney(upliftAction.item.currentNTE)} →{" "} - {formatMoney(upliftAction.item.requestedNTE)} (+ - {formatMoney(upliftAction.item.delta)}) - - )} - setNote(e.target.value)} - /> - - - - - - +
); } diff --git a/src/app/(protected)/vendor-pos/[id].tsx b/src/app/(protected)/vendor-pos/[id].tsx index 684dc0c7..dc47b341 100644 --- a/src/app/(protected)/vendor-pos/[id].tsx +++ b/src/app/(protected)/vendor-pos/[id].tsx @@ -1,90 +1,82 @@ -import { useState, type ReactNode } from "react"; +import { useState } from "react"; import { useNavigate, useParams } from "react-router"; -import { - Alert, - Box, - Button, - Chip, - CircularProgress, - Dialog, - DialogActions, - DialogContent, - DialogTitle, - Paper, - Stack, - Table, - TableBody, - TableCell, - TableHead, - TableRow, - TextField, - Typography, -} from "@mui/material"; -import { useApproveUplift, useDenyUplift } from "@/domain/uplifts/use-cases/use-uplift-actions"; -import { useVendorPoDetail } from "@/domain/vendor-pos/use-cases/use-vendor-po-detail"; +import { Alert, Box, Button, Chip, CircularProgress, Stack, Typography } from "@mui/material"; +import { toast } from "react-toastify"; +import { upliftsApi } from "@/domain/uplifts/api/uplifts-api"; import type { UpliftRequest } from "@/domain/uplifts/types/uplift"; -import { formatDateTime, toDateInputValue } from "@/lib/time-utils"; +import { + useApproveUplift, + useRejectUplift, + useRequestChangesUplift, +} from "@/domain/uplifts/use-cases/use-uplift-actions"; +import { useVendorPoDetail } from "@/domain/vendor-pos/use-cases/use-vendor-po-detail"; +import { + PoDetailsPaper, + SitePaper, + UpliftDecisionDialog, + UpliftHistoryPaper, + VendorSummaryPaper, + WorkOrdersPaper, + type UpliftActionType, + type VendorPoUpliftDecision, +} from "@/app/(protected)/vendor-pos/_components/vendor-po-sections"; function formatMoney(value: number | null | undefined): string { if (value == null) return "—"; return `$${Number(value).toFixed(2)}`; } -function InfoRow({ label, children }: { label: string; children: ReactNode }) { - return ( - - - {label} - - - {children == null || children === "" ? "—" : children} - - - ); -} - export default function VendorPoViewPage() { const { id } = useParams(); const navigate = useNavigate(); const { data: dispatch, isLoading, error, refetch } = useVendorPoDetail(id); const approveUplift = useApproveUplift(id); - const denyUplift = useDenyUplift(id); + const rejectUplift = useRejectUplift(id); + const requestChangesUplift = useRequestChangesUplift(id); - const [upliftAction, setUpliftAction] = useState<{ - type: "approve" | "deny"; - uplift: UpliftRequest; - } | null>(null); + const [upliftAction, setUpliftAction] = useState(null); const [note, setNote] = useState(""); + const noteRequired = upliftAction?.type === "reject" || upliftAction?.type === "request-changes"; + const decisionPending = + approveUplift.isPending || rejectUplift.isPending || requestChangesUplift.isPending; + + const closeUpliftDialog = () => { + setUpliftAction(null); + setNote(""); + }; + const handleUpliftConfirm = () => { if (!upliftAction) return; + if (noteRequired && !note.trim()) return; + const upliftId = upliftAction.uplift.id; + const onDone = () => { + closeUpliftDialog(); + void refetch(); + }; if (upliftAction.type === "approve") { - approveUplift.mutate( - { id: upliftAction.uplift.id, note: note || undefined }, - { - onSuccess: () => { - setUpliftAction(null); - setNote(""); - void refetch(); - }, - }, - ); + approveUplift.mutate({ id: upliftId, note: note || undefined }, { onSuccess: onDone }); + } else if (upliftAction.type === "reject") { + rejectUplift.mutate({ id: upliftId, note: note.trim() }, { onSuccess: onDone }); } else { - if (!note.trim()) return; - denyUplift.mutate( - { id: upliftAction.uplift.id, note: note.trim() }, - { - onSuccess: () => { - setUpliftAction(null); - setNote(""); - void refetch(); - }, - }, - ); + requestChangesUplift.mutate({ id: upliftId, note: note.trim() }, { onSuccess: onDone }); } }; + const downloadEvidence = async (uplift: UpliftRequest) => { + try { + await upliftsApi.downloadEvidence(uplift.id, uplift.evidenceFileName || "uplift-evidence"); + } catch (err) { + toast.error(err instanceof Error ? err.message : "Unable to download evidence."); + } + }; + + const handleDecide = (type: UpliftActionType, uplift: UpliftRequest) => { + setUpliftAction({ type, uplift }); + setNote(""); + }; + if (isLoading) { return ( @@ -141,212 +133,29 @@ export default function VendorPoViewPage() { - - - Vendor - - {vendor ? ( - - {vendor.companyName} - {vendor.contactName} - {vendor.email} - {vendor.phone} - - {[vendor.address, vendor.city, vendor.state, vendor.zip].filter(Boolean).join(", ")} - - {vendor.tradeSpecialties} - - ) : ( - No vendor attached. - )} - - - - - PO Details - - - {dispatch.poNumber} - {dispatch.dispatchNumber} - {dispatch.status} - {formatMoney(dispatch.nteAmount)} - {formatDateTime(dispatch.dispatchedAt)} - {formatDateTime(dispatch.acknowledgedAt)} - {toDateInputValue(dispatch.scheduledDate) || "—"} - {toDateInputValue(dispatch.completedDate) || "—"} - {formatDateTime(dispatch.verifiedAt)} - {dispatch.emailSent ? "Yes" : "No"} - - {Boolean(dispatch.description) && ( - - - Description - - - {dispatch.description} - - - )} - - - {dispatch.primaryLocation != null && ( - - - Site - - - {dispatch.primaryLocation.name} - - {[ - dispatch.primaryLocation.address, - dispatch.primaryLocation.city, - dispatch.primaryLocation.state, - dispatch.primaryLocation.zipCode, - ] - .filter(Boolean) - .join(", ")} - - - - )} - - - - Linked Work Orders ({dispatch.workOrders.length}) - - {dispatch.workOrders.length === 0 ? ( - No work orders linked. - ) : ( - - - - WO # - Title - Trade - Status - Location - - - - {dispatch.workOrders.map((wo) => ( - - - - - {wo.title} - {wo.trade} - {wo.status} - {wo.locationName} - - ))} - -
- )} -
- - - - NTE Uplift History ({dispatch.upliftRequests.length}) - - {dispatch.upliftRequests.length === 0 ? ( - No uplift requests on this PO. - ) : ( - - {dispatch.upliftRequests.map((u) => ( - - - - {formatMoney(u.currentNTE)} → {formatMoney(u.requestedNTE)} - - (Δ {formatMoney(u.delta)}, Tier {u.requiredTier}) - - - - - {Boolean(u.vendorReason) && ( - - Reason: {u.vendorReason} - - )} - {u.status === "Pending" && ( - - - - - )} - - ))} - - )} - + + + + navigate(`/workorders/${workOrderId}`)} + /> + void downloadEvidence(uplift)} + onDecide={handleDecide} + />
- setUpliftAction(null)}> - - {upliftAction?.type === "approve" ? "Approve Uplift" : "Deny Uplift"} - - - setNote(e.target.value)} - required={upliftAction?.type === "deny"} - /> - - - - - - +
); } diff --git a/src/app/(protected)/vendor-pos/_components/vendor-po-sections.tsx b/src/app/(protected)/vendor-pos/_components/vendor-po-sections.tsx new file mode 100644 index 00000000..7e6ecaf1 --- /dev/null +++ b/src/app/(protected)/vendor-pos/_components/vendor-po-sections.tsx @@ -0,0 +1,341 @@ +import { type ReactNode } from "react"; +import { + Box, + Button, + Chip, + Dialog, + DialogActions, + DialogContent, + DialogTitle, + Paper, + Stack, + Table, + TableBody, + TableCell, + TableHead, + TableRow, + TextField, + Typography, +} from "@mui/material"; +import type { UpliftRequest } from "@/domain/uplifts/types/uplift"; +import type { + VendorPoDetail, + VendorPoLocation, + VendorPoVendor, + VendorPoWorkOrder, +} from "@/domain/vendor-pos/types/vendor-po"; +import { formatDateTime, toDateInputValue } from "@/lib/time-utils"; + +export type UpliftActionType = "approve" | "reject" | "request-changes"; + +export type VendorPoUpliftDecision = { type: UpliftActionType; uplift: UpliftRequest }; + +function formatMoney(value: number | null | undefined): string { + if (value == null) return "—"; + return `$${Number(value).toFixed(2)}`; +} + +function InfoRow({ label, children }: { label: string; children: ReactNode }) { + return ( + + + {label} + + + {children == null || children === "" ? "—" : children} + + + ); +} + +export function VendorSummaryPaper({ vendor }: { vendor: VendorPoVendor | null }) { + return ( + + + Vendor + + {vendor ? ( + + {vendor.companyName} + {vendor.contactName} + {vendor.email} + {vendor.phone} + + {[vendor.address, vendor.city, vendor.state, vendor.zip].filter(Boolean).join(", ")} + + {vendor.tradeSpecialties} + + ) : ( + No vendor attached. + )} + + ); +} + +export function PoDetailsPaper({ dispatch }: { dispatch: VendorPoDetail }) { + return ( + + + PO Details + + + {dispatch.poNumber} + {dispatch.dispatchNumber} + {dispatch.status} + {formatMoney(dispatch.nteAmount)} + {formatDateTime(dispatch.dispatchedAt)} + {formatDateTime(dispatch.acknowledgedAt)} + {toDateInputValue(dispatch.scheduledDate) || "—"} + {toDateInputValue(dispatch.completedDate) || "—"} + {formatDateTime(dispatch.verifiedAt)} + {dispatch.emailSent ? "Yes" : "No"} + + {Boolean(dispatch.description) && ( + + + Description + + + {dispatch.description} + + + )} + + ); +} + +export function SitePaper({ location }: { location: VendorPoLocation | null }) { + if (location == null) return null; + return ( + + + Site + + + {location.name} + + {[location.address, location.city, location.state, location.zipCode] + .filter(Boolean) + .join(", ")} + + + + ); +} + +export function WorkOrdersPaper({ + workOrders, + onView, +}: { + workOrders: VendorPoWorkOrder[]; + onView: (workOrderId: string | number) => void; +}) { + return ( + + + Linked Work Orders ({workOrders.length}) + + {workOrders.length === 0 ? ( + No work orders linked. + ) : ( + + + + WO # + Title + Trade + Status + Location + + + + {workOrders.map((wo) => ( + + + + + {wo.title} + {wo.trade} + {wo.status} + {wo.locationName} + + ))} + +
+ )} +
+ ); +} + +function UpliftHistoryItem({ + uplift, + onDownload, + onDecide, +}: { + uplift: UpliftRequest; + onDownload: (uplift: UpliftRequest) => void; + onDecide: (type: UpliftActionType, uplift: UpliftRequest) => void; +}) { + const decisionable = uplift.status === "Pending"; + return ( + + + + {formatMoney(uplift.currentNTE)} → {formatMoney(uplift.requestedNTE)} + + (Tier {uplift.requiredTier}) + + + + + {Boolean(uplift.expiresAt) && ( + + Expires {formatDateTime(uplift.expiresAt)} + + )} + {Boolean(uplift.vendorReason) && ( + + Reason: {uplift.vendorReason} + + )} + {uplift.evidenceDocumentId != null && ( + + + Evidence:{" "} + {uplift.evidenceFileName || `Document #${uplift.evidenceDocumentId}`} + + + + )} + {Boolean(uplift.decisionNote) && ( + + Decision: {uplift.decisionNote} + + )} + {uplift.notificationStatus === "Error" && Boolean(uplift.notificationError) && ( + + Notification error: {uplift.notificationError} + + )} + {decisionable && ( + + + + + + )} + + ); +} + +export function UpliftHistoryPaper({ + upliftRequests, + onDownload, + onDecide, +}: { + upliftRequests: UpliftRequest[]; + onDownload: (uplift: UpliftRequest) => void; + onDecide: (type: UpliftActionType, uplift: UpliftRequest) => void; +}) { + return ( + + + NTE Uplift History ({upliftRequests.length}) + + {upliftRequests.length === 0 ? ( + No uplift requests on this PO. + ) : ( + + {upliftRequests.map((uplift) => ( + + ))} + + )} + + ); +} + +export function UpliftDecisionDialog({ + action, + note, + noteRequired, + decisionPending, + onNoteChange, + onClose, + onConfirm, +}: { + action: VendorPoUpliftDecision | null; + note: string; + noteRequired: boolean; + decisionPending: boolean; + onNoteChange: (value: string) => void; + onClose: () => void; + onConfirm: () => void; +}) { + return ( + + + {action?.type === "approve" + ? "Approve Uplift" + : action?.type === "reject" + ? "Reject Uplift" + : "Request Changes"} + + + onNoteChange(e.target.value)} + /> + + + + + + + ); +} diff --git a/src/app/(protected)/workorders/_components/dispatch/dispatch-detail/dispatch-uplifts-section.tsx b/src/app/(protected)/workorders/_components/dispatch/dispatch-detail/dispatch-uplifts-section.tsx index ae977ef9..afe2da7b 100644 --- a/src/app/(protected)/workorders/_components/dispatch/dispatch-detail/dispatch-uplifts-section.tsx +++ b/src/app/(protected)/workorders/_components/dispatch/dispatch-detail/dispatch-uplifts-section.tsx @@ -1,82 +1,178 @@ import { Box, Button, Chip, Stack, Tooltip, Typography } from "@mui/material"; import type { UpliftRequest } from "@/domain/uplifts/types/uplift"; +import { upliftsApi } from "@/domain/uplifts/api/uplifts-api"; import { formatDateTime } from "@/lib/time-utils"; +import { toast } from "react-toastify"; + +type UpliftAction = "approve" | "reject" | "request-changes"; type DispatchUpliftsSectionProps = { uplifts: UpliftRequest[]; isLocked: boolean; onApprove: (uplift: UpliftRequest) => void; - onDeny: (uplift: UpliftRequest) => void; + onReject: (uplift: UpliftRequest) => void; + onRequestChanges: (uplift: UpliftRequest) => void; }; +const ACTION_LABELS: Record, string> = { + reject: "Reject", + "request-changes": "Request Changes", +}; + +function actionColor(action: UpliftAction): "success" | "error" | "primary" { + if (action === "approve") return "success"; + if (action === "reject") return "error"; + return "primary"; +} + +function actionVariant(action: UpliftAction): "contained" | "outlined" { + return action === "request-changes" ? "outlined" : "contained"; +} + type UpliftCardProps = Omit & { uplift: UpliftRequest; }; -function UpliftCard({ uplift, isLocked, onApprove, onDeny }: UpliftCardProps) { - const canDecide = uplift.canDecide && uplift.status === "Pending" && !isLocked; +type UpliftCardActionsProps = Pick< + UpliftCardProps, + "isLocked" | "onApprove" | "onReject" | "onRequestChanges" +> & { + uplift: UpliftRequest; +}; +function downloadUpliftEvidence(uplift: UpliftRequest) { + return upliftsApi + .downloadEvidence(uplift.id, uplift.evidenceFileName || "uplift-evidence") + .catch((err: unknown) => { + toast.error(err instanceof Error ? err.message : "Unable to download evidence."); + }); +} + +function UpliftCardHeader({ uplift }: { uplift: UpliftRequest }) { + const evidenceDocumentId = uplift.evidenceDocumentId; return ( - + <> {uplift.requiredTier === 2 && ( Tier 2 · Manager approval )} - ${Number(uplift.currentNTE ?? 0).toFixed(2)} → ${Number(uplift.requestedNTE).toFixed(2)}{" "} - (+${Number(uplift.delta).toFixed(2)}) + ${Number(uplift.currentNTE ?? 0).toFixed(2)} → ${Number(uplift.requestedNTE).toFixed(2)} + {Boolean(uplift.expiresAt) && ( + + Expires {formatDateTime(uplift.expiresAt)} + + )} {Boolean(uplift.vendorReason) && ( Reason: {uplift.vendorReason} )} + {evidenceDocumentId != null && ( + + + Evidence:{" "} + {uplift.evidenceFileName || `Document #${evidenceDocumentId}`} + + + + )} + {uplift.notificationStatus === "Error" && Boolean(uplift.notificationError) && ( + + Notification error: {uplift.notificationError} + + )} {Boolean(uplift.requestedByVendorName || uplift.requestedAt) && ( Requested by {uplift.requestedByVendorName || "vendor"} {uplift.requestedAt ? ` · ${formatDateTime(uplift.requestedAt)}` : ""} )} - {uplift.status !== "Pending" && - Boolean(uplift.decidedByName || uplift.decidedAt || uplift.decisionNote) && ( - - {Boolean(uplift.decidedByName || uplift.decidedAt) && ( - - {uplift.status} by {uplift.decidedByName || "—"} - {uplift.decidedAt ? ` · ${formatDateTime(uplift.decidedAt)}` : ""} - - )} - {Boolean(uplift.decisionNote) && ( - - Note: {uplift.decisionNote} - - )} - - )} - {uplift.status === "Pending" && ( - - {(["approve", "deny"] as const).map((action) => ( - - - - - - ))} - + + ); +} + +function UpliftCardDecisionSummary({ uplift }: { uplift: UpliftRequest }) { + if (uplift.status === "Pending") return null; + if (!uplift.decidedByName && !uplift.decidedAt && !uplift.decisionNote) return null; + return ( + + {Boolean(uplift.decidedByName || uplift.decidedAt) && ( + + {uplift.status} by {uplift.decidedByName || "—"} + {uplift.decidedAt ? ` · ${formatDateTime(uplift.decidedAt)}` : ""} + )} + {Boolean(uplift.decisionNote) && ( + + Note: {uplift.decisionNote} + + )} + + ); +} + +function UpliftCardActions({ + uplift, + isLocked, + onApprove, + onReject, + onRequestChanges, +}: UpliftCardActionsProps) { + if (uplift.status !== "Pending") return null; + const canDecide = uplift.canDecide && !isLocked; + const decisionActions: Exclude[] = ["reject", "request-changes"]; + return ( + + + + + + + {decisionActions.map((action) => ( + + + + + + ))} + + ); +} + +function UpliftCard({ uplift, isLocked, onApprove, onReject, onRequestChanges }: UpliftCardProps) { + return ( + + + + ); } @@ -85,7 +181,8 @@ export function DispatchUpliftsSection({ uplifts, isLocked, onApprove, - onDeny, + onReject, + onRequestChanges, }: DispatchUpliftsSectionProps) { return ( @@ -102,7 +199,8 @@ export function DispatchUpliftsSection({ uplift={uplift} isLocked={isLocked} onApprove={onApprove} - onDeny={onDeny} + onReject={onReject} + onRequestChanges={onRequestChanges} /> ))} diff --git a/src/app/(protected)/workorders/_components/dispatch/dispatch-detail/index.tsx b/src/app/(protected)/workorders/_components/dispatch/dispatch-detail/index.tsx index 7189fe1e..179909e0 100644 --- a/src/app/(protected)/workorders/_components/dispatch/dispatch-detail/index.tsx +++ b/src/app/(protected)/workorders/_components/dispatch/dispatch-detail/index.tsx @@ -99,7 +99,10 @@ export function DispatchDetailModal({ uplifts={controller.uplifts} isLocked={controller.isLocked} onApprove={(uplift) => controller.setUpliftAction({ type: "approve", uplift })} - onDeny={(uplift) => controller.setUpliftAction({ type: "deny", uplift })} + onReject={(uplift) => controller.setUpliftAction({ type: "reject", uplift })} + onRequestChanges={(uplift) => + controller.setUpliftAction({ type: "request-changes", uplift }) + } /> controller.setUpliftAction(null)} onConfirm={controller.handleUpliftConfirm} isPending={ - controller.approveUpliftMutation.isPending || controller.denyUpliftMutation.isPending + controller.approveUpliftMutation.isPending || + controller.rejectUpliftMutation.isPending || + controller.requestChangesUpliftMutation.isPending } /> )} diff --git a/src/app/(protected)/workorders/_components/dispatch/dispatch-detail/uplift-decision-dialog.tsx b/src/app/(protected)/workorders/_components/dispatch/dispatch-detail/uplift-decision-dialog.tsx index c8e7cf2f..e68a26d5 100644 --- a/src/app/(protected)/workorders/_components/dispatch/dispatch-detail/uplift-decision-dialog.tsx +++ b/src/app/(protected)/workorders/_components/dispatch/dispatch-detail/uplift-decision-dialog.tsx @@ -11,7 +11,7 @@ import { type UpliftDecisionDialogProps = { open: boolean; - type: "approve" | "deny"; + type: "approve" | "reject" | "request-changes"; requestedNTE: number; currentNTE: number | null; onClose: () => void; @@ -19,6 +19,13 @@ type UpliftDecisionDialogProps = { isPending?: boolean; }; +function titleFor(type: UpliftDecisionDialogProps["type"], requestedNTE: number): string { + if (type === "approve") { + return `Approve uplift to $${requestedNTE.toFixed(2)}?`; + } + return type === "reject" ? "Reject Uplift" : "Request Changes"; +} + export function UpliftDecisionDialog({ open, type, @@ -29,6 +36,7 @@ export function UpliftDecisionDialog({ isPending = false, }: UpliftDecisionDialogProps) { const [note, setNote] = useState(""); + const noteRequired = type === "reject" || type === "request-changes"; const handleClose = () => { setNote(""); @@ -36,15 +44,14 @@ export function UpliftDecisionDialog({ }; const handleConfirm = () => { + if (noteRequired && !note.trim()) return; onConfirm(note.trim()); setNote(""); }; return ( - - {type === "approve" ? `Approve uplift to $${requestedNTE.toFixed(2)}?` : "Deny Uplift"} - + {titleFor(type, requestedNTE)} {type === "approve" && ( @@ -57,7 +64,8 @@ export function UpliftDecisionDialog({ multiline minRows={2} className="mt-2" - label={type === "deny" ? "Denial reason (required)" : "Optional note"} + label={noteRequired ? "Note (required)" : "Optional note"} + required={noteRequired} value={note} onChange={(event) => setNote(event.target.value)} /> @@ -66,8 +74,8 @@ export function UpliftDecisionDialog({ + )} + {canRevise && ( + + )} + + )} + + ); +} diff --git a/src/app/v/[token]/dispatch/_components/uplift-request-form.tsx b/src/app/v/[token]/dispatch/_components/uplift-request-form.tsx new file mode 100644 index 00000000..f6d0e901 --- /dev/null +++ b/src/app/v/[token]/dispatch/_components/uplift-request-form.tsx @@ -0,0 +1,147 @@ +import { useState } from "react"; +import { Text } from "@/components/ui/text"; +import { validateVendorDocument } from "@/domain/vendor-portal/lib/document-validation"; +import { + isApprovalRequired, + validateUpliftAmount, + type UpliftFormValues, +} from "@/domain/vendor-portal/lib/uplift-validation"; + +type UpliftRequestFormProps = { + mode: "create" | "revise"; + currentNte?: number; + initialRequested?: string; + initialReason?: string; + hasExistingEvidence?: boolean; + submitting: boolean; + uploadingPercent: number | undefined; + errorMessage: string; + onSubmit: (values: UpliftFormValues) => void; + onCancel?: () => void; +}; + +export function UpliftRequestForm({ + mode, + currentNte, + initialRequested = "", + initialReason = "", + hasExistingEvidence = false, + submitting, + uploadingPercent, + errorMessage, + onSubmit, + onCancel, +}: UpliftRequestFormProps) { + const [requested, setRequested] = useState(initialRequested); + const [reason, setReason] = useState(initialReason); + const [evidenceFile, setEvidenceFile] = useState(); + const [formError, setFormError] = useState(""); + + const requiresApproval = isApprovalRequired(requested, currentNte); + + const submit = (event: React.FormEvent) => { + event.preventDefault(); + const amount = Number.parseFloat(requested); + const validationError = validateUpliftAmount(requested, currentNte, reason, { + file: evidenceFile, + hasExisting: hasExistingEvidence, + }); + setFormError(validationError); + if (validationError) { + return; + } + onSubmit({ amount, reason: reason.trim(), evidenceFile }); + }; + + const chooseFile = (file?: File) => { + const validationMessage = file ? validateVendorDocument(file) : undefined; + setFormError(validationMessage ?? ""); + setEvidenceFile(file); + }; + + const shownError = errorMessage || formError; + const busy = submitting || uploadingPercent !== undefined; + + return ( +
void submit(event)} + className="vp-uplift-form" + aria-label={mode === "revise" ? "Revise uplift request" : "Request NTE uplift"} + > + {mode === "create" && ( +
+ Current NTE + ${Number(currentNte ?? 0).toFixed(2)} +
+ )} +
+ + + + { + setRequested(event.target.value); + setFormError(""); + }} + placeholder="0.00" + /> +
+ {requiresApproval && ( + <> +
+ + + +