From 2a6fe6f35e3cd95734a044e88bd89b9325b7e599 Mon Sep 17 00:00:00 2001 From: Adam Moussa <166072409+amoussa1229@users.noreply.github.com> Date: Mon, 20 Apr 2026 13:26:08 -0400 Subject: [PATCH] Add NTE uplift UI, vendor PO pages, and portal POs tab Vendors can now request NTE uplifts from the dispatch detail page and dispatchers can approve or deny them (with tier-gated buttons) from the work order view. Admin uplift queue page lists all requests with status/tier filters. New Vendor POs section lists every PO with a detail page showing vendor, linked WOs, uplift history, checklist, signoffs, comments, and scoped audit log with print support. Vendor portal gets a POs tab alongside Work so vendors can reconcile by PO number. --- src/App.js | 16 + src/components/Sidebar.js | 4 + src/pages/uplifts/UpliftQueuePage.css | 135 +++++++ src/pages/uplifts/UpliftQueuePage.js | 182 +++++++++ src/pages/vendor-portal/DashboardPage.js | 4 +- src/pages/vendor-portal/DispatchDetailPage.js | 151 ++++++++ src/pages/vendor-portal/PosPage.js | 144 +++++++ src/pages/vendor-portal/VendorPortalLayout.js | 35 +- src/pages/vendor-portal/api.js | 4 + src/pages/vendor-portal/vendor-portal.css | 138 +++++++ src/pages/vendor-pos/list/List.js | 110 ++++++ src/pages/vendor-pos/list/Table.js | 91 +++++ src/pages/vendor-pos/list/api.js | 31 ++ src/pages/vendor-pos/list/styles.css | 111 ++++++ .../vendor-pos/view/VendorPoViewPage.css | 297 ++++++++++++++ src/pages/vendor-pos/view/VendorPoViewPage.js | 363 ++++++++++++++++++ .../workorders/view/WorkOrderViewPage.css | 72 ++++ .../workorders/view/WorkOrderViewPage.js | 94 +++++ src/services/api.js | 12 + 19 files changed, 1988 insertions(+), 6 deletions(-) create mode 100644 src/pages/uplifts/UpliftQueuePage.css create mode 100644 src/pages/uplifts/UpliftQueuePage.js create mode 100644 src/pages/vendor-portal/PosPage.js create mode 100644 src/pages/vendor-pos/list/List.js create mode 100644 src/pages/vendor-pos/list/Table.js create mode 100644 src/pages/vendor-pos/list/api.js create mode 100644 src/pages/vendor-pos/list/styles.css create mode 100644 src/pages/vendor-pos/view/VendorPoViewPage.css create mode 100644 src/pages/vendor-pos/view/VendorPoViewPage.js diff --git a/src/App.js b/src/App.js index 9512c6d0..09ca4da0 100644 --- a/src/App.js +++ b/src/App.js @@ -50,6 +50,13 @@ import EventFormPage from "./pages/calendar/form/EventFormPage"; import VendorsList from "./pages/vendors/list/List"; import VendorFormPage from "./pages/vendors/form/VendorFormPage"; +// UPLIFTS +import UpliftQueuePage from "./pages/uplifts/UpliftQueuePage"; + +// VENDOR POs +import VendorPoList from "./pages/vendor-pos/list/List"; +import VendorPoViewPage from "./pages/vendor-pos/view/VendorPoViewPage"; + // SETTINGS import DropdownOptionsPage from "./pages/settings/DropdownOptionsPage"; import TaskTemplatesPage from "./pages/settings/TaskTemplatesPage"; @@ -57,6 +64,7 @@ import TaskTemplatesPage from "./pages/settings/TaskTemplatesPage"; // VENDOR PORTAL (public, token-authenticated, separate tree) import VendorPortalLayout from "./pages/vendor-portal/VendorPortalLayout"; import VendorPortalDashboard from "./pages/vendor-portal/DashboardPage"; +import VendorPortalPos from "./pages/vendor-portal/PosPage"; import VendorPortalDispatchDetail from "./pages/vendor-portal/DispatchDetailPage"; // Protected Route Component @@ -77,6 +85,7 @@ export default function App() { }> } /> } /> + } /> } /> @@ -147,6 +156,13 @@ export default function App() { } /> } /> + {/* UPLIFTS */} + } /> + + {/* VENDOR POs */} + } /> + } /> + {/* SETTINGS */} } /> } /> diff --git a/src/components/Sidebar.js b/src/components/Sidebar.js index 1f0a7c73..17de7a7b 100644 --- a/src/components/Sidebar.js +++ b/src/components/Sidebar.js @@ -15,6 +15,8 @@ import { FaMapMarkerAlt, FaCog, FaTruck, + FaDollarSign, + FaFileInvoiceDollar, } from "react-icons/fa"; const navItems = [ @@ -27,6 +29,8 @@ const navItems = [ { label: "Reports", icon: , path: "/reports" }, { label: "Work Orders", icon: , path: "/workorders" }, { label: "Vendors", icon: , path: "/vendors" }, + { label: "Vendor POs", icon: , path: "/vendor-pos" }, + { label: "Uplifts", icon: , path: "/uplifts" }, { label: "Follow Ups", icon: , path: "/followups" }, { label: "PM Schedules", icon: , path: "/pmschedules" }, { label: "Assets", icon: , path: "/assets" }, diff --git a/src/pages/uplifts/UpliftQueuePage.css b/src/pages/uplifts/UpliftQueuePage.css new file mode 100644 index 00000000..350047a8 --- /dev/null +++ b/src/pages/uplifts/UpliftQueuePage.css @@ -0,0 +1,135 @@ +.upliftQueuePage { + padding: 24px; +} + +.upliftQueueHeader { + display: flex; + justify-content: space-between; + align-items: center; + flex-wrap: wrap; + gap: 12px; + margin-bottom: 16px; +} + +.upliftQueueHeader h2 { + margin: 0; + font-size: 22px; + color: #1f2937; +} + +.upliftQueueFilters { + display: flex; + gap: 16px; + align-items: center; + font-size: 14px; + color: #374151; +} + +.upliftQueueFilters label { + display: flex; + gap: 6px; + align-items: center; +} + +.upliftQueueFilters select { + padding: 6px 10px; + border: 1px solid #d1d5db; + border-radius: 6px; + font-size: 14px; + background: #fff; +} + +.upliftQueueCount { + color: #6b7280; + font-size: 13px; +} + +.upliftQueueLoading, +.upliftQueueEmpty { + padding: 40px; + text-align: center; + color: #6b7280; + background: #fff; + border: 1px solid #e4e7eb; + border-radius: 8px; +} + +.upliftQueueTable { + width: 100%; + border-collapse: collapse; + background: #fff; + border: 1px solid #e4e7eb; + border-radius: 8px; + overflow: hidden; + font-size: 14px; +} + +.upliftQueueTable th, +.upliftQueueTable td { + padding: 10px 12px; + text-align: left; + border-bottom: 1px solid #f1f3f5; +} + +.upliftQueueTable th { + background: #f9fafb; + font-weight: 600; + color: #374151; + font-size: 13px; +} + +.upliftQueueTable tr:last-child td { + border-bottom: none; +} + +.upliftStatusPill { + display: inline-block; + padding: 3px 9px; + border-radius: 999px; + font-size: 12px; + font-weight: 600; +} + +.upliftStatusPill-pending { background: #fef3c7; color: #92400e; } +.upliftStatusPill-approved { background: #d1fae5; color: #065f46; } +.upliftStatusPill-denied { background: #fee2e2; color: #991b1b; } +.upliftStatusPill-cancelled { background: #e5e7eb; color: #4b5563; } + +.upliftTier { + display: inline-block; + padding: 2px 8px; + border-radius: 999px; + font-size: 12px; + font-weight: 600; +} + +.upliftTier-1 { background: #e0e7ff; color: #3730a3; } +.upliftTier-2 { background: #fef3c7; color: #92400e; } + +.upliftActions { + display: flex; + gap: 6px; + flex-wrap: wrap; +} + +.upliftQueuePager { + display: flex; + gap: 12px; + align-items: center; + justify-content: center; + margin-top: 16px; + font-size: 14px; +} + +.upliftQueuePager button { + padding: 6px 14px; + border: 1px solid #d1d5db; + border-radius: 6px; + background: #fff; + cursor: pointer; +} + +.upliftQueuePager button:disabled { + opacity: 0.5; + cursor: not-allowed; +} diff --git a/src/pages/uplifts/UpliftQueuePage.js b/src/pages/uplifts/UpliftQueuePage.js new file mode 100644 index 00000000..ebd7bc9d --- /dev/null +++ b/src/pages/uplifts/UpliftQueuePage.js @@ -0,0 +1,182 @@ +import React, { useCallback, useEffect, useState } from "react"; +import { useNavigate } from "react-router"; +import { toast } from "react-toastify"; +import { upliftApi } from "../../services/api"; +import "./UpliftQueuePage.css"; + +function formatDateTime(value) { + if (!value) return ""; + const d = new Date(value); + if (isNaN(d)) return ""; + const pad = (n) => String(n).padStart(2, "0"); + return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`; +} + +const STATUS_OPTIONS = ["Pending", "Approved", "Denied", "Cancelled"]; + +export default function UpliftQueuePage() { + const navigate = useNavigate(); + const [status, setStatus] = useState("Pending"); + const [tier, setTier] = useState(""); + const [items, setItems] = useState([]); + const [total, setTotal] = useState(0); + const [page, setPage] = useState(1); + const [pageSize] = useState(25); + const [loading, setLoading] = useState(false); + + const load = useCallback(async () => { + setLoading(true); + try { + const params = { status, page, pageSize }; + if (tier) params.tier = tier; + const res = await upliftApi.list(params); + const data = res?.data ?? res; + setItems(data?.items || []); + setTotal(data?.total || 0); + } catch (err) { + toast.error(err.message || "Failed to load uplift queue"); + } finally { + setLoading(false); + } + }, [status, tier, page, pageSize]); + + useEffect(() => { load(); }, [load]); + + async function approve(req) { + const note = window.prompt("Optional approval note:", "") || ""; + if (!window.confirm(`Approve uplift to $${Number(req.requestedNTE).toFixed(2)}?`)) return; + try { + await upliftApi.approve(req.id, note); + toast.success("Uplift approved — NTE updated"); + load(); + } catch (err) { + toast.error(err.message || "Failed to approve"); + } + } + + async function deny(req) { + const note = window.prompt("Denial reason (required):", ""); + if (!note || !note.trim()) { + toast.error("A reason is required when denying."); + return; + } + try { + await upliftApi.deny(req.id, note.trim()); + toast.success("Uplift denied"); + load(); + } catch (err) { + toast.error(err.message || "Failed to deny"); + } + } + + const totalPages = Math.max(1, Math.ceil(total / pageSize)); + + return ( +
+
+

Uplift Queue

+
+ + + {total} total +
+
+ + {loading ? ( +
Loading…
+ ) : items.length === 0 ? ( +
No uplift requests match these filters.
+ ) : ( + + + + + + + + + + + + + + + + + {items.map((r) => ( + + + + + + + + + + + + + ))} + +
DispatchPOVendorCurrent NTERequestedDeltaTierStatusRequested AtActions
{r.dispatchNumber}{r.poNumber}{r.vendorCompanyName}${Number(r.currentNTE ?? 0).toFixed(2)}${Number(r.requestedNTE).toFixed(2)}${Number(r.delta).toFixed(2)} + + Tier {r.requiredTier} + + + + {r.status} + + {r.requestedAt ? formatDateTime(r.requestedAt) : ""} +
+ {r.workOrderId && ( + + )} + {r.status === "Pending" && ( + <> + + + + )} +
+
+ )} + + {totalPages > 1 && ( +
+ + Page {page} of {totalPages} + +
+ )} +
+ ); +} diff --git a/src/pages/vendor-portal/DashboardPage.js b/src/pages/vendor-portal/DashboardPage.js index 874f14bc..780d89de 100644 --- a/src/pages/vendor-portal/DashboardPage.js +++ b/src/pages/vendor-portal/DashboardPage.js @@ -34,9 +34,9 @@ export default function DashboardPage() { return ( <>
-

Your Dispatches

+

Your Work

- View and manage work orders assigned to your company. + Active dispatches and work orders assigned to your company.
{FILTERS.map((f) => ( diff --git a/src/pages/vendor-portal/DispatchDetailPage.js b/src/pages/vendor-portal/DispatchDetailPage.js index bc9dc777..6b1125a0 100644 --- a/src/pages/vendor-portal/DispatchDetailPage.js +++ b/src/pages/vendor-portal/DispatchDetailPage.js @@ -88,6 +88,142 @@ function CommentsSection({ token, dispatchId, comments, onAdded }) { ); } +function UpliftRequestsSection({ token, dispatchId, currentNTE, upliftRequests, locked, onChanged }) { + const [requested, setRequested] = useState(""); + const [reason, setReason] = useState(""); + const [submitting, setSubmitting] = useState(false); + + const requests = upliftRequests || []; + const pending = requests.find((r) => r.status === "Pending"); + + async function submit(e) { + e.preventDefault(); + const amt = parseFloat(requested); + if (!Number.isFinite(amt) || amt <= 0) { + alert("Enter a valid requested NTE amount."); + return; + } + if (currentNTE != null && amt <= Number(currentNTE)) { + alert(`Requested NTE must be greater than the current NTE ($${Number(currentNTE).toFixed(2)}).`); + return; + } + if (!reason.trim()) { + alert("Please include a reason for the uplift request."); + return; + } + setSubmitting(true); + try { + await vendorPortalApi.requestUplift(token, dispatchId, { requestedNTE: amt, reason: reason.trim() }); + setRequested(""); + setReason(""); + await onChanged(); + } catch (err) { + alert(err.message); + } finally { + setSubmitting(false); + } + } + + async function cancel(requestId) { + if (!window.confirm("Cancel this uplift request?")) return; + try { + await vendorPortalApi.cancelUplift(token, dispatchId, requestId); + await onChanged(); + } catch (err) { + alert(err.message); + } + } + + return ( + <> + {requests.length === 0 && !pending && ( +
+ No uplift requests yet. Use the form below if the work will exceed the current NTE. +
+ )} + + {requests.map((r) => ( +
+
+ Requested + ${Number(r.requestedNTE).toFixed(2)} (was ${Number(r.currentNTE ?? 0).toFixed(2)}) +
+
+ Status + + {r.status} + {r.requiredTier === 2 && r.status === "Pending" && ( + · Manager approval required + )} + +
+ {r.vendorReason && ( +
Reason{r.vendorReason}
+ )} +
+ Requested + {formatDateTime(r.requestedAt)} +
+ {r.decidedAt && ( +
+ Decided + + {formatDateTime(r.decidedAt)} + {r.decidedByName ? ` by ${r.decidedByName}` : ""} + +
+ )} + {r.decisionNote && ( +
Note{r.decisionNote}
+ )} + {r.status === "Pending" && ( +
+ +
+ )} +
+ ))} + + {!locked && !pending && ( +
+
+ Current NTE + ${Number(currentNTE ?? 0).toFixed(2)} +
+
+ New total NTE + setRequested(e.target.value)} + placeholder="0.00" + /> +
+
+ Reason +