mirror of
https://github.com/Sea-Haven-Industries/shoc-frontend-new.git
synced 2026-10-06 15:52:04 +00:00
feat: add vendor portal routes and signature capture
This commit is contained in:
parent
6672d09e1f
commit
668dd69df2
12 changed files with 1799 additions and 0 deletions
88
src/app/v/[token]/_layout.tsx
Normal file
88
src/app/v/[token]/_layout.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
112
src/app/v/[token]/dashboard.tsx
Normal file
112
src/app/v/[token]/dashboard.tsx
Normal 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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
642
src/app/v/[token]/dispatch/[id].tsx
Normal file
642
src/app/v/[token]/dispatch/[id].tsx
Normal 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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
5
src/app/v/[token]/index.tsx
Normal file
5
src/app/v/[token]/index.tsx
Normal 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
170
src/app/v/[token]/pos.tsx
Normal 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>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
97
src/app/v/_components/signature-pad.tsx
Normal file
97
src/app/v/_components/signature-pad.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
21
src/app/v/_components/vendor-portal-context.ts
Normal file
21
src/app/v/_components/vendor-portal-context.ts
Normal 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;
|
||||||
|
}
|
||||||
53
src/app/v/_components/vendor-portal-provider.tsx
Normal file
53
src/app/v/_components/vendor-portal-provider.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
370
src/app/v/_components/vendor-portal.css
Normal file
370
src/app/v/_components/vendor-portal.css
Normal 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;
|
||||||
|
}
|
||||||
136
src/domain/vendor-portal/api/vendor-portal-api.ts
Normal file
136
src/domain/vendor-portal/api/vendor-portal-api.ts
Normal 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" },
|
||||||
|
),
|
||||||
|
};
|
||||||
25
src/domain/vendor-portal/lib/status-helpers.ts
Normal file
25
src/domain/vendor-portal/lib/status-helpers.ts
Normal 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();
|
||||||
|
}
|
||||||
80
src/domain/vendor-portal/types/vendor-portal.ts
Normal file
80
src/domain/vendor-portal/types/vendor-portal.ts
Normal 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[];
|
||||||
|
}
|
||||||
Loading…
Add table
Reference in a new issue