diff --git a/e2e/vendor-portal/portal-lifecycle.spec.ts b/e2e/vendor-portal/portal-lifecycle.spec.ts new file mode 100644 index 00000000..294051a6 --- /dev/null +++ b/e2e/vendor-portal/portal-lifecycle.spec.ts @@ -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; + location?: Record; + 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); + }); +}); diff --git a/src/app/v/[token]/dashboard.tsx b/src/app/v/[token]/dashboard.tsx index eeb222a2..65b87bff 100644 --- a/src/app/v/[token]/dashboard.tsx +++ b/src/app/v/[token]/dashboard.tsx @@ -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("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.
- {FILTERS.map((item) => ( + {VENDOR_PORTAL_DISPATCH_FILTERS.map((item) => (
@@ -88,6 +89,7 @@ export function DispatchDetailSections({ existing={data.signoffs ?? []} signoffType="vendor" nameLabel="Your name" + submissionLocked={locked} onAdded={() => void refresh()} /> diff --git a/src/app/v/[token]/dispatch/_components/dispatch-snapshot-details.tsx b/src/app/v/[token]/dispatch/_components/dispatch-snapshot-details.tsx new file mode 100644 index 00000000..4f71d6d8 --- /dev/null +++ b/src/app/v/[token]/dispatch/_components/dispatch-snapshot-details.tsx @@ -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) { + const service = resolveCanonicalService(data.workOrder); + + return ( +
+ {data.workOrder != null && Boolean(data.workOrder.internalWONumber) && ( +
+ WO # + {data.workOrder.internalWONumber} +
+ )} + {Boolean(service) && ( +
+ Service + {service} +
+ )} + {Boolean(data.poNumber) && ( +
+ PO # + {data.poNumber} +
+ )} + {data.nteAmount != null && ( +
+ NTE + ${Number(data.nteAmount).toFixed(2)} +
+ )} +
+ Invoice + + {data.invoiceStatus || "Not Submitted"} + {data.invoiceNumber ? ` · ${data.invoiceNumber}` : ""} + +
+
+ Payment + {data.paymentStatus || "Unavailable"} +
+ {Boolean(data.scheduledDate) && ( +
+ Scheduled + {formatVendorPortalDateTime(data.scheduledDate)} +
+ )} + {Boolean(data.statusUpdatedAt) && ( +
+ Status updated + {formatVendorPortalDateTime(data.statusUpdatedAt)} +
+ )} +
+ ); +} + +function DispatchLocationDetails({ data }: Pick) { + if (data.location == null) { + return null; + } + + const address = [ + data.location.address, + data.location.city, + data.location.state, + data.location.zipCode, + ] + .filter(Boolean) + .join(", "); + + return ( + <> + + Location + + {Boolean(data.workOrder?.siteCode) && ( +
+ Site code + {data.workOrder?.siteCode} +
+ )} +
{data.location.name}
+
{address}
+ + ); +} + +function DispatcherContactDetails({ data }: Pick) { + const contact = data.dispatcherContact; + if (!contact || (!contact.name && !contact.email && !contact.phone)) { + return null; + } + + return ( + <> + + Dispatcher + + {Boolean(contact.name) && ( +
+ Name + {contact.name} +
+ )} + {Boolean(contact.email) && ( +
+ Email + + {contact.email} + +
+ )} + {Boolean(contact.phone) && ( +
+ Phone + + {contact.phone} + +
+ )} + + ); +} + +export function DispatchSnapshotDetails({ data, locked }: DispatchSnapshotDetailsProps) { + const description = data.description || data.workOrder?.description; + + return ( + <> + + + + {locked && ( + + This dispatch is {data.status.toLowerCase()} and locked to further changes. Comments + remain open. + + )} + {Boolean(description) && ( + <> + + Description + +
{description}
+ + )} + + ); +} diff --git a/src/app/v/[token]/dispatch/_components/dispatch-status-actions.tsx b/src/app/v/[token]/dispatch/_components/dispatch-status-actions.tsx index 7dc3bcea..07d93c92 100644 --- a/src/app/v/[token]/dispatch/_components/dispatch-status-actions.tsx +++ b/src/app/v/[token]/dispatch/_components/dispatch-status-actions.tsx @@ -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 (
{status === "Sent" && ( - + <> + + + )} {status === "Acknowledged" && (
-
- {data.workOrder != null && Boolean(data.workOrder.internalWONumber) && ( -
- WO # - {data.workOrder.internalWONumber} -
- )} - {Boolean(data.poNumber) && ( -
- PO # - {data.poNumber} -
- )} - {data.nteAmount != null && ( -
- NTE - ${Number(data.nteAmount).toFixed(2)} -
- )} -
- Invoice - - {data.invoiceStatus || "Not Submitted"} - {data.invoiceNumber ? ` · ${data.invoiceNumber}` : ""} - -
-
- Payment - {data.paymentStatus || "Unavailable"} -
- {Boolean(data.scheduledDate) && ( -
- Scheduled - {formatVendorPortalDateTime(data.scheduledDate)} -
- )} -
- - {data.location != null && ( - <> - - Location - -
{data.location.name}
-
- {[ - data.location.address, - data.location.city, - data.location.state, - data.location.zipCode, - ] - .filter(Boolean) - .join(", ")} -
- - )} - - {Boolean(data.description || data.workOrder?.description) && ( - <> - - Description - -
{data.description || data.workOrder?.description}
- - )} + 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({ + 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 ( + + Refuse dispatch +
void submit(event)}> + + + Refusing ends your assignment for this dispatch. A reason is optional but helps your + dispatcher reassign the work. + + +