From 168fa69a87f99b5522ad0e5fa5f62523b3fc4ad7 Mon Sep 17 00:00:00 2001 From: Adam Moussa <166072409+amoussa1229@users.noreply.github.com> Date: Mon, 20 Apr 2026 12:02:00 -0400 Subject: [PATCH] Add vendor portal UI - Separate route tree at /v/:token (no SHOC chrome); session resolved via X-Vendor-Token header - Dashboard lists only the vendor's own dispatches with status filters - Dispatch detail: accept + status transitions (Acknowledged -> In Progress -> Completed), request cancel, checklist toggles, customer signoff before vendor signoff, comments attributed by type (dispatcher / vendor / customer) - SignaturePad component (mouse + touch canvas, drawn or typed) - Admin vendor form: portal token panel to generate, copy, rotate, or revoke the portal link --- src/App.js | 12 + src/pages/vendor-portal/DashboardPage.js | 90 +++++ src/pages/vendor-portal/DispatchDetailPage.js | 337 ++++++++++++++++++ src/pages/vendor-portal/SignaturePad.js | 87 +++++ .../vendor-portal/VendorPortalContext.js | 41 +++ src/pages/vendor-portal/VendorPortalLayout.js | 51 +++ src/pages/vendor-portal/api.js | 37 ++ src/pages/vendor-portal/statusHelpers.js | 18 + src/pages/vendor-portal/vendor-portal.css | 237 ++++++++++++ src/pages/vendors/form/VendorFormPage.css | 46 +++ src/pages/vendors/form/VendorFormPage.js | 7 + .../vendors/form/VendorPortalTokenPanel.js | 103 ++++++ src/services/api.js | 3 + 13 files changed, 1069 insertions(+) create mode 100644 src/pages/vendor-portal/DashboardPage.js create mode 100644 src/pages/vendor-portal/DispatchDetailPage.js create mode 100644 src/pages/vendor-portal/SignaturePad.js create mode 100644 src/pages/vendor-portal/VendorPortalContext.js create mode 100644 src/pages/vendor-portal/VendorPortalLayout.js create mode 100644 src/pages/vendor-portal/api.js create mode 100644 src/pages/vendor-portal/statusHelpers.js create mode 100644 src/pages/vendor-portal/vendor-portal.css create mode 100644 src/pages/vendors/form/VendorPortalTokenPanel.js diff --git a/src/App.js b/src/App.js index ccb45c25..9512c6d0 100644 --- a/src/App.js +++ b/src/App.js @@ -54,6 +54,11 @@ import VendorFormPage from "./pages/vendors/form/VendorFormPage"; import DropdownOptionsPage from "./pages/settings/DropdownOptionsPage"; 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 VendorPortalDispatchDetail from "./pages/vendor-portal/DispatchDetailPage"; + // Protected Route Component function ProtectedRoute({ children }) { return isAuthenticated() ? children : ; @@ -68,6 +73,13 @@ export default function App() { {/* Public Routes */} } /> + {/* Vendor Portal — public, token-authenticated via URL */} + }> + } /> + } /> + } /> + + {/* Protected Routes */} d.status !== "Completed" && d.status !== "Verified" && d.status !== "Cancelled" }, + { key: "completed", label: "Completed", match: (d) => d.status === "Completed" || d.status === "Verified" }, + { key: "cancelled", label: "Cancelled", match: (d) => d.status === "Cancelled" }, + { key: "all", label: "All", match: () => true }, +]; + +export default function DashboardPage() { + const { token } = useVendorPortal(); + const navigate = useNavigate(); + const [dispatches, setDispatches] = useState([]); + const [loading, setLoading] = useState(true); + const [err, setErr] = useState(null); + const [filter, setFilter] = useState("open"); + + useEffect(() => { + setLoading(true); + vendorPortalApi + .listDispatches(token) + .then((data) => setDispatches(Array.isArray(data) ? data : [])) + .catch((e) => setErr(e.message)) + .finally(() => setLoading(false)); + }, [token]); + + const activeFilter = FILTERS.find((f) => f.key === filter) ?? FILTERS[0]; + const filtered = dispatches.filter(activeFilter.match); + + return ( + <> +
+

Your Dispatches

+
+ View and manage work orders assigned to your company. +
+
+ {FILTERS.map((f) => ( + + ))} +
+
+ + {loading &&
Loading dispatches…
} + {err &&
{err}
} + + {!loading && !err && filtered.length === 0 && ( +
No dispatches in this view.
+ )} + +
+ {filtered.map((d) => ( +
navigate(`../dispatch/${d.id}`)} + > +
+
+ {d.dispatchNumber} — {d.workOrderTitle || "(untitled)"} +
+
+ WO#{d.internalWONumber} + {d.locationName ? ` · ${d.locationName}` : ""} + {d.locationCity ? `, ${d.locationCity}` : ""} + {d.locationState ? `, ${d.locationState}` : ""} + {d.scheduledDate ? ` · Scheduled ${formatDateTime(d.scheduledDate)}` : ""} +
+
+ {d.status} +
+ ))} +
+ + ); +} diff --git a/src/pages/vendor-portal/DispatchDetailPage.js b/src/pages/vendor-portal/DispatchDetailPage.js new file mode 100644 index 00000000..bc9dc777 --- /dev/null +++ b/src/pages/vendor-portal/DispatchDetailPage.js @@ -0,0 +1,337 @@ +import React, { useCallback, useEffect, useState } from "react"; +import { Link, useParams } from "react-router-dom"; +import { useVendorPortal } from "./VendorPortalContext"; +import { vendorPortalApi } from "./api"; +import { statusClass, formatDateTime } from "./statusHelpers"; +import SignaturePad from "./SignaturePad"; + +function ChecklistSection({ token, dispatchId, items, locked, onItemUpdated }) { + async function toggle(item) { + if (locked) return; + try { + const updated = await vendorPortalApi.updateChecklistItem(token, dispatchId, item.id, !item.isCompleted); + onItemUpdated(updated); + } catch (e) { + alert(e.message); + } + } + + if (!items?.length) { + return
No checklist items for this dispatch.
; + } + + return ( +
+ {items.map((item) => ( +
+ toggle(item)} + disabled={locked} + /> + + {item.isCompleted && item.completedAt && ( + {formatDateTime(item.completedAt)} + )} +
+ ))} +
+ ); +} + +function CommentsSection({ token, dispatchId, comments, onAdded }) { + const [text, setText] = useState(""); + const [submitting, setSubmitting] = useState(false); + + async function submit(e) { + e.preventDefault(); + if (!text.trim()) return; + setSubmitting(true); + try { + const added = await vendorPortalApi.addComment(token, dispatchId, text.trim()); + onAdded(added); + setText(""); + } catch (err) { + alert(err.message); + } finally { + setSubmitting(false); + } + } + + return ( + <> + {comments.length === 0 &&
No comments yet.
} + {comments.map((c) => ( +
+
+ {c.commenter || "Unknown"} · {formatDateTime(c.createdDate)} +
+
{c.commenttext}
+
+ ))} +
+