feat: add vendor portal routes and signature capture

This commit is contained in:
Arthur Bassi 2026-06-12 11:56:41 -03:00
parent 6672d09e1f
commit 668dd69df2
12 changed files with 1799 additions and 0 deletions

View file

@ -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 (
<div className="vp-header">
<div className="vp-brand">Sea Haven Industries — Vendor Portal</div>
{status === "ready" && vendor?.companyName && (
<div className="vp-vendor-name">{vendor.companyName}</div>
)}
</div>
);
}
function VendorPortalNav() {
const { token } = useParams();
const { pathname } = useLocation();
const onDetail = /\/dispatch\//.test(pathname);
return (
<nav className="vp-nav">
<div className="vp-nav-inner">
<NavLink
to={`/v/${token}/dashboard`}
className={({ isActive }) => `vp-nav-tab ${isActive || onDetail ? "active" : ""}`}
>
Work
</NavLink>
<NavLink
to={`/v/${token}/pos`}
className={({ isActive }) => `vp-nav-tab ${isActive ? "active" : ""}`}
>
POs
</NavLink>
</div>
</nav>
);
}
function VendorPortalBody() {
const { status, error } = useVendorPortal();
if (status === "loading") {
return (
<div className="vp-container">
<div className="vp-card vp-muted">Loading…</div>
</div>
);
}
if (status === "error") {
return (
<div className="vp-container">
<div className="vp-error">
<h2>Access Denied</h2>
<p>
{error?.message ||
"This link is invalid or has expired. Please contact your dispatcher for a new link."}
</p>
</div>
</div>
);
}
return (
<>
<VendorPortalNav />
<div className="vp-container">
<Outlet />
</div>
</>
);
}
export default function VendorPortalLayout() {
return (
<VendorPortalProvider>
<div className="vp-shell">
<VendorPortalHeader />
<VendorPortalBody />
</div>
</VendorPortalProvider>
);
}

View file

@ -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 (
<>
<div className="vp-card">
<h2>Your Work</h2>
<div className="vp-muted" style={{ marginBottom: 12 }}>
Active dispatches and work orders assigned to your company.
</div>
<div className="vp-actions" style={{ marginTop: 0 }}>
{FILTERS.map((item) => (
<button
key={item.key}
type="button"
className={`vp-btn ${filter === item.key ? "vp-btn-primary" : "vp-btn-ghost"}`}
onClick={() => setFilter(item.key)}
>
{item.label}{" "}
<span style={{ opacity: 0.7, fontWeight: 400 }}>
({dispatches.filter(item.match).length})
</span>
</button>
))}
</div>
</div>
{isLoading && <div className="vp-card vp-muted">Loading dispatches…</div>}
{error && <div className="vp-error">{error.message}</div>}
{!isLoading && !error && filtered.length === 0 && (
<div className="vp-card vp-empty">No dispatches in this view.</div>
)}
<div className="vp-dispatch-list">
{filtered.map((dispatch) => (
<div
key={dispatch.id}
className="vp-dispatch-card"
onClick={() => navigate(`../dispatch/${dispatch.id}`)}
>
<div>
<div className="vp-dispatch-title">
{dispatch.dispatchNumber} — {dispatch.workOrderTitle || "(untitled)"}
</div>
<div className="vp-dispatch-meta">
WO#{dispatch.internalWONumber}
{dispatch.locationName ? ` · ${dispatch.locationName}` : ""}
{dispatch.locationCity ? `, ${dispatch.locationCity}` : ""}
{dispatch.locationState ? `, ${dispatch.locationState}` : ""}
{dispatch.scheduledDate
? ` · Scheduled ${formatVendorPortalDateTime(dispatch.scheduledDate)}`
: ""}
</div>
</div>
<span className={vendorPortalStatusClass(dispatch.status)}>{dispatch.status}</span>
</div>
))}
</div>
</>
);
}

View file

@ -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 <div className="vp-muted">No checklist items for this dispatch.</div>;
}
return (
<div>
{items.map((item) => (
<div key={item.id} className={`vp-checklist-item ${item.isCompleted ? "done" : ""}`}>
<input
type="checkbox"
id={`cl-${item.id}`}
checked={item.isCompleted}
onChange={() => void toggle(item)}
disabled={locked}
/>
<label htmlFor={`cl-${item.id}`} style={{ flex: 1 }}>
{item.itemText}
</label>
{item.isCompleted && item.completedAt && (
<span className="vp-muted" style={{ fontSize: 12 }}>
{formatVendorPortalDateTime(item.completedAt)}
</span>
)}
</div>
))}
</div>
);
}
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 && <div className="vp-muted">No comments yet.</div>}
{comments.map((comment) => (
<div key={comment.id} className={`vp-comment ${comment.commentType || ""}`}>
<div className="vp-comment-meta">
{comment.commenter || "Unknown"} · {formatVendorPortalDateTime(comment.createdDate)}
</div>
<div>{comment.commenttext}</div>
</div>
))}
<form onSubmit={(event) => void submit(event)} style={{ marginTop: 12 }}>
<textarea
className="vp-textarea"
placeholder="Add a comment for the dispatcher…"
value={text}
onChange={(event) => setText(event.target.value)}
/>
<div className="vp-actions">
<button
type="submit"
className="vp-btn vp-btn-primary"
disabled={submitting || !text.trim()}
>
{submitting ? "Posting…" : "Post Comment"}
</button>
</div>
</form>
</>
);
}
function UpliftRequestsSection({
token,
dispatchId,
currentNte,
upliftRequests,
locked,
onChanged,
}: {
token: string;
dispatchId: number;
currentNte?: number;
upliftRequests: VendorPortalUpliftRequest[];
locked: boolean;
onChanged: () => Promise<void>;
}) {
const [requested, setRequested] = useState("");
const [reason, setReason] = useState("");
const [submitting, setSubmitting] = useState(false);
const pending = upliftRequests.find((request) => request.status === "Pending");
const submit = async (event: React.FormEvent) => {
event.preventDefault();
const amount = Number.parseFloat(requested);
if (!Number.isFinite(amount) || amount <= 0) {
alert("Enter a valid requested NTE amount.");
return;
}
if (currentNte != null && amount <= 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: amount,
reason: reason.trim(),
});
setRequested("");
setReason("");
await onChanged();
} catch (error) {
alert(error instanceof Error ? error.message : "Failed to request uplift");
} finally {
setSubmitting(false);
}
};
const cancel = async (requestId: number) => {
if (!window.confirm("Cancel this uplift request?")) return;
try {
await vendorPortalApi.cancelUplift(token, dispatchId, requestId);
await onChanged();
} catch (error) {
alert(error instanceof Error ? error.message : "Failed to cancel uplift");
}
};
return (
<>
{upliftRequests.length === 0 && !pending && (
<div className="vp-muted" style={{ marginBottom: 12 }}>
No uplift requests yet. Use the form below if the work will exceed the current NTE.
</div>
)}
{upliftRequests.map((request) => (
<div key={request.id} className="vp-uplift-row">
<div className="vp-row">
<strong>Requested</strong>
<span>
${Number(request.requestedNTE).toFixed(2)} (was $
{Number(request.currentNTE ?? 0).toFixed(2)})
</span>
</div>
<div className="vp-row">
<strong>Status</strong>
<span>
<span className={`vp-uplift-status-${String(request.status).toLowerCase()}`}>
{request.status}
</span>
</span>
</div>
{request.vendorReason && (
<div className="vp-row">
<strong>Reason</strong>
<span>{request.vendorReason}</span>
</div>
)}
{request.status === "Pending" && (
<div className="vp-actions">
<button
type="button"
className="vp-btn vp-btn-ghost"
onClick={() => void cancel(request.id)}
>
Cancel Request
</button>
</div>
)}
</div>
))}
{!locked && !pending && (
<form onSubmit={(event) => void submit(event)} style={{ marginTop: 12 }}>
<div className="vp-row">
<strong>Current NTE</strong>
<span>${Number(currentNte ?? 0).toFixed(2)}</span>
</div>
<div className="vp-row">
<strong>New total NTE</strong>
<input
type="number"
step="0.01"
min="0"
className="vp-input"
value={requested}
onChange={(event) => setRequested(event.target.value)}
placeholder="0.00"
/>
</div>
<div className="vp-row">
<strong>Reason</strong>
<textarea
className="vp-textarea"
value={reason}
onChange={(event) => setReason(event.target.value)}
placeholder="Why is the uplift needed?"
/>
</div>
<div className="vp-actions">
<button type="submit" className="vp-btn vp-btn-primary" disabled={submitting}>
{submitting ? "Submitting…" : "Request Uplift"}
</button>
</div>
</form>
)}
</>
);
}
function SignoffSection({
token,
dispatchId,
existing,
signoffType,
nameLabel,
onAdded,
}: {
token: string;
dispatchId: number;
existing: VendorPortalSignoff[];
signoffType: string;
nameLabel: string;
onAdded: () => void;
}) {
const [name, setName] = useState("");
const [method, setMethod] = useState<"drawn" | "typed">("drawn");
const [typedSig, setTypedSig] = useState("");
const [drawnSig, setDrawnSig] = useState("");
const [submitting, setSubmitting] = useState(false);
const existingSignoff = existing.find((signoff) => signoff.signoffType === signoffType);
if (existingSignoff) {
return (
<div>
<div className="vp-row">
<strong>Signed by</strong>
<span>{existingSignoff.name}</span>
</div>
<div className="vp-row">
<strong>Method</strong>
<span>{existingSignoff.signatureMethod}</span>
</div>
<div className="vp-row">
<strong>Signed at</strong>
<span>{formatVendorPortalDateTime(existingSignoff.signedAt)}</span>
</div>
</div>
);
}
const submit = async (event: React.FormEvent) => {
event.preventDefault();
const signature = method === "drawn" ? drawnSig : typedSig;
if (!name.trim() || !signature) {
alert("Name and signature are required");
return;
}
setSubmitting(true);
try {
await vendorPortalApi.addSignoff(token, dispatchId, {
name: name.trim(),
signature,
signatureMethod: method,
signoffType,
});
onAdded();
} catch (error) {
alert(error instanceof Error ? error.message : "Failed to submit signoff");
} finally {
setSubmitting(false);
}
};
return (
<form onSubmit={(event) => void submit(event)}>
<div className="vp-row">
<strong>{nameLabel}</strong>
<input
className="vp-input"
value={name}
onChange={(event) => setName(event.target.value)}
/>
</div>
<div className="vp-row">
<strong>Method</strong>
<div>
<label style={{ marginRight: 12 }}>
<input type="radio" checked={method === "drawn"} onChange={() => setMethod("drawn")} />{" "}
Drawn
</label>
<label>
<input type="radio" checked={method === "typed"} onChange={() => setMethod("typed")} />{" "}
Typed
</label>
</div>
</div>
{method === "drawn" ? (
<div style={{ marginTop: 8 }}>
<SignaturePad onChange={setDrawnSig} />
</div>
) : (
<div className="vp-row">
<strong>Signature</strong>
<input
className="vp-input"
value={typedSig}
onChange={(event) => setTypedSig(event.target.value)}
/>
</div>
)}
<div className="vp-actions">
<button type="submit" className="vp-btn vp-btn-success" disabled={submitting}>
{submitting ? "Signing…" : "Submit Signoff"}
</button>
</div>
</form>
);
}
export default function VendorPortalDispatchPage() {
const { token } = useVendorPortal();
const { id = "" } = useParams();
const dispatchId = Number(id);
const queryClient = useQueryClient();
const [busy, setBusy] = useState(false);
const { data, isLoading, error, refetch } = useQuery({
queryKey: queryKeys.vendorPortal.dispatch(token, dispatchId),
queryFn: () => vendorPortalApi.getDispatch(token, dispatchId),
enabled: Boolean(token && dispatchId),
});
const refresh = useCallback(async () => {
await refetch();
await queryClient.invalidateQueries({ queryKey: queryKeys.vendorPortal.dispatches(token) });
}, [refetch, queryClient, token]);
const runAction = async (action: () => Promise<void>, confirmMsg?: string) => {
if (confirmMsg && !window.confirm(confirmMsg)) return;
setBusy(true);
try {
await action();
await refresh();
} catch (err) {
alert(err instanceof Error ? err.message : "Action failed");
} finally {
setBusy(false);
}
};
if (isLoading && !data) {
return <div className="vp-card vp-muted">Loading dispatch…</div>;
}
if (error && !data) {
return <div className="vp-error">{error.message}</div>;
}
if (!data) return null;
const locked = data.status === "Verified" || data.status === "Cancelled";
const updateLocal = (
updater: (current: VendorPortalDispatchDetail) => VendorPortalDispatchDetail,
) => {
queryClient.setQueryData(
queryKeys.vendorPortal.dispatch(token, dispatchId),
(current: unknown) => {
if (!current) return current;
return updater(current as VendorPortalDispatchDetail);
},
);
};
return (
<>
<Link to={`/v/${token}/dashboard`} className="vp-back-link">
← Back to dispatches
</Link>
<div className="vp-card">
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "flex-start" }}>
<div>
<h2>{data.dispatchNumber}</h2>
<div className="vp-muted">{data.workOrder?.workerOrderTitle}</div>
</div>
<span className={vendorPortalStatusClass(data.status)}>{data.status}</span>
</div>
<div style={{ marginTop: 12 }}>
{data.workOrder?.internalWONumber && (
<div className="vp-row">
<strong>WO #</strong>
<span>{data.workOrder.internalWONumber}</span>
</div>
)}
{data.poNumber && (
<div className="vp-row">
<strong>PO #</strong>
<span>{data.poNumber}</span>
</div>
)}
{data.nteAmount != null && (
<div className="vp-row">
<strong>NTE</strong>
<span>${Number(data.nteAmount).toFixed(2)}</span>
</div>
)}
{data.scheduledDate && (
<div className="vp-row">
<strong>Scheduled</strong>
<span>{formatVendorPortalDateTime(data.scheduledDate)}</span>
</div>
)}
</div>
{data.location && (
<>
<h3 style={{ marginTop: 16 }}>Location</h3>
<div>{data.location.name}</div>
<div className="vp-muted">
{[
data.location.address,
data.location.city,
data.location.state,
data.location.zipCode,
]
.filter(Boolean)
.join(", ")}
</div>
</>
)}
{(data.description || data.workOrder?.description) && (
<>
<h3 style={{ marginTop: 16 }}>Description</h3>
<div>{data.description || data.workOrder?.description}</div>
</>
)}
<div className="vp-actions">
{data.status === "Sent" && (
<button
type="button"
className="vp-btn vp-btn-success"
disabled={busy}
onClick={() => void runAction(() => vendorPortalApi.accept(token, dispatchId))}
>
Accept Dispatch
</button>
)}
{data.status === "Acknowledged" && (
<button
type="button"
className="vp-btn vp-btn-primary"
disabled={busy}
onClick={() =>
void runAction(() => vendorPortalApi.changeStatus(token, dispatchId, "In Progress"))
}
>
Start Work
</button>
)}
{data.status === "In Progress" && (
<button
type="button"
className="vp-btn vp-btn-primary"
disabled={busy}
onClick={() =>
void runAction(
() => vendorPortalApi.changeStatus(token, dispatchId, "Completed"),
"Mark this dispatch as completed?",
)
}
>
Mark Completed
</button>
)}
{!locked && data.status !== "Sent" && (
<button
type="button"
className="vp-btn vp-btn-warn"
disabled={busy}
onClick={() => {
const reason = window.prompt("Reason for cancel request?");
if (reason == null) return;
void runAction(() => vendorPortalApi.requestCancel(token, dispatchId, reason));
}}
>
Request Cancel
</button>
)}
</div>
</div>
<div className="vp-card">
<h3>Checklist</h3>
<ChecklistSection
token={token}
dispatchId={dispatchId}
items={data.checklist ?? []}
locked={locked}
onItemUpdated={(updated) =>
updateLocal((current) => ({
...current,
checklist: (current.checklist ?? []).map((item) =>
item.id === updated.id ? { ...item, ...updated } : item,
),
}))
}
/>
</div>
<div className="vp-card">
<h3>NTE Uplift Requests</h3>
<UpliftRequestsSection
token={token}
dispatchId={dispatchId}
currentNte={data.nteAmount}
upliftRequests={data.upliftRequests ?? []}
locked={locked}
onChanged={refresh}
/>
</div>
<div className="vp-card">
<h3>Customer Signoff</h3>
<SignoffSection
token={token}
dispatchId={dispatchId}
existing={data.signoffs ?? []}
signoffType="customer"
nameLabel="Customer name"
onAdded={() => void refresh()}
/>
</div>
<div className="vp-card">
<h3>Vendor Signoff</h3>
<SignoffSection
token={token}
dispatchId={dispatchId}
existing={data.signoffs ?? []}
signoffType="vendor"
nameLabel="Your name"
onAdded={() => void refresh()}
/>
</div>
<div className="vp-card">
<h3>Comments</h3>
<CommentsSection
token={token}
dispatchId={dispatchId}
comments={data.comments ?? []}
onAdded={(comment) =>
updateLocal((current) => ({
...current,
comments: [...(current.comments ?? []), comment],
}))
}
/>
</div>
</>
);
}

View file

@ -0,0 +1,5 @@
import { Navigate } from "react-router-dom";
export default function VendorPortalIndex() {
return <Navigate to="dashboard" replace />;
}

170
src/app/v/[token]/pos.tsx Normal file
View file

@ -0,0 +1,170 @@
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;
function formatDate(value?: string) {
if (!value) return "—";
const date = new Date(value);
if (Number.isNaN(date.getTime())) return "—";
return date.toISOString().slice(0, 10);
}
function formatMoney(value?: number) {
if (value == null) return "—";
return `$${Number(value).toFixed(2)}`;
}
export default function VendorPortalPosPage() {
const { token } = useVendorPortal();
const navigate = useNavigate();
const [filter, setFilter] = useState<(typeof FILTERS)[number]["key"]>("open");
const [search, setSearch] = useState("");
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 visible = useMemo(() => {
const needle = search.trim().toLowerCase();
return dispatches.filter((dispatch) => {
if (!activeFilter.match(dispatch)) return false;
if (!needle) return true;
return (
(dispatch.poNumber || "").toLowerCase().includes(needle) ||
(dispatch.dispatchNumber || "").toLowerCase().includes(needle) ||
(dispatch.internalWONumber || "").toLowerCase().includes(needle) ||
(dispatch.workOrderTitle || "").toLowerCase().includes(needle) ||
(dispatch.locationName || "").toLowerCase().includes(needle)
);
});
}, [dispatches, activeFilter, search]);
return (
<>
<div className="vp-card">
<h2>Your Purchase Orders</h2>
<div className="vp-muted" style={{ marginBottom: 12 }}>
Every PO issued to your company, with current status and dollar value.
</div>
<div
className="vp-actions"
style={{ marginTop: 0, gap: 12, alignItems: "center", flexWrap: "wrap" }}
>
{FILTERS.map((item) => (
<button
key={item.key}
type="button"
className={`vp-btn ${filter === item.key ? "vp-btn-primary" : "vp-btn-ghost"}`}
onClick={() => setFilter(item.key)}
>
{item.label}{" "}
<span style={{ opacity: 0.7, fontWeight: 400 }}>
({dispatches.filter(item.match).length})
</span>
</button>
))}
<input
className="vp-input"
placeholder="Search PO #, WO, location…"
value={search}
onChange={(event) => setSearch(event.target.value)}
style={{ marginLeft: "auto", minWidth: 240 }}
/>
</div>
</div>
{isLoading && <div className="vp-card vp-muted">Loading POs…</div>}
{error && <div className="vp-error">{error.message}</div>}
{!isLoading && !error && visible.length === 0 && (
<div className="vp-card vp-empty">No POs match this view.</div>
)}
{!isLoading && !error && visible.length > 0 && (
<div className="vp-card" style={{ padding: 0 }}>
<table className="vp-po-table">
<thead>
<tr>
<th>PO #</th>
<th>Dispatch #</th>
<th>Status</th>
<th>NTE</th>
<th>Location</th>
<th>Dispatched</th>
<th>Scheduled</th>
<th>Completed</th>
</tr>
</thead>
<tbody>
{visible.map((dispatch) => (
<tr key={dispatch.id} onClick={() => navigate(`../dispatch/${dispatch.id}`)}>
<td>
<span className="vp-po-number">{dispatch.poNumber || "—"}</span>
</td>
<td className="vp-po-muted-cell">{dispatch.dispatchNumber}</td>
<td>
<span className={vendorPortalStatusClass(dispatch.status)}>
{dispatch.status}
</span>
</td>
<td>{formatMoney(dispatch.nteAmount)}</td>
<td className="vp-po-muted-cell">
{dispatch.locationName || "—"}
{dispatch.locationCity ? ` · ${dispatch.locationCity}` : ""}
{dispatch.locationState ? `, ${dispatch.locationState}` : ""}
</td>
<td className="vp-po-muted-cell">
{dispatch.dispatchedAt
? formatVendorPortalDateTime(dispatch.dispatchedAt)
: "—"}
</td>
<td className="vp-po-muted-cell">{formatDate(dispatch.scheduledDate)}</td>
<td className="vp-po-muted-cell">{formatDate(dispatch.completedDate)}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</>
);
}

View file

@ -0,0 +1,97 @@
import { useEffect, useRef, useState } from "react";
interface SignaturePadProps {
onChange?: (value: string) => void;
}
export function SignaturePad({ onChange }: SignaturePadProps) {
const canvasRef = useRef<HTMLCanvasElement>(null);
const drawingRef = useRef(false);
const lastRef = useRef({ x: 0, y: 0 });
const [isEmpty, setIsEmpty] = useState(true);
useEffect(() => {
const canvas = canvasRef.current;
if (!canvas) return;
const ratio = window.devicePixelRatio || 1;
const rect = canvas.getBoundingClientRect();
canvas.width = rect.width * ratio;
canvas.height = rect.height * ratio;
const ctx = canvas.getContext("2d");
if (!ctx) return;
ctx.scale(ratio, ratio);
ctx.lineCap = "round";
ctx.lineJoin = "round";
ctx.strokeStyle = "#111";
ctx.lineWidth = 2;
}, []);
const pointFromEvent = (event: React.MouseEvent | React.TouchEvent) => {
const canvas = canvasRef.current!;
const rect = canvas.getBoundingClientRect();
const touch = "touches" in event ? event.touches[0] : undefined;
const clientX = touch ? touch.clientX : (event as React.MouseEvent).clientX;
const clientY = touch ? touch.clientY : (event as React.MouseEvent).clientY;
return { x: clientX - rect.left, y: clientY - rect.top };
};
const start = (event: React.MouseEvent | React.TouchEvent) => {
event.preventDefault();
drawingRef.current = true;
lastRef.current = pointFromEvent(event);
};
const move = (event: React.MouseEvent | React.TouchEvent) => {
if (!drawingRef.current || !canvasRef.current) return;
event.preventDefault();
const ctx = canvasRef.current.getContext("2d");
if (!ctx) return;
const point = pointFromEvent(event);
ctx.beginPath();
ctx.moveTo(lastRef.current.x, lastRef.current.y);
ctx.lineTo(point.x, point.y);
ctx.stroke();
lastRef.current = point;
if (isEmpty) setIsEmpty(false);
};
const end = (event: React.MouseEvent | React.TouchEvent) => {
if (!drawingRef.current || !canvasRef.current) return;
event.preventDefault();
drawingRef.current = false;
onChange?.(canvasRef.current.toDataURL("image/png"));
};
const clear = () => {
const canvas = canvasRef.current;
if (!canvas) return;
const ctx = canvas.getContext("2d");
if (!ctx) return;
ctx.clearRect(0, 0, canvas.width, canvas.height);
setIsEmpty(true);
onChange?.("");
};
return (
<div>
<canvas
ref={canvasRef}
className="vp-signature-canvas"
onMouseDown={start}
onMouseMove={move}
onMouseUp={end}
onMouseLeave={end}
onTouchStart={start}
onTouchMove={move}
onTouchEnd={end}
/>
<div style={{ marginTop: 8 }}>
<button type="button" className="vp-btn vp-btn-ghost" onClick={clear} disabled={isEmpty}>
Clear
</button>
</div>
</div>
);
}

View file

@ -0,0 +1,21 @@
import { createContext, useContext } from "react";
import type { VendorPortalSession } from "@/domain/vendor-portal/types/vendor-portal";
export type VendorPortalStatus = "loading" | "ready" | "error";
export interface VendorPortalContextValue {
token: string;
vendor: VendorPortalSession | null;
status: VendorPortalStatus;
error: Error | null;
}
export const VendorPortalContext = createContext<VendorPortalContextValue | null>(null);
export function useVendorPortal(): VendorPortalContextValue {
const context = useContext(VendorPortalContext);
if (!context) {
throw new Error("useVendorPortal must be used within VendorPortalProvider");
}
return context;
}

View file

@ -0,0 +1,53 @@
import { useEffect, useMemo, useState, type ReactNode } from "react";
import { useParams } from "react-router-dom";
import { vendorPortalApi } from "@/domain/vendor-portal/api/vendor-portal-api";
import type { VendorPortalSession } from "@/domain/vendor-portal/types/vendor-portal";
import {
VendorPortalContext,
type VendorPortalContextValue,
type VendorPortalStatus,
} from "@/app/v/_components/vendor-portal-context";
function VendorPortalSessionLoader({ token, children }: { token: string; children: ReactNode }) {
const [vendor, setVendor] = useState<VendorPortalSession | null>(null);
const [status, setStatus] = useState<VendorPortalStatus>("loading");
const [error, setError] = useState<Error | null>(null);
useEffect(() => {
let cancelled = false;
vendorPortalApi
.session(token)
.then((data) => {
if (cancelled) return;
setVendor(data);
setStatus("ready");
})
.catch((err: Error) => {
if (cancelled) return;
setError(err);
setStatus("error");
});
return () => {
cancelled = true;
};
}, [token]);
const value = useMemo<VendorPortalContextValue>(
() => ({ token, vendor, status, error }),
[token, vendor, status, error],
);
return <VendorPortalContext.Provider value={value}>{children}</VendorPortalContext.Provider>;
}
export function VendorPortalProvider({ children }: { children: ReactNode }) {
const { token = "" } = useParams();
return (
<VendorPortalSessionLoader key={token} token={token}>
{children}
</VendorPortalSessionLoader>
);
}

View file

@ -0,0 +1,370 @@
.vp-shell {
min-height: 100vh;
background: var(--color-bg-page);
font-family: var(--font-sans);
color: var(--foreground);
}
.vp-header {
background: var(--color-header-gradient);
color: var(--color-header-text);
padding: 14px 24px;
display: flex;
align-items: center;
justify-content: space-between;
box-shadow: var(--color-header-shadow);
}
.vp-brand {
font-family: var(--font-heading);
font-size: 18px;
font-weight: 600;
letter-spacing: 0.3px;
}
.vp-vendor-name {
font-size: 13px;
color: var(--color-header-text-muted);
}
.vp-container {
max-width: 1080px;
margin: 0 auto;
padding: 24px;
}
.vp-card {
background: var(--background);
border: 1px solid var(--border);
border-radius: var(--radius-xl);
padding: 20px;
margin-bottom: 16px;
box-shadow: var(--shadow-sm);
}
.vp-card h2 {
margin: 0 0 4px;
font-size: 20px;
}
.vp-card h3 {
margin: 0 0 12px;
font-size: 16px;
color: var(--foreground);
}
.vp-muted {
color: var(--muted-foreground);
font-size: 13px;
}
.vp-row {
display: grid;
grid-template-columns: 140px 1fr;
gap: 8px;
margin: 6px 0;
font-size: 14px;
}
.vp-row strong {
color: var(--color-text-secondary);
}
.vp-dispatch-list {
display: grid;
gap: 12px;
}
.vp-dispatch-card {
display: flex;
justify-content: space-between;
align-items: center;
padding: 16px 20px;
background: var(--background);
border: 1px solid var(--border);
border-radius: var(--radius-xl);
cursor: pointer;
transition:
border-color var(--duration-fast) var(--ease-default),
box-shadow var(--duration-fast) var(--ease-default);
}
.vp-dispatch-card:hover {
border-color: var(--primary);
box-shadow: var(--shadow-md);
}
.vp-dispatch-title {
font-weight: 600;
font-size: 15px;
margin-bottom: 4px;
}
.vp-dispatch-meta {
font-size: 13px;
color: var(--muted-foreground);
}
.vp-status-pill {
display: inline-block;
padding: 4px 10px;
border-radius: 999px;
font-size: 12px;
font-weight: 600;
letter-spacing: 0.2px;
}
.vp-status-sent {
background: var(--status-pending-bg);
color: var(--status-pending-text);
}
.vp-status-acknowledged {
background: var(--status-scheduled-bg);
color: var(--status-scheduled-text);
}
.vp-status-in-progress {
background: var(--status-inprogress-bg);
color: var(--status-inprogress-text);
}
.vp-status-completed {
background: var(--status-completed-bg);
color: var(--status-completed-text);
}
.vp-status-verified {
background: var(--status-onsite-bg);
color: var(--status-onsite-text);
}
.vp-status-cancelled {
background: var(--doc-no-bg);
color: var(--doc-no-text);
}
.vp-btn {
display: inline-block;
padding: 10px 18px;
font-size: 14px;
font-weight: 600;
border-radius: var(--radius-md);
border: none;
cursor: pointer;
transition: background var(--duration-fast) var(--ease-default);
}
.vp-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.vp-btn-primary {
background: var(--primary);
color: var(--primary-foreground);
}
.vp-btn-primary:hover:not(:disabled) {
background: var(--primary-hover);
}
.vp-btn-success {
background: var(--success);
color: var(--primary-foreground);
}
.vp-btn-warn {
background: var(--warning);
color: var(--primary-foreground);
}
.vp-btn-ghost {
background: transparent;
color: var(--foreground);
border: 1px solid var(--border);
}
.vp-btn-ghost:hover:not(:disabled) {
background: var(--secondary);
}
.vp-actions {
display: flex;
gap: 8px;
flex-wrap: wrap;
margin-top: 12px;
}
.vp-checklist-item {
display: flex;
align-items: center;
gap: 10px;
padding: 8px 0;
border-bottom: 1px solid var(--border);
font-size: 14px;
}
.vp-checklist-item:last-child {
border-bottom: none;
}
.vp-checklist-item input[type="checkbox"] {
width: 18px;
height: 18px;
cursor: pointer;
}
.vp-checklist-item.done label {
text-decoration: line-through;
color: var(--muted-foreground);
}
.vp-comment {
padding: 10px 12px;
background: var(--input-background);
border-left: 3px solid var(--border);
margin-bottom: 8px;
font-size: 14px;
}
.vp-comment-meta {
font-size: 12px;
color: var(--muted-foreground);
margin-bottom: 4px;
}
.vp-textarea,
.vp-input {
width: 100%;
padding: 10px 12px;
font-size: 14px;
border: 1px solid var(--border);
border-radius: var(--radius-md);
font-family: inherit;
box-sizing: border-box;
background: var(--input-background);
}
.vp-textarea {
min-height: 80px;
resize: vertical;
}
.vp-error {
background: var(--doc-no-bg);
color: var(--doc-no-text);
padding: 16px;
border-radius: var(--radius);
text-align: center;
}
.vp-empty {
text-align: center;
padding: 40px;
color: var(--muted-foreground);
}
.vp-signature-canvas {
border: 1px dashed var(--muted-foreground);
border-radius: var(--radius-md);
background: var(--background);
touch-action: none;
display: block;
width: 100%;
max-width: 500px;
height: 180px;
}
.vp-back-link {
color: var(--primary);
text-decoration: none;
font-size: 14px;
display: inline-block;
margin-bottom: 12px;
}
.vp-back-link:hover {
text-decoration: underline;
}
.vp-uplift-row {
padding: 12px;
border: 1px solid var(--border);
border-radius: var(--radius);
background: var(--input-background);
margin-bottom: 10px;
}
.vp-uplift-status-pending {
display: inline-block;
padding: 3px 9px;
border-radius: 999px;
font-size: 12px;
font-weight: 600;
background: var(--status-pending-bg);
color: var(--status-pending-text);
}
.vp-nav {
background: var(--background);
border-bottom: 1px solid var(--border);
padding: 0 24px;
}
.vp-nav-inner {
max-width: 1080px;
margin: 0 auto;
display: flex;
gap: 4px;
}
.vp-nav-tab {
background: none;
border: none;
padding: 12px 16px;
font-size: 14px;
font-weight: 500;
color: var(--muted-foreground);
cursor: pointer;
border-bottom: 2px solid transparent;
transition:
color var(--duration-fast) var(--ease-default),
border-color var(--duration-fast) var(--ease-default);
text-decoration: none;
display: inline-block;
}
.vp-nav-tab:hover {
color: var(--foreground);
}
.vp-nav-tab.active {
color: var(--foreground);
border-bottom-color: var(--primary);
}
.vp-po-table {
width: 100%;
border-collapse: collapse;
font-size: 14px;
}
.vp-po-table th,
.vp-po-table td {
padding: 10px 12px;
text-align: left;
border-bottom: 1px solid var(--border);
}
.vp-po-table th {
background: var(--input-background);
font-weight: 600;
color: var(--color-text-secondary);
font-size: 13px;
}
.vp-po-table tbody tr {
cursor: pointer;
transition: background var(--duration-fast) var(--ease-default);
}
.vp-po-table tbody tr:hover {
background: var(--input-background);
}
.vp-po-number {
font-weight: 600;
color: var(--foreground);
}
.vp-po-muted-cell {
color: var(--muted-foreground);
font-size: 13px;
}

View file

@ -0,0 +1,136 @@
import { API_PATHS } from "@/api/api-paths";
import { env } from "@/lib/env";
import type {
VendorPortalComment,
VendorPortalDispatchDetail,
VendorPortalDispatchSummary,
VendorPortalSession,
VendorPortalSignoff,
} from "@/domain/vendor-portal/types/vendor-portal";
function resolveVendorPortalUrl(path: string): string {
const base = env.apiUrl.trim().replace(/\/$/, "");
const prefix = base.startsWith("http")
? base
: `${typeof window !== "undefined" ? window.location.origin : "http://localhost:3000"}${base.startsWith("/") ? base : `/${base}`}`;
return `${prefix}/${API_PATHS.vendorPortal.base}${path}`;
}
async function vendorPortalCall<T>(
token: string,
path: string,
options?: { method?: string; body?: unknown },
): Promise<T> {
const response = await fetch(resolveVendorPortalUrl(path), {
method: options?.method ?? "GET",
headers: {
"Content-Type": "application/json",
"X-Vendor-Token": token,
},
body: options?.body ? JSON.stringify(options.body) : undefined,
});
if (response.status === 401) {
throw new Error("Invalid or expired token");
}
const payload = (await response.json().catch(() => ({}))) as Record<string, unknown>;
if (!response.ok) {
const message =
(typeof payload.message === "string" ? payload.message : undefined) ??
(typeof payload.Message === "string" ? payload.Message : undefined) ??
`HTTP ${response.status}`;
throw new Error(message);
}
return (payload.data ?? payload.Data ?? payload) as T;
}
export const vendorPortalApi = {
session: (token: string) =>
vendorPortalCall<VendorPortalSession>(token, API_PATHS.vendorPortal.session),
listDispatches: (token: string, status?: string) =>
vendorPortalCall<VendorPortalDispatchSummary[]>(
token,
status
? `${API_PATHS.vendorPortal.dispatches}?status=${encodeURIComponent(status)}`
: API_PATHS.vendorPortal.dispatches,
),
getDispatch: (token: string, id: string | number) =>
vendorPortalCall<VendorPortalDispatchDetail>(
token,
`${API_PATHS.vendorPortal.dispatches}/${id}`,
),
accept: (token: string, id: string | number) =>
vendorPortalCall<void>(token, `${API_PATHS.vendorPortal.dispatches}/${id}/accept`, {
method: "POST",
}),
changeStatus: (token: string, id: string | number, status: string) =>
vendorPortalCall<void>(token, `${API_PATHS.vendorPortal.dispatches}/${id}/status`, {
method: "POST",
body: { status },
}),
requestCancel: (token: string, id: string | number, reason: string) =>
vendorPortalCall<void>(token, `${API_PATHS.vendorPortal.dispatches}/${id}/request-cancel`, {
method: "POST",
body: { reason },
}),
updateChecklistItem: (
token: string,
id: string | number,
itemId: string | number,
isCompleted: boolean,
) =>
vendorPortalCall<VendorPortalDispatchDetail["checklist"][number]>(
token,
`${API_PATHS.vendorPortal.dispatches}/${id}/checklist/${itemId}`,
{ method: "POST", body: { isCompleted } },
),
addSignoff: (
token: string,
id: string | number,
payload: {
name: string;
signature: string;
signatureMethod: string;
signoffType: string;
},
) =>
vendorPortalCall<VendorPortalSignoff>(
token,
`${API_PATHS.vendorPortal.dispatches}/${id}/signoff`,
{ method: "POST", body: payload },
),
addComment: (token: string, id: string | number, commentText: string) =>
vendorPortalCall<VendorPortalComment>(
token,
`${API_PATHS.vendorPortal.dispatches}/${id}/comments`,
{ method: "POST", body: { commentText } },
),
requestUplift: (
token: string,
id: string | number,
payload: { requestedNTE: number; reason: string },
) =>
vendorPortalCall<void>(token, `${API_PATHS.vendorPortal.dispatches}/${id}/uplift-request`, {
method: "POST",
body: payload,
}),
cancelUplift: (token: string, id: string | number, requestId: string | number) =>
vendorPortalCall<void>(
token,
`${API_PATHS.vendorPortal.dispatches}/${id}/uplift-request/${requestId}/cancel`,
{ method: "POST" },
),
};

View file

@ -0,0 +1,25 @@
export function vendorPortalStatusClass(status: string): string {
const map: Record<string, string> = {
Sent: "vp-status-sent",
Acknowledged: "vp-status-acknowledged",
"In Progress": "vp-status-in-progress",
Completed: "vp-status-completed",
Verified: "vp-status-verified",
Cancelled: "vp-status-cancelled",
};
return `vp-status-pill ${map[status] ?? ""}`;
}
export function formatVendorPortalDateTime(value?: string): string {
if (!value) {
return "";
}
const date = new Date(value);
if (Number.isNaN(date.getTime())) {
return "";
}
return date.toLocaleString();
}

View file

@ -0,0 +1,80 @@
export interface VendorPortalSession {
companyName?: string;
vendorId?: number;
}
export interface VendorPortalDispatchSummary {
id: number;
dispatchNumber: string;
workOrderTitle?: string;
internalWONumber?: string;
locationName?: string;
locationCity?: string;
locationState?: string;
scheduledDate?: string;
status: string;
poNumber?: string;
nteAmount?: number;
dispatchedAt?: string;
completedDate?: string;
}
export interface VendorPortalChecklistItem {
id: number;
itemText: string;
isCompleted: boolean;
completedAt?: string;
}
export interface VendorPortalComment {
id: number;
commenter?: string;
commenttext?: string;
commentType?: string;
createdDate?: string;
}
export interface VendorPortalUpliftRequest {
id: number;
status: string;
requestedNTE: number;
currentNTE?: number;
vendorReason?: string;
requestedAt?: string;
decidedAt?: string;
decidedByName?: string;
decisionNote?: string;
requiredTier?: number;
}
export interface VendorPortalSignoff {
signoffType: string;
name: string;
signatureMethod: string;
signedAt?: string;
}
export interface VendorPortalDispatchDetail extends VendorPortalDispatchSummary {
description?: string;
acknowledgedAt?: string;
workOrder?: {
workerOrderTitle?: string;
internalWONumber?: string;
priority?: string;
trade?: string;
subTrade?: string;
problem?: string;
description?: string;
};
location?: {
name?: string;
address?: string;
city?: string;
state?: string;
zipCode?: string;
};
checklist: VendorPortalChecklistItem[];
comments: VendorPortalComment[];
upliftRequests: VendorPortalUpliftRequest[];
signoffs: VendorPortalSignoff[];
}