mirror of
https://github.com/Sea-Haven-Industries/shoc-frontend-new.git
synced 2026-09-30 06:53:12 +00:00
Merge feature/vendor-portal into dev
This commit is contained in:
commit
c98a905d4d
13 changed files with 1069 additions and 0 deletions
12
src/App.js
12
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 : <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="/*"
|
||||
|
|
|
|||
90
src/pages/vendor-portal/DashboardPage.js
Normal file
90
src/pages/vendor-portal/DashboardPage.js
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
337
src/pages/vendor-portal/DispatchDetailPage.js
Normal file
337
src/pages/vendor-portal/DispatchDetailPage.js
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
87
src/pages/vendor-portal/SignaturePad.js
Normal file
87
src/pages/vendor-portal/SignaturePad.js
Normal 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>
|
||||
);
|
||||
}
|
||||
41
src/pages/vendor-portal/VendorPortalContext.js
Normal file
41
src/pages/vendor-portal/VendorPortalContext.js
Normal 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>;
|
||||
}
|
||||
51
src/pages/vendor-portal/VendorPortalLayout.js
Normal file
51
src/pages/vendor-portal/VendorPortalLayout.js
Normal 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>
|
||||
);
|
||||
}
|
||||
37
src/pages/vendor-portal/api.js
Normal file
37
src/pages/vendor-portal/api.js
Normal 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 } }),
|
||||
};
|
||||
18
src/pages/vendor-portal/statusHelpers.js
Normal file
18
src/pages/vendor-portal/statusHelpers.js
Normal 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();
|
||||
}
|
||||
237
src/pages/vendor-portal/vendor-portal.css
Normal file
237
src/pages/vendor-portal/vendor-portal.css
Normal 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; }
|
||||
46
src/pages/vendors/form/VendorFormPage.css
vendored
46
src/pages/vendors/form/VendorFormPage.css
vendored
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
7
src/pages/vendors/form/VendorFormPage.js
vendored
7
src/pages/vendors/form/VendorFormPage.js
vendored
|
|
@ -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
|
||||
|
|
|
|||
103
src/pages/vendors/form/VendorPortalTokenPanel.js
vendored
Normal file
103
src/pages/vendors/form/VendorPortalTokenPanel.js
vendored
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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 = {
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue