Merge pull request #97 from Sea-Haven-Industries/feat/sh-98-portal-lifecycle-recovered
Some checks are pending
CI / ci (push) Waiting to run
CI / governance (push) Waiting to run
CI / vendor-visual-regression (push) Waiting to run
Deploy / deploy (push) Waiting to run

feat(vendor-portal): complete request lifecycle (SH-98)
This commit is contained in:
Alexandre Brandizzi 2026-08-11 09:01:54 -03:00 • committed by GitHub
commit 47272a0afc
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
22 changed files with 1306 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,
vendorPortalStatusClass,
} 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";
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 [filter, setFilter] = useState<VendorPortalDispatchFilterKey>("open");
const {
data: dispatches = [],
@ -49,8 +31,11 @@ export default function VendorPortalDashboardPage() {
enabled: Boolean(token),
});
const activeFilter = FILTERS.find((item) => item.key === filter) ?? FILTERS[0];
const filtered = useMemo(() => dispatches.filter(activeFilter.match), [dispatches, activeFilter]);
const activeFilter = getVendorPortalDispatchFilter(filter);
const filtered = useMemo(
() => applyVendorPortalDispatchFilter(dispatches, activeFilter),
[dispatches, activeFilter],
);
return (
<>
@ -60,7 +45,7 @@ export default function VendorPortalDashboardPage() {
Active dispatches and work orders assigned to your company.
</div>
<div className="vp-actions" style={{ marginTop: 0 }}>
{FILTERS.map((item) => (
{VENDOR_PORTAL_DISPATCH_FILTERS.map((item) => (
<button
key={item.key}
type="button"

View file

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

View file

@ -76,6 +76,7 @@ export function DispatchDetailSections({
existing={data.signoffs ?? []}
signoffType="customer"
nameLabel="Customer name"
submissionLocked={locked}
onAdded={() => void refresh()}
/>
</div>
@ -88,6 +89,7 @@ export function DispatchDetailSections({
existing={data.signoffs ?? []}
signoffType="vendor"
nameLabel="Your name"
submissionLocked={locked}
onAdded={() => void refresh()}
/>
</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;
busy: boolean;
onAccept: () => void;
onRefuse: () => void;
onStart: () => void;
onComplete: () => void;
onRequestCancel: () => void;
@ -13,6 +14,7 @@ export function DispatchStatusActions({
locked,
busy,
onAccept,
onRefuse,
onStart,
onComplete,
onRequestCancel,
@ -20,9 +22,19 @@ export function DispatchStatusActions({
return (
<div className="vp-actions">
{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" && (
<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 { Text } from "@/components/ui/text";
import {
formatVendorPortalDateTime,
vendorPortalStatusClass,
} from "@/domain/vendor-portal/lib/status-helpers";
import { vendorPortalStatusClass } from "@/domain/vendor-portal/lib/status-helpers";
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";
type DispatchSummaryCardProps = {
@ -13,6 +11,7 @@ type DispatchSummaryCardProps = {
locked: boolean;
busy: boolean;
onAccept: () => void;
onRefuse: () => void;
onStart: () => void;
onComplete: () => void;
onRequestCancel: () => void;
@ -24,6 +23,7 @@ export function DispatchSummaryCard({
locked,
busy,
onAccept,
onRefuse,
onStart,
onComplete,
onRequestCancel,
@ -43,77 +43,14 @@ export function DispatchSummaryCard({
<span className={vendorPortalStatusClass(data.status)}>{data.status}</span>
</div>
<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(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>
</>
)}
<DispatchSnapshotDetails data={data} locked={locked} />
<DispatchStatusActions
status={data.status}
locked={locked}
busy={busy}
onAccept={onAccept}
onRefuse={onRefuse}
onStart={onStart}
onComplete={onComplete}
onRequestCancel={onRequestCancel}

View file

@ -0,0 +1,141 @@
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";
import { guardDialogClose } from "@/lib/dialog-guard";
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;
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 { reset: resetMutation } = refuseMutation;
useEffect(() => {
if (!open) {
reset({ reason: "" });
resetMutation();
}
}, [open, reset, resetMutation]);
const submit = handleSubmit((values) => refuseMutation.mutate(values));
const errorMessage = refuseMutation.error
? "Unable to refuse this dispatch. Please try again."
: "";
return (
<Dialog
open={open}
onClose={guardDialogClose(refuseMutation.isPending, 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[];
signoffType: string;
nameLabel: string;
submissionLocked?: boolean;
onAdded: () => void;
};
@ -20,6 +21,7 @@ export function SignoffSection({
existing,
signoffType,
nameLabel,
submissionLocked = false,
onAdded,
}: SignoffSectionProps) {
const [name, setName] = useState("");
@ -50,6 +52,10 @@ export function SignoffSection({
);
}
if (submissionLocked) {
return <Text variant="feedback">Signoff is unavailable for this dispatch.</Text>;
}
const submit = async (event: React.FormEvent) => {
event.preventDefault();
const signature = method === "drawn" ? drawnSig : typedSig;

View file

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

View file

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

View file

@ -129,6 +129,16 @@ export const vendorPortalApi = {
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) =>
vendorPortalCall<void>(token, `${API_PATHS.vendorPortal.dispatches}/${id}/status`, {
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",
Verified: "vp-status-verified",
Cancelled: "vp-status-cancelled",
Refused: "vp-status-refused",
};
return `vp-status-pill ${map[status] ?? ""}`;
@ -23,3 +24,34 @@ export function formatVendorPortalDateTime(value?: string): string {
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;
dispatchedAt?: string;
completedDate?: string;
refusedAt?: string;
}
export interface VendorPortalDocument {
@ -73,15 +74,25 @@ export interface VendorPortalSignoff {
signedAt?: string;
}
export interface VendorPortalDispatcherContact {
name?: string;
email?: string;
phone?: string;
}
export interface VendorPortalDispatchDetail extends VendorPortalDispatchSummary {
description?: string;
acknowledgedAt?: string;
statusUpdatedAt?: string;
dispatcherContact?: VendorPortalDispatcherContact;
workOrder?: {
workerOrderTitle?: string;
internalWONumber?: string;
priority?: string;
trade?: string;
subTrade?: string;
siteCode?: string;
service?: string;
problem?: string;
description?: string;
};

View file

@ -0,0 +1,99 @@
import { screen } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import { DispatchDetailSections } from "@/app/v/[token]/dispatch/_components/dispatch-detail-sections";
import { isVendorPortalLockedStatus } from "@/domain/vendor-portal/lib/status-helpers";
import type {
VendorPortalDispatchDetail,
VendorPortalSignoff,
} from "@/domain/vendor-portal/types/vendor-portal";
import { renderWithProviders } from "@/test/test-utils";
vi.mock("@/app/v/[token]/dispatch/_components/checklist-section", () => ({
ChecklistSection: () => <div data-testid="checklist-mock" />,
}));
vi.mock("@/app/v/_components/vendor-portal-documents", () => ({
VendorPortalDocuments: () => <div data-testid="documents-mock" />,
}));
vi.mock("@/app/v/[token]/dispatch/_components/uplift-requests-section", () => ({
UpliftRequestsSection: () => <div data-testid="uplift-mock" />,
}));
vi.mock("@/app/v/[token]/dispatch/_components/comments-section", () => ({
CommentsSection: () => <div data-testid="comments-mock" />,
}));
function buildData(
overrides: Partial<VendorPortalDispatchDetail> = {},
): VendorPortalDispatchDetail {
return {
id: 9,
dispatchNumber: "DSP-9",
status: "In Progress",
nteAmount: 500,
checklist: [],
comments: [],
upliftRequests: [],
signoffs: [],
documents: [],
...overrides,
};
}
function renderSections({
status,
signoffs = [],
}: {
status: string;
signoffs?: VendorPortalSignoff[];
}) {
const data = buildData({ status, signoffs });
const locked = isVendorPortalLockedStatus(status);
return renderWithProviders(
<DispatchDetailSections
token="portal-token"
dispatchId={9}
data={data}
locked={locked}
refresh={vi.fn()}
updateLocal={vi.fn()}
/>,
{ withAuth: false },
);
}
describe("DispatchDetailSections signoff locking", () => {
it.each(["Verified", "Cancelled", "Refused"])(
"locks both customer and vendor signoff forms when status is %s",
(status) => {
renderSections({ status });
expect(screen.getAllByText(/Signoff is unavailable for this dispatch/i)).toHaveLength(2);
expect(screen.queryAllByRole("button", { name: "Submit Signoff" })).toHaveLength(0);
},
);
it.each(["Completed", "In Progress"])(
"keeps both signoff forms available when status is %s",
(status) => {
renderSections({ status });
expect(screen.getAllByRole("button", { name: "Submit Signoff" })).toHaveLength(2);
expect(screen.queryByText(/Signoff is unavailable/i)).not.toBeInTheDocument();
},
);
it("keeps an existing signoff visible while the dispatch is locked", () => {
const customerSignoff: VendorPortalSignoff = {
signoffType: "customer",
name: "Ada Lovelace",
signatureMethod: "drawn",
signedAt: "2024-01-02T03:04:05Z",
};
renderSections({ status: "Refused", signoffs: [customerSignoff] });
expect(screen.getByText("Ada Lovelace")).toBeInTheDocument();
expect(screen.getByText("drawn")).toBeInTheDocument();
expect(screen.getByText(/Signoff is unavailable/i)).toBeInTheDocument();
expect(screen.queryAllByRole("button", { name: "Submit Signoff" })).toHaveLength(0);
});
});

View file

@ -0,0 +1,210 @@
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();
});
it("blocks Escape and backdrop close while a refusal is pending, then closes on success", async () => {
let resolveRefusal!: (value: { id: number; status: string }) => void;
vi.spyOn(vendorPortalApi, "refuse").mockImplementation(
() =>
new Promise((resolve) => {
resolveRefusal = resolve;
}),
);
const onClose = vi.fn();
renderDialog({ onClose });
await act(async () => {
fireEvent.click(screen.getByRole("button", { name: "Refuse dispatch" }));
});
await waitFor(() => expect(screen.getByRole("button", { name: "Refusing…" })).toBeDisabled());
expect(screen.getByRole("button", { name: "Cancel" })).toBeDisabled();
const backdrop = document.querySelector(".MuiBackdrop-root");
expect(backdrop).not.toBeNull();
fireEvent.click(backdrop as Element);
fireEvent.keyDown(document.body, { key: "Escape" });
expect(onClose).not.toHaveBeenCalled();
await act(async () => {
resolveRefusal({ id: 9, status: "Refused" });
});
await waitFor(() => expect(onClose).toHaveBeenCalled());
});
it("clears the stale error and reason after closing and reopening", async () => {
vi.spyOn(vendorPortalApi, "refuse").mockRejectedValueOnce(new Error("nope"));
const onClose = vi.fn();
const onRefused = vi.fn();
const portalValue: VendorPortalContextValue = {
token: "portal-token",
vendor: { companyName: "Gateway Plumbing", vendorId: 101 },
status: "ready",
error: null,
};
const renderEl = (open: boolean) => (
<VendorPortalContext.Provider value={portalValue}>
<RefuseDispatchDialog open={open} dispatchId={9} onClose={onClose} onRefused={onRefused} />
</VendorPortalContext.Provider>
);
const { rerender } = renderWithProviders(renderEl(true), { withAuth: false });
fireEvent.change(screen.getByLabelText(/Reason/i), { target: { value: "too busy" } });
await act(async () => {
fireEvent.click(screen.getByRole("button", { name: "Refuse dispatch" }));
});
await waitFor(() => expect(screen.getByRole("alert")).toHaveTextContent(/Unable to refuse/i));
expect(screen.getByText(`8/${REFUSE_REASON_MAX_LENGTH}`)).toBeInTheDocument();
await act(async () => {
rerender(renderEl(false));
});
await act(async () => {
rerender(renderEl(true));
});
await waitFor(() => expect(screen.queryByRole("alert")).not.toBeInTheDocument());
expect(screen.getByText(`0/${REFUSE_REASON_MAX_LENGTH}`)).toBeInTheDocument();
});
});

View file

@ -0,0 +1,46 @@
import { render, screen } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import { SignoffSection } from "@/app/v/[token]/dispatch/_components/signoff-section";
function renderSection(overrides: Partial<React.ComponentProps<typeof SignoffSection>> = {}) {
return render(
<SignoffSection
token="portal-token"
dispatchId={9}
existing={[]}
signoffType="customer"
nameLabel="Customer name"
onAdded={vi.fn()}
{...overrides}
/>,
);
}
describe("SignoffSection", () => {
it("shows status-neutral locked copy when submission is locked", () => {
renderSection({ submissionLocked: true });
expect(screen.getByText(/Signoff is unavailable for this dispatch/i)).toBeInTheDocument();
expect(screen.queryByText(/refused/i)).not.toBeInTheDocument();
expect(screen.queryByRole("button", { name: "Submit Signoff" })).not.toBeInTheDocument();
});
it("shows the submission form when unlocked", () => {
renderSection();
expect(screen.getByRole("button", { name: "Submit Signoff" })).toBeInTheDocument();
});
it("shows an existing signoff even while locked", () => {
renderSection({
signoffType: "vendor",
nameLabel: "Your name",
submissionLocked: true,
existing: [{ signoffType: "vendor", name: "Grace Hopper", signatureMethod: "typed" }],
});
expect(screen.getByText("Grace Hopper")).toBeInTheDocument();
expect(screen.queryByText(/Signoff is unavailable/i)).not.toBeInTheDocument();
expect(screen.queryByRole("button", { name: "Submit Signoff" })).not.toBeInTheDocument();
});
});

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("");
});
});