feat(vendor-portal): complete request lifecycle (SH-98)

This commit is contained in:
Alexandre Brandizzi 2026-08-10 13:14:16 -03:00
parent 78ed8571a0
commit 4d2ee40d60
20 changed files with 1083 additions and 130 deletions

View file

@ -0,0 +1,221 @@
import { expect, test, type Page, type Route } from "@playwright/test";
const PORTAL_TOKEN = "portal-e2e-token";
const futureDate = new Date(Date.now() + 3 * 24 * 60 * 60 * 1000).toISOString();
const pastDate = new Date(Date.now() - 2 * 24 * 60 * 60 * 1000).toISOString();
interface DispatchDetail {
id: number;
dispatchNumber: string;
workOrderTitle?: string;
internalWONumber?: string;
status: string;
nteAmount?: number;
scheduledDate?: string;
poNumber?: string;
dispatchedAt?: string;
completedDate?: string;
refusedAt?: string;
statusUpdatedAt?: string;
description?: string;
workOrder?: Record<string, unknown>;
location?: Record<string, unknown>;
dispatcherContact?: { name?: string; email?: string; phone?: string };
checklist: never[];
comments: never[];
upliftRequests: never[];
signoffs: never[];
documents: never[];
}
const sentDetail: DispatchDetail = {
id: 101,
dispatchNumber: "DSP-101",
workOrderTitle: "Emergency boiler repair",
internalWONumber: "WO-8842",
status: "Sent",
nteAmount: 1250,
scheduledDate: futureDate,
poNumber: "PO-555",
statusUpdatedAt: "2026-08-09T16:30:00Z",
description: "Boiler is leaking; vendor visit required.",
workOrder: {
workerOrderTitle: "Emergency boiler repair",
internalWONumber: "WO-8842",
trade: "Plumbing",
subTrade: "Backflow Preventers",
siteCode: "STL-01",
service: "Boiler Repair",
},
location: {
name: "Central Distribution Center",
address: "100 Industrial Pkwy",
city: "St. Louis",
state: "MO",
zipCode: "63101",
},
dispatcherContact: {
name: "Dana Field",
email: "dana@seahavenind.com",
phone: "314-555-0142",
},
checklist: [],
comments: [],
upliftRequests: [],
signoffs: [],
documents: [],
};
const listDispatches = [
{
id: 101,
dispatchNumber: "DSP-101",
workOrderTitle: "Emergency boiler repair",
internalWONumber: "WO-8842",
locationName: "Central Distribution Center",
locationCity: "St. Louis",
locationState: "MO",
status: "Sent",
nteAmount: 1250,
scheduledDate: futureDate,
},
{
id: 102,
dispatchNumber: "DSP-102",
workOrderTitle: "HVAC quarterly service",
status: "Refused",
refusedAt: "2026-08-08T10:00:00Z",
},
{
id: 103,
dispatchNumber: "DSP-103",
workOrderTitle: "PastAcknowledged job",
status: "Acknowledged",
scheduledDate: pastDate,
},
];
async function fulfill(route: Route, body: unknown, status = 200) {
await route.fulfill({
status,
contentType: "application/json",
body: JSON.stringify(body),
});
}
async function mockPortalApi(page: Page, options: { sessionError?: boolean } = {}) {
const detailState: { current: DispatchDetail } = { current: { ...sentDetail } };
await page.route("**/api/vendor-portal/session", (route) => {
if (options.sessionError) {
fulfill(route, { message: "Invalid or expired token" }, 401);
return;
}
fulfill(route, { data: { companyName: "Gateway Plumbing", vendorId: 101 } });
});
await page.route(/\/api\/vendor-portal\/dispatches\/\d+\/refuse$/, async (route) => {
const body = route.request().postDataJSON() as { reason?: string };
detailState.current = {
...detailState.current,
status: "Refused",
refusedAt: new Date().toISOString(),
};
fulfill(route, {
data: {
id: detailState.current.id,
status: "Refused",
refusedAt: detailState.current.refusedAt,
reason: body?.reason,
},
});
});
await page.route(/\/api\/vendor-portal\/dispatches\/\d+$/, (route) =>
fulfill(route, { data: detailState.current }),
);
await page.route(/\/api\/vendor-portal\/dispatches(?:\?.*)?$/, (route) =>
fulfill(route, { data: listDispatches }),
);
return detailState;
}
test.describe("Vendor portal lifecycle", () => {
test("upcoming filter, detail snapshot, refuse dialog, and locked Refused state", async ({
page,
}) => {
await mockPortalApi(page);
await page.goto(`/v/${PORTAL_TOKEN}/dashboard`);
await expect(page.getByRole("heading", { name: "Your Work" })).toBeVisible();
await expect(page.getByRole("button", { name: /^Upcoming/ })).toBeVisible();
await expect(page.getByRole("button", { name: /^Refused/ })).toBeVisible();
await page.getByRole("button", { name: /^Upcoming/ }).click();
await expect(page.getByText("DSP-101 — Emergency boiler repair")).toBeVisible();
await expect(page.getByText("DSP-103")).toHaveCount(0);
await page.getByText("DSP-101 — Emergency boiler repair").click();
await expect(page).toHaveURL(/\/dispatch\/101$/);
await expect(page.getByRole("heading", { name: "DSP-101" })).toBeVisible();
await expect(page.getByText("Boiler Repair", { exact: true })).toBeVisible();
await expect(page.getByText("STL-01")).toBeVisible();
await expect(page.getByText("$1250.00").first()).toBeVisible();
await expect(page.getByRole("link", { name: "dana@seahavenind.com" })).toBeVisible();
await expect(page.getByText("Dana Field")).toBeVisible();
await expect(page.getByText("314-555-0142")).toBeVisible();
await expect(page.getByText(/Status updated/i)).toBeVisible();
await page.getByRole("button", { name: "Refuse Dispatch" }).click();
const dialog = page.getByRole("dialog", { name: "Refuse dispatch" });
await expect(dialog).toBeVisible();
await dialog.getByLabel(/Reason/i).fill("Equipment unavailable this week.");
await expect(dialog.getByText(/\d+\/500/)).toBeVisible();
await dialog.getByRole("button", { name: "Refuse dispatch" }).click();
await expect(dialog).toHaveCount(0);
await expect(page.getByText("Refused", { exact: true })).toBeVisible();
await expect(page.getByText(/refused and locked/i)).toBeVisible();
await expect(page.getByRole("button", { name: "Accept Dispatch" })).toHaveCount(0);
await expect(page.getByRole("button", { name: "Refuse Dispatch" })).toHaveCount(0);
await expect(page.getByRole("button", { name: "Submit Signoff" })).toHaveCount(0);
});
test("covers the refuse dialog at a mobile viewport", async ({ page }) => {
await page.setViewportSize({ width: 390, height: 844 });
await mockPortalApi(page);
await page.goto(`/v/${PORTAL_TOKEN}/dashboard`);
await page.getByText("DSP-101 — Emergency boiler repair").click();
await expect(page).toHaveURL(/\/dispatch\/101$/);
await page.getByRole("button", { name: "Refuse Dispatch" }).click();
const dialog = page.getByRole("dialog", { name: "Refuse dispatch" });
await expect(dialog).toBeVisible();
await page.keyboard.press("Escape");
await expect(dialog).toHaveCount(0);
await page.getByRole("button", { name: "Refuse Dispatch" }).click();
await page
.getByRole("dialog", { name: "Refuse dispatch" })
.getByRole("button", {
name: "Refuse dispatch",
})
.click();
await expect(page.getByText("Refused", { exact: true })).toBeVisible();
await expect(page.getByText(/refused and locked/i)).toBeVisible();
});
test("shows an expired-token state when the session is rejected", async ({ page }) => {
await mockPortalApi(page, { sessionError: true });
await page.goto(`/v/${PORTAL_TOKEN}/dashboard`);
await expect(page.getByRole("heading", { name: "Access Denied" })).toBeVisible();
await expect(page.getByRole("alert")).toContainText(/invalid or expired/i);
});
});

View file

@ -8,36 +8,18 @@ import {
formatVendorPortalDateTime, formatVendorPortalDateTime,
vendorPortalStatusClass, vendorPortalStatusClass,
} from "@/domain/vendor-portal/lib/status-helpers"; } from "@/domain/vendor-portal/lib/status-helpers";
import type { VendorPortalDispatchSummary } from "@/domain/vendor-portal/types/vendor-portal"; import {
VENDOR_PORTAL_DISPATCH_FILTERS,
applyVendorPortalDispatchFilter,
getVendorPortalDispatchFilter,
type VendorPortalDispatchFilterKey,
} from "@/domain/vendor-portal/lib/dispatch-filters";
import { queryKeys } from "@/infra/query-key/query-key"; 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() { export default function VendorPortalDashboardPage() {
const { token } = useVendorPortal(); const { token } = useVendorPortal();
const navigate = useNavigate(); const navigate = useNavigate();
const [filter, setFilter] = useState<(typeof FILTERS)[number]["key"]>("open"); const [filter, setFilter] = useState<VendorPortalDispatchFilterKey>("open");
const { const {
data: dispatches = [], data: dispatches = [],
@ -49,8 +31,11 @@ export default function VendorPortalDashboardPage() {
enabled: Boolean(token), enabled: Boolean(token),
}); });
const activeFilter = FILTERS.find((item) => item.key === filter) ?? FILTERS[0]; const activeFilter = getVendorPortalDispatchFilter(filter);
const filtered = useMemo(() => dispatches.filter(activeFilter.match), [dispatches, activeFilter]); const filtered = useMemo(
() => applyVendorPortalDispatchFilter(dispatches, activeFilter),
[dispatches, activeFilter],
);
return ( return (
<> <>
@ -60,7 +45,7 @@ export default function VendorPortalDashboardPage() {
Active dispatches and work orders assigned to your company. Active dispatches and work orders assigned to your company.
</div> </div>
<div className="vp-actions" style={{ marginTop: 0 }}> <div className="vp-actions" style={{ marginTop: 0 }}>
{FILTERS.map((item) => ( {VENDOR_PORTAL_DISPATCH_FILTERS.map((item) => (
<button <button
key={item.key} key={item.key}
type="button" type="button"

View file

@ -4,8 +4,10 @@ import { useQuery, useQueryClient } from "@tanstack/react-query";
import { Text } from "@/components/ui/text"; import { Text } from "@/components/ui/text";
import { DispatchDetailSections } from "@/app/v/[token]/dispatch/_components/dispatch-detail-sections"; import { DispatchDetailSections } from "@/app/v/[token]/dispatch/_components/dispatch-detail-sections";
import { DispatchSummaryCard } from "@/app/v/[token]/dispatch/_components/dispatch-summary-card"; import { DispatchSummaryCard } from "@/app/v/[token]/dispatch/_components/dispatch-summary-card";
import { RefuseDispatchDialog } from "@/app/v/[token]/dispatch/_components/refuse-dispatch-dialog";
import { useVendorPortal } from "@/app/v/_components/vendor-portal-context"; import { useVendorPortal } from "@/app/v/_components/vendor-portal-context";
import { vendorPortalApi } from "@/domain/vendor-portal/api/vendor-portal-api"; import { vendorPortalApi } from "@/domain/vendor-portal/api/vendor-portal-api";
import { isVendorPortalLockedStatus } from "@/domain/vendor-portal/lib/status-helpers";
import type { VendorPortalDispatchDetail } from "@/domain/vendor-portal/types/vendor-portal"; import type { VendorPortalDispatchDetail } from "@/domain/vendor-portal/types/vendor-portal";
import { queryKeys } from "@/infra/query-key/query-key"; import { queryKeys } from "@/infra/query-key/query-key";
@ -16,6 +18,7 @@ export default function VendorPortalDispatchPage() {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const [busy, setBusy] = useState(false); const [busy, setBusy] = useState(false);
const [actionError, setActionError] = useState(""); const [actionError, setActionError] = useState("");
const [refuseOpen, setRefuseOpen] = useState(false);
const { data, isLoading, error, refetch } = useQuery({ const { data, isLoading, error, refetch } = useQuery({
queryKey: queryKeys.vendorPortal.dispatch(token, dispatchId), queryKey: queryKeys.vendorPortal.dispatch(token, dispatchId),
@ -74,7 +77,7 @@ export default function VendorPortalDispatchPage() {
if (!data) return null; if (!data) return null;
const locked = data.status === "Verified" || data.status === "Cancelled"; const locked = isVendorPortalLockedStatus(data.status);
return ( return (
<> <>
@ -84,6 +87,7 @@ export default function VendorPortalDispatchPage() {
locked={locked} locked={locked}
busy={busy} busy={busy}
onAccept={() => void runAction(() => vendorPortalApi.accept(token, dispatchId))} onAccept={() => void runAction(() => vendorPortalApi.accept(token, dispatchId))}
onRefuse={() => setRefuseOpen(true)}
onStart={() => onStart={() =>
void runAction(() => vendorPortalApi.changeStatus(token, dispatchId, "In Progress")) void runAction(() => vendorPortalApi.changeStatus(token, dispatchId, "In Progress"))
} }
@ -107,6 +111,13 @@ export default function VendorPortalDispatchPage() {
refresh={refresh} refresh={refresh}
updateLocal={updateLocal} updateLocal={updateLocal}
/> />
<RefuseDispatchDialog
open={refuseOpen}
dispatchId={dispatchId}
onClose={() => setRefuseOpen(false)}
onRefused={() => setActionError("")}
/>
</> </>
); );
} }

View file

@ -76,6 +76,7 @@ export function DispatchDetailSections({
existing={data.signoffs ?? []} existing={data.signoffs ?? []}
signoffType="customer" signoffType="customer"
nameLabel="Customer name" nameLabel="Customer name"
submissionLocked={data.status === "Refused"}
onAdded={() => void refresh()} onAdded={() => void refresh()}
/> />
</div> </div>
@ -88,6 +89,7 @@ export function DispatchDetailSections({
existing={data.signoffs ?? []} existing={data.signoffs ?? []}
signoffType="vendor" signoffType="vendor"
nameLabel="Your name" nameLabel="Your name"
submissionLocked={data.status === "Refused"}
onAdded={() => void refresh()} onAdded={() => void refresh()}
/> />
</div> </div>

View file

@ -0,0 +1,166 @@
import { Text } from "@/components/ui/text";
import { formatVendorPortalDateTime } from "@/domain/vendor-portal/lib/status-helpers";
import type { VendorPortalDispatchDetail } from "@/domain/vendor-portal/types/vendor-portal";
type DispatchSnapshotDetailsProps = {
data: VendorPortalDispatchDetail;
locked: boolean;
};
function resolveCanonicalService(workOrder: VendorPortalDispatchDetail["workOrder"]): string {
if (workOrder?.service) {
return workOrder.service;
}
return [workOrder?.trade, workOrder?.subTrade].filter(Boolean).join(" · ");
}
function DispatchCommercialDetails({ data }: Pick<DispatchSnapshotDetailsProps, "data">) {
const service = resolveCanonicalService(data.workOrder);
return (
<div style={{ marginTop: 12 }}>
{data.workOrder != null && Boolean(data.workOrder.internalWONumber) && (
<div className="vp-row">
<strong>WO #</strong>
<span>{data.workOrder.internalWONumber}</span>
</div>
)}
{Boolean(service) && (
<div className="vp-row">
<strong>Service</strong>
<span>{service}</span>
</div>
)}
{Boolean(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>
)}
<div className="vp-row">
<strong>Invoice</strong>
<span>
{data.invoiceStatus || "Not Submitted"}
{data.invoiceNumber ? ` · ${data.invoiceNumber}` : ""}
</span>
</div>
<div className="vp-row">
<strong>Payment</strong>
<span>{data.paymentStatus || "Unavailable"}</span>
</div>
{Boolean(data.scheduledDate) && (
<div className="vp-row">
<strong>Scheduled</strong>
<span>{formatVendorPortalDateTime(data.scheduledDate)}</span>
</div>
)}
{Boolean(data.statusUpdatedAt) && (
<div className="vp-row">
<strong>Status updated</strong>
<span>{formatVendorPortalDateTime(data.statusUpdatedAt)}</span>
</div>
)}
</div>
);
}
function DispatchLocationDetails({ data }: Pick<DispatchSnapshotDetailsProps, "data">) {
if (data.location == null) {
return null;
}
const address = [
data.location.address,
data.location.city,
data.location.state,
data.location.zipCode,
]
.filter(Boolean)
.join(", ");
return (
<>
<Text variant="heading" sx={{ marginTop: 2 }}>
Location
</Text>
{Boolean(data.workOrder?.siteCode) && (
<div className="vp-row">
<strong>Site code</strong>
<span>{data.workOrder?.siteCode}</span>
</div>
)}
<div>{data.location.name}</div>
<div className="vp-muted">{address}</div>
</>
);
}
function DispatcherContactDetails({ data }: Pick<DispatchSnapshotDetailsProps, "data">) {
const contact = data.dispatcherContact;
if (!contact || (!contact.name && !contact.email && !contact.phone)) {
return null;
}
return (
<>
<Text variant="heading" sx={{ marginTop: 2 }}>
Dispatcher
</Text>
{Boolean(contact.name) && (
<div className="vp-row">
<strong>Name</strong>
<span>{contact.name}</span>
</div>
)}
{Boolean(contact.email) && (
<div className="vp-row">
<strong>Email</strong>
<span>
<a href={`mailto:${contact.email}`}>{contact.email}</a>
</span>
</div>
)}
{Boolean(contact.phone) && (
<div className="vp-row">
<strong>Phone</strong>
<span>
<a href={`tel:${contact.phone}`}>{contact.phone}</a>
</span>
</div>
)}
</>
);
}
export function DispatchSnapshotDetails({ data, locked }: DispatchSnapshotDetailsProps) {
const description = data.description || data.workOrder?.description;
return (
<>
<DispatchCommercialDetails data={data} />
<DispatchLocationDetails data={data} />
<DispatcherContactDetails data={data} />
{locked && (
<Text variant="feedback" className="vp-muted" sx={{ marginTop: 2 }}>
This dispatch is {data.status.toLowerCase()} and locked to further changes. Comments
remain open.
</Text>
)}
{Boolean(description) && (
<>
<Text variant="heading" sx={{ marginTop: 2 }}>
Description
</Text>
<div>{description}</div>
</>
)}
</>
);
}

View file

@ -3,6 +3,7 @@ type DispatchStatusActionsProps = {
locked: boolean; locked: boolean;
busy: boolean; busy: boolean;
onAccept: () => void; onAccept: () => void;
onRefuse: () => void;
onStart: () => void; onStart: () => void;
onComplete: () => void; onComplete: () => void;
onRequestCancel: () => void; onRequestCancel: () => void;
@ -13,6 +14,7 @@ export function DispatchStatusActions({
locked, locked,
busy, busy,
onAccept, onAccept,
onRefuse,
onStart, onStart,
onComplete, onComplete,
onRequestCancel, onRequestCancel,
@ -20,9 +22,19 @@ export function DispatchStatusActions({
return ( return (
<div className="vp-actions"> <div className="vp-actions">
{status === "Sent" && ( {status === "Sent" && (
<button type="button" className="vp-btn vp-btn-success" disabled={busy} onClick={onAccept}> <>
Accept Dispatch <button
</button> type="button"
className="vp-btn vp-btn-success"
disabled={busy}
onClick={onAccept}
>
Accept Dispatch
</button>
<button type="button" className="vp-btn vp-btn-warn" disabled={busy} onClick={onRefuse}>
Refuse Dispatch
</button>
</>
)} )}
{status === "Acknowledged" && ( {status === "Acknowledged" && (
<button type="button" className="vp-btn vp-btn-primary" disabled={busy} onClick={onStart}> <button type="button" className="vp-btn vp-btn-primary" disabled={busy} onClick={onStart}>

View file

@ -1,10 +1,8 @@
import { Link } from "react-router"; import { Link } from "react-router";
import { Text } from "@/components/ui/text"; import { Text } from "@/components/ui/text";
import { import { vendorPortalStatusClass } from "@/domain/vendor-portal/lib/status-helpers";
formatVendorPortalDateTime,
vendorPortalStatusClass,
} from "@/domain/vendor-portal/lib/status-helpers";
import type { VendorPortalDispatchDetail } from "@/domain/vendor-portal/types/vendor-portal"; import type { VendorPortalDispatchDetail } from "@/domain/vendor-portal/types/vendor-portal";
import { DispatchSnapshotDetails } from "@/app/v/[token]/dispatch/_components/dispatch-snapshot-details";
import { DispatchStatusActions } from "@/app/v/[token]/dispatch/_components/dispatch-status-actions"; import { DispatchStatusActions } from "@/app/v/[token]/dispatch/_components/dispatch-status-actions";
type DispatchSummaryCardProps = { type DispatchSummaryCardProps = {
@ -13,6 +11,7 @@ type DispatchSummaryCardProps = {
locked: boolean; locked: boolean;
busy: boolean; busy: boolean;
onAccept: () => void; onAccept: () => void;
onRefuse: () => void;
onStart: () => void; onStart: () => void;
onComplete: () => void; onComplete: () => void;
onRequestCancel: () => void; onRequestCancel: () => void;
@ -24,6 +23,7 @@ export function DispatchSummaryCard({
locked, locked,
busy, busy,
onAccept, onAccept,
onRefuse,
onStart, onStart,
onComplete, onComplete,
onRequestCancel, onRequestCancel,
@ -43,77 +43,14 @@ export function DispatchSummaryCard({
<span className={vendorPortalStatusClass(data.status)}>{data.status}</span> <span className={vendorPortalStatusClass(data.status)}>{data.status}</span>
</div> </div>
<div style={{ marginTop: 12 }}> <DispatchSnapshotDetails data={data} locked={locked} />
{data.workOrder != null && Boolean(data.workOrder.internalWONumber) && (
<div className="vp-row">
<strong>WO #</strong>
<span>{data.workOrder.internalWONumber}</span>
</div>
)}
{Boolean(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>
)}
<div className="vp-row">
<strong>Invoice</strong>
<span>
{data.invoiceStatus || "Not Submitted"}
{data.invoiceNumber ? ` · ${data.invoiceNumber}` : ""}
</span>
</div>
<div className="vp-row">
<strong>Payment</strong>
<span>{data.paymentStatus || "Unavailable"}</span>
</div>
{Boolean(data.scheduledDate) && (
<div className="vp-row">
<strong>Scheduled</strong>
<span>{formatVendorPortalDateTime(data.scheduledDate)}</span>
</div>
)}
</div>
{data.location != null && (
<>
<Text variant="heading" sx={{ marginTop: 2 }}>
Location
</Text>
<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>
</>
)}
{Boolean(data.description || data.workOrder?.description) && (
<>
<Text variant="heading" sx={{ marginTop: 2 }}>
Description
</Text>
<div>{data.description || data.workOrder?.description}</div>
</>
)}
<DispatchStatusActions <DispatchStatusActions
status={data.status} status={data.status}
locked={locked} locked={locked}
busy={busy} busy={busy}
onAccept={onAccept} onAccept={onAccept}
onRefuse={onRefuse}
onStart={onStart} onStart={onStart}
onComplete={onComplete} onComplete={onComplete}
onRequestCancel={onRequestCancel} onRequestCancel={onRequestCancel}

View file

@ -0,0 +1,133 @@
import { useEffect } from "react";
import { useForm, useWatch } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { Dialog, DialogActions, DialogContent, DialogTitle } from "@mui/material";
import { Text } from "@/components/ui/text";
import { useVendorPortal } from "@/app/v/_components/vendor-portal-context";
import { vendorPortalApi } from "@/domain/vendor-portal/api/vendor-portal-api";
import {
REFUSE_REASON_MAX_LENGTH,
refuseDispatchSchema,
type RefuseDispatchValues,
} from "@/domain/vendor-portal/schemas/refuse-dispatch-schema";
import { queryKeys } from "@/infra/query-key/query-key";
type RefuseDispatchDialogProps = {
open: boolean;
dispatchId: number;
onClose: () => void;
onRefused: () => void;
};
export function RefuseDispatchDialog({
open,
dispatchId,
onClose,
onRefused,
}: RefuseDispatchDialogProps) {
const { token } = useVendorPortal();
const queryClient = useQueryClient();
const {
register,
handleSubmit,
control,
reset,
formState: { errors },
} = useForm<RefuseDispatchValues>({
resolver: zodResolver(refuseDispatchSchema),
mode: "onChange",
defaultValues: { reason: "" },
});
const reasonValue = useWatch({ control, name: "reason" }) ?? "";
const isOverLimit = reasonValue.length > REFUSE_REASON_MAX_LENGTH;
useEffect(() => {
if (!open) {
reset({ reason: "" });
}
}, [open, reset]);
const refuseMutation = useMutation({
mutationFn: (values: RefuseDispatchValues) => {
const trimmed = values.reason.trim();
return vendorPortalApi.refuse(token, dispatchId, trimmed || undefined);
},
onSuccess: async () => {
await Promise.all([
queryClient.invalidateQueries({
queryKey: queryKeys.vendorPortal.dispatch(token, dispatchId),
}),
queryClient.invalidateQueries({
queryKey: queryKeys.vendorPortal.dispatches(token),
}),
]);
onRefused();
onClose();
},
});
const submit = handleSubmit((values) => refuseMutation.mutate(values));
const errorMessage = refuseMutation.error
? "Unable to refuse this dispatch. Please try again."
: "";
return (
<Dialog open={open} onClose={onClose} aria-labelledby="refuse-dispatch-title">
<DialogTitle id="refuse-dispatch-title">Refuse dispatch</DialogTitle>
<form onSubmit={(event) => void submit(event)}>
<DialogContent>
<Text variant="body" className="vp-muted" sx={{ marginBottom: 2 }}>
Refusing ends your assignment for this dispatch. A reason is optional but helps your
dispatcher reassign the work.
</Text>
<label htmlFor="refuse-reason" className="vp-muted" style={{ fontSize: 13 }}>
Reason (optional)
</label>
<textarea
id="refuse-reason"
className="vp-textarea"
placeholder="Tell the dispatcher why you are refusing…"
aria-invalid={Boolean(errors.reason)}
aria-describedby={errors.reason ? "refuse-reason-error" : "refuse-reason-counter"}
maxLength={REFUSE_REASON_MAX_LENGTH}
{...register("reason")}
style={{ marginTop: 6 }}
/>
<div
id="refuse-reason-counter"
className="vp-muted"
style={{ fontSize: 12, textAlign: "right" }}
>
{reasonValue.length}/{REFUSE_REASON_MAX_LENGTH}
</div>
<Text variant="error" when={isOverLimit} id="refuse-reason-error">
{errors.reason?.message ?? "Reason must be 500 characters or fewer."}
</Text>
<Text variant="error" when={Boolean(errorMessage)}>
{errorMessage}
</Text>
</DialogContent>
<DialogActions>
<button
type="button"
className="vp-btn vp-btn-ghost"
onClick={onClose}
disabled={refuseMutation.isPending}
>
Cancel
</button>
<button
type="submit"
className="vp-btn vp-btn-warn"
disabled={refuseMutation.isPending || isOverLimit}
>
{refuseMutation.isPending ? "Refusing…" : "Refuse dispatch"}
</button>
</DialogActions>
</form>
</Dialog>
);
}

View file

@ -11,6 +11,7 @@ type SignoffSectionProps = {
existing: VendorPortalSignoff[]; existing: VendorPortalSignoff[];
signoffType: string; signoffType: string;
nameLabel: string; nameLabel: string;
submissionLocked?: boolean;
onAdded: () => void; onAdded: () => void;
}; };
@ -20,6 +21,7 @@ export function SignoffSection({
existing, existing,
signoffType, signoffType,
nameLabel, nameLabel,
submissionLocked = false,
onAdded, onAdded,
}: SignoffSectionProps) { }: SignoffSectionProps) {
const [name, setName] = useState(""); const [name, setName] = useState("");
@ -50,6 +52,10 @@ export function SignoffSection({
); );
} }
if (submissionLocked) {
return <Text variant="feedback">Signoff is unavailable after this dispatch was refused.</Text>;
}
const submit = async (event: React.FormEvent) => { const submit = async (event: React.FormEvent) => {
event.preventDefault(); event.preventDefault();
const signature = method === "drawn" ? drawnSig : typedSig; const signature = method === "drawn" ? drawnSig : typedSig;

View file

@ -8,32 +8,14 @@ import {
formatVendorPortalDateTime, formatVendorPortalDateTime,
vendorPortalStatusClass, vendorPortalStatusClass,
} from "@/domain/vendor-portal/lib/status-helpers"; } from "@/domain/vendor-portal/lib/status-helpers";
import type { VendorPortalDispatchSummary } from "@/domain/vendor-portal/types/vendor-portal"; import {
VENDOR_PORTAL_DISPATCH_FILTERS,
applyVendorPortalDispatchFilter,
getVendorPortalDispatchFilter,
type VendorPortalDispatchFilterKey,
} from "@/domain/vendor-portal/lib/dispatch-filters";
import { queryKeys } from "@/infra/query-key/query-key"; 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) { function formatDate(value?: string) {
if (!value) return "—"; if (!value) return "—";
const date = new Date(value); const date = new Date(value);
@ -49,7 +31,7 @@ function formatMoney(value?: number) {
export default function VendorPortalPosPage() { export default function VendorPortalPosPage() {
const { token } = useVendorPortal(); const { token } = useVendorPortal();
const navigate = useNavigate(); const navigate = useNavigate();
const [filter, setFilter] = useState<(typeof FILTERS)[number]["key"]>("open"); const [filter, setFilter] = useState<VendorPortalDispatchFilterKey>("open");
const [search, setSearch] = useState(""); const [search, setSearch] = useState("");
const { const {
@ -62,12 +44,11 @@ export default function VendorPortalPosPage() {
enabled: Boolean(token), enabled: Boolean(token),
}); });
const activeFilter = FILTERS.find((item) => item.key === filter) ?? FILTERS[0]; const activeFilter = getVendorPortalDispatchFilter(filter);
const visible = useMemo(() => { const visible = useMemo(() => {
const needle = search.trim().toLowerCase(); const needle = search.trim().toLowerCase();
return dispatches.filter((dispatch) => { return applyVendorPortalDispatchFilter(dispatches, activeFilter).filter((dispatch) => {
if (!activeFilter.match(dispatch)) return false;
if (!needle) return true; if (!needle) return true;
return ( return (
(dispatch.poNumber || "").toLowerCase().includes(needle) || (dispatch.poNumber || "").toLowerCase().includes(needle) ||
@ -90,7 +71,7 @@ export default function VendorPortalPosPage() {
className="vp-actions" className="vp-actions"
style={{ marginTop: 0, gap: 12, alignItems: "center", flexWrap: "wrap" }} style={{ marginTop: 0, gap: 12, alignItems: "center", flexWrap: "wrap" }}
> >
{FILTERS.map((item) => ( {VENDOR_PORTAL_DISPATCH_FILTERS.map((item) => (
<button <button
key={item.key} key={item.key}
type="button" type="button"

View file

@ -138,6 +138,10 @@
background: var(--doc-no-bg); background: var(--doc-no-bg);
color: var(--doc-no-text); color: var(--doc-no-text);
} }
.vp-status-refused {
background: var(--doc-no-bg);
color: var(--doc-no-text);
}
.vp-btn { .vp-btn {
display: inline-block; display: inline-block;

View file

@ -129,6 +129,16 @@ export const vendorPortalApi = {
method: "POST", method: "POST",
}), }),
refuse: (token: string, id: string | number, reason?: string) =>
vendorPortalCall<{ id: number; status: string; refusedAt?: string }>(
token,
`${API_PATHS.vendorPortal.dispatches}/${id}/refuse`,
{
method: "POST",
body: reason ? { reason } : {},
},
),
changeStatus: (token: string, id: string | number, status: string) => changeStatus: (token: string, id: string | number, status: string) =>
vendorPortalCall<void>(token, `${API_PATHS.vendorPortal.dispatches}/${id}/status`, { vendorPortalCall<void>(token, `${API_PATHS.vendorPortal.dispatches}/${id}/status`, {
method: "POST", method: "POST",

View file

@ -0,0 +1,71 @@
import type { VendorPortalDispatchSummary } from "@/domain/vendor-portal/types/vendor-portal";
import {
isVendorPortalTerminalStatus,
isVendorPortalUpcomingDispatch,
} from "@/domain/vendor-portal/lib/status-helpers";
export interface VendorPortalDispatchFilter {
key: VendorPortalDispatchFilterKey;
label: string;
match: (dispatch: VendorPortalDispatchSummary) => boolean;
sort?: (dispatches: VendorPortalDispatchSummary[]) => VendorPortalDispatchSummary[];
}
export type VendorPortalDispatchFilterKey =
"open" | "upcoming" | "completed" | "refused" | "cancelled" | "all";
const sortUpcomingByScheduledDateAscending = (
dispatches: VendorPortalDispatchSummary[],
): VendorPortalDispatchSummary[] =>
[...dispatches].sort((left, right) => {
const leftTime = left.scheduledDate ? new Date(left.scheduledDate).getTime() : 0;
const rightTime = right.scheduledDate ? new Date(right.scheduledDate).getTime() : 0;
return leftTime - rightTime;
});
export const VENDOR_PORTAL_DISPATCH_FILTERS: readonly VendorPortalDispatchFilter[] = [
{
key: "open",
label: "Open",
match: (dispatch) => !isVendorPortalTerminalStatus(dispatch.status),
},
{
key: "upcoming",
label: "Upcoming",
match: (dispatch) => isVendorPortalUpcomingDispatch(dispatch),
sort: sortUpcomingByScheduledDateAscending,
},
{
key: "completed",
label: "Completed",
match: (dispatch) => dispatch.status === "Completed" || dispatch.status === "Verified",
},
{
key: "refused",
label: "Refused",
match: (dispatch) => dispatch.status === "Refused",
},
{
key: "cancelled",
label: "Cancelled",
match: (dispatch) => dispatch.status === "Cancelled",
},
{ key: "all", label: "All", match: () => true },
] as const;
export function getVendorPortalDispatchFilter(
key: VendorPortalDispatchFilterKey,
): VendorPortalDispatchFilter {
return (
VENDOR_PORTAL_DISPATCH_FILTERS.find((filter) => filter.key === key) ??
VENDOR_PORTAL_DISPATCH_FILTERS[0]
);
}
export function applyVendorPortalDispatchFilter(
dispatches: VendorPortalDispatchSummary[],
filter: VendorPortalDispatchFilter,
): VendorPortalDispatchSummary[] {
const matched = dispatches.filter(filter.match);
return filter.sort ? filter.sort(matched) : matched;
}

View file

@ -6,6 +6,7 @@ export function vendorPortalStatusClass(status: string): string {
Completed: "vp-status-completed", Completed: "vp-status-completed",
Verified: "vp-status-verified", Verified: "vp-status-verified",
Cancelled: "vp-status-cancelled", Cancelled: "vp-status-cancelled",
Refused: "vp-status-refused",
}; };
return `vp-status-pill ${map[status] ?? ""}`; return `vp-status-pill ${map[status] ?? ""}`;
@ -23,3 +24,34 @@ export function formatVendorPortalDateTime(value?: string): string {
return date.toLocaleString(); return date.toLocaleString();
} }
const TERMINAL_STATUSES = new Set(["Completed", "Verified", "Cancelled", "Refused"]);
const LOCKED_STATUSES = new Set(["Verified", "Cancelled", "Refused"]);
export function isVendorPortalTerminalStatus(status: string): boolean {
return TERMINAL_STATUSES.has(status);
}
export function isVendorPortalLockedStatus(status: string): boolean {
return LOCKED_STATUSES.has(status);
}
export function isVendorPortalUpcomingDispatch(
dispatch: { status: string; scheduledDate?: string },
now: Date = new Date(),
): boolean {
if (isVendorPortalTerminalStatus(dispatch.status)) {
return false;
}
if (!dispatch.scheduledDate) {
return false;
}
const scheduled = new Date(dispatch.scheduledDate);
if (Number.isNaN(scheduled.getTime())) {
return false;
}
return scheduled.getTime() >= now.getTime();
}

View file

@ -0,0 +1,9 @@
import { z } from "zod";
export const REFUSE_REASON_MAX_LENGTH = 500;
export const refuseDispatchSchema = z.object({
reason: z.string().max(REFUSE_REASON_MAX_LENGTH, "Reason must be 500 characters or fewer."),
});
export type RefuseDispatchValues = z.infer<typeof refuseDispatchSchema>;

View file

@ -20,6 +20,7 @@ export interface VendorPortalDispatchSummary {
nteAmount?: number; nteAmount?: number;
dispatchedAt?: string; dispatchedAt?: string;
completedDate?: string; completedDate?: string;
refusedAt?: string;
} }
export interface VendorPortalDocument { export interface VendorPortalDocument {
@ -73,15 +74,25 @@ export interface VendorPortalSignoff {
signedAt?: string; signedAt?: string;
} }
export interface VendorPortalDispatcherContact {
name?: string;
email?: string;
phone?: string;
}
export interface VendorPortalDispatchDetail extends VendorPortalDispatchSummary { export interface VendorPortalDispatchDetail extends VendorPortalDispatchSummary {
description?: string; description?: string;
acknowledgedAt?: string; acknowledgedAt?: string;
statusUpdatedAt?: string;
dispatcherContact?: VendorPortalDispatcherContact;
workOrder?: { workOrder?: {
workerOrderTitle?: string; workerOrderTitle?: string;
internalWONumber?: string; internalWONumber?: string;
priority?: string; priority?: string;
trade?: string; trade?: string;
subTrade?: string; subTrade?: string;
siteCode?: string;
service?: string;
problem?: string; problem?: string;
description?: string; description?: string;
}; };

View file

@ -0,0 +1,140 @@
import { act, fireEvent, screen, waitFor } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { RefuseDispatchDialog } from "@/app/v/[token]/dispatch/_components/refuse-dispatch-dialog";
import {
VendorPortalContext,
type VendorPortalContextValue,
} from "@/app/v/_components/vendor-portal-context";
import { vendorPortalApi } from "@/domain/vendor-portal/api/vendor-portal-api";
import { REFUSE_REASON_MAX_LENGTH } from "@/domain/vendor-portal/schemas/refuse-dispatch-schema";
import { queryKeys } from "@/infra/query-key/query-key";
import { createTestQueryClient, renderWithProviders } from "@/test/test-utils";
function renderDialog({
token = "portal-token",
dispatchId = 9,
onClose = vi.fn(),
onRefused = vi.fn(),
}: Partial<{
token: string;
dispatchId: number;
onClose: () => void;
onRefused: () => void;
}> = {}) {
const queryClient = createTestQueryClient();
const invalidateSpy = vi.spyOn(queryClient, "invalidateQueries");
const portalValue: VendorPortalContextValue = {
token,
vendor: { companyName: "Gateway Plumbing", vendorId: 101 },
status: "ready",
error: null,
};
const utils = renderWithProviders(
<VendorPortalContext.Provider value={portalValue}>
<RefuseDispatchDialog open dispatchId={dispatchId} onClose={onClose} onRefused={onRefused} />
</VendorPortalContext.Provider>,
{ withAuth: false, queryClient },
);
return { ...utils, invalidateSpy, queryClient };
}
beforeEach(() => {
vi.spyOn(vendorPortalApi, "refuse").mockResolvedValue({ id: 9, status: "Refused" });
});
afterEach(() => {
vi.restoreAllMocks();
});
describe("RefuseDispatchDialog", () => {
it("submits without a reason when the field is left empty", async () => {
renderDialog();
await act(async () => {
fireEvent.click(screen.getByRole("button", { name: "Refuse dispatch" }));
});
await waitFor(() =>
expect(vendorPortalApi.refuse).toHaveBeenCalledWith("portal-token", 9, undefined),
);
});
it("trims a supplied reason before sending", async () => {
renderDialog();
fireEvent.change(screen.getByLabelText(/Reason/i), { target: { value: " too busy " } });
await act(async () => {
fireEvent.click(screen.getByRole("button", { name: "Refuse dispatch" }));
});
await waitFor(() =>
expect(vendorPortalApi.refuse).toHaveBeenCalledWith("portal-token", 9, "too busy"),
);
});
it("blocks submission when the reason exceeds the length cap", async () => {
renderDialog();
const overLimit = "a".repeat(REFUSE_REASON_MAX_LENGTH + 1);
fireEvent.change(screen.getByLabelText(/Reason/i), { target: { value: overLimit } });
await waitFor(() =>
expect(screen.getByRole("button", { name: "Refuse dispatch" })).toBeDisabled(),
);
expect(vendorPortalApi.refuse).not.toHaveBeenCalled();
});
it("shows a live character counter bounded by the limit", () => {
renderDialog();
fireEvent.change(screen.getByLabelText(/Reason/i), { target: { value: "abc" } });
expect(screen.getByText(`3/${REFUSE_REASON_MAX_LENGTH}`)).toBeInTheDocument();
});
it("invalidates the detail and list cache and closes on success", async () => {
const onClose = vi.fn();
const onRefused = vi.fn();
const { invalidateSpy } = renderDialog({ onClose, onRefused });
await act(async () => {
fireEvent.click(screen.getByRole("button", { name: "Refuse dispatch" }));
});
await waitFor(() => expect(onClose).toHaveBeenCalled());
expect(onRefused).toHaveBeenCalled();
expect(invalidateSpy).toHaveBeenCalledWith({
queryKey: queryKeys.vendorPortal.dispatch("portal-token", 9),
});
expect(invalidateSpy).toHaveBeenCalledWith({
queryKey: queryKeys.vendorPortal.dispatches("portal-token"),
});
});
it("surfaces a safe inline error and keeps the dialog open on failure", async () => {
vi.spyOn(vendorPortalApi, "refuse").mockRejectedValueOnce(new Error("nope"));
const onClose = vi.fn();
renderDialog({ onClose });
await act(async () => {
fireEvent.click(screen.getByRole("button", { name: "Refuse dispatch" }));
});
await waitFor(() => expect(screen.getByRole("alert")).toHaveTextContent(/Unable to refuse/i));
expect(onClose).not.toHaveBeenCalled();
});
it("closes without refusing when cancel is pressed", async () => {
const onClose = vi.fn();
renderDialog({ onClose });
fireEvent.click(screen.getByRole("button", { name: "Cancel" }));
await waitFor(() => expect(onClose).toHaveBeenCalled());
expect(vendorPortalApi.refuse).not.toHaveBeenCalled();
});
});

View file

@ -0,0 +1,90 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { vendorPortalApi } from "@/domain/vendor-portal/api/vendor-portal-api";
type FetchCall = {
url: string;
method: string;
body: unknown;
headers: Record<string, string>;
};
function mockFetch(response: unknown, status = 200) {
const calls: FetchCall[] = [];
vi.stubGlobal(
"fetch",
vi.fn(async (url: string | URL, init?: RequestInit) => {
const body = init?.body ? JSON.parse(String(init.body)) : undefined;
const headers = (init?.headers ?? {}) as Record<string, string>;
calls.push({
url: String(url),
method: init?.method ?? "GET",
body,
headers,
});
return {
ok: status >= 200 && status < 300,
status,
json: async () =>
response == null
? ({} as Record<string, unknown>)
: ({ data: response } as Record<string, unknown>),
} as Response;
}),
);
return calls;
}
beforeEach(() => {
vi.stubEnv("VITE_API_BASE_URL", "");
});
afterEach(() => {
vi.unstubAllGlobals();
vi.unstubAllEnvs();
});
describe("vendorPortalApi.refuse", () => {
it("POSTs to the refuse endpoint with a reason when provided", async () => {
const calls = mockFetch({ id: 9, status: "Refused", refusedAt: "2026-08-10T12:00:00Z" });
await vendorPortalApi.refuse("portal-token", 9, "Too far away");
expect(calls).toHaveLength(1);
expect(calls[0].url).toContain("/vendor-portal/dispatches/9/refuse");
expect(calls[0].method).toBe("POST");
expect(calls[0].headers["X-Vendor-Token"]).toBe("portal-token");
expect(calls[0].body).toEqual({ reason: "Too far away" });
});
it("omits the reason from the body when none is supplied", async () => {
const calls = mockFetch({ id: 9, status: "Refused" });
await vendorPortalApi.refuse("portal-token", 9);
expect(calls[0].body).toEqual({});
});
it("returns the refused payload from the API", async () => {
mockFetch({ id: 9, status: "Refused", refusedAt: "2026-08-10T12:00:00Z" });
const result = await vendorPortalApi.refuse("portal-token", 9);
expect(result).toEqual({ id: 9, status: "Refused", refusedAt: "2026-08-10T12:00:00Z" });
});
it("throws when the API rejects the refusal", async () => {
vi.stubGlobal(
"fetch",
vi.fn(async () => ({
ok: false,
status: 409,
json: async () => ({ message: "Dispatch already terminal" }),
})) as unknown as typeof fetch,
);
await expect(vendorPortalApi.refuse("portal-token", 9, "nope")).rejects.toThrow(
"Dispatch already terminal",
);
});
});

View file

@ -0,0 +1,45 @@
import { describe, expect, it } from "vitest";
import {
applyVendorPortalDispatchFilter,
getVendorPortalDispatchFilter,
} from "@/domain/vendor-portal/lib/dispatch-filters";
import type { VendorPortalDispatchSummary } from "@/domain/vendor-portal/types/vendor-portal";
const dispatch = (
id: number,
status: string,
scheduledDate?: string,
): VendorPortalDispatchSummary => ({
id,
dispatchNumber: `DSP-${id}`,
status,
scheduledDate,
});
describe("vendor portal dispatch filters", () => {
it("keeps Refused separate from the Open view", () => {
const dispatches = [dispatch(1, "Sent"), dispatch(2, "Refused")];
expect(
applyVendorPortalDispatchFilter(dispatches, getVendorPortalDispatchFilter("open")),
).toEqual([dispatches[0]]);
expect(
applyVendorPortalDispatchFilter(dispatches, getVendorPortalDispatchFilter("refused")),
).toEqual([dispatches[1]]);
});
it("sorts Upcoming by schedule without mutating the cached list", () => {
const later = dispatch(2, "Acknowledged", "2099-08-12T09:00:00Z");
const sooner = dispatch(1, "Sent", "2099-08-11T09:00:00Z");
const dispatches = [later, sooner];
const result = applyVendorPortalDispatchFilter(
dispatches,
getVendorPortalDispatchFilter("upcoming"),
);
expect(result).toEqual([sooner, later]);
expect(dispatches).toEqual([later, sooner]);
});
});

View file

@ -0,0 +1,87 @@
import { describe, expect, it } from "vitest";
import {
formatVendorPortalDateTime,
isVendorPortalLockedStatus,
isVendorPortalTerminalStatus,
isVendorPortalUpcomingDispatch,
vendorPortalStatusClass,
} from "@/domain/vendor-portal/lib/status-helpers";
describe("vendorPortalStatusClass", () => {
it("applies a distinct class for the Refused status", () => {
expect(vendorPortalStatusClass("Refused")).toBe("vp-status-pill vp-status-refused");
});
it("keeps the established class for existing statuses", () => {
expect(vendorPortalStatusClass("Sent")).toBe("vp-status-pill vp-status-sent");
expect(vendorPortalStatusClass("Cancelled")).toBe("vp-status-pill vp-status-cancelled");
});
});
describe("isVendorPortalLockedStatus", () => {
it("adds Refused without regressing the established Completed behavior", () => {
expect(isVendorPortalLockedStatus("Refused")).toBe(true);
expect(isVendorPortalLockedStatus("Verified")).toBe(true);
expect(isVendorPortalLockedStatus("Cancelled")).toBe(true);
expect(isVendorPortalLockedStatus("Completed")).toBe(false);
});
});
describe("isVendorPortalTerminalStatus", () => {
it("treats Refused, Verified, Cancelled, and Completed as terminal", () => {
expect(isVendorPortalTerminalStatus("Refused")).toBe(true);
expect(isVendorPortalTerminalStatus("Verified")).toBe(true);
expect(isVendorPortalTerminalStatus("Cancelled")).toBe(true);
expect(isVendorPortalTerminalStatus("Completed")).toBe(true);
});
it("treats active statuses as non-terminal", () => {
expect(isVendorPortalTerminalStatus("Sent")).toBe(false);
expect(isVendorPortalTerminalStatus("Acknowledged")).toBe(false);
expect(isVendorPortalTerminalStatus("In Progress")).toBe(false);
});
});
describe("isVendorPortalUpcomingDispatch", () => {
const now = new Date("2026-08-10T12:00:00Z");
it("includes a non-terminal dispatch scheduled in the future", () => {
expect(
isVendorPortalUpcomingDispatch(
{ status: "Acknowledged", scheduledDate: "2026-08-11T09:00:00Z" },
now,
),
).toBe(true);
});
it("excludes terminal statuses even when scheduled in the future", () => {
for (const status of ["Refused", "Verified", "Cancelled", "Completed"]) {
expect(
isVendorPortalUpcomingDispatch({ status, scheduledDate: "2026-08-11T09:00:00Z" }, now),
).toBe(false);
}
});
it("excludes dispatches without a scheduled date", () => {
expect(isVendorPortalUpcomingDispatch({ status: "Sent", scheduledDate: undefined }, now)).toBe(
false,
);
});
it("excludes past scheduled dates", () => {
expect(
isVendorPortalUpcomingDispatch(
{ status: "In Progress", scheduledDate: "2026-08-09T09:00:00Z" },
now,
),
).toBe(false);
});
});
describe("formatVendorPortalDateTime", () => {
it("returns an empty string for missing or invalid values", () => {
expect(formatVendorPortalDateTime(undefined)).toBe("");
expect(formatVendorPortalDateTime("not-a-date")).toBe("");
});
});