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/[id].tsx b/src/app/(protected)/workorders/[id].tsx index cfdcf7d7..ea7ebf00 100644 --- a/src/app/(protected)/workorders/[id].tsx +++ b/src/app/(protected)/workorders/[id].tsx @@ -1,39 +1,21 @@ -import { useState } from "react"; -import { useNavigate, useParams } from "react-router"; +import { useParams } from "react-router"; import { Alert, Box, CircularProgress } from "@mui/material"; import { DispatchCreateModal } from "./_components/dispatch-create-modal"; import { DispatchDetailModal } from "./_components/dispatch-detail-modal"; -import { VendorEditorDialog } from "./_components/vendor-editor-dialog"; -import { useVendorEditor } from "./_components/use-vendor-editor"; -import { WorkOrderActivity } from "./_components/work-order-activity"; -import { - DispatchAccordion, - WorkOrderDetailsSection, - WorkOrderHeader, - WorkOrderSummaryAccordions, -} from "./_components/work-order-view-sections"; -import { useWorkOrderDetail } from "@/domain/work-orders/use-cases/use-work-order-detail"; -import { - useChangeWorkOrderAssignment, - useChangeWorkOrderStatus, -} from "@/domain/work-orders/use-cases/use-work-order-mutations"; -import { useWorkOrderUsers } from "@/domain/work-orders/use-cases/use-work-orders-list"; +import { WorkOrderActivityPanel } from "./_components/view/work-order-activity-panel"; +import { WorkOrderDispatchesAccordion } from "./_components/view/work-order-dispatches-accordion"; +import { WorkOrderVendorEditorDialog } from "./_components/view/work-order-vendor-editor-dialog"; +import { WorkOrderViewAccordions } from "./_components/view/work-order-view-accordions"; +import { WorkOrderViewDetails } from "./_components/view/work-order-view-details"; +import { WorkOrderViewHeader } from "./_components/view/work-order-view-header"; +import { useWorkOrderViewPage } from "./_hooks/use-work-order-view-page"; export default function WorkOrderViewPage() { const { id } = useParams(); - const navigate = useNavigate(); const workOrderId = id ?? ""; + const view = useWorkOrderViewPage(workOrderId); - const { data: wo, isLoading, error, refetch } = useWorkOrderDetail(workOrderId); - const { data: users = [] } = useWorkOrderUsers(); - const statusMutation = useChangeWorkOrderStatus(workOrderId); - const assignmentMutation = useChangeWorkOrderAssignment(workOrderId); - const vendorEditor = useVendorEditor({ workOrderId, onSaved: () => void refetch() }); - - const [dispatchOpen, setDispatchOpen] = useState(false); - const [dispatchDetailId, setDispatchDetailId] = useState(null); - - if (isLoading) { + if (view.isLoading) { return ( @@ -41,11 +23,11 @@ export default function WorkOrderViewPage() { ); } - if (error || !wo) { + if (view.error || !view.wo) { return ( - {error instanceof Error ? error.message : "Work order not found."} + {view.error instanceof Error ? view.error.message : "Work order not found."} ); @@ -53,56 +35,67 @@ export default function WorkOrderViewPage() { return ( - statusMutation.mutate(status)} - onDispatch={() => setDispatchOpen(true)} - onEdit={() => navigate(`/workorders/edit/${id}`)} - onBack={() => navigate("/workorders")} - /> - assignmentMutation.mutate(payload)} - /> - - - void vendorEditor.open(event, vendorId, vendorName) - } - onViewDispatch={setDispatchDetailId} - /> - view.statusMutation.mutate(status)} + onOpenDispatch={() => view.setDispatchOpen(true)} /> - {dispatchOpen && ( + + view.assignmentMutation.mutate({ userId, userName })} + onUnassign={() => view.assignmentMutation.mutate({ userId: "", userName: "" })} + /> + + + + + + + + {view.dispatchOpen && ( setDispatchOpen(false)} - onDispatched={() => void refetch()} + workOrder={view.wo} + onClose={() => view.setDispatchOpen(false)} + onDispatched={() => void view.refetch()} /> )} - {dispatchDetailId != null && ( + {view.dispatchDetailId != null && ( setDispatchDetailId(null)} - onUpdated={() => void refetch()} + onClose={() => view.setDispatchDetailId(null)} + onUpdated={() => void view.refetch()} /> )} - { + if (!view.vendorMutation.isPending) view.setVendorEditor(null); + }} + onSave={view.saveVendorEditor} + onEditorChange={view.setVendorEditor} /> ); diff --git a/src/app/(protected)/workorders/_components/comp-doc/comp-doc-details-section.tsx b/src/app/(protected)/workorders/_components/comp-doc/comp-doc-details-section.tsx new file mode 100644 index 00000000..3a38f2e3 --- /dev/null +++ b/src/app/(protected)/workorders/_components/comp-doc/comp-doc-details-section.tsx @@ -0,0 +1,75 @@ +import { Building2, MapPin, Users, Wrench } from "lucide-react"; +import type { CompDocSiteInfo } from "@/domain/work-orders/types/completion-doc"; +import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row"; +import { CompDocDetailRow } from "./comp-doc-detail-row"; +import { CompDocEditableRow } from "./comp-doc-editable-row"; + +type CompDocDetailsSectionProps = { + row: WorkOrderTableRow; + site: CompDocSiteInfo; + editAddress: string; + onAddressChange: (value: string) => void; + editDateCompleted: string; + onDateCompletedChange: (value: string) => void; + amazonRep: string; + seaRep: string; + isGenerated: boolean; +}; + +export function CompDocDetailsSection({ + row, + site, + editAddress, + onAddressChange, + editDateCompleted, + onDateCompletedChange, + amazonRep, + seaRep, + isGenerated, +}: CompDocDetailsSectionProps) { + return ( +
+
+ Work Order Details +
+
+ + + + + + +
+
+ ); +} diff --git a/src/app/(protected)/workorders/_components/comp-doc/comp-doc-dialog-footer-draft.tsx b/src/app/(protected)/workorders/_components/comp-doc/comp-doc-dialog-footer-draft.tsx new file mode 100644 index 00000000..fad1529f --- /dev/null +++ b/src/app/(protected)/workorders/_components/comp-doc/comp-doc-dialog-footer-draft.tsx @@ -0,0 +1,50 @@ +import { Ban, Save, Sparkles } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { COMP_DOC_FOOTER_BTN_SX } from "./comp-doc-footer-btn-sx"; + +type CompDocDialogFooterDraftProps = { + onMarkNotRequired: () => void; + onSaveDraft: () => void; + onGenerate: () => void; +}; + +export function CompDocDialogFooterDraft({ + onMarkNotRequired, + onSaveDraft, + onGenerate, +}: CompDocDialogFooterDraftProps) { + return ( + <> + +
+ + +
+ + ); +} diff --git a/src/app/(protected)/workorders/_components/comp-doc/comp-doc-dialog-footer-generated.tsx b/src/app/(protected)/workorders/_components/comp-doc/comp-doc-dialog-footer-generated.tsx new file mode 100644 index 00000000..91b9792c --- /dev/null +++ b/src/app/(protected)/workorders/_components/comp-doc/comp-doc-dialog-footer-generated.tsx @@ -0,0 +1,35 @@ +import { CheckCircle2, Download } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { COMP_DOC_FOOTER_BTN_SX } from "./comp-doc-footer-btn-sx"; + +type CompDocDialogFooterGeneratedProps = { + generatedAt: string; + onDownloadPdf: () => void; +}; + +export function CompDocDialogFooterGenerated({ + generatedAt, + onDownloadPdf, +}: CompDocDialogFooterGeneratedProps) { + return ( + <> + + Generated {generatedAt} + +
+ +
+ + ); +} diff --git a/src/app/(protected)/workorders/_components/comp-doc/comp-doc-dialog-footer-not-required.tsx b/src/app/(protected)/workorders/_components/comp-doc/comp-doc-dialog-footer-not-required.tsx new file mode 100644 index 00000000..da1976bb --- /dev/null +++ b/src/app/(protected)/workorders/_components/comp-doc/comp-doc-dialog-footer-not-required.tsx @@ -0,0 +1,47 @@ +import { Ban, Sparkles, Undo2 } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { COMP_DOC_FOOTER_BTN_SX } from "./comp-doc-footer-btn-sx"; + +type CompDocDialogFooterNotRequiredProps = { + onUndo: () => void; +}; + +export function CompDocDialogFooterNotRequired({ onUndo }: CompDocDialogFooterNotRequiredProps) { + return ( + <> + + Marked{" "} + not required for this work order + +
+ + +
+ + ); +} diff --git a/src/app/(protected)/workorders/_components/comp-doc/comp-doc-draft-toast.tsx b/src/app/(protected)/workorders/_components/comp-doc/comp-doc-draft-toast.tsx new file mode 100644 index 00000000..910b7227 --- /dev/null +++ b/src/app/(protected)/workorders/_components/comp-doc/comp-doc-draft-toast.tsx @@ -0,0 +1,29 @@ +import { CheckCircle2 } from "lucide-react"; +import { toast } from "react-toastify"; + +export function showCompDocDraftSavedToast() { + toast( +
+ Draft saved +
, + { + position: "bottom-center", + autoClose: 3000, + hideProgressBar: true, + closeButton: false, + icon: false, + className: "!min-h-0 !bg-transparent !p-0 !shadow-none", + }, + ); +} diff --git a/src/app/(protected)/workorders/_components/comp-doc/comp-doc-editable-row.tsx b/src/app/(protected)/workorders/_components/comp-doc/comp-doc-editable-row.tsx new file mode 100644 index 00000000..8e9261e2 --- /dev/null +++ b/src/app/(protected)/workorders/_components/comp-doc/comp-doc-editable-row.tsx @@ -0,0 +1,54 @@ +import type { CSSProperties } from "react"; + +const COMP_DOC_INPUT_CLS = "w-full rounded-md px-2.5 py-1.5 focus:outline-none focus:ring-1"; + +const COMP_DOC_INPUT_STYLE: CSSProperties = { + fontSize: 13, + border: "1px solid var(--color-border)", + color: "var(--color-text-primary)", + background: "var(--color-bg-surface)", +}; + +const COMP_DOC_INPUT_DISABLED_STYLE: CSSProperties = { + opacity: 0.6, + cursor: "default", + pointerEvents: "none", +}; + +type CompDocEditableRowProps = { + label: string; + value: string; + onChange: (value: string) => void; + type?: string; + placeholder?: string; + disabled?: boolean; +}; + +export function CompDocEditableRow({ + label, + value, + onChange, + type = "text", + placeholder = "", + disabled = false, +}: CompDocEditableRowProps) { + return ( +
+
+ {label} +
+ onChange(e.target.value)} + readOnly={disabled} + placeholder={placeholder} + className={COMP_DOC_INPUT_CLS} + style={{ + ...COMP_DOC_INPUT_STYLE, + ...(disabled ? COMP_DOC_INPUT_DISABLED_STYLE : {}), + }} + /> +
+ ); +} diff --git a/src/app/(protected)/workorders/_components/comp-doc/comp-doc-footer-btn-sx.ts b/src/app/(protected)/workorders/_components/comp-doc/comp-doc-footer-btn-sx.ts new file mode 100644 index 00000000..f5c0f2ba --- /dev/null +++ b/src/app/(protected)/workorders/_components/comp-doc/comp-doc-footer-btn-sx.ts @@ -0,0 +1,8 @@ +export const COMP_DOC_FOOTER_BTN_SX = { + minHeight: 28, + height: 28, + fontSize: 12, + px: 1.25, + py: 0.5, + gap: 0.75, +} as const; diff --git a/src/app/(protected)/workorders/_components/comp-doc/comp-doc-not-required-stamp.tsx b/src/app/(protected)/workorders/_components/comp-doc/comp-doc-not-required-stamp.tsx new file mode 100644 index 00000000..ab3cbf04 --- /dev/null +++ b/src/app/(protected)/workorders/_components/comp-doc/comp-doc-not-required-stamp.tsx @@ -0,0 +1,25 @@ +import { Ban } from "lucide-react"; + +export function CompDocNotRequiredStamp() { + return ( +
+
+ NOT REQUIRED +
+
+ ); +} diff --git a/src/app/(protected)/workorders/_components/comp-doc/comp-doc-notes-field.tsx b/src/app/(protected)/workorders/_components/comp-doc/comp-doc-notes-field.tsx new file mode 100644 index 00000000..f6deb5e5 --- /dev/null +++ b/src/app/(protected)/workorders/_components/comp-doc/comp-doc-notes-field.tsx @@ -0,0 +1,50 @@ +type CompDocNotesFieldProps = { + notes: string; + onNotesChange: (value: string) => void; + isGenerated: boolean; +}; + +export function CompDocNotesField({ notes, onNotesChange, isGenerated }: CompDocNotesFieldProps) { + return ( +
+ +