From 668dd69df2f8b194d4d7101d31acf88cd643575e Mon Sep 17 00:00:00 2001 From: Arthur Bassi Date: Fri, 12 Jun 2026 11:56:41 -0300 Subject: [PATCH] feat: add vendor portal routes and signature capture --- src/app/v/[token]/_layout.tsx | 88 +++ src/app/v/[token]/dashboard.tsx | 112 +++ src/app/v/[token]/dispatch/[id].tsx | 642 ++++++++++++++++++ src/app/v/[token]/index.tsx | 5 + src/app/v/[token]/pos.tsx | 170 +++++ src/app/v/_components/signature-pad.tsx | 97 +++ .../v/_components/vendor-portal-context.ts | 21 + .../v/_components/vendor-portal-provider.tsx | 53 ++ src/app/v/_components/vendor-portal.css | 370 ++++++++++ .../vendor-portal/api/vendor-portal-api.ts | 136 ++++ .../vendor-portal/lib/status-helpers.ts | 25 + .../vendor-portal/types/vendor-portal.ts | 80 +++ 12 files changed, 1799 insertions(+) create mode 100644 src/app/v/[token]/_layout.tsx create mode 100644 src/app/v/[token]/dashboard.tsx create mode 100644 src/app/v/[token]/dispatch/[id].tsx create mode 100644 src/app/v/[token]/index.tsx create mode 100644 src/app/v/[token]/pos.tsx create mode 100644 src/app/v/_components/signature-pad.tsx create mode 100644 src/app/v/_components/vendor-portal-context.ts create mode 100644 src/app/v/_components/vendor-portal-provider.tsx create mode 100644 src/app/v/_components/vendor-portal.css create mode 100644 src/domain/vendor-portal/api/vendor-portal-api.ts create mode 100644 src/domain/vendor-portal/lib/status-helpers.ts create mode 100644 src/domain/vendor-portal/types/vendor-portal.ts diff --git a/src/app/v/[token]/_layout.tsx b/src/app/v/[token]/_layout.tsx new file mode 100644 index 00000000..79813fd8 --- /dev/null +++ b/src/app/v/[token]/_layout.tsx @@ -0,0 +1,88 @@ +import { NavLink, Outlet, useLocation, useParams } from "react-router-dom"; +import { useVendorPortal } from "@/app/v/_components/vendor-portal-context"; +import { VendorPortalProvider } from "@/app/v/_components/vendor-portal-provider"; +import "@/app/v/_components/vendor-portal.css"; + +function VendorPortalHeader() { + const { vendor, status } = useVendorPortal(); + + return ( +
+
Sea Haven Industries — Vendor Portal
+ {status === "ready" && vendor?.companyName && ( +
{vendor.companyName}
+ )} +
+ ); +} + +function VendorPortalNav() { + const { token } = useParams(); + const { pathname } = useLocation(); + const onDetail = /\/dispatch\//.test(pathname); + + return ( + + ); +} + +function VendorPortalBody() { + const { status, error } = useVendorPortal(); + + if (status === "loading") { + return ( +
+
Loading…
+
+ ); + } + + if (status === "error") { + return ( +
+
+

Access Denied

+

+ {error?.message || + "This link is invalid or has expired. Please contact your dispatcher for a new link."} +

+
+
+ ); + } + + return ( + <> + +
+ +
+ + ); +} + +export default function VendorPortalLayout() { + return ( + +
+ + +
+
+ ); +} diff --git a/src/app/v/[token]/dashboard.tsx b/src/app/v/[token]/dashboard.tsx new file mode 100644 index 00000000..d601c87a --- /dev/null +++ b/src/app/v/[token]/dashboard.tsx @@ -0,0 +1,112 @@ +import { useMemo, useState } from "react"; +import { useNavigate } from "react-router-dom"; +import { useQuery } from "@tanstack/react-query"; +import { useVendorPortal } from "@/app/v/_components/vendor-portal-context"; +import { vendorPortalApi } from "@/domain/vendor-portal/api/vendor-portal-api"; +import { + formatVendorPortalDateTime, + vendorPortalStatusClass, +} from "@/domain/vendor-portal/lib/status-helpers"; +import type { VendorPortalDispatchSummary } from "@/domain/vendor-portal/types/vendor-portal"; +import { queryKeys } from "@/infra/query-key/query-key"; + +const FILTERS = [ + { + key: "open", + label: "Open", + match: (dispatch: VendorPortalDispatchSummary) => + dispatch.status !== "Completed" && + dispatch.status !== "Verified" && + dispatch.status !== "Cancelled", + }, + { + key: "completed", + label: "Completed", + match: (dispatch: VendorPortalDispatchSummary) => + dispatch.status === "Completed" || dispatch.status === "Verified", + }, + { + key: "cancelled", + label: "Cancelled", + match: (dispatch: VendorPortalDispatchSummary) => dispatch.status === "Cancelled", + }, + { key: "all", label: "All", match: () => true }, +] as const; + +export default function VendorPortalDashboardPage() { + const { token } = useVendorPortal(); + const navigate = useNavigate(); + const [filter, setFilter] = useState<(typeof FILTERS)[number]["key"]>("open"); + + const { + data: dispatches = [], + isLoading, + error, + } = useQuery({ + queryKey: queryKeys.vendorPortal.dispatches(token), + queryFn: () => vendorPortalApi.listDispatches(token), + enabled: Boolean(token), + }); + + const activeFilter = FILTERS.find((item) => item.key === filter) ?? FILTERS[0]; + const filtered = useMemo(() => dispatches.filter(activeFilter.match), [dispatches, activeFilter]); + + return ( + <> +
+

Your Work

+
+ Active dispatches and work orders assigned to your company. +
+
+ {FILTERS.map((item) => ( + + ))} +
+
+ + {isLoading &&
Loading dispatches…
} + {error &&
{error.message}
} + + {!isLoading && !error && filtered.length === 0 && ( +
No dispatches in this view.
+ )} + +
+ {filtered.map((dispatch) => ( +
navigate(`../dispatch/${dispatch.id}`)} + > +
+
+ {dispatch.dispatchNumber} — {dispatch.workOrderTitle || "(untitled)"} +
+
+ WO#{dispatch.internalWONumber} + {dispatch.locationName ? ` · ${dispatch.locationName}` : ""} + {dispatch.locationCity ? `, ${dispatch.locationCity}` : ""} + {dispatch.locationState ? `, ${dispatch.locationState}` : ""} + {dispatch.scheduledDate + ? ` · Scheduled ${formatVendorPortalDateTime(dispatch.scheduledDate)}` + : ""} +
+
+ {dispatch.status} +
+ ))} +
+ + ); +} diff --git a/src/app/v/[token]/dispatch/[id].tsx b/src/app/v/[token]/dispatch/[id].tsx new file mode 100644 index 00000000..67aa7145 --- /dev/null +++ b/src/app/v/[token]/dispatch/[id].tsx @@ -0,0 +1,642 @@ +import { useCallback, useState } from "react"; +import { Link, useParams } from "react-router-dom"; +import { useQuery, useQueryClient } from "@tanstack/react-query"; +import { SignaturePad } from "@/app/v/_components/signature-pad"; +import { useVendorPortal } from "@/app/v/_components/vendor-portal-context"; +import { vendorPortalApi } from "@/domain/vendor-portal/api/vendor-portal-api"; +import { + formatVendorPortalDateTime, + vendorPortalStatusClass, +} from "@/domain/vendor-portal/lib/status-helpers"; +import type { + VendorPortalChecklistItem, + VendorPortalComment, + VendorPortalDispatchDetail, + VendorPortalSignoff, + VendorPortalUpliftRequest, +} from "@/domain/vendor-portal/types/vendor-portal"; +import { queryKeys } from "@/infra/query-key/query-key"; + +function ChecklistSection({ + token, + dispatchId, + items, + locked, + onItemUpdated, +}: { + token: string; + dispatchId: number; + items: VendorPortalChecklistItem[]; + locked: boolean; + onItemUpdated: (item: VendorPortalChecklistItem) => void; +}) { + const toggle = async (item: VendorPortalChecklistItem) => { + if (locked) return; + try { + const updated = await vendorPortalApi.updateChecklistItem( + token, + dispatchId, + item.id, + !item.isCompleted, + ); + onItemUpdated(updated); + } catch (error) { + alert(error instanceof Error ? error.message : "Failed to update checklist"); + } + }; + + if (!items.length) { + return
No checklist items for this dispatch.
; + } + + return ( +
+ {items.map((item) => ( +
+ void toggle(item)} + disabled={locked} + /> + + {item.isCompleted && item.completedAt && ( + + {formatVendorPortalDateTime(item.completedAt)} + + )} +
+ ))} +
+ ); +} + +function CommentsSection({ + token, + dispatchId, + comments, + onAdded, +}: { + token: string; + dispatchId: number; + comments: VendorPortalComment[]; + onAdded: (comment: VendorPortalComment) => void; +}) { + const [text, setText] = useState(""); + const [submitting, setSubmitting] = useState(false); + + const submit = async (event: React.FormEvent) => { + event.preventDefault(); + if (!text.trim()) return; + setSubmitting(true); + try { + const added = await vendorPortalApi.addComment(token, dispatchId, text.trim()); + onAdded(added); + setText(""); + } catch (error) { + alert(error instanceof Error ? error.message : "Failed to post comment"); + } finally { + setSubmitting(false); + } + }; + + return ( + <> + {comments.length === 0 &&
No comments yet.
} + {comments.map((comment) => ( +
+
+ {comment.commenter || "Unknown"} · {formatVendorPortalDateTime(comment.createdDate)} +
+
{comment.commenttext}
+
+ ))} +
void submit(event)} style={{ marginTop: 12 }}> +