chore: merge origin/dev into slide-over media branch

Sync with latest dev and add completedDate to the unscheduled highlight
fixture so the merge build stays type-safe.
This commit is contained in:
arthur.bassi 2026-08-11 14:02:42 -03:00
commit 4637f3ab14
58 changed files with 1990 additions and 175 deletions

View file

@ -41,3 +41,25 @@ jobs:
cache: npm
- run: npm ci
- run: npm run verify
vendor-visual-regression:
runs-on: ubuntu-latest
container: mcr.microsoft.com/playwright:v1.61.1-noble
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: "24"
cache: npm
- run: npm ci
- run: npm run test:e2e:visual
- name: Upload visual diff artifacts
if: failure()
uses: actions/upload-artifact@v4
with:
name: vendor-visual-regression-diffs
path: |
test-results/visual
playwright-report-visual
if-no-files-found: ignore
retention-days: 14

1
.gitignore vendored
View file

@ -10,6 +10,7 @@
/coverage
/test-results
/playwright-report
/playwright-report-visual
/blob-report
/playwright/.cache

View file

@ -2,6 +2,7 @@ dist
build
node_modules
coverage
playwright-report-visual
package-lock.json
*.tsbuildinfo
@ -10,4 +11,4 @@ src/pages/**
src/**/*.js
src/**/*.jsx
src/**/*.css
!src/styles/globals.css
!src/styles/globals.css

Binary file not shown.

After

Width:  |  Height:  |  Size: 130 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 116 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 142 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 98 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 96 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 97 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 102 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 111 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

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

26
package-lock.json generated
View file

@ -4048,9 +4048,9 @@
"license": "MIT"
},
"node_modules/fast-uri": {
"version": "3.1.4",
"resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.4.tgz",
"integrity": "sha512-8JnbkQ4juDyvYs4mgFGQqg4yCYtFDtUtmp2QIQq11ZZe5CFQ5wcqm1rqDgAh/QdMySuBnPzMUiJUNZG5N/AiQw==",
"version": "3.1.5",
"resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.5.tgz",
"integrity": "sha512-gHwA1O9LDIcKunMKhObS/HimwtehO1nPUECKAu5TpKgaO19fcWEl4bliWe1jWxVFvIXztJjjQ4L8XQ1EU9f7Jw==",
"dev": true,
"funding": [
{
@ -4578,9 +4578,9 @@
"license": "MIT"
},
"node_modules/js-yaml": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.0.tgz",
"integrity": "sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q==",
"version": "4.3.1",
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.1.tgz",
"integrity": "sha512-CY6crGq313MX8GkwvB7tzgp99vjQxY1++5y10/BKN/GUfHqWaOGQMNZkBvqSzsZKWk/ijwHlWzzkLulsGHhjWQ==",
"dev": true,
"funding": [
{
@ -5302,9 +5302,9 @@
"license": "MIT"
},
"node_modules/nanoid": {
"version": "3.3.16",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.16.tgz",
"integrity": "sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==",
"version": "3.3.18",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz",
"integrity": "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==",
"funding": [
{
"type": "github",
@ -5561,9 +5561,9 @@
}
},
"node_modules/postcss": {
"version": "8.5.18",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.18.tgz",
"integrity": "sha512-xdB1oSLHbz1vRWgCDalrCqEFTWzFlhqFC5tIHLMOSUIjhm3XXQ1qrFy8S/ESr1JYRRXqM3c1QFiMZUJdUTqyMQ==",
"version": "8.5.26",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.26.tgz",
"integrity": "sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ==",
"funding": [
{
"type": "opencollective",
@ -5580,7 +5580,7 @@
],
"license": "MIT",
"dependencies": {
"nanoid": "^3.3.12",
"nanoid": "^3.3.17",
"picocolors": "^1.1.1",
"source-map-js": "^1.2.1"
},

View file

@ -10,6 +10,7 @@
"test": "vitest run",
"test:watch": "vitest",
"test:e2e": "playwright test",
"test:e2e:visual": "playwright test --config playwright.visual.config.ts",
"test:e2e:ui": "playwright test --ui",
"lint": "eslint . --max-warnings=0",
"lint:fix": "eslint . --fix --max-warnings=0",

View file

@ -2,6 +2,7 @@ import { defineConfig, devices } from "@playwright/test";
export default defineConfig({
testDir: "./e2e",
testIgnore: "**/*.visual.spec.ts",
fullyParallel: true,
forbidOnly: Boolean(process.env.CI),
retries: process.env.CI ? 2 : 0,

View file

@ -0,0 +1,43 @@
import { defineConfig, devices } from "@playwright/test";
export default defineConfig({
testDir: "./e2e",
testMatch: "**/*.visual.spec.ts",
fullyParallel: false,
forbidOnly: true,
retries: 0,
workers: 1,
reporter: [["list"], ["html", { outputFolder: "playwright-report-visual", open: "never" }]],
outputDir: "test-results/visual",
snapshotPathTemplate: "{testDir}/__screenshots__/{testFilePath}/{arg}{ext}",
expect: {
toHaveScreenshot: {
animations: "disabled",
caret: "hide",
// Keep the changed-pixel budget strict while ignoring sub-perceptual
// antialiasing differences between local and hosted container runtimes.
maxDiffPixels: 5,
threshold: 0.1,
},
},
use: {
...devices["Desktop Chrome"],
baseURL: "http://localhost:4174",
colorScheme: "light",
deviceScaleFactor: 1,
locale: "en-US",
launchOptions: {
args: ["--disable-gpu", "--font-render-hinting=none"],
},
reducedMotion: "reduce",
timezoneId: "UTC",
trace: "retain-on-failure",
viewport: { width: 1440, height: 1000 },
},
webServer: {
command: "npm run dev -- --port 4174 --strictPort",
url: "http://localhost:4174",
reuseExistingServer: false,
timeout: 120_000,
},
});

View file

@ -1,6 +1,6 @@
import { useState } from "react";
import { Outlet } from "react-router";
import { Box } from "@mui/material";
import { Box, useMediaQuery, useTheme } from "@mui/material";
import { ProtectedRoute } from "@/components/auth/protected-route";
import { AppSidebar } from "@/components/layout/app-sidebar";
@ -8,19 +8,36 @@ import { getSidebarWidth } from "@/config/menu";
import { AppTopbar } from "@/components/layout/app-topbar";
export default function AdminShellLayout() {
const theme = useTheme();
const isDesktop = useMediaQuery(theme.breakpoints.up("md"));
const [navCollapsed, setNavCollapsed] = useState(false);
const sidebarWidth = getSidebarWidth(navCollapsed);
const [mobileNavOpen, setMobileNavOpen] = useState(false);
const sidebarWidth = isDesktop ? getSidebarWidth(navCollapsed) : 0;
const handleToggleNav = () => {
if (isDesktop) {
setNavCollapsed((value) => !value);
return;
}
setMobileNavOpen((value) => !value);
};
return (
<ProtectedRoute>
<Box className="flex h-screen w-full overflow-hidden bg-background">
<AppSidebar collapsed={navCollapsed} />
<AppSidebar
collapsed={isDesktop ? navCollapsed : false}
mobile={!isDesktop}
open={isDesktop || mobileNavOpen}
onClose={() => setMobileNavOpen(false)}
/>
<Box
className="flex h-screen min-w-0 flex-1 flex-col overflow-hidden"
sx={{ width: `calc(100% - ${sidebarWidth}px)` }}
>
<AppTopbar onToggleNav={() => setNavCollapsed((value) => !value)} />
<AppTopbar onToggleNav={handleToggleNav} />
<Box
component="main"

View file

@ -0,0 +1,56 @@
import type { MouseEvent } from "react";
import { Dialog, DialogContent, DialogTitle } from "@mui/material";
import { MiniCalendar } from "@/app/(protected)/workorders/_components/wizard/mini-calendar";
import { dialogBlurBackdropSlotProps } from "@/components/ui/dialog-blur-backdrop";
type DueDateCalendarDialogProps = {
open: boolean;
onOpenChange: (open: boolean) => void;
value: string;
onSave: (iso: string) => void;
};
/**
* Emergency Due Date picker — dedicated calendar Dialog (no time, no week/range).
* Reuses the shared MiniCalendar used by the standard Due Date popover.
*/
export function DueDateCalendarDialog({
open,
onOpenChange,
value,
onSave,
}: DueDateCalendarDialogProps) {
return (
<Dialog
open={open}
onClose={() => onOpenChange(false)}
maxWidth={false}
slotProps={{
...dialogBlurBackdropSlotProps,
paper: {
className: "w-full",
style: { borderRadius: 12, maxWidth: 320 },
onClick: (e: MouseEvent<HTMLDivElement>) => e.stopPropagation(),
},
}}
>
<DialogContent className="p-5" onClick={(e) => e.stopPropagation()}>
<DialogTitle
className="mb-3 p-0"
style={{ fontSize: 16, color: "var(--color-text-primary)" }}
>
Due Date
</DialogTitle>
<div className="flex justify-center">
<MiniCalendar
value={value}
onChange={(iso) => {
onSave(iso);
onOpenChange(false);
}}
/>
</div>
</DialogContent>
</Dialog>
);
}

View file

@ -0,0 +1,71 @@
import { useState } from "react";
import { Calendar as CalendarIcon, ChevronDown } from "lucide-react";
import { DateCell } from "@/app/(protected)/workorders/_components/list/table/cells/date-cell";
import { DueDateCalendarDialog } from "@/app/(protected)/workorders/_components/list/table/cells/due-date-calendar-dialog";
import { HighlightText } from "@/app/(protected)/workorders/_components/list/table/cells/highlight-text";
import type { WOType } from "@/domain/work-orders/types/work-order-wizard";
type DueDateCellProps = {
type: WOType | "";
value: string;
q?: string;
highlight?: boolean;
onSave: (date: string) => void;
};
/**
* Board / slide-over Due Date editor.
* Emergency → dedicated calendar Dialog; other types → standard DateCell popover.
*/
export function DueDateCell({ type, value, q, highlight, onSave }: DueDateCellProps) {
const [open, setOpen] = useState(false);
if (type !== "Emergency") {
return <DateCell value={value} q={q} highlight={highlight} onSave={onSave} />;
}
return (
<>
<button
type="button"
onClick={(e) => {
e.stopPropagation();
setOpen(true);
}}
className="group/cell flex w-full items-center whitespace-nowrap rounded transition-opacity hover:opacity-80"
style={
highlight
? {
boxShadow: "0 0 0 2px var(--color-accent)",
background: "var(--color-primary-tint)",
padding: "2px 8px",
margin: "0 -8px",
}
: undefined
}
aria-haspopup="dialog"
>
<span className="flex min-w-0 flex-1 items-center gap-1.5 overflow-hidden">
<CalendarIcon
size={11}
className="shrink-0"
style={{ color: highlight ? "var(--primary)" : "var(--color-text-muted)" }}
/>
{value ? (
<span className="truncate text-[13px] text-[var(--color-text-secondary)]">
<HighlightText text={value.replace(/^\d{4}-/, "")} q={q} />
</span>
) : (
<span className="text-[13px] text-[var(--color-text-muted)]">—</span>
)}
</span>
<ChevronDown
size={14}
className="ml-2 shrink-0 opacity-0 transition-opacity group-hover/cell:opacity-100"
style={{ color: "var(--muted-foreground)" }}
/>
</button>
<DueDateCalendarDialog open={open} onOpenChange={setOpen} value={value} onSave={onSave} />
</>
);
}

View file

@ -49,7 +49,7 @@ export function StatusCell({
setAnchorEl(e.currentTarget);
}}
title="This WO missed its service window. Reschedule to unlock status updates."
title="This WO missed its deadline. Update the Due Date to unlock status updates."
className="group/cell flex w-full cursor-pointer items-center gap-1.5 whitespace-nowrap leading-none transition-opacity hover:opacity-80"
style={{ fontSize: 12.5, fontWeight: 600, color: "var(--status-pastdue-text)" }}
>
@ -83,8 +83,7 @@ export function StatusCell({
color: "var(--status-pending-text)",
}}
>
This work order is past due. A new date must be scheduled before the status can be
updated.
This work order is past due. Update the Due Date before the status can be updated.
</Text>
<button
@ -98,7 +97,7 @@ export function StatusCell({
}}
className="mt-2 block text-[11px] font-medium text-[var(--color-primary)]"
>
Reschedule →
Update Due Date →
</button>
</div>
</div>

View file

@ -7,6 +7,7 @@ import type { WorkOrderTableDraft } from "@/domain/work-orders/types/work-order-
import { AssignedToCell } from "./cells/assigned-to-cell";
import { ApptTimeCell } from "./cells/appt-time-cell";
import { DateCell } from "./cells/date-cell";
import { DueDateCell } from "./cells/due-date-cell";
import { EditableWONumberCell } from "./cells/editable-wo-number-cell";
import { PMTypeCell } from "./cells/pm-type-cell";
import { SiteCell } from "./cells/site-cell";
@ -106,7 +107,11 @@ export function WoTableInlineRowCells({
</td>
<td className="group/cell" style={{ padding: "0 14px" }}>
<DateCell value={draft.dueDate} onSave={(date) => onPatch({ dueDate: date })} />
<DueDateCell
type={draft.type}
value={draft.dueDate}
onSave={(date) => onPatch({ dueDate: date })}
/>
</td>
<td className="group/cell" style={{ padding: "0 14px" }}>

View file

@ -3,6 +3,7 @@ import type { LocationOption, WorkOrderUser } from "@/domain/work-orders/types/w
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
import { AssignedToCell } from "./cells/assigned-to-cell";
import { DateCell } from "./cells/date-cell";
import { DueDateCell } from "./cells/due-date-cell";
import { EditableWONumberCell } from "./cells/editable-wo-number-cell";
import { SiteCell } from "./cells/site-cell";
import { TypeCell } from "./cells/type-cell";
@ -16,6 +17,7 @@ type WoTableRowCellsProps = {
sitePlace?: { city: string; state: string };
search?: string;
forceScheduleHighlight?: boolean;
forceDueDateHighlight?: boolean;
mutationCellStyle?: CSSProperties;
handlers: WoTableRowHandlers;
onPatchRow: (patch: Partial<WorkOrderTableRow>) => void;
@ -29,6 +31,7 @@ export function WoTableRowCells({
sitePlace,
search,
forceScheduleHighlight,
forceDueDateHighlight,
mutationCellStyle,
handlers,
onPatchRow,
@ -104,7 +107,7 @@ export function WoTableRowCells({
<DateCell
value={row.scheduledOn}
q={search}
highlight={forceScheduleHighlight || row.isPastDue}
highlight={forceScheduleHighlight}
allowWeekMode
targetWeek={row.targetWeek}
onSave={(date) => handlers.onScheduleDate(row.id, date)}
@ -116,7 +119,13 @@ export function WoTableRowCells({
style={{ padding: "0 14px", ...mutationCellStyle }}
onClick={(e) => e.stopPropagation()}
>
<DateCell value={row.dueDate} q={search} onSave={(date) => onPatchRow({ dueDate: date })} />
<DueDateCell
type={row.type}
value={row.dueDate}
q={search}
highlight={forceDueDateHighlight || row.isPastDue}
onSave={(date) => onPatchRow({ dueDate: date })}
/>
</td>
</>
);

View file

@ -45,6 +45,7 @@ type WoTableRowProps = {
pmTypes: string[];
search?: string;
forceScheduleHighlight?: boolean;
forceDueDateHighlight?: boolean;
isSelected: boolean;
isFlashing: boolean;
dragViz: DragVizState;
@ -61,6 +62,7 @@ export function WoTableRow({
pmTypes,
search,
forceScheduleHighlight,
forceDueDateHighlight,
isSelected,
isFlashing,
dragViz,
@ -154,6 +156,7 @@ export function WoTableRow({
sitePlace={sitePlace}
search={search}
forceScheduleHighlight={forceScheduleHighlight}
forceDueDateHighlight={forceDueDateHighlight}
mutationCellStyle={mutationCellStyle}
handlers={handlers}
onPatchRow={patchRow}

View file

@ -54,7 +54,7 @@ export function WoTable({
onOpenAdvancedFilters,
onPatch,
}: WoTableProps) {
const [scheduleHighlightId, setScheduleHighlightId] = useState<string | number | null>(null);
const [dueDateHighlightId, setDueDateHighlightId] = useState<string | number | null>(null);
const { data: locationSites = [] } = useLocationSites();
const sitePlaceLookups = useMemo(() => buildSitePlaceLookups(locationSites), [locationSites]);
const createMutation = useCreateWorkOrderFromWizard(() => {
@ -69,7 +69,7 @@ export function WoTable({
onOpenCompDoc,
onOpenMedia,
onPatch,
onScheduleHighlight: setScheduleHighlightId,
onScheduleHighlight: setDueDateHighlightId,
});
const renderRow = (row: WorkOrderTableRow, forceScheduleHighlight = false) => (
@ -83,7 +83,8 @@ export function WoTable({
vendors={vendors}
pmTypes={pmTypes}
search={tableData.search}
forceScheduleHighlight={forceScheduleHighlight || scheduleHighlightId === row.id}
forceScheduleHighlight={forceScheduleHighlight}
forceDueDateHighlight={dueDateHighlightId === row.id}
isSelected={tableData.selectedId === row.id}
isFlashing={tableData.flashId === row.id}
dragViz={tableData.dragViz}

View file

@ -0,0 +1,69 @@
import { useState } from "react";
import { CalendarIcon, ChevronDown } from "lucide-react";
import { DueDateCalendarDialog } from "@/app/(protected)/workorders/_components/list/table/cells/due-date-calendar-dialog";
import { WizardDateField } from "@/app/(protected)/workorders/_components/wizard/wizard-date-field";
import type { WOType } from "@/domain/work-orders/types/work-order-wizard";
import { fmtDate } from "@/domain/work-orders/utils/wizard-date-utils";
type WizardDueDateFieldProps = {
type: WOType | "";
value: string;
placeholder: string;
onChange: (iso: string) => void;
};
/**
* Wizard Due Date editor.
* Emergency → dedicated calendar Dialog; other types → WizardDateField popover.
*/
export function WizardDueDateField({
type,
value,
placeholder,
onChange,
}: WizardDueDateFieldProps) {
const [open, setOpen] = useState(false);
if (type !== "Emergency") {
return <WizardDateField value={value} placeholder={placeholder} onChange={onChange} />;
}
return (
<>
<button
type="button"
onClick={() => setOpen(true)}
className="flex w-full items-center gap-2 transition-colors"
style={{
height: 36,
border: "1px solid var(--color-border)",
borderRadius: 6,
background: "var(--color-bg-surface)",
padding: "0 10px",
outline: "none",
}}
onFocus={(e) => {
e.currentTarget.style.boxShadow = "0 0 0 2px var(--color-primary)";
}}
onBlur={(e) => {
e.currentTarget.style.boxShadow = "none";
}}
aria-haspopup="dialog"
>
<CalendarIcon size={16} className="shrink-0" style={{ color: "var(--muted-foreground)" }} />
<span
className="flex-1 truncate text-left"
style={{
fontFamily: "var(--font-sans)",
fontSize: 13,
color: value ? "var(--foreground)" : "var(--muted-foreground)",
}}
>
{value ? fmtDate(value) : placeholder}
</span>
<ChevronDown size={13} className="shrink-0" style={{ color: "var(--muted-foreground)" }} />
</button>
<DueDateCalendarDialog open={open} onOpenChange={setOpen} value={value} onSave={onChange} />
</>
);
}

View file

@ -3,6 +3,7 @@ import { Text } from "@/components/ui/text";
import type { WorkOrderWizardDraft } from "@/domain/work-orders/types/work-order-wizard";
import { WO_TYPE_META, WO_TYPES, type WOType } from "@/domain/work-orders/types/work-order-wizard";
import { WizardDateField } from "@/app/(protected)/workorders/_components/wizard/wizard-date-field";
import { WizardDueDateField } from "@/app/(protected)/workorders/_components/wizard/wizard-due-date-field";
import { WizardFieldSelect } from "@/app/(protected)/workorders/_components/wizard/wizard-field-select";
import {
DLG_INPUT_CLS,
@ -197,7 +198,8 @@ export function WizardStepTypeSchedule({
>
Due Date
</WizLabelRow>
<WizardDateField
<WizardDueDateField
type={draft.type}
value={draft.dueDate}
placeholder="No due date"
onChange={(iso) => onUpdate("dueDate", iso)}

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

@ -32,9 +32,10 @@ function getNavItemAppearance(isActive: boolean, isHover: boolean) {
type NavItemProps = NavItemDef & {
collapsed: boolean;
active: boolean;
onNavigate?: () => void;
};
function NavItem({ label, icon: Icon, path, collapsed, active, badge }: NavItemProps) {
function NavItem({ label, icon: Icon, path, collapsed, active, badge, onNavigate }: NavItemProps) {
const [hovered, setHovered] = useState(false);
const isHover = hovered && !active;
const { itemColor, iconColor, itemBg } = getNavItemAppearance(active, isHover);
@ -45,6 +46,7 @@ function NavItem({ label, icon: Icon, path, collapsed, active, badge }: NavItemP
aria-current={active ? "page" : undefined}
className="no-underline"
title={collapsed ? label : undefined}
onClick={onNavigate}
>
<Box
onMouseEnter={() => setHovered(true)}
@ -118,18 +120,24 @@ function NavItem({ label, icon: Icon, path, collapsed, active, badge }: NavItemP
type AppSidebarProps = {
collapsed: boolean;
mobile?: boolean;
open?: boolean;
onClose?: () => void;
};
export function AppSidebar({ collapsed }: AppSidebarProps) {
export function AppSidebar({ collapsed, mobile = false, open = true, onClose }: AppSidebarProps) {
const width = getSidebarWidth(collapsed);
const { pathname } = useLocation();
const activePath = getActiveNavPath(pathname);
return (
<Drawer
variant="permanent"
variant={mobile ? "temporary" : "permanent"}
open={open}
onClose={onClose}
ModalProps={{ keepMounted: true }}
sx={{
width,
width: mobile ? 0 : width,
flexShrink: 0,
"& .MuiDrawer-paper": {
width,
@ -157,7 +165,13 @@ export function AppSidebar({ collapsed }: AppSidebarProps) {
{collapsed ? <BrandMark size="compact" /> : <BrandLockup size="compact" />}
</Box>
<Box component="nav" className="flex-1 overflow-y-auto overflow-x-hidden" sx={{ py: 1 }}>
<Box
component="nav"
id="primary-navigation"
aria-label="Primary navigation"
className="flex-1 overflow-y-auto overflow-x-hidden"
sx={{ py: 1 }}
>
{navGroups.map((group, groupIndex) => (
<Box key={group.title || `group-${groupIndex}`}>
{collapsed ? (
@ -198,6 +212,7 @@ export function AppSidebar({ collapsed }: AppSidebarProps) {
badge={badge}
collapsed={collapsed}
active={path === activePath}
onNavigate={mobile ? onClose : undefined}
/>
</Box>
))}

View file

@ -6,6 +6,7 @@ import {
ClipboardList,
FileText,
LayoutDashboard,
MapPin,
Bell,
BarChart3,
Star,
@ -52,7 +53,10 @@ export const navGroups: NavGroup[] = [
},
{
title: "CUSTOMERS & SITES",
items: [{ label: "Accounts", icon: BriefcaseBusiness, path: "/accounts" }],
items: [
{ label: "Accounts", icon: BriefcaseBusiness, path: "/accounts" },
{ label: "Sites", icon: MapPin, path: "/locations" },
],
},
// {
// title: "INTERNAL",

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

@ -87,7 +87,7 @@ function tryPastDueRule({ fieldKey, actionKey }: AuditActionRuleContext): string
fieldKey.includes("pastdue") || actionKey.includes("pastdue") || actionKey === "markedpastdue";
if (!matches) return null;
return "WO marked as Past Due. Scheduled date has passed without completion.";
return "WO marked as Past Due. Due date has passed without completion.";
}
function tryCreatedRule({ fieldKey, actionKey }: AuditActionRuleContext): string | null {

View file

@ -12,22 +12,19 @@ import { weekRange } from "@/domain/work-orders/utils/list-week-utils";
const TERMINAL_STATUSES = new Set(["Completed", "Canceled", "Done", "Cancelled"]);
export function computeIsPastDue(row: WorkOrderTableRow): boolean {
if (TERMINAL_STATUSES.has(row.status)) {
if (!row.dueDate || TERMINAL_STATUSES.has(row.status)) {
return false;
}
const today = new Date().toISOString().slice(0, 10);
const date = row.scheduledOn || row.dueDate;
if (!date) {
return false;
}
return date < today;
return row.dueDate < today;
}
export function enrichTableRow(row: WorkOrderTableRow): WorkOrderTableRow {
return {
...row,
// Prefer API board flag when true; otherwise recompute from scheduledOn/dueDate.
isPastDue: row.isPastDue || computeIsPastDue(row),
// Recompute from dueDate (same date-only rule as BE). Avoid sticky
// `apiTrue || compute` so a future Due Date clears Past Due immediately.
isPastDue: computeIsPastDue(row),
};
}

View file

@ -0,0 +1,62 @@
import { ThemeProvider } from "@mui/material";
import { fireEvent, render, screen } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import { DueDateCalendarDialog } from "@/app/(protected)/workorders/_components/list/table/cells/due-date-calendar-dialog";
import { DueDateCell } from "@/app/(protected)/workorders/_components/list/table/cells/due-date-cell";
import { WizardDueDateField } from "@/app/(protected)/workorders/_components/wizard/wizard-due-date-field";
import { muiTheme } from "@/lib/theme/mui-theme";
function renderWithTheme(ui: React.ReactElement) {
return render(<ThemeProvider theme={muiTheme}>{ui}</ThemeProvider>);
}
describe("DueDateCalendarDialog", () => {
it("saves the selected day as ISO and closes", () => {
const onSave = vi.fn();
const onOpenChange = vi.fn();
renderWithTheme(
<DueDateCalendarDialog open onOpenChange={onOpenChange} value="2026-07-15" onSave={onSave} />,
);
expect(screen.getByRole("dialog")).toBeInTheDocument();
expect(screen.getByText("Due Date")).toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: "20" }));
expect(onSave).toHaveBeenCalledWith("2026-07-20");
expect(onOpenChange).toHaveBeenCalledWith(false);
});
});
describe("DueDateCell", () => {
it("opens a dialog for Emergency and keeps popover for PM", () => {
const onSave = vi.fn();
const { rerender } = renderWithTheme(
<DueDateCell type="Emergency" value="2026-07-15" onSave={onSave} />,
);
fireEvent.click(screen.getByRole("button", { name: /07-15|7\/15|15/i }));
expect(screen.getByRole("dialog")).toBeInTheDocument();
rerender(
<ThemeProvider theme={muiTheme}>
<DueDateCell type="PM" value="2026-07-15" onSave={onSave} />
</ThemeProvider>,
);
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
});
});
describe("WizardDueDateField", () => {
it("opens a dialog when type is Emergency", () => {
renderWithTheme(
<WizardDueDateField type="Emergency" value="" placeholder="No due date" onChange={vi.fn()} />,
);
fireEvent.click(screen.getByRole("button", { name: /No due date/i }));
expect(screen.getByRole("dialog")).toBeInTheDocument();
});
});

View file

@ -0,0 +1,119 @@
import { renderWithProviders } from "@/test/test-utils";
import { screen } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import { WoTableBody } from "@/app/(protected)/workorders/_components/list/table/wo-table-body";
import { WoTableRowCells } from "@/app/(protected)/workorders/_components/list/table/wo-table-row-cells";
import type { WoTableRowHandlers } from "@/app/(protected)/workorders/_components/list/table/wo-table-row";
import type { ReturnTypeOfUseWorkOrdersTableData } from "@/app/(protected)/workorders/_components/list/table/wo-table-types";
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
const HIGHLIGHT_BOX_SHADOW = "0 0 0 2px var(--color-accent)";
const unscheduledRow: WorkOrderTableRow = {
id: 42,
woNumber: "WO-42",
site: "CHI-1042",
type: "PM",
dispatcherId: "u1",
dispatcherName: "Test Dispatcher",
scheduledOn: "",
completedDate: "",
targetWeek: "",
dayGroup: "",
dayLabel: "",
location: "Chicago Loop",
locationId: "mock-loc-1",
pm: "HVAC — Quarterly PM",
serviceNotes: "",
extraServices: [],
vendorId: "",
company: "",
tech: "",
techPhone: "",
apptStart: "",
apptEnd: "",
apptTime: "",
status: "Pending",
docStatus: "No",
pocName: "",
pocPhone: "",
pocNotes: "",
dueDate: "2026-07-15",
rescheduleCount: 0,
carriedOver: 0,
originalWeek: "",
originalDate: "",
isPastDue: false,
scheduleWeekOnly: false,
mediaCount: 0,
title: "",
priority: "",
primaryDispatchId: null,
rowVersion: "",
dispatchRowVersion: "",
flagColor: null,
};
const handlers: WoTableRowHandlers = {
onRowClick: vi.fn(),
onView: vi.fn(),
onEdit: vi.fn(),
onOpenMedia: vi.fn(),
onPatch: vi.fn(),
onScheduleDate: vi.fn(),
onScheduleWeek: vi.fn(),
onOpenSlideOver: vi.fn(),
onReorder: vi.fn(),
onDragStart: vi.fn(),
onDragOver: vi.fn(),
onDrop: vi.fn(),
onDragEnd: vi.fn(),
};
/** Mirrors WoTable.renderRow: body `true` → Schedule On only. */
function renderUnscheduledHarness() {
const tableData = {
advActive: false,
unscheduled: [unscheduledRow],
unschedOpen: true,
draftDayKey: null,
displayGroups: [],
todayKey: null,
startDayDraft: vi.fn(),
setUnschedOpen: vi.fn(),
} as unknown as ReturnTypeOfUseWorkOrdersTableData;
const renderRow = (row: WorkOrderTableRow, forceScheduleHighlight = false) => (
<tr key={String(row.id)}>
<WoTableRowCells
row={row}
allRows={[row]}
users={[]}
sites={[]}
forceScheduleHighlight={forceScheduleHighlight}
forceDueDateHighlight={false}
handlers={handlers}
onPatchRow={vi.fn()}
/>
</tr>
);
return renderWithProviders(
<table>
<WoTableBody tableData={tableData} renderRow={renderRow} renderInlineRow={() => null} />
</table>,
{ withAuth: false },
);
}
describe("WoTableBody unscheduled Schedule On highlight (SH-165)", () => {
it("force-highlights Schedule On for Unscheduled rows, not Due Date", () => {
renderUnscheduledHarness();
const scheduleOn = screen.getByRole("button", { name: /^—$/ });
const dueDate = screen.getByRole("button", { name: /07-15/ });
expect(scheduleOn).toHaveStyle({ boxShadow: HIGHLIGHT_BOX_SHADOW });
expect(dueDate).not.toHaveStyle({ boxShadow: HIGHLIGHT_BOX_SHADOW });
});
});

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

View file

@ -124,6 +124,24 @@ describe("expandTablePatchToBoardFieldOps", () => {
}),
).toEqual([{ field: "siteCode", value: "BK5" }]);
});
it("keeps dueDate and scheduledDate patches independent", () => {
expect(expandTablePatchToBoardFieldOps({ dueDate: "2026-08-01" })).toEqual([
{ field: "dueDate", value: "2026-08-01" },
]);
expect(
expandTablePatchToBoardFieldOps({
scheduledOn: "2026-07-15",
scheduleWeekOnly: false,
}),
).toEqual([
{ field: "scheduleWeekOnly", value: "false" },
{ field: "scheduledDate", value: "2026-07-15" },
]);
expect(expandTablePatchToBoardFieldOps({ dueDate: "2026-08-01", isPastDue: false })).toEqual([
{ field: "dueDate", value: "2026-08-01" },
]);
});
});
describe("buildBoardPatchPayload", () => {

View file

@ -65,7 +65,7 @@ describe("formatBoardAuditAction", () => {
oldValue: "false",
newValue: "true",
}),
).toBe("WO marked as Past Due. Scheduled date has passed without completion.");
).toBe("WO marked as Past Due. Due date has passed without completion.");
});
it("falls back to from → to for unknown fields", () => {

View file

@ -3,6 +3,8 @@ import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-ta
import { EMPTY_ADVANCED_FILTERS } from "@/domain/work-orders/types/work-order-advanced-filters";
import {
buildDayGroups,
computeIsPastDue,
enrichTableRow,
matchesAdvancedFilters,
splitScheduledUnscheduled,
} from "@/domain/work-orders/utils/table-filter-utils";
@ -10,6 +12,11 @@ import { deriveDayFields } from "@/domain/work-orders/utils/table-day-utils";
import { mapWorkOrderTableRow } from "@/domain/work-orders/mappers/work-order-mapper";
import { mondayOf, todayIso } from "@/domain/work-orders/utils/wizard-date-utils";
function shiftIso(days: number): string {
const d = new Date(`${todayIso()}T12:00:00.000Z`);
d.setUTCDate(d.getUTCDate() + days);
return d.toISOString().slice(0, 10);
}
function makeRow(overrides: Partial<WorkOrderTableRow> = {}): WorkOrderTableRow {
return {
id: "1",
@ -124,6 +131,122 @@ describe("matchesAdvancedFilters", () => {
};
expect(matchesAdvancedFilters(row, noMatch)).toBe(false);
});
it("matches Overdue when isPastDue is true", () => {
const weekMonday = mondayOf(todayIso());
const pastDue = makeRow({
scheduledOn: weekMonday,
...deriveDayFields(weekMonday),
isPastDue: true,
});
const filters = {
...EMPTY_ADVANCED_FILTERS,
dateRange: "custom" as const,
customFrom: "0000-01-01",
customTo: "9999-12-31",
types: new Set(["Overdue" as const]),
};
expect(matchesAdvancedFilters(pastDue, filters)).toBe(true);
expect(
matchesAdvancedFilters(makeRow({ isPastDue: false, scheduledOn: weekMonday }), filters),
).toBe(false);
});
});
describe("computeIsPastDue", () => {
it("is true when dueDate is before today even if scheduledOn is future", () => {
expect(
computeIsPastDue(
makeRow({
dueDate: shiftIso(-1),
scheduledOn: shiftIso(5),
status: "Scheduled",
}),
),
).toBe(true);
});
it("is false when dueDate is future even if scheduledOn is past", () => {
expect(
computeIsPastDue(
makeRow({
dueDate: shiftIso(5),
scheduledOn: shiftIso(-1),
status: "Incomplete",
}),
),
).toBe(false);
});
it("is false when dueDate is empty", () => {
expect(
computeIsPastDue(
makeRow({
dueDate: "",
scheduledOn: shiftIso(-3),
status: "Scheduled",
}),
),
).toBe(false);
});
it("is false for terminal statuses even with past dueDate", () => {
expect(
computeIsPastDue(
makeRow({
dueDate: shiftIso(-2),
scheduledOn: shiftIso(-2),
status: "Completed",
}),
),
).toBe(false);
expect(
computeIsPastDue(
makeRow({
dueDate: shiftIso(-2),
status: "Canceled",
}),
),
).toBe(false);
});
it("is false when dueDate is today", () => {
expect(
computeIsPastDue(
makeRow({
dueDate: todayIso(),
scheduledOn: shiftIso(-1),
status: "Scheduled",
}),
),
).toBe(false);
});
});
describe("enrichTableRow", () => {
it("recomputes isPastDue from dueDate and ignores sticky API true", () => {
const enriched = enrichTableRow(
makeRow({
dueDate: shiftIso(5),
scheduledOn: shiftIso(-1),
isPastDue: true,
status: "Scheduled",
}),
);
expect(enriched.isPastDue).toBe(false);
});
it("sets isPastDue true from past dueDate even when API flag is false", () => {
const enriched = enrichTableRow(
makeRow({
dueDate: shiftIso(-1),
scheduledOn: shiftIso(5),
isPastDue: false,
status: "Incomplete",
}),
);
expect(enriched.isPastDue).toBe(true);
});
});
describe("mapWorkOrderTableRow", () => {