mirror of
https://github.com/Sea-Haven-Industries/shoc-frontend-new.git
synced 2026-09-30 08:03:13 +00:00
feat(vendor-portal): complete request lifecycle (SH-98)
This commit is contained in:
parent
78ed8571a0
commit
4d2ee40d60
20 changed files with 1083 additions and 130 deletions
221
e2e/vendor-portal/portal-lifecycle.spec.ts
Normal file
221
e2e/vendor-portal/portal-lifecycle.spec.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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("")}
|
||||||
|
/>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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}>
|
||||||
|
|
|
||||||
|
|
@ -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}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
71
src/domain/vendor-portal/lib/dispatch-filters.ts
Normal file
71
src/domain/vendor-portal/lib/dispatch-filters.ts
Normal 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;
|
||||||
|
}
|
||||||
|
|
@ -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();
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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>;
|
||||||
|
|
@ -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;
|
||||||
};
|
};
|
||||||
|
|
|
||||||
140
src/test/app/v/refuse-dispatch-dialog.test.tsx
Normal file
140
src/test/app/v/refuse-dispatch-dialog.test.tsx
Normal 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
90
src/test/domain/vendor-portal/api/vendor-portal-api.test.ts
Normal file
90
src/test/domain/vendor-portal/api/vendor-portal-api.test.ts
Normal 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",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
45
src/test/domain/vendor-portal/lib/dispatch-filters.test.ts
Normal file
45
src/test/domain/vendor-portal/lib/dispatch-filters.test.ts
Normal 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]);
|
||||||
|
});
|
||||||
|
});
|
||||||
87
src/test/domain/vendor-portal/lib/status-helpers.test.ts
Normal file
87
src/test/domain/vendor-portal/lib/status-helpers.test.ts
Normal 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("");
|
||||||
|
});
|
||||||
|
});
|
||||||
Loading…
Add table
Reference in a new issue