Merge feature/vendor-portal into dev

This commit is contained in:
Adam Moussa 2026-04-20 12:45:09 -04:00
commit c98a905d4d
13 changed files with 1069 additions and 0 deletions

View file

@ -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 : <Navigate to="/login" replace />;
@ -68,6 +73,13 @@ export default function App() {
{/* Public Routes */}
<Route path="/login" element={<LoginPage />} />
{/* Vendor Portal — public, token-authenticated via URL */}
<Route path="/v/:token" element={<VendorPortalLayout />}>
<Route index element={<Navigate to="dashboard" replace />} />
<Route path="dashboard" element={<VendorPortalDashboard />} />
<Route path="dispatch/:id" element={<VendorPortalDispatchDetail />} />
</Route>
{/* Protected Routes */}
<Route
path="/*"

View file

@ -0,0 +1,90 @@
import React, { useEffect, useState } from "react";
import { useNavigate } from "react-router-dom";
import { useVendorPortal } from "./VendorPortalContext";
import { vendorPortalApi } from "./api";
import { statusClass, formatDateTime } from "./statusHelpers";
const FILTERS = [
{ key: "open", label: "Open", match: (d) => 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 (
<>
<div className="vp-card">
<h2>Your Dispatches</h2>
<div className="vp-muted" style={{ marginBottom: 12 }}>
View and manage work orders assigned to your company.
</div>
<div className="vp-actions" style={{ marginTop: 0 }}>
{FILTERS.map((f) => (
<button
key={f.key}
className={`vp-btn ${filter === f.key ? "vp-btn-primary" : "vp-btn-ghost"}`}
onClick={() => setFilter(f.key)}
>
{f.label}
{" "}
<span style={{ opacity: 0.7, fontWeight: 400 }}>
({dispatches.filter(f.match).length})
</span>
</button>
))}
</div>
</div>
{loading && <div className="vp-card vp-muted">Loading dispatches…</div>}
{err && <div className="vp-error">{err}</div>}
{!loading && !err && filtered.length === 0 && (
<div className="vp-card vp-empty">No dispatches in this view.</div>
)}
<div className="vp-dispatch-list">
{filtered.map((d) => (
<div
key={d.id}
className="vp-dispatch-card"
onClick={() => navigate(`../dispatch/${d.id}`)}
>
<div>
<div className="vp-dispatch-title">
{d.dispatchNumber} — {d.workOrderTitle || "(untitled)"}
</div>
<div className="vp-dispatch-meta">
WO#{d.internalWONumber}
{d.locationName ? ` · ${d.locationName}` : ""}
{d.locationCity ? `, ${d.locationCity}` : ""}
{d.locationState ? `, ${d.locationState}` : ""}
{d.scheduledDate ? ` · Scheduled ${formatDateTime(d.scheduledDate)}` : ""}
</div>
</div>
<span className={statusClass(d.status)}>{d.status}</span>
</div>
))}
</div>
</>
);
}

View file

@ -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 <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={() => 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 }}>{formatDateTime(item.completedAt)}</span>
)}
</div>
))}
</div>
);
}
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 && <div className="vp-muted">No comments yet.</div>}
{comments.map((c) => (
<div key={c.id} className={`vp-comment ${c.commentType || ""}`}>
<div className="vp-comment-meta">
{c.commenter || "Unknown"} · {formatDateTime(c.createdDate)}
</div>
<div>{c.commenttext}</div>
</div>
))}
<form onSubmit={submit} style={{ marginTop: 12 }}>
<textarea
className="vp-textarea"
placeholder="Add a comment for the dispatcher…"
value={text}
onChange={(e) => setText(e.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 SignoffSection({ token, dispatchId, existing, signoffType, nameLabel, onAdded }) {
const [name, setName] = useState("");
const [method, setMethod] = useState("drawn");
const [typedSig, setTypedSig] = useState("");
const [drawnSig, setDrawnSig] = useState("");
const [submitting, setSubmitting] = useState(false);
const existingSignoff = existing?.find((s) => s.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>{formatDateTime(existingSignoff.signedAt)}</span></div>
</div>
);
}
async function submit(e) {
e.preventDefault();
const signature = method === "drawn" ? drawnSig : typedSig;
if (!name.trim() || !signature) {
alert("Name and signature are required");
return;
}
setSubmitting(true);
try {
const added = await vendorPortalApi.addSignoff(token, dispatchId, {
name: name.trim(),
signature,
signatureMethod: method,
signoffType,
});
onAdded(added);
} catch (err) {
alert(err.message);
} finally {
setSubmitting(false);
}
}
return (
<form onSubmit={submit}>
<div className="vp-row">
<strong>{nameLabel}</strong>
<input className="vp-input" value={name} onChange={(e) => setName(e.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={(e) => setTypedSig(e.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 DispatchDetailPage() {
const { token } = useVendorPortal();
const { id } = useParams();
const dispatchId = Number(id);
const [data, setData] = useState(null);
const [loading, setLoading] = useState(true);
const [err, setErr] = useState(null);
const [busy, setBusy] = useState(false);
const refresh = useCallback(async () => {
setLoading(true);
try {
const d = await vendorPortalApi.getDispatch(token, dispatchId);
setData(d);
} catch (e) {
setErr(e.message);
} finally {
setLoading(false);
}
}, [token, dispatchId]);
useEffect(() => { refresh(); }, [refresh]);
async function runAction(fn, confirmMsg) {
if (confirmMsg && !window.confirm(confirmMsg)) return;
setBusy(true);
try {
await fn();
await refresh();
} catch (e) {
alert(e.message);
} finally {
setBusy(false);
}
}
if (loading && !data) return <div className="vp-card vp-muted">Loading dispatch…</div>;
if (err && !data) return <div className="vp-error">{err}</div>;
if (!data) return null;
const locked = data.status === "Verified" || data.status === "Cancelled";
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={statusClass(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.workOrder?.priority && <div className="vp-row"><strong>Priority</strong><span>{data.workOrder.priority}</span></div>}
{data.workOrder?.trade && <div className="vp-row"><strong>Trade</strong><span>{data.workOrder.trade}{data.workOrder.subTrade ? ` / ${data.workOrder.subTrade}` : ""}</span></div>}
{data.workOrder?.problem && <div className="vp-row"><strong>Problem</strong><span>{data.workOrder.problem}</span></div>}
{data.scheduledDate && <div className="vp-row"><strong>Scheduled</strong><span>{formatDateTime(data.scheduledDate)}</span></div>}
{data.acknowledgedAt && <div className="vp-row"><strong>Acknowledged</strong><span>{formatDateTime(data.acknowledgedAt)}</span></div>}
{data.completedDate && <div className="vp-row"><strong>Completed</strong><span>{formatDateTime(data.completedDate)}</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 className="vp-btn vp-btn-success" disabled={busy}
onClick={() => runAction(() => vendorPortalApi.accept(token, dispatchId))}>
Accept Dispatch
</button>
)}
{data.status === "Acknowledged" && (
<button className="vp-btn vp-btn-primary" disabled={busy}
onClick={() => runAction(() => vendorPortalApi.changeStatus(token, dispatchId, "In Progress"))}>
Start Work
</button>
)}
{data.status === "In Progress" && (
<button className="vp-btn vp-btn-primary" disabled={busy}
onClick={() => runAction(() => vendorPortalApi.changeStatus(token, dispatchId, "Completed"),
"Mark this dispatch as completed?")}>
Mark Completed
</button>
)}
{!locked && data.status !== "Sent" && (
<button className="vp-btn vp-btn-warn" disabled={busy}
onClick={() => {
const reason = window.prompt("Reason for cancel request?");
if (reason == null) return;
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) => setData((d) => ({
...d,
checklist: d.checklist.map((it) => it.id === updated.id ? { ...it, ...updated } : it),
}))}
/>
</div>
<div className="vp-card">
<h3>Customer Signoff</h3>
<div className="vp-muted" style={{ marginBottom: 8 }}>
Have the on-site customer representative sign to acknowledge completion.
</div>
<SignoffSection
token={token}
dispatchId={dispatchId}
existing={data.signoffs}
signoffType="customer"
nameLabel="Customer name"
onAdded={() => refresh()}
/>
</div>
<div className="vp-card">
<h3>Vendor Signoff</h3>
<SignoffSection
token={token}
dispatchId={dispatchId}
existing={data.signoffs}
signoffType="vendor"
nameLabel="Your name"
onAdded={() => refresh()}
/>
</div>
<div className="vp-card">
<h3>Comments</h3>
<CommentsSection
token={token}
dispatchId={dispatchId}
comments={data.comments || []}
onAdded={(c) => setData((d) => ({ ...d, comments: [...(d.comments || []), c] }))}
/>
</div>
</>
);
}

View file

@ -0,0 +1,87 @@
import React, { useEffect, useRef, useState } from "react";
export default function SignaturePad({ onChange }) {
const canvasRef = useRef(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");
ctx.scale(ratio, ratio);
ctx.lineCap = "round";
ctx.lineJoin = "round";
ctx.strokeStyle = "#111";
ctx.lineWidth = 2;
}, []);
function pointFromEvent(e) {
const canvas = canvasRef.current;
const rect = canvas.getBoundingClientRect();
const touch = e.touches?.[0];
const clientX = touch ? touch.clientX : e.clientX;
const clientY = touch ? touch.clientY : e.clientY;
return { x: clientX - rect.left, y: clientY - rect.top };
}
function start(e) {
e.preventDefault();
drawingRef.current = true;
lastRef.current = pointFromEvent(e);
}
function move(e) {
if (!drawingRef.current) return;
e.preventDefault();
const ctx = canvasRef.current.getContext("2d");
const p = pointFromEvent(e);
ctx.beginPath();
ctx.moveTo(lastRef.current.x, lastRef.current.y);
ctx.lineTo(p.x, p.y);
ctx.stroke();
lastRef.current = p;
if (isEmpty) setIsEmpty(false);
}
function end(e) {
if (!drawingRef.current) return;
e.preventDefault();
drawingRef.current = false;
onChange?.(canvasRef.current.toDataURL("image/png"));
}
function clear() {
const canvas = canvasRef.current;
const ctx = canvas.getContext("2d");
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,41 @@
import React, { createContext, useContext, useEffect, useState } from "react";
import { useParams } from "react-router-dom";
import { vendorPortalApi } from "./api";
const VendorPortalContext = createContext(null);
export function useVendorPortal() {
const ctx = useContext(VendorPortalContext);
if (!ctx) throw new Error("useVendorPortal used outside provider");
return ctx;
}
export function VendorPortalProvider({ children }) {
const { token } = useParams();
const [vendor, setVendor] = useState(null);
const [status, setStatus] = useState("loading");
const [error, setError] = useState(null);
useEffect(() => {
let cancelled = false;
setStatus("loading");
vendorPortalApi
.session(token)
.then((data) => {
if (cancelled) return;
setVendor(data);
setStatus("ready");
})
.catch((err) => {
if (cancelled) return;
setError(err);
setStatus("error");
});
return () => {
cancelled = true;
};
}, [token]);
const value = { token, vendor, status, error };
return <VendorPortalContext.Provider value={value}>{children}</VendorPortalContext.Provider>;
}

View file

@ -0,0 +1,51 @@
import React from "react";
import { Outlet } from "react-router-dom";
import { VendorPortalProvider, useVendorPortal } from "./VendorPortalContext";
import "./vendor-portal.css";
function Header() {
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 Body() {
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 (
<div className="vp-container">
<Outlet />
</div>
);
}
export default function VendorPortalLayout() {
return (
<VendorPortalProvider>
<div className="vp-shell">
<Header />
<Body />
</div>
</VendorPortalProvider>
);
}

View file

@ -0,0 +1,37 @@
const PREFIX = "/api/vendor-portal";
async function call(token, path, { method = "GET", body } = {}) {
const res = await fetch(`${PREFIX}${path}`, {
method,
headers: {
"Content-Type": "application/json",
"X-Vendor-Token": token,
},
body: body ? JSON.stringify(body) : undefined,
});
if (res.status === 401) {
const err = new Error("Invalid or expired token");
err.status = 401;
throw err;
}
const payload = await res.json().catch(() => ({}));
if (!res.ok) {
const err = new Error(payload?.message || payload?.Message || `HTTP ${res.status}`);
err.status = res.status;
throw err;
}
return payload?.data ?? payload?.Data ?? payload;
}
export const vendorPortalApi = {
session: (token) => call(token, "/session"),
listDispatches: (token, status) => call(token, status ? `/dispatches?status=${encodeURIComponent(status)}` : "/dispatches"),
getDispatch: (token, id) => call(token, `/dispatches/${id}`),
accept: (token, id) => call(token, `/dispatches/${id}/accept`, { method: "POST" }),
changeStatus: (token, id, status) => call(token, `/dispatches/${id}/status`, { method: "POST", body: { status } }),
requestCancel: (token, id, reason) => call(token, `/dispatches/${id}/request-cancel`, { method: "POST", body: { reason } }),
updateChecklistItem: (token, id, itemId, isCompleted) =>
call(token, `/dispatches/${id}/checklist/${itemId}`, { method: "POST", body: { isCompleted } }),
addSignoff: (token, id, payload) => call(token, `/dispatches/${id}/signoff`, { method: "POST", body: payload }),
addComment: (token, id, commentText) => call(token, `/dispatches/${id}/comments`, { method: "POST", body: { commentText } }),
};

View file

@ -0,0 +1,18 @@
export function statusClass(status) {
const map = {
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 formatDateTime(d) {
if (!d) return "";
const date = new Date(d);
if (isNaN(date.getTime())) return "";
return date.toLocaleString();
}

View file

@ -0,0 +1,237 @@
.vp-shell {
min-height: 100vh;
background: #f5f6f8;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
color: #222;
}
.vp-header {
background: #1f2d3d;
color: #fff;
padding: 14px 24px;
display: flex;
align-items: center;
justify-content: space-between;
}
.vp-brand {
font-size: 18px;
font-weight: 600;
letter-spacing: 0.3px;
}
.vp-vendor-name {
font-size: 13px;
opacity: 0.85;
}
.vp-container {
max-width: 1080px;
margin: 0 auto;
padding: 24px;
}
.vp-card {
background: #fff;
border: 1px solid #e4e7eb;
border-radius: 10px;
padding: 20px;
margin-bottom: 16px;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.04);
}
.vp-card h2 {
margin: 0 0 4px;
font-size: 20px;
}
.vp-card h3 {
margin: 0 0 12px;
font-size: 16px;
color: #1f2d3d;
}
.vp-muted {
color: #6b7280;
font-size: 13px;
}
.vp-row {
display: grid;
grid-template-columns: 140px 1fr;
gap: 8px;
margin: 6px 0;
font-size: 14px;
}
.vp-row strong {
color: #374151;
}
.vp-dispatch-list {
display: grid;
gap: 12px;
}
.vp-dispatch-card {
display: flex;
justify-content: space-between;
align-items: center;
padding: 16px 20px;
background: #fff;
border: 1px solid #e4e7eb;
border-radius: 10px;
cursor: pointer;
transition: border-color 0.15s, box-shadow 0.15s;
}
.vp-dispatch-card:hover {
border-color: #3b82f6;
box-shadow: 0 2px 6px rgba(59, 130, 246, 0.12);
}
.vp-dispatch-title {
font-weight: 600;
font-size: 15px;
margin-bottom: 4px;
}
.vp-dispatch-meta {
font-size: 13px;
color: #6b7280;
}
.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: #fef3c7; color: #92400e; }
.vp-status-acknowledged { background: #dbeafe; color: #1e40af; }
.vp-status-in-progress { background: #e0e7ff; color: #3730a3; }
.vp-status-completed { background: #d1fae5; color: #065f46; }
.vp-status-verified { background: #cffafe; color: #155e75; }
.vp-status-cancelled { background: #fee2e2; color: #991b1b; }
.vp-btn {
display: inline-block;
padding: 10px 18px;
font-size: 14px;
font-weight: 600;
border-radius: 6px;
border: none;
cursor: pointer;
transition: background 0.15s;
}
.vp-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.vp-btn-primary { background: #2563eb; color: #fff; }
.vp-btn-primary:hover:not(:disabled) { background: #1d4ed8; }
.vp-btn-success { background: #10b981; color: #fff; }
.vp-btn-success:hover:not(:disabled) { background: #059669; }
.vp-btn-warn { background: #f59e0b; color: #fff; }
.vp-btn-warn:hover:not(:disabled) { background: #d97706; }
.vp-btn-danger { background: #ef4444; color: #fff; }
.vp-btn-danger:hover:not(:disabled) { background: #dc2626; }
.vp-btn-ghost { background: transparent; color: #374151; border: 1px solid #d1d5db; }
.vp-btn-ghost:hover:not(:disabled) { background: #f3f4f6; }
.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 #f1f3f5;
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: #9ca3af; }
.vp-comment {
padding: 10px 12px;
background: #f9fafb;
border-left: 3px solid #d1d5db;
margin-bottom: 8px;
font-size: 14px;
}
.vp-comment.vendor { border-left-color: #10b981; }
.vp-comment.dispatcher { border-left-color: #3b82f6; }
.vp-comment.customer { border-left-color: #8b5cf6; }
.vp-comment.internal { border-left-color: #f59e0b; }
.vp-comment-meta {
font-size: 12px;
color: #6b7280;
margin-bottom: 4px;
}
.vp-textarea, .vp-input {
width: 100%;
padding: 10px 12px;
font-size: 14px;
border: 1px solid #d1d5db;
border-radius: 6px;
font-family: inherit;
box-sizing: border-box;
}
.vp-textarea { min-height: 80px; resize: vertical; }
.vp-error {
background: #fee2e2;
color: #991b1b;
padding: 16px;
border-radius: 8px;
text-align: center;
}
.vp-empty {
text-align: center;
padding: 40px;
color: #6b7280;
}
.vp-signature-canvas {
border: 1px dashed #9ca3af;
border-radius: 6px;
background: #fff;
touch-action: none;
display: block;
width: 100%;
max-width: 500px;
height: 180px;
}
.vp-back-link {
color: #2563eb;
text-decoration: none;
font-size: 14px;
display: inline-block;
margin-bottom: 12px;
}
.vp-back-link:hover { text-decoration: underline; }

View file

@ -33,3 +33,49 @@
font-size: 14px;
cursor: pointer;
}
.vendorPortalPanel {
border: 1px solid #e4e7eb;
background: #f8fafc;
border-radius: 6px;
padding: 14px 16px;
font-size: 13px;
}
.vendorPortalPanel-label {
font-weight: 600;
color: #374151;
margin-bottom: 4px;
font-size: 12px;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.vendorPortalPanel-url {
display: flex;
align-items: center;
gap: 8px;
margin: 6px 0 10px;
}
.vendorPortalPanel-url input {
flex: 1;
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
font-size: 12px;
padding: 6px 8px;
border: 1px solid #d1d5db;
border-radius: 4px;
background: #fff;
}
.vendorPortalPanel-meta {
color: #6b7280;
font-size: 12px;
margin-bottom: 10px;
}
.vendorPortalPanel-actions {
display: flex;
gap: 8px;
flex-wrap: wrap;
}

View file

@ -3,6 +3,7 @@ import { useParams, useNavigate } from "react-router";
import { toast } from "react-toastify";
import { vendorApi } from "../../../services/api";
import { fetchDropdownOptions } from "../../workorders/api";
import VendorPortalTokenPanel from "./VendorPortalTokenPanel";
import "../../../styles/FormPage.css";
import "./VendorFormPage.css";
@ -215,6 +216,12 @@ export default function VendorFormPage() {
</label>
</div>
{isEdit && (
<div className="form-group">
<VendorPortalTokenPanel vendorId={id} />
</div>
)}
<div className="form-actions">
<button type="button" className="btn btn-default" onClick={() => navigate("/vendors")} disabled={loading}>
Cancel

View file

@ -0,0 +1,103 @@
import React, { useEffect, useState } from "react";
import { toast } from "react-toastify";
import { vendorApi } from "../../../services/api";
export default function VendorPortalTokenPanel({ vendorId }) {
const [tokenInfo, setTokenInfo] = useState(null);
const [loading, setLoading] = useState(false);
const [busy, setBusy] = useState(false);
useEffect(() => {
if (!vendorId) return;
setLoading(true);
vendorApi.getPortalToken(vendorId)
.then(setTokenInfo)
.catch(() => toast.error("Failed to load portal token"))
.finally(() => setLoading(false));
}, [vendorId]);
async function copyUrl() {
if (!tokenInfo?.portalUrl) return;
try {
await navigator.clipboard.writeText(tokenInfo.portalUrl);
toast.success("Portal URL copied");
} catch {
toast.error("Copy failed");
}
}
async function rotate() {
if (!window.confirm("Rotate the portal token? The previous link will stop working immediately.")) return;
setBusy(true);
try {
const updated = await vendorApi.rotatePortalToken(vendorId);
setTokenInfo(updated);
toast.success("Portal token rotated");
} catch {
toast.error("Rotate failed");
} finally {
setBusy(false);
}
}
async function revoke() {
if (!window.confirm("Revoke all active portal tokens? The vendor will lose access until a new link is generated.")) return;
setBusy(true);
try {
await vendorApi.revokePortalToken(vendorId);
setTokenInfo(null);
toast.success("Portal tokens revoked");
} catch {
toast.error("Revoke failed");
} finally {
setBusy(false);
}
}
async function regenerate() {
setBusy(true);
try {
const fresh = await vendorApi.getPortalToken(vendorId);
setTokenInfo(fresh);
} catch {
toast.error("Failed to generate token");
} finally {
setBusy(false);
}
}
if (loading) return <div className="vendorPortalPanel vendorPortalPanel-meta">Loading portal link…</div>;
if (!tokenInfo) {
return (
<div className="vendorPortalPanel">
<div className="vendorPortalPanel-label">Vendor Portal</div>
<div className="vendorPortalPanel-meta">No active portal link. Generate one so this vendor can access the portal.</div>
<div className="vendorPortalPanel-actions">
<button type="button" className="btn btn-primary" onClick={regenerate} disabled={busy}>
Generate Link
</button>
</div>
</div>
);
}
return (
<div className="vendorPortalPanel">
<div className="vendorPortalPanel-label">Vendor Portal</div>
<div className="vendorPortalPanel-url">
<input type="text" readOnly value={tokenInfo.portalUrl} onFocus={(e) => e.target.select()} />
<button type="button" className="btn btn-default" onClick={copyUrl}>Copy</button>
</div>
<div className="vendorPortalPanel-meta">
Issued {new Date(tokenInfo.issuedAt).toLocaleDateString()}
{" · "}Expires {new Date(tokenInfo.expiresAt).toLocaleDateString()}
{tokenInfo.lastUsedAt ? ` · Last used ${new Date(tokenInfo.lastUsedAt).toLocaleString()}` : " · Never used"}
</div>
<div className="vendorPortalPanel-actions">
<button type="button" className="btn btn-warning" onClick={rotate} disabled={busy}>Rotate</button>
<button type="button" className="btn btn-danger" onClick={revoke} disabled={busy}>Revoke</button>
</div>
</div>
);
}

View file

@ -139,6 +139,9 @@ export const vendorApi = {
create: (data) => api.post('/vendors', data),
update: (id, data) => api.put(`/vendors/${id}`, data),
delete: (id) => api.delete(`/vendors/${id}`),
getPortalToken: (id) => api.get(`/vendors/${id}/portal-token`),
rotatePortalToken: (id) => api.post(`/vendors/${id}/portal-token/rotate`),
revokePortalToken: (id) => api.post(`/vendors/${id}/portal-token/revoke`),
};
export const taskTemplateApi = {