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 +