chore(work-orders): merge origin/dev into SH-243 type filter branch

This commit is contained in:
Arthur Bassi 2026-08-20 16:43:26 -03:00
commit f522c46911
56 changed files with 2022 additions and 224 deletions

View file

@ -63,6 +63,10 @@ a status or mark unverified work Done.
- **MUST NOT** leave comments that only restate what Prettier or ESLint already - **MUST NOT** leave comments that only restate what Prettier or ESLint already
enforces (formatting, naming nits the linter catches). Style is settled by the enforces (formatting, naming nits the linter catches). Style is settled by the
gates; review is for behavior, correctness, security, and architecture. gates; review is for behavior, correctness, security, and architecture.
- **MUST NOT** put Jira issue keys or ticket titles in source comments, JSDoc,
or test names (for example `(SH-183)`). Ticket identity belongs in the PR,
commit message, and branch — not in the code. Flag and request removal if a
diff adds them.
- **MUST** make every comment actionable: tie it to a behavior, a risk, or an - **MUST** make every comment actionable: tie it to a behavior, a risk, or an
evidence-based convention in these docs, and offer a concrete fix or a evidence-based convention in these docs, and offer a concrete fix or a
targeted question. Use GitHub suggestion blocks when safe. targeted question. Use GitHub suggestion blocks when safe.

View file

@ -68,6 +68,7 @@ interface MockState {
patchedBody?: Record<string, unknown>; patchedBody?: Record<string, unknown>;
patchedCompanyId?: string; patchedCompanyId?: string;
deletedId?: string; deletedId?: string;
deleteConfirmedOpenWorkOrders?: boolean;
} }
async function fulfillJson(route: Route, body: unknown, status = 200) { async function fulfillJson(route: Route, body: unknown, status = 200) {
@ -307,8 +308,9 @@ async function mockVendorApi(
}, },
}), }),
); );
await page.route(/\/api\/vendors\/\d+$/, async (route) => { await page.route(/\/api\/vendors\/\d+(\?.*)?$/, async (route) => {
const id = route.request().url().split("/").pop() ?? ""; const requestUrl = new URL(route.request().url());
const id = requestUrl.pathname.split("/").pop() ?? "";
if (route.request().method() === "PUT") { if (route.request().method() === "PUT") {
state.updatedBody = route.request().postDataJSON(); state.updatedBody = route.request().postDataJSON();
const vendor = vendorRecords.find((item) => String(item.Id) === id); const vendor = vendorRecords.find((item) => String(item.Id) === id);
@ -321,6 +323,8 @@ async function mockVendorApi(
return; return;
} }
if (route.request().method() === "DELETE") { if (route.request().method() === "DELETE") {
state.deleteConfirmedOpenWorkOrders =
requestUrl.searchParams.get("confirmOpenWorkOrders") === "true";
if (options.deleteConflict) { if (options.deleteConflict) {
await fulfillJson( await fulfillJson(
route, route,
@ -576,20 +580,28 @@ test.describe("Vendor directory prototype parity", () => {
await expect(page.getByRole("button", { name: "Close drawer" })).toHaveCount(0); await expect(page.getByRole("button", { name: "Close drawer" })).toHaveCount(0);
}); });
test("blocks deactivation for linked work orders and preserves the vendor on a raced 409", async ({ test("confirms deactivation past linked work orders and preserves the vendor on a raced 409", async ({
page, page,
}) => { }) => {
const blockedState = await mockVendorApi(page, { deactivationBlocked: true }); const confirmState = await mockVendorApi(page, { deactivationBlocked: true });
await page.goto("/vendors"); await page.goto("/vendors");
await page.getByRole("button", { name: "Edit vendor Gateway Plumbing" }).click(); await page.getByRole("button", { name: "Edit vendor Gateway Plumbing" }).click();
await page.getByRole("switch", { name: "Active status" }).click(); await page.getByRole("switch", { name: "Active status" }).click();
const blockedDialog = page.getByRole("dialog", { name: "Deactivate Vendor" }); const dialog = page.getByRole("dialog", { name: "Deactivate this vendor?" });
await expect(blockedDialog).toContainText("WO-501 — Emergency boiler repair"); await expect(dialog).toContainText("It still has 1 open work order");
await expect(blockedDialog.getByRole("button", { name: /^Deactivate$/ })).toBeDisabled(); await expect(
expect(blockedState.deletedId).toBeUndefined(); dialog.getByRole("link", { name: /WO-501 — Emergency boiler repair/ }),
).toHaveAttribute("href", "/workorders/501");
// SH-254: the open work orders inform the decision, they no longer block it.
const confirm = dialog.getByRole("button", { name: "Deactivate anyway" });
await expect(confirm).toBeEnabled();
await confirm.click();
await expect.poll(() => confirmState.deletedId).toBe("1");
expect(confirmState.deleteConfirmedOpenWorkOrders).toBe(true);
await blockedDialog.getByRole("button", { name: "Cancel" }).click();
await page.unrouteAll({ behavior: "wait" }); await page.unrouteAll({ behavior: "wait" });
const racedState = await mockVendorApi(page, { deleteConflict: true }); const racedState = await mockVendorApi(page, { deleteConflict: true });
@ -597,13 +609,13 @@ test.describe("Vendor directory prototype parity", () => {
await page.getByRole("button", { name: "Edit vendor Gateway Plumbing" }).click(); await page.getByRole("button", { name: "Edit vendor Gateway Plumbing" }).click();
await page.getByRole("switch", { name: "Active status" }).click(); await page.getByRole("switch", { name: "Active status" }).click();
await page await page
.getByRole("dialog", { name: "Deactivate Vendor" }) .getByRole("dialog", { name: "Deactivate this vendor?" })
.getByRole("button", { .getByRole("button", {
name: /^Deactivate$/, name: /^Deactivate$/,
}) })
.click(); .click();
await expect(page.getByRole("dialog", { name: "Deactivate Vendor" })).toContainText( await expect(page.getByRole("dialog", { name: "Deactivate this vendor?" })).toContainText(
/open work orders|conflict/i, /open work orders|conflict/i,
); );
expect(racedState.deletedId).toBeUndefined(); expect(racedState.deletedId).toBeUndefined();

View file

@ -0,0 +1,124 @@
import { expect, test, type Page, type Route } from "@playwright/test";
const FROZEN_NOW = "2026-08-19T15:00:00.000Z";
const TEST_USER = {
token: "wo-day-pagination-e2e-token",
expiration: "2030-01-01T00:00:00.000Z",
email: "admin@seahavenind.com",
userRoles: "Admin",
phoneNumber: "5551234567",
fullname: "Work Order E2E Admin",
id: "1",
};
async function fulfillJson(route: Route, body: unknown, status = 200) {
await route.fulfill({ status, contentType: "application/json", body: JSON.stringify(body) });
}
function boardRow(id: number, woNumber: string, scheduledDate: string) {
return {
id,
woNumber,
workOrderType: 2,
siteCode: woNumber,
lifecycleStatusLabel: "Scheduled",
scheduledDate,
dispatcherId: "1",
dispatcherName: "Pat Dispatcher",
pm: "HVAC",
};
}
async function mockWorkOrderBoard(page: Page) {
const wednesday = Array.from({ length: 60 }, (_, index) =>
boardRow(1000 + index, `WO-${1000 + index}`, "2026-08-19"),
);
const monday = Array.from({ length: 5 }, (_, index) =>
boardRow(2000 + index, `MON-${2000 + index}`, "2026-08-17"),
);
await page.route(
(url) => url.pathname.startsWith("/api/"),
async (route) => {
const url = route.request().url();
if (url.includes("workorders/board/search")) {
await fulfillJson(route, { items: [], totalCount: 0, page: 1, pageSize: 100 });
return;
}
if (url.includes("workorders/board") && route.request().method() === "GET") {
const weekStart = new URL(url).searchParams.get("weekStart") ?? "2026-08-17";
const scheduled =
weekStart === "2026-08-17" ? [...wednesday, ...monday] : monday.slice(0, 1);
await fulfillJson(route, {
weekStart,
weekEnd: weekStart === "2026-08-17" ? "2026-08-21" : "2026-08-14",
counts: { returned: scheduled.length, total: scheduled.length },
unscheduled: [],
scheduled,
});
return;
}
await fulfillJson(route, { data: [], items: [] });
},
);
}
test.describe("work order per-day pagination (SH-241)", () => {
test.setTimeout(60_000);
test.beforeEach(async ({ page }) => {
await page.clock.install({ time: new Date(FROZEN_NOW) });
await page.clock.resume();
await page.addInitScript((user) => {
localStorage.setItem("auth", JSON.stringify(user));
const style = document.createElement("style");
style.textContent = ".tsqd-parent-container { display: none !important; }";
document.documentElement.appendChild(style);
}, TEST_USER);
await mockWorkOrderBoard(page);
});
test("changes rows-per-page, hides on global filter, and resets on day or week change", async ({
page,
}) => {
const pager = page.getByTestId("wo-day-pagination");
await page.goto("/workorders");
await expect(page.getByRole("heading", { name: "Work Orders" })).toBeVisible({
timeout: 20_000,
});
await expect(pager).toBeVisible();
await expect(page.getByText("Page 1 of 2")).toBeVisible();
await expect(page.getByText("WO-1000").first()).toBeVisible();
await expect(page.getByText("WO-1050")).toHaveCount(0);
await page.getByLabel("Rows per page").selectOption("25");
await expect(page.getByText("Page 1 of 3")).toBeVisible();
await expect(page.getByText("WO-1025")).toHaveCount(0);
await page.getByTestId("wo-day-pagination-next").evaluate((node) => {
(node as HTMLButtonElement).click();
});
await expect(page.getByText("Page 2 of 3")).toBeVisible();
await expect(page.getByText("WO-1025").first()).toBeVisible();
await expect(page.getByText("WO-1000")).toHaveCount(0);
await page.getByRole("tab", { name: "Mon · 08/17" }).click({ force: true });
await expect(page.getByText("Page 1 of 1")).toBeVisible();
await expect(page.getByText("MON-2000").first()).toBeVisible();
await page.getByRole("button", { name: "Week 8/17–8/21" }).click({ force: true });
await page.getByRole("button", { name: /Aug 10/ }).click({ force: true });
await expect(page.getByTestId("wo-day-pagination")).toBeVisible();
await expect(page.getByText("Page 1 of")).toBeVisible();
await page.getByRole("button", { name: "Advanced Filters" }).click();
await page.getByRole("button", { name: /Indicators/i }).click();
await page.getByRole("button", { name: "Rescheduled ≥ 2" }).click();
await page.getByRole("button", { name: "Apply filters" }).click();
await expect(pager).toHaveCount(0);
await page.getByRole("button", { name: "Clear all" }).click();
await expect(page.getByTestId("wo-day-pagination")).toBeVisible();
});
});

View file

@ -80,6 +80,19 @@ test.describe("Work Orders week/day navigation (SH-240)", () => {
await expect(page.getByRole("heading", { name: "Work Orders" })).toBeVisible({ await expect(page.getByRole("heading", { name: "Work Orders" })).toBeVisible({
timeout: 15_000, timeout: 15_000,
}); });
const dispatcher = page.getByRole("button", { name: "All dispatchers" });
await expect(dispatcher).toBeVisible();
await expect(
page.getByLabel("Week and day").getByRole("button", { name: "All dispatchers" }),
).toHaveCount(0);
const titleBox = await page.getByRole("heading", { name: "Work Orders" }).boundingBox();
const dispatcherBox = await dispatcher.boundingBox();
const weekBox = await page.getByRole("button", { name: "Week 8/17–8/21" }).boundingBox();
expect(titleBox).toBeTruthy();
expect(dispatcherBox).toBeTruthy();
expect(weekBox).toBeTruthy();
expect(dispatcherBox!.y).toBeGreaterThan(titleBox!.y);
expect(weekBox!.y).toBeGreaterThan(dispatcherBox!.y);
await expect(page.getByRole("button", { name: "Week 8/17–8/21" })).toBeVisible(); await expect(page.getByRole("button", { name: "Week 8/17–8/21" })).toBeVisible();
await expect(page.getByRole("button", { name: "Week 8/17–8/21" })).toContainText("Live"); await expect(page.getByRole("button", { name: "Week 8/17–8/21" })).toContainText("Live");

View file

@ -38,13 +38,18 @@ export function useVendorDeactivation(onSuccess?: () => void): VendorDeactivatio
const confirm = () => { const confirm = () => {
if (!target || target.id == null) return; if (!target || target.id == null) return;
setError(null); setError(null);
deleteVendor.mutate(target.id, { // The dialog has shown whatever open work orders exist, so confirming here is
onSuccess: () => { // the explicit confirmation the API requires to deactivate past them (SH-254).
setTarget(null); deleteVendor.mutate(
onSuccess?.(); { id: target.id, confirmOpenWorkOrders: (impact?.openWorkOrders.length ?? 0) > 0 },
{
onSuccess: () => {
setTarget(null);
onSuccess?.();
},
onError: (err: Error) => setError(err.message || "Failed to deactivate vendor"),
}, },
onError: (err: Error) => setError(err.message || "Failed to deactivate vendor"), );
});
}; };
return { return {

View file

@ -93,6 +93,7 @@ export interface VendorRosterForm {
roster: VendorCompanyRoster | undefined; roster: VendorCompanyRoster | undefined;
companies: VendorFacetCompany[]; companies: VendorFacetCompany[];
trades: string[]; trades: string[];
tradesLoading: boolean;
selectedCompanyId: string | number | null; selectedCompanyId: string | number | null;
selectCompany: (company: VendorFacetCompany | null) => Promise<void>; selectCompany: (company: VendorFacetCompany | null) => Promise<void>;
clearSelectedCompany: (nextName?: string) => void; clearSelectedCompany: (nextName?: string) => void;
@ -110,7 +111,7 @@ export function useVendorRosterForm({
mode === "update" ? vendorId : undefined, mode === "update" ? vendorId : undefined,
mode === "update" ? companyId : undefined, mode === "update" ? companyId : undefined,
); );
const { data: facets } = useVendorFacets(); const { data: facets, isLoading: facetsLoading } = useVendorFacets();
const save = useSaveVendorCompanyRoster(); const save = useSaveVendorCompanyRoster();
const [conflict, setConflict] = useState<VendorRosterConflict | null>(null); const [conflict, setConflict] = useState<VendorRosterConflict | null>(null);
@ -223,6 +224,7 @@ export function useVendorRosterForm({
roster: routeRoster, roster: routeRoster,
companies, companies,
trades, trades,
tradesLoading: facetsLoading,
selectedCompanyId: selection.selectedCompanyId, selectedCompanyId: selection.selectedCompanyId,
selectCompany: selection.selectCompany, selectCompany: selection.selectCompany,
clearSelectedCompany: selection.clearSelectedCompany, clearSelectedCompany: selection.clearSelectedCompany,

View file

@ -68,6 +68,7 @@ export function VendorCreateModal({ open, onClose }: VendorCreateModalProps) {
errors={form.errors} errors={form.errors}
companies={form.companies} companies={form.companies}
tradeOptions={form.trades} tradeOptions={form.trades}
tradeOptionsLoading={form.tradesLoading}
selectedCompanyId={form.selectedCompanyId} selectedCompanyId={form.selectedCompanyId}
onSelectCompany={form.selectCompany} onSelectCompany={form.selectCompany}
onClearSelectedCompany={form.clearSelectedCompany} onClearSelectedCompany={form.clearSelectedCompany}

View file

@ -8,11 +8,13 @@ import {
DialogContent, DialogContent,
DialogContentText, DialogContentText,
DialogTitle, DialogTitle,
Link,
List, List,
ListItem, ListItem,
Stack, Stack,
Typography, Typography,
} from "@mui/material"; } from "@mui/material";
import { Link as RouterLink } from "react-router";
import type { VendorDeactivationImpact, VendorListItem } from "@/domain/vendors/types/vendor"; import type { VendorDeactivationImpact, VendorListItem } from "@/domain/vendors/types/vendor";
interface VendorDeactivationDialogProps { interface VendorDeactivationDialogProps {
@ -26,6 +28,15 @@ interface VendorDeactivationDialogProps {
onConfirm: () => void; onConfirm: () => void;
} }
function deactivationMessage(companyName: string | undefined, openCount: number): string {
const name = companyName ? `"${companyName}"` : "This vendor";
if (openCount === 0) {
return `${name} will no longer be selectable for new work orders.`;
}
const plural = openCount === 1 ? "work order" : "work orders";
return `${name} will no longer be selectable for new work orders. It still has ${openCount} open ${plural} — they'll keep it as-is unless you reassign them.`;
}
export function VendorDeactivationDialog({ export function VendorDeactivationDialog({
target, target,
isLoading, isLoading,
@ -36,16 +47,16 @@ export function VendorDeactivationDialog({
onClose, onClose,
onConfirm, onConfirm,
}: VendorDeactivationDialogProps) { }: VendorDeactivationDialogProps) {
const hasBlockingImpact = Boolean(impact && !impact.canDeactivate); const openWorkOrders = impact?.openWorkOrders ?? [];
const hasOpenWorkOrders = openWorkOrders.length > 0;
return ( return (
<Dialog open={Boolean(target)} onClose={isSaving ? undefined : onClose}> <Dialog open={Boolean(target)} onClose={isSaving ? undefined : onClose}>
<DialogTitle>Deactivate Vendor</DialogTitle> <DialogTitle>Deactivate this vendor?</DialogTitle>
<DialogContent> <DialogContent>
<Stack spacing={2}> <Stack spacing={2}>
<DialogContentText> <DialogContentText>
Deactivate &quot;{target?.companyName}&quot;? Existing work-order and audit history will {deactivationMessage(target?.companyName, openWorkOrders.length)}
be preserved.
</DialogContentText> </DialogContentText>
{isLoading && ( {isLoading && (
@ -63,19 +74,13 @@ export function VendorDeactivationDialog({
</Alert> </Alert>
)} )}
{impact != null && !isLoading && !impact.canDeactivate && ( {hasOpenWorkOrders && (
<Alert severity="error">
This vendor cannot be deactivated because it still has open work orders.
</Alert>
)}
{impact != null && impact.openWorkOrders.length > 0 && (
<Box> <Box>
<Typography variant="subtitle2" className="font-semibold uppercase"> <Typography variant="subtitle2" className="font-semibold uppercase">
Open work orders ({impact.openWorkOrders.length}) Open work orders ({openWorkOrders.length})
</Typography> </Typography>
<List dense sx={{ mt: 0.5 }}> <List dense sx={{ mt: 0.5 }}>
{impact.openWorkOrders.map((wo) => ( {openWorkOrders.map((wo) => (
<ListItem <ListItem
key={String(wo.workOrderId)} key={String(wo.workOrderId)}
sx={{ px: 0, py: 0.25 }} sx={{ px: 0, py: 0.25 }}
@ -88,11 +93,11 @@ export function VendorDeactivationDialog({
} }
> >
<Stack> <Stack>
<Typography variant="body2"> <Link component={RouterLink} to={`/workorders/${wo.workOrderId}`}>
{wo.workOrderNumber {wo.workOrderNumber
? `${wo.workOrderNumber} — ${wo.workOrderTitle || "Untitled"}` ? `${wo.workOrderNumber} — ${wo.workOrderTitle || "Untitled"}`
: wo.workOrderTitle || `Work order ${wo.workOrderId}`} : wo.workOrderTitle || `Work order ${wo.workOrderId}`}
</Typography> </Link>
{Boolean(wo.status) && ( {Boolean(wo.status) && (
<Typography variant="caption" sx={{ color: "text.secondary" }}> <Typography variant="caption" sx={{ color: "text.secondary" }}>
{wo.status} {wo.status}
@ -113,12 +118,12 @@ export function VendorDeactivationDialog({
Cancel Cancel
</Button> </Button>
<Button <Button
color="error" color={hasOpenWorkOrders ? "warning" : "error"}
variant="contained" variant="contained"
onClick={onConfirm} onClick={onConfirm}
disabled={isSaving || isLoading || hasBlockingImpact || Boolean(impactError)} disabled={isSaving || isLoading || Boolean(impactError)}
> >
{isSaving ? "Deactivating..." : "Deactivate"} {isSaving ? "Deactivating..." : hasOpenWorkOrders ? "Deactivate anyway" : "Deactivate"}
</Button> </Button>
</DialogActions> </DialogActions>
</Dialog> </Dialog>

View file

@ -299,6 +299,7 @@ function DrawerEditor({
control={form.control} control={form.control}
errors={form.errors} errors={form.errors}
tradeOptions={form.trades} tradeOptions={form.trades}
tradeOptionsLoading={form.tradesLoading}
showTechnicianStatus={false} showTechnicianStatus={false}
/> />
{selectedIndex >= 0 && ( {selectedIndex >= 0 && (

View file

@ -25,6 +25,7 @@ interface VendorRosterFormFieldsProps {
errors: FieldErrors<VendorCompanyRosterFormValues>; errors: FieldErrors<VendorCompanyRosterFormValues>;
companies?: VendorFacetCompany[]; companies?: VendorFacetCompany[];
tradeOptions?: string[]; tradeOptions?: string[];
tradeOptionsLoading?: boolean;
selectedCompanyId?: string | number | null; selectedCompanyId?: string | number | null;
onSelectCompany?: (company: VendorFacetCompany | null) => Promise<void>; onSelectCompany?: (company: VendorFacetCompany | null) => Promise<void>;
onClearSelectedCompany?: (nextName?: string) => void; onClearSelectedCompany?: (nextName?: string) => void;
@ -236,6 +237,7 @@ interface TechnicianRowProps {
onRemove: () => void; onRemove: () => void;
canRemove: boolean; canRemove: boolean;
tradeOptions: string[]; tradeOptions: string[];
tradeOptionsLoading?: boolean;
showStatus: boolean; showStatus: boolean;
} }
@ -246,6 +248,7 @@ function TechnicianRow({
onRemove, onRemove,
canRemove, canRemove,
tradeOptions, tradeOptions,
tradeOptionsLoading,
showStatus, showStatus,
}: TechnicianRowProps) { }: TechnicianRowProps) {
return ( return (
@ -315,7 +318,12 @@ function TechnicianRow({
)} )}
/> />
</Stack> </Stack>
<VendorTradeSpecialtiesField control={control} index={index} tradeOptions={tradeOptions} /> <VendorTradeSpecialtiesField
control={control}
index={index}
tradeOptions={tradeOptions}
tradeOptionsLoading={tradeOptionsLoading}
/>
{showStatus && ( {showStatus && (
<Controller <Controller
control={control} control={control}
@ -341,11 +349,13 @@ function TechniciansFieldArray({
control, control,
errors, errors,
tradeOptions, tradeOptions,
tradeOptionsLoading,
showStatus, showStatus,
}: { }: {
control: Control<VendorCompanyRosterFormValues>; control: Control<VendorCompanyRosterFormValues>;
errors: FieldErrors<VendorCompanyRosterFormValues>; errors: FieldErrors<VendorCompanyRosterFormValues>;
tradeOptions: string[]; tradeOptions: string[];
tradeOptionsLoading?: boolean;
showStatus: boolean; showStatus: boolean;
}) { }) {
const { fields, append, remove } = useFieldArray({ control, name: "technicians" }); const { fields, append, remove } = useFieldArray({ control, name: "technicians" });
@ -389,6 +399,7 @@ function TechniciansFieldArray({
onRemove={() => remove(index)} onRemove={() => remove(index)}
canRemove canRemove
tradeOptions={tradeOptions} tradeOptions={tradeOptions}
tradeOptionsLoading={tradeOptionsLoading}
showStatus={showStatus} showStatus={showStatus}
/> />
)) ))
@ -399,7 +410,13 @@ function TechniciansFieldArray({
} }
export function VendorRosterFormFields(props: VendorRosterFormFieldsProps) { export function VendorRosterFormFields(props: VendorRosterFormFieldsProps) {
const { control, errors, tradeOptions = [], showTechnicianStatus = true } = props; const {
control,
errors,
tradeOptions = [],
tradeOptionsLoading = false,
showTechnicianStatus = true,
} = props;
return ( return (
<Stack spacing={3}> <Stack spacing={3}>
<CompanyFields {...props} /> <CompanyFields {...props} />
@ -408,6 +425,7 @@ export function VendorRosterFormFields(props: VendorRosterFormFieldsProps) {
control={control} control={control}
errors={errors} errors={errors}
tradeOptions={tradeOptions} tradeOptions={tradeOptions}
tradeOptionsLoading={tradeOptionsLoading}
showStatus={showTechnicianStatus} showStatus={showTechnicianStatus}
/> />
</Stack> </Stack>

View file

@ -124,6 +124,7 @@ export default function VendorRosterPage({ vendorId, companyId }: VendorRosterPa
control={form.control} control={form.control}
errors={form.errors} errors={form.errors}
tradeOptions={form.trades} tradeOptions={form.trades}
tradeOptionsLoading={form.tradesLoading}
{...companySelectionProps} {...companySelectionProps}
/> />

View file

@ -1,4 +1,4 @@
import { useState } from "react"; import { useRef, useState } from "react";
import { Controller, type Control } from "react-hook-form"; import { Controller, type Control } from "react-hook-form";
import AddIcon from "@mui/icons-material/Add"; import AddIcon from "@mui/icons-material/Add";
import ArrowDownwardIcon from "@mui/icons-material/ArrowDownward"; import ArrowDownwardIcon from "@mui/icons-material/ArrowDownward";
@ -14,8 +14,43 @@ import {
TextField, TextField,
Typography, Typography,
} from "@mui/material"; } from "@mui/material";
import { Text } from "@/components/ui/text";
import type { VendorCompanyRosterFormValues } from "@/domain/vendors/schemas/vendor-roster-schema"; import type { VendorCompanyRosterFormValues } from "@/domain/vendors/schemas/vendor-roster-schema";
/**
* `Text variant="feedback"` stays mounted when silent so the aria-live region can announce later.
* An empty paragraph still claims a line box, which would add a permanent gap under the trades
* input and shift the approved drawer layout. Collapse the box while keeping the node in the DOM
* (never `display: none`, which would stop announcements).
*/
function collapsedWhenSilent(visible: boolean) {
return visible ? undefined : { height: 0, margin: 0, overflow: "hidden" as const };
}
function TradeFeedbackRegions({
listUnavailable,
message,
}: {
listUnavailable: boolean;
message: string | null;
}) {
return (
<>
<Text
variant="feedback"
tone="warning"
when={listUnavailable}
sx={collapsedWhenSilent(listUnavailable)}
>
Trade list is unavailable right now — you can still type a trade manually.
</Text>
<Text variant="feedback" when={Boolean(message)} sx={collapsedWhenSilent(Boolean(message))}>
{message}
</Text>
</>
);
}
function splitTrades(value: string | undefined): string[] { function splitTrades(value: string | undefined): string[] {
return (value ?? "") return (value ?? "")
.split(",") .split(",")
@ -23,18 +58,59 @@ function splitTrades(value: string | undefined): string[] {
.filter(Boolean); .filter(Boolean);
} }
function dedupeTrades(options: string[], current: string[]): string[] {
const seen = new Set<string>();
const merged: string[] = [];
for (const trade of [...options, ...current]) {
const key = trade.toLowerCase();
if (seen.has(key)) continue;
seen.add(key);
merged.push(trade);
}
return merged;
}
function matchTradeOption(input: string, options: string[]): string | null {
const exact = options.find((option) => option === input);
if (exact) return exact;
const normalized = input.toLowerCase();
return options.find((option) => option.toLowerCase() === normalized) ?? null;
}
interface VendorTradeSpecialtiesFieldProps { interface VendorTradeSpecialtiesFieldProps {
control: Control<VendorCompanyRosterFormValues>; control: Control<VendorCompanyRosterFormValues>;
index: number; index: number;
tradeOptions: string[]; tradeOptions: string[];
tradeOptionsLoading?: boolean;
}
/**
* SH-249: an empty tradeOptions array means two different things. While the facets
* query is in flight it means "not loaded yet"; only once it settles does it mean
* "vocabulary genuinely unavailable". Failing open during the cold-fetch window let
* free text through the canonical gate permanently, because anything committed there
* is added to knownTradesRef and stays selectable after the real list arrives.
*
* SH-249: why a typed trade was refused. Extracted to module scope so the field
* component stays inside the maintainability gate's function-length cap.
*/
function resolveRejectionMessage(value: string, loading: boolean): string {
return loading
? "Trades are still loading. Wait for the list, then pick a trade."
: `"${value}" is not in the trades list. Pick a trade from the list.`;
} }
export function VendorTradeSpecialtiesField({ export function VendorTradeSpecialtiesField({
control, control,
index, index,
tradeOptions, tradeOptions,
tradeOptionsLoading = false,
}: VendorTradeSpecialtiesFieldProps) { }: VendorTradeSpecialtiesFieldProps) {
const [tradeInput, setTradeInput] = useState(""); const [tradeInput, setTradeInput] = useState("");
const [feedbackMessage, setFeedbackMessage] = useState<string | null>(null);
const knownTradesRef = useRef<Set<string>>(new Set());
const tradeListUnavailable = !tradeOptionsLoading && tradeOptions.length === 0;
const gateActive = tradeOptions.length > 0 || tradeOptionsLoading;
return ( return (
<Box> <Box>
@ -47,10 +123,28 @@ export function VendorTradeSpecialtiesField({
name={`technicians.${index}.tradeSpecialties`} name={`technicians.${index}.tradeSpecialties`}
render={({ field }) => { render={({ field }) => {
const trades = splitTrades(field.value); const trades = splitTrades(field.value);
for (const trade of trades) knownTradesRef.current.add(trade);
const selectableTrades = dedupeTrades(tradeOptions, [...knownTradesRef.current]);
const commit = (next: string[]) => field.onChange(next.join(", ")); const commit = (next: string[]) => field.onChange(next.join(", "));
const add = (value: string) => { const add = (value: string) => {
const normalized = value.trim(); const normalized = value.trim();
if (normalized && !trades.includes(normalized)) commit([...trades, normalized]); setFeedbackMessage(null);
if (!normalized) {
setTradeInput("");
return;
}
const canonical = matchTradeOption(normalized, selectableTrades);
if (gateActive && !canonical) {
setFeedbackMessage(resolveRejectionMessage(normalized, tradeOptionsLoading));
return;
}
const trade = canonical ?? normalized;
if (trades.some((existing) => existing.toLowerCase() === trade.toLowerCase())) {
setFeedbackMessage(`${trade} is already on this technician.`);
setTradeInput("");
return;
}
commit([...trades, trade]);
setTradeInput(""); setTradeInput("");
}; };
const move = (tradeIndex: number, direction: -1 | 1) => { const move = (tradeIndex: number, direction: -1 | 1) => {
@ -64,31 +158,31 @@ export function VendorTradeSpecialtiesField({
return ( return (
<Stack spacing={1} className="mt-2"> <Stack spacing={1} className="mt-2">
<Box className="flex flex-wrap gap-1.5"> <Box className="flex flex-wrap gap-1.5">
{trades.length === 0 ? ( <Text variant="description" tone="muted" when={trades.length === 0}>
<Typography variant="body2" sx={{ color: "text.secondary" }}> No trades selected.
No trades selected. </Text>
</Typography> {trades.map((trade, tradeIndex) => (
) : ( <Chip
trades.map((trade, tradeIndex) => ( key={`${trade}-${tradeIndex}`}
<Chip label={tradeIndex === 0 ? `${trade} (primary)` : trade}
key={`${trade}-${tradeIndex}`} onDelete={() =>
label={tradeIndex === 0 ? `${trade} (primary)` : trade} commit(trades.filter((_, itemIndex) => itemIndex !== tradeIndex))
onDelete={() => }
commit(trades.filter((_, itemIndex) => itemIndex !== tradeIndex)) deleteIcon={<CloseIcon data-testid={`remove-trade-${trade}`} />}
} />
deleteIcon={<CloseIcon data-testid={`remove-trade-${trade}`} />} ))}
/>
))
)}
</Box> </Box>
<Stack direction="row" spacing={1} sx={{ alignItems: "center" }}> <Stack direction="row" spacing={1} sx={{ alignItems: "center" }}>
<Autocomplete <Autocomplete
freeSolo freeSolo
options={tradeOptions} options={selectableTrades}
value={null} value={null}
inputValue={tradeInput} inputValue={tradeInput}
onInputChange={(_event, value, reason) => { onInputChange={(_event, value, reason) => {
if (reason === "input") setTradeInput(value); if (reason === "input") {
setTradeInput(value);
setFeedbackMessage(null);
}
}} }}
onChange={(_event, value) => { onChange={(_event, value) => {
if (typeof value === "string") add(value); if (typeof value === "string") add(value);
@ -147,6 +241,9 @@ export function VendorTradeSpecialtiesField({
); );
}} }}
/> />
{/* Outside the spaced Stack: an always-mounted live region would otherwise inherit Stack
spacing and add a permanent gap under the trades input, shifting the approved layout. */}
<TradeFeedbackRegions listUnavailable={tradeListUnavailable} message={feedbackMessage} />
</Box> </Box>
); );
} }

View file

@ -15,6 +15,7 @@ import type {
} from "@/domain/work-orders/types/work-order"; } from "@/domain/work-orders/types/work-order";
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row"; import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
import type { WizardWOStatus } from "@/domain/work-orders/types/work-order-wizard"; import type { WizardWOStatus } from "@/domain/work-orders/types/work-order-wizard";
import { toVendorTablePatch } from "@/domain/work-orders/utils/vendor-assignment-patch";
import { DocBadge } from "./slide-over-doc-badge"; import { DocBadge } from "./slide-over-doc-badge";
type SlideOverInfoTabEditViewProps = { type SlideOverInfoTabEditViewProps = {
@ -140,14 +141,7 @@ export function SlideOverInfoTabEditView({
tech={draft.tech} tech={draft.tech}
techPhone={draft.techPhone} techPhone={draft.techPhone}
vendors={vendors} vendors={vendors}
onSave={(patch) => onSave={(patch) => onDraftChange(toVendorTablePatch(patch))}
onDraftChange({
vendorId: patch.vendorId,
company: patch.company,
tech: patch.tech,
techPhone: patch.techPhone,
})
}
/> />
</SlideOverInfoRow> </SlideOverInfoRow>
<SlideOverInfoRow label="Status"> <SlideOverInfoRow label="Status">

View file

@ -27,6 +27,7 @@ function userInitials(name: string): string {
function DispatcherAvatar({ name, color }: { name: string; color: string }) { function DispatcherAvatar({ name, color }: { name: string; color: string }) {
return ( return (
<span <span
aria-hidden
className="flex h-5 w-5 shrink-0 items-center justify-center rounded-full text-[9px] font-semibold" className="flex h-5 w-5 shrink-0 items-center justify-center rounded-full text-[9px] font-semibold"
style={{ background: `${color}25`, color }} style={{ background: `${color}25`, color }}
> >
@ -75,8 +76,8 @@ export function DispatcherFilter({
return ( return (
<Skeleton <Skeleton
variant="rounded" variant="rounded"
width={160} width={140}
height={38} height={18}
animation="wave" animation="wave"
aria-label="Loading dispatchers" aria-label="Loading dispatchers"
/> />
@ -89,35 +90,13 @@ export function DispatcherFilter({
onAnchor={setAnchor} onAnchor={setAnchor}
open={open} open={open}
disabled={disabled} disabled={disabled}
className="flex w-auto shrink-0 items-center gap-2 border whitespace-nowrap transition-colors" className="flex w-auto shrink-0 items-center gap-1.5 whitespace-nowrap transition-colors"
style={ style={{ fontSize: 13, fontWeight: 500, color: "var(--muted-foreground)" }}
!allSelected
? {
height: 38,
padding: "0 12px",
borderRadius: 6,
fontSize: 13,
fontWeight: 600,
background: "var(--color-primary-tint)",
borderColor: "#bfe2f5",
color: "var(--primary)",
}
: {
height: 38,
padding: "0 12px",
borderRadius: 6,
fontSize: 13,
fontWeight: 600,
background: "var(--card)",
borderColor: "var(--border)",
color: "var(--foreground)",
}
}
aria-haspopup="listbox" aria-haspopup="listbox"
> >
<Users size={15} style={{ color: "var(--muted-foreground)" }} /> <Users size={13} aria-hidden style={{ color: "var(--muted-foreground)" }} />
{label} {label}
<ChevronDown size={14} style={{ color: "var(--muted-foreground)" }} /> <ChevronDown size={13} aria-hidden style={{ color: "var(--muted-foreground)" }} />
</MenuAnchorButton> </MenuAnchorButton>
<Popover <Popover

View file

@ -1,7 +1,5 @@
import type { WorkOrderUser } from "@/domain/work-orders/types/work-order";
import type { WOType } from "@/domain/work-orders/types/work-order-wizard"; import type { WOType } from "@/domain/work-orders/types/work-order-wizard";
import { DayTabs } from "@/app/(protected)/workorders/_components/list/filter-bar/day-tabs"; import { DayTabs } from "@/app/(protected)/workorders/_components/list/filter-bar/day-tabs";
import { DispatcherFilter } from "@/app/(protected)/workorders/_components/list/filter-bar/dispatcher-filter";
import { WeekSelector } from "@/app/(protected)/workorders/_components/list/filter-bar/week-selector"; import { WeekSelector } from "@/app/(protected)/workorders/_components/list/filter-bar/week-selector";
import { WoTypeFilter } from "@/app/(protected)/workorders/_components/list/filter-bar/wo-type-filter"; import { WoTypeFilter } from "@/app/(protected)/workorders/_components/list/filter-bar/wo-type-filter";
import { Text } from "@/components/ui/text"; import { Text } from "@/components/ui/text";
@ -11,13 +9,8 @@ type WorkOrdersFilterBarProps = {
onWeekChange: (mondayIso: string) => void; onWeekChange: (mondayIso: string) => void;
selectedDayIso: string; selectedDayIso: string;
onSelectedDayChange: (dayIso: string) => void; onSelectedDayChange: (dayIso: string) => void;
dispatcherIds: Set<string>;
onDispatcherChange: (ids: Set<string>) => void;
types: Set<WOType>; types: Set<WOType>;
onTypesChange: (value: Set<WOType>) => void; onTypesChange: (value: Set<WOType>) => void;
users: WorkOrderUser[];
usersLoading?: boolean;
currentUserId?: string | number;
filteredCount: number; filteredCount: number;
scopeTotalCount: number; scopeTotalCount: number;
disabled?: boolean; disabled?: boolean;
@ -28,13 +21,8 @@ export function WorkOrdersFilterBar({
onWeekChange, onWeekChange,
selectedDayIso, selectedDayIso,
onSelectedDayChange, onSelectedDayChange,
dispatcherIds,
onDispatcherChange,
types, types,
onTypesChange, onTypesChange,
users,
usersLoading = false,
currentUserId,
filteredCount, filteredCount,
scopeTotalCount, scopeTotalCount,
disabled = false, disabled = false,
@ -46,22 +34,8 @@ export function WorkOrdersFilterBar({
className="-mx-2.5 shrink-0 border-b border-(--color-border) bg-(--color-bg-surface)" className="-mx-2.5 shrink-0 border-b border-(--color-border) bg-(--color-bg-surface)"
aria-disabled={disabled} aria-disabled={disabled}
> >
<div className="flex items-center gap-3 px-4 pt-2">
<DispatcherFilter
selectedIds={dispatcherIds}
onChange={onDispatcherChange}
users={users}
usersLoading={usersLoading}
currentUserId={currentUserId}
disabled={disabled}
/>
<div className="flex-1" />
<Text variant="caption" tone="muted" className="shrink-0">
{resultLabel}
</Text>
</div>
<div <div
className="flex h-14 shrink-0 flex-nowrap items-center gap-3 px-4 pt-1 pb-2" className="flex h-14 shrink-0 flex-nowrap items-center gap-3 px-4 py-2"
aria-label="Week and day" aria-label="Week and day"
> >
<WeekSelector weekMonday={weekMonday} onChange={onWeekChange} disabled={disabled} /> <WeekSelector weekMonday={weekMonday} onChange={onWeekChange} disabled={disabled} />
@ -74,6 +48,9 @@ export function WorkOrdersFilterBar({
/> />
</div> </div>
<div className="flex-1" /> <div className="flex-1" />
<Text variant="caption" tone="muted" className="shrink-0">
{resultLabel}
</Text>
<WoTypeFilter selected={types} onChange={onTypesChange} disabled={disabled} /> <WoTypeFilter selected={types} onChange={onTypesChange} disabled={disabled} />
</div> </div>
</div> </div>

View file

@ -10,6 +10,7 @@ import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-ta
import { import {
formatClosabilityMissingTitle, formatClosabilityMissingTitle,
getClosabilityGaps, getClosabilityGaps,
tableRowToClosabilityInput,
} from "@/domain/work-orders/utils/wo-closability"; } from "@/domain/work-orders/utils/wo-closability";
import { HoverChevron } from "./hover-chevron"; import { HoverChevron } from "./hover-chevron";
import { WoTableStatusLabel } from "./wo-table-status-label"; import { WoTableStatusLabel } from "./wo-table-status-label";
@ -22,20 +23,7 @@ type StatusCellProps = {
}; };
function rowClosabilityGaps(wo: WorkOrderTableRow) { function rowClosabilityGaps(wo: WorkOrderTableRow) {
return getClosabilityGaps({ return getClosabilityGaps(tableRowToClosabilityInput(wo));
dispatcherId: wo.dispatcherId,
dispatcherName: wo.dispatcherName,
company: wo.company,
tech: wo.tech,
completedDate: wo.completedDate ?? "",
woNumber: wo.woNumber,
pm: wo.pm,
docStatus: wo.docStatus,
mediaCount: wo.mediaCount,
type: wo.type,
upliftSummary: wo.upliftSummary,
hasPendingUplift: wo.hasPendingUplift,
});
} }
function PastDueStatusCell({ function PastDueStatusCell({

View file

@ -36,7 +36,13 @@ export function useWoTableRowHandlers({
onScheduleHighlight, onScheduleHighlight,
onDuplicateFound, onDuplicateFound,
}: UseWoTableRowHandlersOptions): WoTableRowHandlers { }: UseWoTableRowHandlersOptions): WoTableRowHandlers {
const { dragState: dragStateRef, setDragViz, reorderRows, endDrag, triggerFlash } = tableData; const {
dragState: dragStateRef,
setDragViz,
reorderRows,
endDrag,
revealScheduledRow,
} = tableData;
return useMemo( return useMemo(
() => ({ () => ({
@ -77,13 +83,13 @@ export function useWoTableRowHandlers({
rescheduleNotify(date, { rescheduleNotify(date, {
onNavigate: () => { onNavigate: () => {
onNavigateToDay(date); onNavigateToDay(date);
setTimeout(() => triggerFlash(id), 180); revealScheduledRow(id);
}, },
}); });
return; return;
} }
onNavigateToDay(date); onNavigateToDay(date);
triggerFlash(id); revealScheduledRow(id);
}, },
onScheduleWeek: (id: string | number, week: string) => { onScheduleWeek: (id: string | number, week: string) => {
onPatch(id, { onPatch(id, {
@ -132,7 +138,7 @@ export function useWoTableRowHandlers({
setDragViz, setDragViz,
reorderRows, reorderRows,
endDrag, endDrag,
triggerFlash, revealScheduledRow,
weekMonday, weekMonday,
onNavigateToDay, onNavigateToDay,
onOpenAdvancedFilters, onOpenAdvancedFilters,

View file

@ -0,0 +1,80 @@
import { ChevronLeft, ChevronRight } from "lucide-react";
import { NavArrowButton } from "@/components/ui/nav-arrow-button";
import { Text } from "@/components/ui/text";
import {
DAY_PAGE_SIZE_OPTIONS,
formatDayPaginationSummary,
type DayPageSize,
} from "@/domain/work-orders/utils/day-pagination";
type WoDayPaginationFooterProps = {
dayLabel: string;
itemCount: number;
pageSize: DayPageSize;
pageIndex: number;
pageCount: number;
onPageSizeChange: (pageSize: DayPageSize) => void;
onPrev: () => void;
onNext: () => void;
};
export function WoDayPaginationFooter({
dayLabel,
itemCount,
pageSize,
pageIndex,
pageCount,
onPageSizeChange,
onPrev,
onNext,
}: WoDayPaginationFooterProps) {
const canPrev = pageIndex > 0;
const canNext = pageIndex < pageCount - 1;
const pageLabel = `Page ${pageIndex + 1} of ${pageCount}`;
const summary = formatDayPaginationSummary(dayLabel, itemCount);
return (
<nav
className="flex shrink-0 items-center justify-between gap-3"
aria-label="Day pagination"
data-testid="wo-day-pagination"
>
<Text variant="caption" tone="muted">
{summary}
</Text>
<div className="flex items-center gap-3">
<label className="flex items-center gap-2">
<Text variant="caption" tone="muted">
Rows per page
</Text>
<select
aria-label="Rows per page"
className="rounded-md border border-border bg-card px-2 py-1 text-xs"
value={pageSize}
onChange={(event) => onPageSizeChange(Number(event.target.value) as DayPageSize)}
>
{DAY_PAGE_SIZE_OPTIONS.map((option) => (
<option key={option} value={option}>
{option}
</option>
))}
</select>
</label>
<div className="flex items-center gap-2">
<NavArrowButton aria-label="Previous page" disabled={!canPrev} onClick={onPrev}>
<ChevronLeft size={15} />
</NavArrowButton>
<Text variant="caption">{pageLabel}</Text>
<NavArrowButton
aria-label="Next page"
data-testid="wo-day-pagination-next"
disabled={!canNext}
onClick={onNext}
>
<ChevronRight size={15} />
</NavArrowButton>
</div>
</div>
</nav>
);
}

View file

@ -78,7 +78,7 @@ export function WoTableBody({ tableData, renderRow, renderInlineRow }: WoTableBo
<WoTableSectionHeader <WoTableSectionHeader
variant="day" variant="day"
label={group.label} label={group.label}
count={group.items.length} count={group.totalCount ?? group.items.length}
isToday={group.key === tableData.todayKey} isToday={group.key === tableData.todayKey}
onAddWo={() => tableData.startDayDraft(group.key, group.date)} onAddWo={() => tableData.startDayDraft(group.key, group.date)}
/> />

View file

@ -16,6 +16,7 @@ import { PMTypeCell } from "./cells/pm-type-cell";
import { StatusCell } from "./cells/status-cell"; import { StatusCell } from "./cells/status-cell";
import { TypeCell } from "./cells/type-cell"; import { TypeCell } from "./cells/type-cell";
import { VendorCell } from "./cells/vendor-cell"; import { VendorCell } from "./cells/vendor-cell";
import { toVendorTablePatch } from "@/domain/work-orders/utils/vendor-assignment-patch";
import { toInlineDraftRow } from "./to-inline-draft-row"; import { toInlineDraftRow } from "./to-inline-draft-row";
import { WoTableInlineIdentityCells } from "./wo-table-inline-identity-cells"; import { WoTableInlineIdentityCells } from "./wo-table-inline-identity-cells";
@ -193,14 +194,7 @@ export function WoTableInlineRowCells({
tech={draft.tech} tech={draft.tech}
techPhone={draft.techPhone} techPhone={draft.techPhone}
vendors={vendors} vendors={vendors}
onSave={(p) => onSave={(p) => onPatch(toVendorTablePatch(p))}
onPatch({
vendorId: p.vendorId,
company: p.company,
tech: p.tech,
techPhone: p.techPhone,
})
}
/> />
</td> </td>
<td className="group/cell" style={{ padding: "0 14px" }}> <td className="group/cell" style={{ padding: "0 14px" }}>

View file

@ -10,6 +10,7 @@ import { UpliftCell } from "./cells/uplift-cell";
import { VendorCell } from "./cells/vendor-cell"; import { VendorCell } from "./cells/vendor-cell";
import { EMPTY_UPLIFT_SUMMARY } from "@/domain/work-orders/types/work-order-uplift"; import { EMPTY_UPLIFT_SUMMARY } from "@/domain/work-orders/types/work-order-uplift";
import { canOpenUpliftsDialog } from "@/domain/work-orders/utils/uplift-display-utils"; import { canOpenUpliftsDialog } from "@/domain/work-orders/utils/uplift-display-utils";
import { toVendorTablePatch } from "@/domain/work-orders/utils/vendor-assignment-patch";
import type { WoTableRowHandlers } from "./wo-table-row"; import type { WoTableRowHandlers } from "./wo-table-row";
type WoTableRowServiceCellsProps = { type WoTableRowServiceCellsProps = {
@ -67,14 +68,7 @@ export function WoTableRowServiceCells({
techPhone={row.techPhone} techPhone={row.techPhone}
vendors={vendors} vendors={vendors}
q={search} q={search}
onSave={(p) => onSave={(p) => onPatchRow(toVendorTablePatch(p))}
onPatchRow({
vendorId: p.vendorId,
company: p.company,
tech: p.tech,
techPhone: p.techPhone,
})
}
/> />
</td> </td>
<td <td

View file

@ -8,11 +8,20 @@ const CARD_STYLE: React.CSSProperties = {
boxShadow: "0 1px 3px rgba(38,34,98,.05)", boxShadow: "0 1px 3px rgba(38,34,98,.05)",
}; };
export function WoTableShell({ children }: { children: React.ReactNode }) { export function WoTableShell({
children,
footer,
}: {
children: React.ReactNode;
footer?: React.ReactNode;
}) {
return ( return (
<div className="min-h-0 flex-1 overflow-hidden px-2 py-4"> <div className="flex min-h-0 flex-1 flex-col overflow-hidden px-2 py-4">
<div className="relative h-full overflow-auto" style={CARD_STYLE}> <div className="flex min-h-0 flex-1 flex-col overflow-hidden" style={CARD_STYLE}>
{children} <div className="min-h-0 flex-1 overflow-auto">{children}</div>
{Boolean(footer) && (
<div className="shrink-0 border-t border-border px-3 py-2">{footer}</div>
)}
</div> </div>
</div> </div>
); );

View file

@ -12,6 +12,7 @@ import type {
import { isUnassignedQueueReady } from "@/domain/work-orders/utils/week-day-nav-utils"; import { isUnassignedQueueReady } from "@/domain/work-orders/utils/week-day-nav-utils";
import { DuplicateWoNumberDialog } from "@/app/(protected)/workorders/_components/list/duplicate-wo-number-dialog"; import { DuplicateWoNumberDialog } from "@/app/(protected)/workorders/_components/list/duplicate-wo-number-dialog";
import { WoTableSkeleton } from "@/app/(protected)/workorders/_components/loading/wo-table-skeleton"; import { WoTableSkeleton } from "@/app/(protected)/workorders/_components/loading/wo-table-skeleton";
import { WoDayPaginationFooter } from "./wo-day-pagination-footer";
import type { ReturnTypeOfUseWorkOrdersTableData } from "./wo-table-types"; import type { ReturnTypeOfUseWorkOrdersTableData } from "./wo-table-types";
import { buildSitePlaceLookups, resolveSitePlace } from "./site-place-lookup"; import { buildSitePlaceLookups, resolveSitePlace } from "./site-place-lookup";
import { useWoTableInlineCreate } from "./use-wo-table-inline-create"; import { useWoTableInlineCreate } from "./use-wo-table-inline-create";
@ -69,6 +70,24 @@ function WoTableEmptyShell({ message, actionLabel, onAction }: WoTableEmptyShell
); );
} }
function WoTableDayFooter({ tableData }: { tableData: ReturnTypeOfUseWorkOrdersTableData }) {
if (!tableData.showDayPagination) {
return undefined;
}
return (
<WoDayPaginationFooter
dayLabel={tableData.dayFooterLabel}
itemCount={tableData.dayItemCount}
pageSize={tableData.dayPageSize}
pageIndex={tableData.dayPageIndex}
pageCount={tableData.dayPageCount}
onPageSizeChange={tableData.setDayPageSize}
onPrev={tableData.goToPrevDayPage}
onNext={tableData.goToNextDayPage}
/>
);
}
export function WoTable({ export function WoTable({
tableData, tableData,
users, users,
@ -188,7 +207,7 @@ export function WoTable({
return ( return (
<> <>
<WoTableShell> <WoTableShell footer={WoTableDayFooter({ tableData })}>
<table <table
style={{ style={{
width: "100%", width: "100%",

View file

@ -0,0 +1,72 @@
import { useCallback, useEffect, useState } from "react";
import {
DEFAULT_DAY_PAGE_SIZE,
findItemPageIndex,
type DayPageSize,
} from "@/domain/work-orders/utils/day-pagination";
type DayPageItem = { id: string | number };
type UseWorkOrdersDayPaginationInput = {
weekMonday: string;
selectedDayIso: string;
advActive: boolean;
dayItems: readonly DayPageItem[];
onRevealed: (id: string | number) => void;
};
export function useWorkOrdersDayPagination({
weekMonday,
selectedDayIso,
advActive,
dayItems,
onRevealed,
}: UseWorkOrdersDayPaginationInput) {
const [pageSize, setPageSizeState] = useState<DayPageSize>(DEFAULT_DAY_PAGE_SIZE);
const [page, setPage] = useState(0);
const [pendingRevealId, setPendingRevealId] = useState<string | number | null>(null);
useEffect(() => {
setPage(0);
}, [weekMonday, selectedDayIso, advActive]);
useEffect(() => {
if (pendingRevealId == null) {
return;
}
const nextPage = findItemPageIndex(dayItems, pendingRevealId, pageSize);
if (nextPage == null) {
return;
}
setPage(nextPage);
const revealedId = pendingRevealId;
setPendingRevealId(null);
onRevealed(revealedId);
}, [pendingRevealId, dayItems, pageSize, onRevealed]);
const setPageSize = useCallback((next: DayPageSize) => {
setPageSizeState(next);
setPage(0);
}, []);
const goToPrevPage = useCallback(() => {
setPage((current) => Math.max(0, current - 1));
}, []);
const goToNextPage = useCallback(() => {
setPage((current) => current + 1);
}, []);
const revealRow = useCallback((id: string | number) => {
setPendingRevealId(id);
}, []);
return {
pageSize,
page,
setPageSize,
goToPrevPage,
goToNextPage,
revealRow,
};
}

View file

@ -1,10 +1,15 @@
import { useMemo } from "react"; import { useMemo } from "react";
import type { useWorkOrdersListFilters } from "@/app/(protected)/workorders/_hooks/use-work-orders-list-filters"; import type { useWorkOrdersListFilters } from "@/app/(protected)/workorders/_hooks/use-work-orders-list-filters";
import { useWorkOrdersBoardSource } from "@/app/(protected)/workorders/_hooks/use-work-orders-board-source"; import { useWorkOrdersBoardSource } from "@/app/(protected)/workorders/_hooks/use-work-orders-board-source";
import { useWorkOrdersDayPagination } from "@/app/(protected)/workorders/_hooks/use-work-orders-day-pagination";
import { useWorkOrdersTableLocalState } from "@/app/(protected)/workorders/_hooks/use-work-orders-table-local-state"; import { useWorkOrdersTableLocalState } from "@/app/(protected)/workorders/_hooks/use-work-orders-table-local-state";
import { currentWeekMonday } from "@/domain/work-orders/utils/list-week-utils"; import { currentWeekMonday } from "@/domain/work-orders/utils/list-week-utils";
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row"; import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
import { deriveDayFields } from "@/domain/work-orders/utils/table-day-utils"; import { deriveDayFields } from "@/domain/work-orders/utils/table-day-utils";
import {
pageSelectedDayGroups,
shouldShowDayPagination,
} from "@/domain/work-orders/utils/day-pagination";
import { import {
buildSelectedDayGroup, buildSelectedDayGroup,
isDayScopedSearchEmpty, isDayScopedSearchEmpty,
@ -51,7 +56,7 @@ export function useWorkOrdersTableData(filters: FiltersReturn) {
advActive, advActive,
isFetching, isFetching,
}); });
const { displayRows } = local; const { displayRows, ...localUi } = local;
const advResults = useMemo(() => (advActive ? displayRows : []), [advActive, displayRows]); const advResults = useMemo(() => (advActive ? displayRows : []), [advActive, displayRows]);
@ -85,16 +90,27 @@ export function useWorkOrdersTableData(filters: FiltersReturn) {
: [buildSelectedDayGroup(filters.weekMonday, filters.selectedDayIso, scheduled)], : [buildSelectedDayGroup(filters.weekMonday, filters.selectedDayIso, scheduled)],
[advActive, filters.selectedDayIso, filters.weekMonday, scheduled], [advActive, filters.selectedDayIso, filters.weekMonday, scheduled],
); );
const pagination = useWorkOrdersDayPagination({
weekMonday: filters.weekMonday,
selectedDayIso: filters.selectedDayIso,
advActive,
dayItems: dayGroups[0]?.items ?? EMPTY_WEEKLY_ITEMS,
onRevealed: local.triggerFlash,
});
const pagedDay = useMemo(
() => pageSelectedDayGroups(dayGroups, pagination.page, pagination.pageSize),
[dayGroups, pagination.page, pagination.pageSize],
);
const searchActive = filters.search.trim() !== ""; const searchActive = filters.search.trim() !== "";
const displayGroups = dayGroups; const displayGroups = pagedDay.groups;
const dayVisibleCount = displayGroups[0]?.items.length ?? 0; const dayTotalCount = pagedDay.totalCount;
const noSearchResults = const noSearchResults =
!advActive && !advActive &&
isDayScopedSearchEmpty({ isDayScopedSearchEmpty({
searchActive, searchActive,
unassignedCount: unassigned.length, unassignedCount: unassigned.length,
dayItemCount: dayVisibleCount, dayItemCount: dayTotalCount,
unassignedQueueReady: isUnassignedQueueReady(unassignedLoading, unassignedError), unassignedQueueReady: isUnassignedQueueReady(unassignedLoading, unassignedError),
}); });
const isEmptyWeek = const isEmptyWeek =
@ -127,30 +143,24 @@ export function useWorkOrdersTableData(filters: FiltersReturn) {
noSearchResults, noSearchResults,
isEmptyWeek, isEmptyWeek,
todayKey, todayKey,
draftDayKey: local.draftDayKey, ...localUi,
draft: local.draft,
setDraft: local.setDraft,
unassignedOpen: local.unassignedOpen,
setUnassignedOpen: local.setUnassignedOpen,
unschedOpen: local.unschedOpen,
setUnschedOpen: local.setUnschedOpen,
selectedId: local.selectedId,
setSelectedId: local.setSelectedId,
selectedRow, selectedRow,
flashId: local.flashId,
triggerFlash: local.triggerFlash,
dragViz: local.dragViz,
setDragViz: local.setDragViz,
dragState: local.dragState,
patchRow: local.patchRow,
clearRowPatch: local.clearRowPatch,
reorderRows: local.reorderRows,
endDrag: local.endDrag,
startDayDraft: local.startDayDraft,
cancelDraft: local.cancelDraft,
addRow: local.addRow,
scopeTotalCount: data?.totalCount ?? 0, scopeTotalCount: data?.totalCount ?? 0,
totalCount: advActive ? (data?.totalCount ?? advResults.length) : dayVisibleCount, totalCount: advActive ? (data?.totalCount ?? advResults.length) : dayTotalCount,
search: filters.search, search: filters.search,
showDayPagination: shouldShowDayPagination({
flatListMode: advActive,
isLoading,
noSearchResults,
}),
dayPageSize: pagination.pageSize,
dayPageIndex: pagedDay.pageIndex,
dayPageCount: pagedDay.pageCount,
dayItemCount: pagedDay.totalCount,
dayFooterLabel: displayGroups[0]?.label ?? "",
setDayPageSize: pagination.setPageSize,
goToPrevDayPage: pagination.goToPrevPage,
goToNextDayPage: pagination.goToNextPage,
revealScheduledRow: pagination.revealRow,
}; };
} }

View file

@ -2,6 +2,7 @@ import { Box } from "@mui/material";
import { PageHeader } from "@/components/ui/page-header"; import { PageHeader } from "@/components/ui/page-header";
import { QueryErrorPanel } from "@/components/ui/query-error-panel"; import { QueryErrorPanel } from "@/components/ui/query-error-panel";
import { WorkOrdersAdvancedFiltersBanner } from "@/app/(protected)/workorders/_components/list/work-orders-advanced-filters-banner"; import { WorkOrdersAdvancedFiltersBanner } from "@/app/(protected)/workorders/_components/list/work-orders-advanced-filters-banner";
import { DispatcherFilter } from "@/app/(protected)/workorders/_components/list/filter-bar/dispatcher-filter";
import { WorkOrdersFilterBar } from "@/app/(protected)/workorders/_components/list/filter-bar/work-orders-filter-bar"; import { WorkOrdersFilterBar } from "@/app/(protected)/workorders/_components/list/filter-bar/work-orders-filter-bar";
import { WorkOrdersListHeader } from "@/app/(protected)/workorders/_components/list/work-orders-list-header"; import { WorkOrdersListHeader } from "@/app/(protected)/workorders/_components/list/work-orders-list-header";
import { WorkOrdersListPagePanels } from "@/app/(protected)/workorders/_components/list/work-orders-list-page-panels"; import { WorkOrdersListPagePanels } from "@/app/(protected)/workorders/_components/list/work-orders-list-page-panels";
@ -30,6 +31,16 @@ export default function WorkOrdersListPage() {
surface surface
eyebrow="OPERATIONS" eyebrow="OPERATIONS"
title="Work Orders" title="Work Orders"
belowTitle={
<DispatcherFilter
selectedIds={filters.dispatcherIds}
onChange={filters.setDispatcherIds}
users={page.users}
usersLoading={page.usersLoading}
currentUserId={user?.id}
disabled={filters.advApplied != null}
/>
}
actions={ actions={
<WorkOrdersListHeader <WorkOrdersListHeader
search={filters.search} search={filters.search}
@ -47,13 +58,8 @@ export default function WorkOrdersListPage() {
onWeekChange={filters.setWeekMonday} onWeekChange={filters.setWeekMonday}
selectedDayIso={filters.selectedDayIso} selectedDayIso={filters.selectedDayIso}
onSelectedDayChange={filters.setSelectedDayIso} onSelectedDayChange={filters.setSelectedDayIso}
dispatcherIds={filters.dispatcherIds}
onDispatcherChange={filters.setDispatcherIds}
types={filters.types} types={filters.types}
onTypesChange={filters.setTypes} onTypesChange={filters.setTypes}
users={page.users}
usersLoading={page.usersLoading}
currentUserId={user?.id}
filteredCount={tableData.totalCount} filteredCount={tableData.totalCount}
scopeTotalCount={tableData.scopeTotalCount} scopeTotalCount={tableData.scopeTotalCount}
disabled={filters.advApplied != null} disabled={filters.advApplied != null}

View file

@ -1,4 +1,4 @@
import type { ReactElement } from "react"; import type { ReactElement, ReactNode } from "react";
import { Stack } from "@mui/material"; import { Stack } from "@mui/material";
import { Text } from "@/components/ui/text"; import { Text } from "@/components/ui/text";
@ -8,6 +8,7 @@ type PageHeaderProps = {
eyebrow?: string; eyebrow?: string;
title: string; title: string;
subtitle?: string; subtitle?: string;
belowTitle?: ReactNode;
actions?: ReactElement; actions?: ReactElement;
className?: string; className?: string;
/** White surface bar (list page headers). */ /** White surface bar (list page headers). */
@ -18,6 +19,7 @@ export function PageHeader({
eyebrow, eyebrow,
title, title,
subtitle, subtitle,
belowTitle,
actions, actions,
className, className,
surface = false, surface = false,
@ -63,6 +65,7 @@ export function PageHeader({
> >
{subtitle} {subtitle}
</Text> </Text>
{Boolean(belowTitle) && <div className="mt-1">{belowTitle}</div>}
</Stack> </Stack>
{Boolean(actions) && ( {Boolean(actions) && (
<Stack <Stack

View file

@ -101,8 +101,11 @@ export const vendorsApi = {
return mapVendor(handleApiResponse(data)); return mapVendor(handleApiResponse(data));
}, },
delete: async (id: string | number): Promise<void> => { // SH-254: confirmOpenWorkOrders tells the API the caller has been shown the
await apiDelete(`${API_PATHS.rest.vendors}/${id}`); // vendor's open work orders and chose to proceed. Without it the API still blocks.
delete: async (id: string | number, confirmOpenWorkOrders = false): Promise<void> => {
const suffix = confirmOpenWorkOrders ? "?confirmOpenWorkOrders=true" : "";
await apiDelete(`${API_PATHS.rest.vendors}/${id}${suffix}`);
}, },
getDeactivationImpact: async (id: string | number): Promise<VendorDeactivationImpact> => { getDeactivationImpact: async (id: string | number): Promise<VendorDeactivationImpact> => {

View file

@ -3,11 +3,17 @@ import { toast } from "react-toastify";
import { vendorsApi } from "@/domain/vendors/api/vendors-api"; import { vendorsApi } from "@/domain/vendors/api/vendors-api";
import { queryKeys } from "@/infra/query-key/query-key"; import { queryKeys } from "@/infra/query-key/query-key";
export function useDeleteVendor(): UseMutationResult<void, Error, string | number> { export interface DeleteVendorInput {
id: string | number;
confirmOpenWorkOrders?: boolean;
}
export function useDeleteVendor(): UseMutationResult<void, Error, DeleteVendorInput> {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
return useMutation({ return useMutation({
mutationFn: (id: string | number) => vendorsApi.delete(id), mutationFn: ({ id, confirmOpenWorkOrders = false }: DeleteVendorInput) =>
vendorsApi.delete(id, confirmOpenWorkOrders),
onSuccess: () => { onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: queryKeys.vendors.all }); void queryClient.invalidateQueries({ queryKey: queryKeys.vendors.all });
toast.success("Vendor deactivated"); toast.success("Vendor deactivated");

View file

@ -42,6 +42,7 @@ const LOCAL_ONLY_KEYS = new Set<keyof WorkOrderTablePatch>([
"originalDate", "originalDate",
/** Optimistic overlay only — backend owns isAddOn after schedule ops (SH-184). */ /** Optimistic overlay only — backend owns isAddOn after schedule ops (SH-184). */
"isAddOn", "isAddOn",
"primaryDispatchStatus",
]); ]);
export function isLocalOnlyTablePatch(patch: WorkOrderTablePatch): boolean { export function isLocalOnlyTablePatch(patch: WorkOrderTablePatch): boolean {

View file

@ -267,6 +267,10 @@ function readHasPendingUplift(
return undefined; return undefined;
} }
function readPrimaryDispatchStatus(item: Record<string, unknown>): string {
return readString(item, "primaryDispatchStatus", "PrimaryDispatchStatus");
}
export function mapWorkOrderTableRow(raw: unknown): WorkOrderTableRow { export function mapWorkOrderTableRow(raw: unknown): WorkOrderTableRow {
const item = asRecord(raw); const item = asRecord(raw);
const scheduledOn = toDateInputValue( const scheduledOn = toDateInputValue(
@ -348,6 +352,7 @@ export function mapWorkOrderTableRow(raw: unknown): WorkOrderTableRow {
extraServices: readStringArray(item, "extraServices", "ExtraServices"), extraServices: readStringArray(item, "extraServices", "ExtraServices"),
vendorId: vendorIdNum != null ? String(vendorIdNum) : "", vendorId: vendorIdNum != null ? String(vendorIdNum) : "",
company: readString(item, "company", "Company", "vendorName", "VendorName"), company: readString(item, "company", "Company", "vendorName", "VendorName"),
primaryDispatchStatus: readPrimaryDispatchStatus(item),
tech: readString(item, "techName", "TechName", "tech", "Tech", "technician", "Technician"), tech: readString(item, "techName", "TechName", "tech", "Tech", "technician", "Technician"),
techPhone: readString(item, "techPhone", "TechPhone"), techPhone: readString(item, "techPhone", "TechPhone"),
apptStart, apptStart,

View file

@ -42,6 +42,7 @@ export interface WorkOrderBoardDetailInfoApi {
dayGroup?: string | null; dayGroup?: string | null;
vendorId?: number | null; vendorId?: number | null;
vendorName?: string | null; vendorName?: string | null;
primaryDispatchStatus?: string | null;
techName?: string | null; techName?: string | null;
techPhone?: string | null; techPhone?: string | null;
apptDate?: string | null; apptDate?: string | null;

View file

@ -184,6 +184,7 @@ export interface WorkOrderBoardRowApi {
dayGroup?: string | null; dayGroup?: string | null;
vendorId?: number | null; vendorId?: number | null;
vendorName?: string | null; vendorName?: string | null;
primaryDispatchStatus?: string | null;
techName?: string | null; techName?: string | null;
techPhone?: string | null; techPhone?: string | null;
apptDate?: string | null; apptDate?: string | null;

View file

@ -37,6 +37,7 @@ export interface WorkOrderTableRow {
extraServices: string[]; extraServices: string[];
vendorId: string; vendorId: string;
company: string; company: string;
primaryDispatchStatus?: string;
tech: string; tech: string;
techPhone: string; techPhone: string;
apptStart: string; apptStart: string;
@ -138,6 +139,7 @@ export type WorkOrderDayGroup = {
label: string; label: string;
date: string; date: string;
items: WorkOrderTableRow[]; items: WorkOrderTableRow[];
totalCount?: number;
}; };
export type WorkOrderTablePatch = Partial<WorkOrderTableRow>; export type WorkOrderTablePatch = Partial<WorkOrderTableRow>;

View file

@ -0,0 +1,80 @@
import type { WorkOrderDayGroup } from "@/domain/work-orders/types/work-order-table-row";
export const DAY_PAGE_SIZE_OPTIONS = [25, 50, 100, 200] as const;
export const DEFAULT_DAY_PAGE_SIZE = 50;
export type DayPageSize = (typeof DAY_PAGE_SIZE_OPTIONS)[number];
export type PaginatedSlice<T> = {
visible: T[];
pageCount: number;
pageIndex: number;
};
export function pageIndexForItem(itemIndex: number, pageSize: number): number {
if (itemIndex < 0) {
return 0;
}
const size = pageSize > 0 ? pageSize : DEFAULT_DAY_PAGE_SIZE;
return Math.floor(itemIndex / size);
}
export function findItemPageIndex<T extends { id: string | number }>(
items: readonly T[],
id: string | number,
pageSize: number,
): number | null {
const index = items.findIndex((item) => String(item.id) === String(id));
if (index < 0) {
return null;
}
return pageIndexForItem(index, pageSize);
}
export function paginateItems<T>(
items: readonly T[],
pageIndex: number,
pageSize: number,
): PaginatedSlice<T> {
const size = pageSize > 0 ? pageSize : DEFAULT_DAY_PAGE_SIZE;
const pageCount = Math.max(1, Math.ceil(items.length / size) || 1);
const page = Math.min(Math.max(0, pageIndex), pageCount - 1);
const start = page * size;
return {
visible: items.slice(start, start + size),
pageCount,
pageIndex: page,
};
}
export function shouldShowDayPagination(input: {
flatListMode: boolean;
isLoading: boolean;
noSearchResults: boolean;
}): boolean {
return !input.flatListMode && !input.isLoading && !input.noSearchResults;
}
export function formatDayPaginationSummary(dayLabel: string, itemCount: number): string {
const label = dayLabel.trim().toUpperCase();
const noun = itemCount === 1 ? "item" : "items";
return `${label} · ${itemCount} ${noun}`;
}
export function pageSelectedDayGroups(
dayGroups: WorkOrderDayGroup[],
pageIndex: number,
pageSize: number,
): { groups: WorkOrderDayGroup[]; pageIndex: number; pageCount: number; totalCount: number } {
const group = dayGroups[0];
if (!group) {
return { groups: dayGroups, pageIndex: 0, pageCount: 1, totalCount: 0 };
}
const slice = paginateItems(group.items, pageIndex, pageSize);
return {
groups: [{ ...group, items: slice.visible, totalCount: group.items.length }],
pageIndex: slice.pageIndex,
pageCount: slice.pageCount,
totalCount: group.items.length,
};
}

View file

@ -3,7 +3,7 @@ import type { WorkOrderDetail } from "@/domain/work-orders/types/work-order-deta
/** /**
* Overlay authoritative closability fields from board detail onto a legacy GetById detail. * Overlay authoritative closability fields from board detail onto a legacy GetById detail.
* Board `info.type`, `completion.docStatus`, and `media.length` are the SH-114/SH-183 sources. * Board `info.type`, `completion.docStatus`, and `media.length` are the SH-114 sources.
*/ */
export function enrichDetailClosabilityFromBoard( export function enrichDetailClosabilityFromBoard(
detail: WorkOrderDetail, detail: WorkOrderDetail,

View file

@ -0,0 +1,23 @@
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
export type VendorAssignmentFields = {
vendorId: string;
company: string;
tech: string;
techPhone: string;
};
export type VendorTablePatch = Pick<
WorkOrderTableRow,
"vendorId" | "company" | "tech" | "techPhone" | "primaryDispatchStatus"
>;
export function toVendorTablePatch(fields: VendorAssignmentFields): VendorTablePatch {
return {
vendorId: fields.vendorId,
company: fields.company,
tech: fields.tech,
techPhone: fields.techPhone,
primaryDispatchStatus: "",
};
}

View file

@ -1,16 +1,20 @@
import type { WorkOrderDispatchSummary } from "@/domain/work-orders/types/dispatch";
import type { WorkOrderDetail } from "@/domain/work-orders/types/work-order-detail"; import type { WorkOrderDetail } from "@/domain/work-orders/types/work-order-detail";
import type { import type {
CompletionDocStatus, CompletionDocStatus,
WorkOrderTableRow,
WorkOrderUpliftSummary, WorkOrderUpliftSummary,
} from "@/domain/work-orders/types/work-order-table-row"; } from "@/domain/work-orders/types/work-order-table-row";
import type { WOType, WorkOrderWizardDraft } from "@/domain/work-orders/types/work-order-wizard"; import type { WOType, WorkOrderWizardDraft } from "@/domain/work-orders/types/work-order-wizard";
/** Fields required to evaluate whether a WO may be marked Completed (SH-183 ∪ pending uplift). */ /** Fields required to evaluate whether a WO may be marked Completed. */
export type WoClosabilityInput = { export type WoClosabilityInput = {
dispatcherId: string; dispatcherId: string;
dispatcherName: string; dispatcherName: string;
/** Company / vendor assignment — required for Completed (SH-183). */ /** Company / vendor assignment — required for Completed. */
company: string; company: string;
/** Board vendor id; assignment is valid with vendorId even when company label is empty. */
vendorId: string;
/** Technician is optional for closability; modeled separately from company. */ /** Technician is optional for closability; modeled separately from company. */
tech: string; tech: string;
/** Work completed date (API CompletedDate). Empty until set. */ /** Work completed date (API CompletedDate). Empty until set. */
@ -57,8 +61,8 @@ function hasDispatcher(row: Pick<WoClosabilityInput, "dispatcherId" | "dispatche
return Boolean(row.dispatcherId.trim() || row.dispatcherName.trim()); return Boolean(row.dispatcherId.trim() || row.dispatcherName.trim());
} }
function hasCompany(company: string): boolean { function hasAssignedCompany(row: Pick<WoClosabilityInput, "company" | "vendorId">): boolean {
return Boolean(company.trim()); return Boolean((row.company ?? "").trim() || (row.vendorId ?? "").trim());
} }
function hasService(pm: string): boolean { function hasService(pm: string): boolean {
@ -80,7 +84,7 @@ export function getClosabilityGaps(row: WoClosabilityInput): string[] {
if (!hasDispatcher(row)) { if (!hasDispatcher(row)) {
gaps.push("Dispatcher"); gaps.push("Dispatcher");
} }
if (!hasCompany(row.company)) { if (!hasAssignedCompany(row)) {
gaps.push("Company"); gaps.push("Company");
} }
if (!row.completedDate.trim()) { if (!row.completedDate.trim()) {
@ -143,6 +147,7 @@ export function wizardDraftToClosabilityInput(draft: WorkOrderWizardDraft): WoCl
dispatcherId: draft.assigneeId, dispatcherId: draft.assigneeId,
dispatcherName: draft.assigneeName, dispatcherName: draft.assigneeName,
company: draft.vendorName, company: draft.vendorName,
vendorId: draft.vendorId ?? "",
tech: "", tech: "",
completedDate: "", completedDate: "",
woNumber: draft.woNumber, woNumber: draft.woNumber,
@ -153,19 +158,55 @@ export function wizardDraftToClosabilityInput(draft: WorkOrderWizardDraft): WoCl
}; };
} }
const INACTIVE_DISPATCH_STATUSES = new Set(["cancelled", "canceled", "refused"]);
export function isInactiveDispatchStatus(status: string | undefined): boolean {
return INACTIVE_DISPATCH_STATUSES.has((status ?? "").trim().toLowerCase());
}
export function tableRowToClosabilityInput(wo: WorkOrderTableRow): WoClosabilityInput {
const inactive = isInactiveDispatchStatus(wo.primaryDispatchStatus);
return {
dispatcherId: wo.dispatcherId,
dispatcherName: wo.dispatcherName,
company: inactive ? "" : wo.company,
vendorId: inactive ? "" : (wo.vendorId ?? ""),
tech: wo.tech,
completedDate: wo.completedDate ?? "",
woNumber: wo.woNumber,
pm: wo.pm,
docStatus: wo.docStatus,
mediaCount: wo.mediaCount,
type: wo.type,
upliftSummary: wo.upliftSummary,
hasPendingUplift: wo.hasPendingUplift,
};
}
function currentAssignedDispatch(
dispatches: WorkOrderDispatchSummary[],
): WorkOrderDispatchSummary | undefined {
return dispatches.find((dispatch) => {
if (isInactiveDispatchStatus(dispatch.status)) {
return false;
}
const vendorId = String(dispatch.vendorId ?? "").trim();
return Boolean((dispatch.vendorName ?? "").trim() || vendorId);
});
}
/** /**
* Map standalone detail DTO onto closability. Uses authoritative `type` / `docStatus` / * Map standalone detail DTO onto closability. Uses authoritative `type` / `docStatus` /
* `mediaCount` (enriched from board detail on the view route). Never treats `source` * `mediaCount` (enriched from board detail on the view route). Never treats `source`
* as WorkOrderType. `problem` is the same service field the board maps into `pm`. * as WorkOrderType. `problem` is the same service field the board maps into `pm`.
*/ */
export function detailToClosabilityInput(wo: WorkOrderDetail): WoClosabilityInput { export function detailToClosabilityInput(wo: WorkOrderDetail): WoClosabilityInput {
const vendorName = wo.dispatches.find((dispatch) => const assigned = currentAssignedDispatch(wo.dispatches);
Boolean(dispatch.vendorName.trim()),
)?.vendorName;
return { return {
dispatcherId: "", dispatcherId: "",
dispatcherName: wo.assignedTo, dispatcherName: wo.assignedTo,
company: vendorName ?? "", company: assigned?.vendorName ?? "",
vendorId: assigned ? String(assigned.vendorId ?? "").trim() : "",
tech: "", tech: "",
completedDate: wo.completedDate, completedDate: wo.completedDate,
woNumber: wo.workOrderNumber || wo.internalWONumber, woNumber: wo.workOrderNumber || wo.internalWONumber,

View file

@ -0,0 +1,192 @@
import { fireEvent, screen } from "@testing-library/react";
import { useEffect } from "react";
import { useForm, useWatch, type Control } from "react-hook-form";
import { describe, expect, it } from "vitest";
import { VendorTradeSpecialtiesField } from "@/app/(protected)/vendors/_components/vendor-trade-specialties-field";
import {
emptyRosterTechnician,
emptyVendorCompanyRosterForm,
type VendorCompanyRosterFormValues,
} from "@/domain/vendors/schemas/vendor-roster-schema";
import { renderWithProviders } from "@/test/test-utils";
function TradeSpecialtiesSpy({
control,
valuesRef,
}: {
control: Control<VendorCompanyRosterFormValues>;
valuesRef: { current: string };
}) {
const value = useWatch({ control, name: "technicians.0.tradeSpecialties" }) ?? "";
useEffect(() => {
valuesRef.current = value;
}, [value, valuesRef]);
return null;
}
function TradeSpecialtiesHarness({
tradeOptions,
tradeOptionsLoading = false,
initialTradeSpecialties = "",
valuesRef,
}: {
tradeOptions: string[];
tradeOptionsLoading?: boolean;
initialTradeSpecialties?: string;
valuesRef: { current: string };
}) {
const { control } = useForm<VendorCompanyRosterFormValues>({
defaultValues: {
...emptyVendorCompanyRosterForm,
technicians: [{ ...emptyRosterTechnician, tradeSpecialties: initialTradeSpecialties }],
},
});
return (
<>
<VendorTradeSpecialtiesField
control={control}
index={0}
tradeOptions={tradeOptions}
tradeOptionsLoading={tradeOptionsLoading}
/>
<TradeSpecialtiesSpy control={control} valuesRef={valuesRef} />
</>
);
}
function getAddTradeInput(): HTMLElement {
return screen.getByRole("combobox", { name: "Add Trade" });
}
function typeAndCommitTrade(input: HTMLElement, value: string, commit: "enter" | "button") {
fireEvent.change(input, { target: { value } });
if (commit === "enter") {
fireEvent.keyDown(input, { key: "Enter" });
} else {
fireEvent.click(screen.getByRole("button", { name: "Add trade" }));
}
}
describe("VendorTradeSpecialtiesField", () => {
it("offers facet trades as selectable options when the facets query is populated", async () => {
const valuesRef = { current: "" };
renderWithProviders(
<TradeSpecialtiesHarness
tradeOptions={["Plumbing", "HVAC", "Electrical"]}
valuesRef={valuesRef}
/>,
);
const input = getAddTradeInput();
fireEvent.change(input, { target: { value: "H" } });
fireEvent.click(await screen.findByRole("option", { name: "HVAC" }));
expect(await screen.findByText("HVAC (primary)")).toBeInTheDocument();
expect(valuesRef.current).toBe("HVAC");
});
it("does not fail open while the trades facet is still loading", () => {
const valuesRef = { current: "" };
renderWithProviders(
<TradeSpecialtiesHarness tradeOptions={[]} tradeOptionsLoading valuesRef={valuesRef} />,
);
// A cold fetch must not look like an outage.
expect(screen.queryByText(/trade list is unavailable right now/i)).toBeNull();
const input = getAddTradeInput();
typeAndCommitTrade(input, "Plumbing", "enter");
expect(screen.getByText(/trades are still loading/i)).toBeInTheDocument();
expect(screen.queryByText("Plumbing (primary)")).toBeNull();
expect(valuesRef.current).toBe("");
});
it("has no selectable options when the trades facet is empty, warns, and still accepts typed trades", () => {
const valuesRef = { current: "" };
renderWithProviders(<TradeSpecialtiesHarness tradeOptions={[]} valuesRef={valuesRef} />);
expect(screen.getByText(/trade list is unavailable right now/i)).toBeInTheDocument();
const input = getAddTradeInput();
fireEvent.change(input, { target: { value: "P" } });
expect(screen.queryByRole("listbox")).toBeNull();
expect(screen.queryAllByRole("option")).toHaveLength(0);
typeAndCommitTrade(input, "Plumbing", "enter");
expect(screen.getByText("Plumbing (primary)")).toBeInTheDocument();
expect(valuesRef.current).toBe("Plumbing");
});
it("rejects trades outside the canonical list with visible feedback and keeps the draft", () => {
const valuesRef = { current: "" };
renderWithProviders(
<TradeSpecialtiesHarness tradeOptions={["Plumbing", "HVAC"]} valuesRef={valuesRef} />,
);
const input = getAddTradeInput();
typeAndCommitTrade(input, "Sprinkler Fitting", "button");
expect(screen.getByText(/is not in the trades list/i)).toBeInTheDocument();
expect(screen.queryByText("Sprinkler Fitting (primary)")).toBeNull();
expect(valuesRef.current).toBe("");
expect(input).toHaveValue("Sprinkler Fitting");
});
it("commits the canonical casing when a typed trade matches case-insensitively", () => {
const valuesRef = { current: "" };
renderWithProviders(
<TradeSpecialtiesHarness tradeOptions={["Plumbing", "HVAC"]} valuesRef={valuesRef} />,
);
const input = getAddTradeInput();
typeAndCommitTrade(input, "hvac", "enter");
expect(screen.getByText("HVAC (primary)")).toBeInTheDocument();
expect(valuesRef.current).toBe("HVAC");
});
it("keeps legacy free-text trades visible, removable, and re-selectable", async () => {
const valuesRef = { current: "" };
renderWithProviders(
<TradeSpecialtiesHarness
tradeOptions={["Plumbing", "HVAC"]}
initialTradeSpecialties="Backflow Testing, HVAC"
valuesRef={valuesRef}
/>,
);
expect(screen.getByText("Backflow Testing (primary)")).toBeInTheDocument();
expect(screen.getByText("HVAC")).toBeInTheDocument();
fireEvent.click(screen.getByTestId("remove-trade-Backflow Testing"));
expect(screen.queryByText(/Backflow Testing/)).toBeNull();
expect(valuesRef.current).toBe("HVAC");
const input = getAddTradeInput();
fireEvent.change(input, { target: { value: "Back" } });
fireEvent.click(await screen.findByRole("option", { name: "Backflow Testing" }));
expect(await screen.findByText("Backflow Testing")).toBeInTheDocument();
expect(valuesRef.current).toBe("HVAC, Backflow Testing");
});
it("flags duplicate trades instead of silently doing nothing", () => {
const valuesRef = { current: "" };
renderWithProviders(
<TradeSpecialtiesHarness
tradeOptions={["Plumbing"]}
initialTradeSpecialties="Plumbing"
valuesRef={valuesRef}
/>,
);
const input = getAddTradeInput();
typeAndCommitTrade(input, "plumbing", "enter");
expect(screen.getByText(/is already on this technician/i)).toBeInTheDocument();
expect(valuesRef.current).toBe("Plumbing");
});
});

View file

@ -177,7 +177,7 @@ describe("VendorsListPage", () => {
expect(screen.getByRole("heading", { level: 2, name: "Adam Whyte" })).toBeInTheDocument(); expect(screen.getByRole("heading", { level: 2, name: "Adam Whyte" })).toBeInTheDocument();
}); });
it("blocks deactivation when the preflight reports open work orders", async () => { it("offers deactivate-anyway when the preflight reports open work orders", async () => {
setupDefaults(); setupDefaults();
useVendorCompanyRoster.mockReturnValue({ useVendorCompanyRoster.mockReturnValue({
data: activeRoster, data: activeRoster,
@ -213,13 +213,52 @@ describe("VendorsListPage", () => {
await userEvent.click(screen.getByRole("switch", { name: "Active status" })); await userEvent.click(screen.getByRole("switch", { name: "Active status" }));
expect( expect(
screen.getByText(/cannot be deactivated because it still has open work orders/), screen.getByText(
/will no longer be selectable for new work orders\. It still has 1 open work order —/,
),
).toBeInTheDocument(); ).toBeInTheDocument();
expect(screen.getByText(/Boiler repair/)).toBeInTheDocument(); expect(screen.getByRole("link", { name: /Boiler repair/ })).toHaveAttribute(
expect(screen.getByRole("button", { name: /^Deactivate$/ })).toBeDisabled(); "href",
expect(mutate).not.toHaveBeenCalled(); "/workorders/101",
);
const confirm = screen.getByRole("button", { name: "Deactivate anyway" });
expect(confirm).toBeEnabled();
await userEvent.click(confirm);
expect(mutate).toHaveBeenCalledWith({ id: 1, confirmOpenWorkOrders: true }, expect.anything());
}); });
it("deactivates without the confirmation flag when nothing is linked", async () => {
setupDefaults();
useVendorCompanyRoster.mockReturnValue({
data: activeRoster,
isLoading: false,
isError: false,
error: null,
refetch: vi.fn(),
});
useVendorDeactivationImpact.mockReturnValue({
data: { vendorId: 1, canDeactivate: true, openWorkOrders: [] },
isLoading: false,
error: null,
});
useVendorsList.mockImplementation((params: { isActive?: boolean; pageSize?: number }) => {
if (params.pageSize === 1) return result([], 1);
return params.isActive ? result([activeVendor], 1) : result([], 0);
});
renderWithProviders(<VendorsListPage />, { route: "/vendors", withAuth: false });
await userEvent.click(screen.getByRole("button", { name: "Edit vendor Gateway Plumbing" }));
await userEvent.click(screen.getByRole("switch", { name: "Active status" }));
expect(screen.queryByText(/It still has/)).not.toBeInTheDocument();
await userEvent.click(screen.getByRole("button", { name: /^Deactivate$/ }));
expect(mutate).toHaveBeenCalledWith({ id: 1, confirmOpenWorkOrders: false }, expect.anything());
}, 10_000);
it("preserves inline edits when deactivation is cancelled", async () => { it("preserves inline edits when deactivation is cancelled", async () => {
setupDefaults(); setupDefaults();
useVendorCompanyRoster.mockReturnValue({ useVendorCompanyRoster.mockReturnValue({
@ -247,7 +286,7 @@ describe("VendorsListPage", () => {
await userEvent.type(company, "Draft Company Name"); await userEvent.type(company, "Draft Company Name");
await userEvent.click(screen.getByRole("switch", { name: "Active status" })); await userEvent.click(screen.getByRole("switch", { name: "Active status" }));
await userEvent.click( await userEvent.click(
within(screen.getByRole("dialog", { name: "Deactivate Vendor" })).getByRole("button", { within(screen.getByRole("dialog", { name: "Deactivate this vendor?" })).getByRole("button", {
name: "Cancel", name: "Cancel",
}), }),
); );

View file

@ -0,0 +1,125 @@
import { screen, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { describe, expect, it, vi } from "vitest";
import { DispatcherFilter } from "@/app/(protected)/workorders/_components/list/filter-bar/dispatcher-filter";
import { ASSIGNEE_FILTER_UNASSIGNED } from "@/domain/work-orders/types/work-order-list";
import { renderWithProviders } from "@/test/test-utils";
const users = [
{ id: "u1", name: "Alex Rivera" },
{ id: "u2", name: "Blair Cho" },
];
describe("DispatcherFilter (SH-242)", () => {
it("renders a plain trigger without a boxed border", () => {
renderWithProviders(
<DispatcherFilter selectedIds={new Set()} onChange={vi.fn()} users={users} />,
{ withAuth: false },
);
const trigger = screen.getByRole("button", { name: "All dispatchers" });
expect(trigger.className.split(/\s+/)).not.toContain("border");
expect(trigger.style.height).not.toBe("38px");
expect(trigger.style.borderColor).toBe("");
expect(trigger.style.background).toBe("");
expect(trigger.style.fontWeight).toBe("500");
});
it("opens the menu from the keyboard", async () => {
const user = userEvent.setup();
renderWithProviders(
<DispatcherFilter
selectedIds={new Set()}
onChange={vi.fn()}
users={users}
currentUserId="u1"
/>,
{ withAuth: false },
);
screen.getByRole("button", { name: "All dispatchers" }).focus();
await user.keyboard("{Enter}");
expect(screen.getByRole("button", { name: "My WOs only" })).toBeInTheDocument();
});
it("shows loading skeleton while dispatchers are fetching", () => {
renderWithProviders(
<DispatcherFilter selectedIds={new Set()} onChange={vi.fn()} users={[]} usersLoading />,
{ withAuth: false },
);
expect(screen.getByLabelText("Loading dispatchers")).toBeInTheDocument();
expect(screen.queryByRole("button", { name: "All dispatchers" })).not.toBeInTheDocument();
});
it("disables the trigger when requested", () => {
renderWithProviders(
<DispatcherFilter selectedIds={new Set()} onChange={vi.fn()} users={users} disabled />,
{ withAuth: false },
);
expect(screen.getByRole("button", { name: "All dispatchers" })).toBeDisabled();
});
it("labels My WOs, Unassigned, and a multi-select count", () => {
const { rerender } = renderWithProviders(
<DispatcherFilter
selectedIds={new Set(["u1"])}
onChange={vi.fn()}
users={users}
currentUserId="u1"
/>,
{ withAuth: false },
);
expect(screen.getByRole("button", { name: "My WOs" })).toBeInTheDocument();
rerender(
<DispatcherFilter
selectedIds={new Set([ASSIGNEE_FILTER_UNASSIGNED])}
onChange={vi.fn()}
users={users}
currentUserId="u1"
/>,
);
expect(screen.getByRole("button", { name: "Unassigned" })).toBeInTheDocument();
rerender(
<DispatcherFilter
selectedIds={new Set(["u1", "u2"])}
onChange={vi.fn()}
users={users}
currentUserId="u1"
/>,
);
expect(screen.getByRole("button", { name: "2 selected" })).toBeInTheDocument();
});
it("scopes All, My WOs, Unassigned, and a named dispatcher", async () => {
const user = userEvent.setup();
const onChange = vi.fn();
renderWithProviders(
<DispatcherFilter
selectedIds={new Set()}
onChange={onChange}
users={users}
currentUserId="u1"
/>,
{ withAuth: false },
);
await user.click(screen.getByRole("button", { name: "All dispatchers" }));
await user.click(screen.getByRole("button", { name: "My WOs only" }));
expect(onChange).toHaveBeenLastCalledWith(new Set(["u1"]));
await user.click(screen.getByRole("button", { name: "Unassigned" }));
expect(onChange).toHaveBeenLastCalledWith(new Set([ASSIGNEE_FILTER_UNASSIGNED]));
await user.click(screen.getByRole("button", { name: "Blair Cho" }));
expect(onChange).toHaveBeenLastCalledWith(new Set(["u2"]));
const menu = screen.getByRole("presentation");
await user.click(within(menu).getByRole("button", { name: "All dispatchers" }));
expect(onChange).toHaveBeenLastCalledWith(new Set());
});
});

View file

@ -9,6 +9,7 @@ const incompleteRow = {
dispatcherId: "", dispatcherId: "",
dispatcherName: "", dispatcherName: "",
company: "", company: "",
vendorId: "",
tech: "", tech: "",
completedDate: "", completedDate: "",
woNumber: "", woNumber: "",

View file

@ -9,6 +9,7 @@ const pastDueRow = {
dispatcherId: "", dispatcherId: "",
dispatcherName: "", dispatcherName: "",
company: "", company: "",
vendorId: "",
tech: "", tech: "",
completedDate: "", completedDate: "",
woNumber: "", woNumber: "",

View file

@ -120,12 +120,12 @@ describe("StatusCell pending uplift closability", () => {
expect(onChangeStatus).toHaveBeenCalledWith("Completed"); expect(onChangeStatus).toHaveBeenCalledWith("Completed");
}); });
it("disables Completed when company is missing (SH-183)", () => { it("disables Completed when company is missing", () => {
const onChangeStatus = vi.fn(); const onChangeStatus = vi.fn();
renderWithProviders( renderWithProviders(
<StatusCell <StatusCell
wo={makeRow({ company: "" })} wo={makeRow({ company: "", vendorId: "" })}
onChangeStatus={onChangeStatus} onChangeStatus={onChangeStatus}
onOpenSlideOver={vi.fn()} onOpenSlideOver={vi.fn()}
/>, />,
@ -141,7 +141,119 @@ describe("StatusCell pending uplift closability", () => {
expect(onChangeStatus).not.toHaveBeenCalled(); expect(onChangeStatus).not.toHaveBeenCalled();
}); });
it("allows Completed when technician is empty if company is set (SH-183)", () => { it("allows Completed when vendorId is set even if company label is empty", () => {
const onChangeStatus = vi.fn();
renderWithProviders(
<StatusCell
wo={makeRow({ company: "", vendorId: "7", tech: "" })}
onChangeStatus={onChangeStatus}
onOpenSlideOver={vi.fn()}
/>,
{ withAuth: false },
);
fireEvent.click(screen.getByRole("button", { name: /in progress/i }));
fireEvent.click(screen.getByRole("button", { name: /completed/i }));
expect(onChangeStatus).toHaveBeenCalledWith("Completed");
});
it("allows Completed when vendorId has an empty company label and a live dispatch status", () => {
const onChangeStatus = vi.fn();
renderWithProviders(
<StatusCell
wo={makeRow({ company: "", vendorId: "7", tech: "", primaryDispatchStatus: "Sent" })}
onChangeStatus={onChangeStatus}
onOpenSlideOver={vi.fn()}
/>,
{ withAuth: false },
);
fireEvent.click(screen.getByRole("button", { name: /in progress/i }));
fireEvent.click(screen.getByRole("button", { name: /completed/i }));
expect(onChangeStatus).toHaveBeenCalledWith("Completed");
});
it.each(["Cancelled", "Canceled", "Refused"] as const)(
"disables Completed when vendorId is leftover from a %s dispatch",
(primaryDispatchStatus) => {
const onChangeStatus = vi.fn();
renderWithProviders(
<StatusCell
wo={makeRow({
company: "",
vendorId: "45",
tech: "",
primaryDispatchStatus,
})}
onChangeStatus={onChangeStatus}
onOpenSlideOver={vi.fn()}
/>,
{ withAuth: false },
);
fireEvent.click(screen.getByRole("button", { name: /in progress/i }));
const completed = screen.getByRole("button", { name: /completed/i });
expect(completed).toBeDisabled();
expect(completed).toHaveAttribute("title", "Missing: Company");
fireEvent.click(completed);
expect(onChangeStatus).not.toHaveBeenCalled();
},
);
it("disables Completed when the board omits vendorId after a Refused primary", () => {
const onChangeStatus = vi.fn();
renderWithProviders(
<StatusCell
wo={makeRow({
company: "",
vendorId: "",
tech: "",
primaryDispatchStatus: "Refused",
})}
onChangeStatus={onChangeStatus}
onOpenSlideOver={vi.fn()}
/>,
{ withAuth: false },
);
fireEvent.click(screen.getByRole("button", { name: /in progress/i }));
const completed = screen.getByRole("button", { name: /completed/i });
expect(completed).toBeDisabled();
expect(completed).toHaveAttribute("title", "Missing: Company");
});
it("allows Completed after a vendor patch clears leftover refused status", () => {
const onChangeStatus = vi.fn();
renderWithProviders(
<StatusCell
wo={makeRow({
company: "New Co",
vendorId: "9",
tech: "Pat",
primaryDispatchStatus: "",
})}
onChangeStatus={onChangeStatus}
onOpenSlideOver={vi.fn()}
/>,
{ withAuth: false },
);
fireEvent.click(screen.getByRole("button", { name: /in progress/i }));
fireEvent.click(screen.getByRole("button", { name: /completed/i }));
expect(onChangeStatus).toHaveBeenCalledWith("Completed");
});
it("allows Completed when technician is empty if company is set", () => {
const onChangeStatus = vi.fn(); const onChangeStatus = vi.fn();
renderWithProviders( renderWithProviders(

View file

@ -13,6 +13,7 @@ function buildTableData() {
endDrag: vi.fn(), endDrag: vi.fn(),
setUnschedOpen: vi.fn(), setUnschedOpen: vi.fn(),
triggerFlash: vi.fn(), triggerFlash: vi.fn(),
revealScheduledRow: vi.fn(),
setSelectedId: vi.fn(), setSelectedId: vi.fn(),
} as never; } as never;
} }

View file

@ -13,13 +13,13 @@ vi.mock("@/app/(protected)/workorders/_components/list/table/reschedule-notify",
weekMoveNotify, weekMoveNotify,
})); }));
function buildTableData(triggerFlash = vi.fn()) { function buildTableData(revealScheduledRow = vi.fn()) {
return { return {
dragState: { current: { dragId: null, overId: null } }, dragState: { current: { dragId: null, overId: null } },
setDragViz: vi.fn(), setDragViz: vi.fn(),
reorderRows: vi.fn(), reorderRows: vi.fn(),
endDrag: vi.fn(), endDrag: vi.fn(),
triggerFlash, revealScheduledRow,
setSelectedId: vi.fn(), setSelectedId: vi.fn(),
} as never; } as never;
} }
@ -28,11 +28,11 @@ describe("useWoTableRowHandlers schedule navigation (SH-240)", () => {
it("selects the scheduled weekday in the same week so the row stays visible", () => { it("selects the scheduled weekday in the same week so the row stays visible", () => {
const onNavigateToDay = vi.fn(); const onNavigateToDay = vi.fn();
const onPatch = vi.fn(); const onPatch = vi.fn();
const triggerFlash = vi.fn(); const revealScheduledRow = vi.fn();
const { result } = renderHook(() => const { result } = renderHook(() =>
useWoTableRowHandlers({ useWoTableRowHandlers({
tableData: buildTableData(triggerFlash), tableData: buildTableData(revealScheduledRow),
weekMonday: "2026-05-18", weekMonday: "2026-05-18",
onNavigateToDay, onNavigateToDay,
onOpenSlideOver: vi.fn(), onOpenSlideOver: vi.fn(),
@ -46,16 +46,17 @@ describe("useWoTableRowHandlers schedule navigation (SH-240)", () => {
expect(onPatch).toHaveBeenCalledWith("wo-1", applySchedulePatch("2026-05-21", false)); expect(onPatch).toHaveBeenCalledWith("wo-1", applySchedulePatch("2026-05-21", false));
expect(onNavigateToDay).toHaveBeenCalledWith("2026-05-21"); expect(onNavigateToDay).toHaveBeenCalledWith("2026-05-21");
expect(triggerFlash).toHaveBeenCalledWith("wo-1"); expect(revealScheduledRow).toHaveBeenCalledWith("wo-1");
expect(rescheduleNotify).not.toHaveBeenCalled(); expect(rescheduleNotify).not.toHaveBeenCalled();
}); });
it("sends cross-week view-it navigation to the target day, not Monday", () => { it("sends cross-week view-it navigation to the target day, not Monday", () => {
const onNavigateToDay = vi.fn(); const onNavigateToDay = vi.fn();
const revealScheduledRow = vi.fn();
const { result } = renderHook(() => const { result } = renderHook(() =>
useWoTableRowHandlers({ useWoTableRowHandlers({
tableData: buildTableData(), tableData: buildTableData(revealScheduledRow),
weekMonday: "2026-05-18", weekMonday: "2026-05-18",
onNavigateToDay, onNavigateToDay,
onOpenSlideOver: vi.fn(), onOpenSlideOver: vi.fn(),
@ -76,6 +77,7 @@ describe("useWoTableRowHandlers schedule navigation (SH-240)", () => {
const { onNavigate } = rescheduleNotify.mock.calls[0][1] as { onNavigate: () => void }; const { onNavigate } = rescheduleNotify.mock.calls[0][1] as { onNavigate: () => void };
onNavigate(); onNavigate();
expect(onNavigateToDay).toHaveBeenCalledWith("2026-05-27"); expect(onNavigateToDay).toHaveBeenCalledWith("2026-05-27");
expect(revealScheduledRow).toHaveBeenCalledWith("wo-1");
}); });
it("opens Advanced Filters for week-only moves instead of the removed Unscheduled section", () => { it("opens Advanced Filters for week-only moves instead of the removed Unscheduled section", () => {

View file

@ -0,0 +1,75 @@
import { act, renderHook } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import { useWorkOrdersDayPagination } from "@/app/(protected)/workorders/_hooks/use-work-orders-day-pagination";
import { DEFAULT_DAY_PAGE_SIZE } from "@/domain/work-orders/utils/day-pagination";
describe("useWorkOrdersDayPagination (SH-241)", () => {
const onRevealed = vi.fn();
const base = {
weekMonday: "2026-08-17",
selectedDayIso: "2026-08-17",
advActive: false,
dayItems: [] as { id: string }[],
onRevealed,
};
it("defaults to 50 rows per page", () => {
const { result } = renderHook(() => useWorkOrdersDayPagination(base));
expect(result.current.pageSize).toBe(DEFAULT_DAY_PAGE_SIZE);
expect(result.current.page).toBe(0);
});
it("resets to page 1 when switching days", () => {
const { result, rerender } = renderHook(
(props: typeof base) => useWorkOrdersDayPagination(props),
{ initialProps: base },
);
act(() => result.current.goToNextPage());
expect(result.current.page).toBe(1);
rerender({ ...base, selectedDayIso: "2026-08-18" });
expect(result.current.page).toBe(0);
});
it("resets to page 1 when the week changes", () => {
const { result, rerender } = renderHook(
(props: typeof base) => useWorkOrdersDayPagination(props),
{ initialProps: base },
);
act(() => result.current.goToNextPage());
rerender({ ...base, weekMonday: "2026-08-10" });
expect(result.current.page).toBe(0);
});
it("resets to page 1 when rows-per-page changes", () => {
const { result } = renderHook(() => useWorkOrdersDayPagination(base));
act(() => result.current.goToNextPage());
act(() => result.current.setPageSize(25));
expect(result.current.page).toBe(0);
expect(result.current.pageSize).toBe(25);
});
it("resets to page 1 when a global filter is applied", () => {
const { result, rerender } = renderHook(
(props: typeof base) => useWorkOrdersDayPagination(props),
{ initialProps: base },
);
act(() => result.current.goToNextPage());
rerender({ ...base, advActive: true });
expect(result.current.page).toBe(0);
});
it("selects the page that contains a revealed row beyond the first page", () => {
const dayItems = Array.from({ length: 60 }, (_, index) => ({ id: `wo-${index}` }));
const revealed = vi.fn();
const { result, rerender } = renderHook(
(props: typeof base) => useWorkOrdersDayPagination(props),
{ initialProps: { ...base, onRevealed: revealed } },
);
act(() => result.current.revealRow("wo-55"));
rerender({ ...base, dayItems, onRevealed: revealed, selectedDayIso: "2026-08-18" });
expect(result.current.page).toBe(1);
expect(revealed).toHaveBeenCalledWith("wo-55");
});
});

View file

@ -0,0 +1,31 @@
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { describe, expect, it, vi } from "vitest";
import { WoDayPaginationFooter } from "@/app/(protected)/workorders/_components/list/table/wo-day-pagination-footer";
describe("WoDayPaginationFooter (SH-241)", () => {
it("changes rows-per-page and navigates pages", async () => {
const user = userEvent.setup();
const onPageSizeChange = vi.fn();
const onNext = vi.fn();
render(
<WoDayPaginationFooter
dayLabel="Mon · 06/01"
itemCount={60}
pageSize={50}
pageIndex={0}
pageCount={2}
onPageSizeChange={onPageSizeChange}
onPrev={vi.fn()}
onNext={onNext}
/>,
);
expect(screen.getByText("MON · 06/01 · 60 items")).toBeInTheDocument();
expect(screen.getByText("Page 1 of 2")).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Previous page" })).toBeDisabled();
await user.selectOptions(screen.getByLabelText("Rows per page"), "25");
expect(onPageSizeChange).toHaveBeenCalledWith(25);
await user.click(screen.getByRole("button", { name: "Next page" }));
expect(onNext).toHaveBeenCalled();
});
});

View file

@ -43,6 +43,7 @@ function tableData(
selectedRow: null, selectedRow: null,
flashId: null, flashId: null,
triggerFlash: vi.fn(), triggerFlash: vi.fn(),
revealScheduledRow: vi.fn(),
dragViz: null, dragViz: null,
setDragViz: vi.fn(), setDragViz: vi.fn(),
dragState: { current: { dragId: null, overId: null } }, dragState: { current: { dragId: null, overId: null } },
@ -56,6 +57,15 @@ function tableData(
scopeTotalCount: 0, scopeTotalCount: 0,
totalCount: 0, totalCount: 0,
search: "no-match", search: "no-match",
showDayPagination: false,
dayPageSize: 50,
dayPageIndex: 0,
dayPageCount: 1,
dayItemCount: 0,
dayFooterLabel: "Mon · 05/18",
setDayPageSize: vi.fn(),
goToPrevDayPage: vi.fn(),
goToNextDayPage: vi.fn(),
...overrides, ...overrides,
} as ReturnTypeOfUseWorkOrdersTableData; } as ReturnTypeOfUseWorkOrdersTableData;
} }
@ -79,7 +89,7 @@ function renderTable(data: ReturnTypeOfUseWorkOrdersTableData) {
); );
} }
describe("WoTable search empty vs Unassigned queue (SH-165/SH-240)", () => { describe("WoTable search empty vs Unassigned queue (SH-165/SH-241)", () => {
it("shows empty search only after the Unassigned queue has settled empty", () => { it("shows empty search only after the Unassigned queue has settled empty", () => {
const { getByText, queryByText } = renderTable(tableData()); const { getByText, queryByText } = renderTable(tableData());

View file

@ -0,0 +1,112 @@
import { screen } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import WorkOrdersListPage from "@/app/(protected)/workorders/index";
import { EMPTY_ADVANCED_FILTERS } from "@/domain/work-orders/types/work-order-advanced-filters";
import { renderWithProviders } from "@/test/test-utils";
vi.mock("react-router", async () => {
const actual = await vi.importActual<typeof import("react-router")>("react-router");
return {
...actual,
useNavigate: () => vi.fn(),
};
});
vi.mock("@/providers/auth-context", () => ({
useAuthContext: () => ({ user: { id: "u1" } }),
}));
vi.mock("@/app/(protected)/workorders/_hooks/use-work-orders-list-filters", () => ({
useWorkOrdersListFilters: () => ({
search: "",
handleSearchChange: vi.fn(),
advOpen: false,
setAdvOpen: vi.fn(),
advApplied: null,
advInitial: EMPTY_ADVANCED_FILTERS,
applyAdvancedFilters: vi.fn(),
clearAdvancedFilters: vi.fn(),
clearAdvancedFilterChip: vi.fn(),
activeFilterCount: 0,
weekMonday: "2026-07-20",
selectedDayIso: "2026-07-20",
setWeekMonday: vi.fn(),
setSelectedDayIso: vi.fn(),
navigateToDay: vi.fn(),
dispatcherIds: new Set<string>(),
setDispatcherIds: vi.fn(),
types: new Set(),
setTypes: vi.fn(),
}),
}));
vi.mock("@/app/(protected)/workorders/_hooks/use-work-orders-table-data", () => ({
useWorkOrdersTableData: () => ({
rows: [],
totalCount: 0,
scopeTotalCount: 0,
isFetching: false,
isLoading: false,
setSelectedId: vi.fn(),
patchRow: vi.fn(),
}),
}));
vi.mock("@/domain/work-orders/use-cases/use-work-order-table-mutations", () => ({
useWorkOrderTableMutations: () => ({ patchField: vi.fn() }),
}));
vi.mock("@/domain/work-orders/use-cases/use-work-orders-list", () => ({
useWorkOrderUsers: () => ({
data: [{ id: "u1", name: "Dispatcher One" }],
}),
}));
vi.mock("@/domain/work-orders/use-cases/use-work-order-detail", () => ({
useWorkOrderLocations: () => ({ data: [] }),
useWorkOrderVendors: () => ({ data: [] }),
useWorkOrderBoardDetail: () => ({
data: undefined,
isLoading: false,
isFetching: false,
error: null,
refetch: vi.fn(),
}),
}));
vi.mock("@/domain/settings/dropdown-options/use-cases/use-dropdown-options-by-category", () => ({
useDropdownOptionsByCategory: () => ({ data: [] }),
}));
vi.mock("@/domain/work-orders/mocks/is-wo-table-mock-enabled", () => ({
isWoTableMockEnabled: () => false,
}));
vi.mock("@/app/(protected)/workorders/_components/list/table/wo-table", () => ({
WoTable: () => null,
}));
vi.mock("@/app/(protected)/workorders/_components/media/media-upload-modal", () => ({
MediaUploadModal: () => null,
}));
describe("Work Orders dispatcher filter layout (SH-242)", () => {
it("places the dispatcher trigger under the title and above week/day controls", () => {
renderWithProviders(<WorkOrdersListPage />, { route: "/workorders", withAuth: false });
const title = screen.getByRole("heading", { name: "Work Orders" });
const dispatcher = screen.getByRole("button", { name: "All dispatchers" });
const weekAndDay = screen.getByLabelText("Week and day");
const week = screen.getByRole("button", { name: "Week 7/20–7/24" });
expect(title.compareDocumentPosition(dispatcher) & Node.DOCUMENT_POSITION_FOLLOWING).toBe(
Node.DOCUMENT_POSITION_FOLLOWING,
);
expect(dispatcher.compareDocumentPosition(weekAndDay) & Node.DOCUMENT_POSITION_FOLLOWING).toBe(
Node.DOCUMENT_POSITION_FOLLOWING,
);
expect(weekAndDay.contains(dispatcher)).toBe(false);
expect(weekAndDay.contains(week)).toBe(true);
expect(screen.getByText("0 of 0")).toBeInTheDocument();
});
});

View file

@ -5,6 +5,7 @@ import {
isLocalOnlyTablePatch, isLocalOnlyTablePatch,
mapDocStatusToApi, mapDocStatusToApi,
} from "@/domain/work-orders/mappers/work-order-board-patch-mapper"; } from "@/domain/work-orders/mappers/work-order-board-patch-mapper";
import { toVendorTablePatch } from "@/domain/work-orders/utils/vendor-assignment-patch";
describe("mapDocStatusToApi", () => { describe("mapDocStatusToApi", () => {
it("maps NN to NotNeeded", () => { it("maps NN to NotNeeded", () => {
@ -25,6 +26,10 @@ describe("isLocalOnlyTablePatch", () => {
it("does not treat site as local-only", () => { it("does not treat site as local-only", () => {
expect(isLocalOnlyTablePatch({ site: "BK5" })).toBe(false); expect(isLocalOnlyTablePatch({ site: "BK5" })).toBe(false);
}); });
it("treats primaryDispatchStatus overlay as local-only", () => {
expect(isLocalOnlyTablePatch({ primaryDispatchStatus: "" })).toBe(true);
});
}); });
describe("expandTablePatchToBoardFieldOps", () => { describe("expandTablePatchToBoardFieldOps", () => {
@ -136,6 +141,17 @@ describe("expandTablePatchToBoardFieldOps", () => {
}), }),
).toEqual([{ field: "vendorId", value: "7" }]); ).toEqual([{ field: "vendorId", value: "7" }]);
expect(
expandTablePatchToBoardFieldOps(
toVendorTablePatch({
vendorId: "9",
company: "New Co",
tech: "Pat",
techPhone: "555",
}),
),
).toEqual([{ field: "vendorId", value: "9" }]);
expect( expect(
expandTablePatchToBoardFieldOps({ expandTablePatchToBoardFieldOps({
site: "BK5", site: "BK5",

View file

@ -50,3 +50,52 @@ describe("mapWorkOrderTableRow isAddOn", () => {
expect(row.isAddOn).toBe(false); expect(row.isAddOn).toBe(false);
}); });
}); });
describe("mapWorkOrderTableRow primaryDispatchStatus", () => {
it.each([
["primaryDispatchStatus", "Refused"],
["PrimaryDispatchStatus", "Canceled"],
] as const)("maps exact DTO field %s onto primaryDispatchStatus", (key, value) => {
const row = mapWorkOrderTableRow({ id: 9, vendorId: 45, [key]: value });
expect(row.vendorId).toBe("45");
expect(row.primaryDispatchStatus).toBe(value);
});
it("maps backend #74 omitted vendor with Refused primaryDispatchStatus", () => {
const row = mapWorkOrderTableRow({
id: 9,
vendorId: null,
vendorName: null,
primaryDispatchStatus: "Refused",
});
expect(row.vendorId).toBe("");
expect(row.company).toBe("");
expect(row.primaryDispatchStatus).toBe("Refused");
});
it("maps live vendorId with empty company label and Sent status", () => {
const row = mapWorkOrderTableRow({
id: 9,
vendorId: 45,
vendorName: "",
primaryDispatchStatus: "Sent",
});
expect(row.vendorId).toBe("45");
expect(row.company).toBe("");
expect(row.primaryDispatchStatus).toBe("Sent");
});
it("maps vendor PATCH replacement dispatch as Pending with a new primaryDispatchId", () => {
const row = mapWorkOrderTableRow({
id: 1,
VendorId: 5,
VendorName: "New Co",
PrimaryDispatchStatus: "Pending",
PrimaryDispatchId: 11,
});
expect(row.vendorId).toBe("5");
expect(row.company).toBe("New Co");
expect(row.primaryDispatchStatus).toBe("Pending");
expect(row.primaryDispatchId).toBe(11);
});
});

View file

@ -21,10 +21,16 @@ vi.mock("@/domain/work-orders/api/work-orders-api", () => ({
vi.mock("react-toastify", () => ({ toast: { error: vi.fn() } })); vi.mock("react-toastify", () => ({ toast: { error: vi.fn() } }));
import { mapWorkOrderTableRow } from "@/domain/work-orders/mappers/work-order-table-row-mapper";
import { import {
applySchedulePatch, applySchedulePatch,
useWorkOrderTableMutations, useWorkOrderTableMutations,
} from "@/domain/work-orders/use-cases/use-work-order-table-mutations"; } from "@/domain/work-orders/use-cases/use-work-order-table-mutations";
import { toVendorTablePatch } from "@/domain/work-orders/utils/vendor-assignment-patch";
import {
getClosabilityGaps,
tableRowToClosabilityInput,
} from "@/domain/work-orders/utils/wo-closability";
function delay(ms: number): Promise<void> { function delay(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms)); return new Promise((resolve) => setTimeout(resolve, ms));
@ -345,3 +351,135 @@ describe("useWorkOrderTableMutations schedule isAddOn from BE", () => {
); );
}); });
}); });
describe("useWorkOrderTableMutations vendor assignment round-trip", () => {
let rowState: WorkOrderTableRow;
beforeEach(() => {
getById.mockReset();
update.mockReset();
patchBoardField.mockReset();
rowState = {
...BASE_ROW,
vendorId: "1",
company: "",
tech: "",
completedDate: "2026-07-15",
docStatus: "Yes",
mediaCount: 1,
woNumber: "20260623001",
primaryDispatchStatus: "Refused",
primaryDispatchId: 10,
dispatchRowVersion: "disp-v1",
};
});
it("keeps Company off the gap list when vendor PATCH returns a Pending assignment", async () => {
const returnedRow = mapWorkOrderTableRow({
id: 42,
VendorId: 9,
VendorName: "New Co",
PrimaryDispatchStatus: "Pending",
PrimaryDispatchId: 11,
DispatcherId: "d1",
DispatcherName: "Dispatcher",
CompletedDate: "2026-07-15",
InternalWONumber: "20260623001",
PM: "HVAC",
DocStatus: "Yes",
MediaCount: 1,
WorkOrderType: "PM",
});
patchBoardField.mockResolvedValue(returnedRow);
const onPatch = vi.fn();
const { result } = renderHook(
() =>
useWorkOrderTableMutations({
onPatch,
clearPatch: () => undefined,
getRow: () => rowState,
}),
{ wrapper: makeWrapper() },
);
act(() => {
result.current.patchField(
42,
toVendorTablePatch({
vendorId: "9",
company: "New Co",
tech: "",
techPhone: "",
}),
);
});
await waitFor(() =>
expect(onPatch).toHaveBeenCalledWith(
42,
expect.objectContaining({
primaryDispatchStatus: "Pending",
primaryDispatchId: 11,
vendorId: "9",
}),
),
);
expect(patchBoardField).toHaveBeenCalledWith(
42,
expect.objectContaining({
field: "vendorId",
value: "9",
dispatchVersion: "disp-v1",
primaryDispatchId: 10,
}),
);
const payload = patchBoardField.mock.calls[0]?.[1] as Record<string, unknown>;
expect(payload).not.toHaveProperty("primaryDispatchStatus");
expect(returnedRow.primaryDispatchId).not.toBe(10);
expect(getClosabilityGaps(tableRowToClosabilityInput(returnedRow))).not.toContain("Company");
});
it("keeps a Company gap when vendor PATCH still returns Refused", async () => {
const returnedRow = mapWorkOrderTableRow({
id: 42,
VendorId: 9,
VendorName: "New Co",
PrimaryDispatchStatus: "Refused",
PrimaryDispatchId: 10,
});
patchBoardField.mockResolvedValue(returnedRow);
const onPatch = vi.fn();
const { result } = renderHook(
() =>
useWorkOrderTableMutations({
onPatch,
clearPatch: () => undefined,
getRow: () => rowState,
}),
{ wrapper: makeWrapper() },
);
act(() => {
result.current.patchField(
42,
toVendorTablePatch({
vendorId: "9",
company: "New Co",
tech: "",
techPhone: "",
}),
);
});
await waitFor(() =>
expect(onPatch).toHaveBeenCalledWith(
42,
expect.objectContaining({ primaryDispatchStatus: "Refused" }),
),
);
expect(getClosabilityGaps(tableRowToClosabilityInput(returnedRow))).toContain("Company");
});
});

View file

@ -0,0 +1,84 @@
import { describe, expect, it } from "vitest";
import {
DAY_PAGE_SIZE_OPTIONS,
DEFAULT_DAY_PAGE_SIZE,
findItemPageIndex,
formatDayPaginationSummary,
pageIndexForItem,
paginateItems,
shouldShowDayPagination,
} from "@/domain/work-orders/utils/day-pagination";
describe("day pagination utils (SH-241)", () => {
it("defaults to 50 and exposes 25/50/100/200", () => {
expect(DEFAULT_DAY_PAGE_SIZE).toBe(50);
expect(DAY_PAGE_SIZE_OPTIONS).toEqual([25, 50, 100, 200]);
});
it("paginates 60 items at 50 per page", () => {
const items = Array.from({ length: 60 }, (_, index) => index);
const first = paginateItems(items, 0, 50);
expect(first.visible).toHaveLength(50);
expect(first.pageCount).toBe(2);
const second = paginateItems(items, 1, 50);
expect(second.visible).toEqual(items.slice(50));
});
it("re-paginates immediately when page size changes", () => {
const items = Array.from({ length: 60 }, (_, index) => index);
expect(paginateItems(items, 0, 25).visible).toHaveLength(25);
expect(paginateItems(items, 0, 25).pageCount).toBe(3);
});
it("clamps an out-of-range page to the last page", () => {
const paged = paginateItems([1, 2, 3], 9, 2);
expect(paged.pageIndex).toBe(1);
expect(paged.visible).toEqual([3]);
});
it("shows pagination in day view even when the page is not full", () => {
expect(
shouldShowDayPagination({
flatListMode: false,
isLoading: false,
noSearchResults: false,
}),
).toBe(true);
});
it("hides pagination in flat-list / loading / empty search", () => {
expect(
shouldShowDayPagination({
flatListMode: true,
isLoading: false,
noSearchResults: false,
}),
).toBe(false);
expect(
shouldShowDayPagination({
flatListMode: false,
isLoading: true,
noSearchResults: false,
}),
).toBe(false);
expect(
shouldShowDayPagination({
flatListMode: false,
isLoading: false,
noSearchResults: true,
}),
).toBe(false);
});
it("maps a row past the first page onto page 2 at the default size", () => {
const items = Array.from({ length: 60 }, (_, index) => ({ id: `wo-${index}` }));
expect(pageIndexForItem(50, 50)).toBe(1);
expect(findItemPageIndex(items, "wo-55", 50)).toBe(1);
expect(findItemPageIndex(items, "missing", 50)).toBeNull();
});
it("formats the AAP footer summary", () => {
expect(formatDayPaginationSummary("Mon · 06/01", 9)).toBe("MON · 06/01 · 9 items");
expect(formatDayPaginationSummary("Tue · 06/02", 1)).toBe("TUE · 06/02 · 1 item");
});
});

View file

@ -0,0 +1,37 @@
import { describe, expect, it } from "vitest";
import { toVendorTablePatch } from "@/domain/work-orders/utils/vendor-assignment-patch";
import {
getClosabilityGaps,
tableRowToClosabilityInput,
} from "@/domain/work-orders/utils/wo-closability";
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
describe("toVendorTablePatch", () => {
it("clears leftover primaryDispatchStatus so a new vendor is a live assignment", () => {
const patch = toVendorTablePatch({
vendorId: "9",
company: "New Co",
tech: "Pat",
techPhone: "555",
});
expect(patch.primaryDispatchStatus).toBe("");
expect(patch.vendorId).toBe("9");
const input = tableRowToClosabilityInput({
dispatcherId: "u1",
dispatcherName: "Alex",
company: patch.company ?? "",
vendorId: patch.vendorId ?? "",
tech: patch.tech ?? "",
completedDate: "2026-07-15",
woNumber: "20260623001",
pm: "HVAC PM",
docStatus: "Yes",
mediaCount: 1,
type: "PM",
primaryDispatchStatus: patch.primaryDispatchStatus,
} as WorkOrderTableRow);
expect(getClosabilityGaps(input)).not.toContain("Company");
});
});

View file

@ -1,6 +1,7 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { readDetailWoType } from "@/domain/work-orders/utils/read-detail-closability-fields"; import { readDetailWoType } from "@/domain/work-orders/utils/read-detail-closability-fields";
import type { WorkOrderDetail } from "@/domain/work-orders/types/work-order-detail"; import type { WorkOrderDetail } from "@/domain/work-orders/types/work-order-detail";
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
import { EMPTY_WIZARD_DRAFT } from "@/domain/work-orders/types/work-order-wizard"; import { EMPTY_WIZARD_DRAFT } from "@/domain/work-orders/types/work-order-wizard";
import type { WoClosabilityInput } from "@/domain/work-orders/utils/wo-closability"; import type { WoClosabilityInput } from "@/domain/work-orders/utils/wo-closability";
import { import {
@ -13,6 +14,7 @@ import {
hasPendingUplift, hasPendingUplift,
isCompletionStatusLabel, isCompletionStatusLabel,
isRealWoNumber, isRealWoNumber,
tableRowToClosabilityInput,
wizardDraftToClosabilityInput, wizardDraftToClosabilityInput,
} from "@/domain/work-orders/utils/wo-closability"; } from "@/domain/work-orders/utils/wo-closability";
@ -21,6 +23,7 @@ function makeInput(overrides: Partial<WoClosabilityInput> = {}): WoClosabilityIn
dispatcherId: "u1", dispatcherId: "u1",
dispatcherName: "Alex", dispatcherName: "Alex",
company: "Acme HVAC", company: "Acme HVAC",
vendorId: "",
tech: "Sam", tech: "Sam",
completedDate: "2026-07-01", completedDate: "2026-07-01",
woNumber: "1001", woNumber: "1001",
@ -74,7 +77,7 @@ describe("getClosabilityGaps", () => {
expect(getClosabilityGaps(makeInput())).toEqual([]); expect(getClosabilityGaps(makeInput())).toEqual([]);
}); });
it("allows company without technician (SH-183)", () => { it("allows company without technician", () => {
const gaps = getClosabilityGaps(makeInput({ tech: "" })); const gaps = getClosabilityGaps(makeInput({ tech: "" }));
expect(gaps).toEqual([]); expect(gaps).toEqual([]);
expect(gaps).not.toContain("Technician"); expect(gaps).not.toContain("Technician");
@ -85,6 +88,32 @@ describe("getClosabilityGaps", () => {
expect(getClosabilityGaps(makeInput({ company: " ", tech: "Sam" }))).toEqual(["Company"]); expect(getClosabilityGaps(makeInput({ company: " ", tech: "Sam" }))).toEqual(["Company"]);
}); });
it("allows Completed when vendorId is set and company/technician are empty", () => {
expect(getClosabilityGaps(makeInput({ company: "", vendorId: "7", tech: "" }))).toEqual([]);
});
it("blocks Completed when both company and vendorId are empty even with a technician", () => {
expect(getClosabilityGaps(makeInput({ company: "", vendorId: " ", tech: "Sam" }))).toEqual([
"Company",
]);
});
it.each(["PM", "Reactive", "Emergency", "Overdue", ""] as const)(
"blocks Completed without a company for type %s",
(type) => {
const gaps = getClosabilityGaps(
makeInput({
company: "",
vendorId: "",
type,
docStatus: "Yes",
mediaCount: 1,
}),
);
expect(gaps).toContain("Company");
},
);
it("lists field gaps before uplift and document gaps", () => { it("lists field gaps before uplift and document gaps", () => {
expect( expect(
getClosabilityGaps( getClosabilityGaps(
@ -188,6 +217,7 @@ describe("wizardDraftToClosabilityInput", () => {
dispatcherId: "u1", dispatcherId: "u1",
dispatcherName: "Alice", dispatcherName: "Alice",
company: "ACME", company: "ACME",
vendorId: "",
tech: "", tech: "",
completedDate: "", completedDate: "",
woNumber: "20260623001", woNumber: "20260623001",
@ -221,6 +251,7 @@ describe("detailToClosabilityInput", () => {
const input = detailToClosabilityInput(detail); const input = detailToClosabilityInput(detail);
expect(input.dispatcherName).toBe("Alice"); expect(input.dispatcherName).toBe("Alice");
expect(input.company).toBe("ACME Tech"); expect(input.company).toBe("ACME Tech");
expect(input.vendorId).toBe("");
expect(input.woNumber).toBe("20260623001"); expect(input.woNumber).toBe("20260623001");
expect(input.pm).toBe("HVAC PM"); expect(input.pm).toBe("HVAC PM");
expect(getClosabilityGaps(input)).toEqual([]); expect(getClosabilityGaps(input)).toEqual([]);
@ -277,6 +308,171 @@ describe("detailToClosabilityInput", () => {
expect(getClosabilityGaps(detailToClosabilityInput(detail))).toEqual(["At least 1 photo"]); expect(getClosabilityGaps(detailToClosabilityInput(detail))).toEqual(["At least 1 photo"]);
}); });
it("treats vendorId without vendorName as assigned company", () => {
const detail = {
assignedTo: "Alice",
completedDate: "2026-07-15",
workOrderNumber: "20260623001",
internalWONumber: "SH-1",
problem: "HVAC PM",
source: "procurement-integration",
type: "",
docStatus: "",
mediaCount: 0,
dispatches: [{ vendorName: "", vendorId: 45 }],
} as WorkOrderDetail;
const input = detailToClosabilityInput(detail);
expect(input.company).toBe("");
expect(input.vendorId).toBe("45");
expect(getClosabilityGaps(input)).toEqual([]);
});
it.each(["Cancelled", "Canceled", "Refused", "CANCELLED", " canceled "])(
"ignores inactive dispatch status %s when resolving assigned company",
(status) => {
const inactiveOnly = {
assignedTo: "Alice",
completedDate: "2026-07-15",
workOrderNumber: "20260623001",
internalWONumber: "SH-1",
problem: "HVAC PM",
source: "procurement-integration",
type: "",
docStatus: "",
mediaCount: 0,
dispatches: [{ vendorName: "Old Co", vendorId: 45, status }],
} as WorkOrderDetail;
expect(detailToClosabilityInput(inactiveOnly).vendorId).toBe("");
expect(detailToClosabilityInput(inactiveOnly).company).toBe("");
expect(getClosabilityGaps(detailToClosabilityInput(inactiveOnly))).toContain("Company");
},
);
it.each(["Verified", "Completed", "Sent"])(
"treats dispatch status %s as a live company assignment",
(status) => {
const liveAssignment = {
assignedTo: "Alice",
completedDate: "2026-07-15",
workOrderNumber: "20260623001",
internalWONumber: "SH-1",
problem: "HVAC PM",
source: "procurement-integration",
type: "",
docStatus: "",
mediaCount: 0,
dispatches: [{ vendorName: "ACME Tech", vendorId: 45, status }],
} as WorkOrderDetail;
const input = detailToClosabilityInput(liveAssignment);
expect(input.company).toBe("ACME Tech");
expect(input.vendorId).toBe("45");
expect(getClosabilityGaps(input)).toEqual([]);
},
);
it("uses the active dispatch after an inactive cancelled spelling", () => {
const supersededThenCurrent = {
assignedTo: "Alice",
completedDate: "2026-07-15",
workOrderNumber: "20260623001",
internalWONumber: "SH-1",
problem: "HVAC PM",
source: "procurement-integration",
type: "",
docStatus: "",
mediaCount: 0,
dispatches: [
{ vendorName: "Old Co", vendorId: 1, status: "Canceled" },
{ vendorName: "ACME Tech", vendorId: 45, status: "Sent" },
],
} as WorkOrderDetail;
const input = detailToClosabilityInput(supersededThenCurrent);
expect(input.company).toBe("ACME Tech");
expect(input.vendorId).toBe("45");
expect(getClosabilityGaps(input)).toEqual([]);
});
});
describe("tableRowToClosabilityInput", () => {
const closableRow = {
dispatcherId: "u1",
dispatcherName: "Alex",
company: "",
vendorId: "45",
tech: "",
completedDate: "2026-07-15",
woNumber: "20260623001",
pm: "HVAC PM",
docStatus: "",
mediaCount: 0,
type: "",
} as WorkOrderTableRow;
it("keeps vendorId as a live assignment when primary dispatch status is absent", () => {
const input = tableRowToClosabilityInput(closableRow);
expect(input.vendorId).toBe("45");
expect(getClosabilityGaps(input)).toEqual([]);
});
it.each(["Cancelled", "Canceled", "Refused"] as const)(
"clears company assignment when primary dispatch status is %s",
(primaryDispatchStatus) => {
const input = tableRowToClosabilityInput({ ...closableRow, primaryDispatchStatus });
expect(input.company).toBe("");
expect(input.vendorId).toBe("");
expect(getClosabilityGaps(input)).toContain("Company");
},
);
it("raises Company when the board omits vendor assignment (inactive primary)", () => {
const input = tableRowToClosabilityInput({
...closableRow,
vendorId: "",
company: "",
primaryDispatchStatus: "Refused",
});
expect(input.vendorId).toBe("");
expect(getClosabilityGaps(input)).toContain("Company");
});
it("keeps a live vendorId after a vendor patch clears leftover refused status", () => {
const input = tableRowToClosabilityInput({
...closableRow,
company: "New Co",
vendorId: "9",
primaryDispatchStatus: "",
});
expect(getClosabilityGaps(input)).toEqual([]);
});
it.each(["Pending", "Sent"] as const)(
"keeps company assignment after vendor save when primary dispatch status is %s",
(primaryDispatchStatus) => {
const input = tableRowToClosabilityInput({
...closableRow,
company: "New Co",
vendorId: "9",
primaryDispatchStatus,
});
expect(input.vendorId).toBe("9");
expect(getClosabilityGaps(input)).not.toContain("Company");
},
);
it("raises Company when leftover Refused remains on the patched vendor id", () => {
const input = tableRowToClosabilityInput({
...closableRow,
company: "New Co",
vendorId: "9",
primaryDispatchStatus: "Refused",
});
expect(getClosabilityGaps(input)).toContain("Company");
});
}); });
describe("readDetailWoType", () => { describe("readDetailWoType", () => {