Compare commits

..

No commits in common. "4c9f8b7d81ca26fb9fa8e4d9f9a06aa2fa492f32" and "5195749623762e476723fced47f89530b3ca1bfe" have entirely different histories.

37 changed files with 129 additions and 1159 deletions

View file

@ -63,10 +63,6 @@ a status or mark unverified work Done.
- **MUST NOT** leave comments that only restate what Prettier or ESLint already
enforces (formatting, naming nits the linter catches). Style is settled by the
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
evidence-based convention in these docs, and offer a concrete fix or a
targeted question. Use GitHub suggestion blocks when safe.

View file

@ -1,124 +0,0 @@
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

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,4 +1,4 @@
import { useRef, useState } from "react";
import { useState } from "react";
import { Controller, type Control } from "react-hook-form";
import AddIcon from "@mui/icons-material/Add";
import ArrowDownwardIcon from "@mui/icons-material/ArrowDownward";
@ -14,43 +14,8 @@ import {
TextField,
Typography,
} from "@mui/material";
import { Text } from "@/components/ui/text";
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[] {
return (value ?? "")
.split(",")
@ -58,59 +23,18 @@ function splitTrades(value: string | undefined): string[] {
.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 {
control: Control<VendorCompanyRosterFormValues>;
index: number;
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({
control,
index,
tradeOptions,
tradeOptionsLoading = false,
}: VendorTradeSpecialtiesFieldProps) {
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 (
<Box>
@ -123,28 +47,10 @@ export function VendorTradeSpecialtiesField({
name={`technicians.${index}.tradeSpecialties`}
render={({ field }) => {
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 add = (value: string) => {
const normalized = value.trim();
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]);
if (normalized && !trades.includes(normalized)) commit([...trades, normalized]);
setTradeInput("");
};
const move = (tradeIndex: number, direction: -1 | 1) => {
@ -158,31 +64,31 @@ export function VendorTradeSpecialtiesField({
return (
<Stack spacing={1} className="mt-2">
<Box className="flex flex-wrap gap-1.5">
<Text variant="description" tone="muted" when={trades.length === 0}>
No trades selected.
</Text>
{trades.map((trade, tradeIndex) => (
<Chip
key={`${trade}-${tradeIndex}`}
label={tradeIndex === 0 ? `${trade} (primary)` : trade}
onDelete={() =>
commit(trades.filter((_, itemIndex) => itemIndex !== tradeIndex))
}
deleteIcon={<CloseIcon data-testid={`remove-trade-${trade}`} />}
/>
))}
{trades.length === 0 ? (
<Typography variant="body2" sx={{ color: "text.secondary" }}>
No trades selected.
</Typography>
) : (
trades.map((trade, tradeIndex) => (
<Chip
key={`${trade}-${tradeIndex}`}
label={tradeIndex === 0 ? `${trade} (primary)` : trade}
onDelete={() =>
commit(trades.filter((_, itemIndex) => itemIndex !== tradeIndex))
}
deleteIcon={<CloseIcon data-testid={`remove-trade-${trade}`} />}
/>
))
)}
</Box>
<Stack direction="row" spacing={1} sx={{ alignItems: "center" }}>
<Autocomplete
freeSolo
options={selectableTrades}
options={tradeOptions}
value={null}
inputValue={tradeInput}
onInputChange={(_event, value, reason) => {
if (reason === "input") {
setTradeInput(value);
setFeedbackMessage(null);
}
if (reason === "input") setTradeInput(value);
}}
onChange={(_event, value) => {
if (typeof value === "string") add(value);
@ -241,9 +147,6 @@ 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>
);
}

View file

@ -15,7 +15,6 @@ import type {
} from "@/domain/work-orders/types/work-order";
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
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";
type SlideOverInfoTabEditViewProps = {
@ -141,7 +140,14 @@ export function SlideOverInfoTabEditView({
tech={draft.tech}
techPhone={draft.techPhone}
vendors={vendors}
onSave={(patch) => onDraftChange(toVendorTablePatch(patch))}
onSave={(patch) =>
onDraftChange({
vendorId: patch.vendorId,
company: patch.company,
tech: patch.tech,
techPhone: patch.techPhone,
})
}
/>
</SlideOverInfoRow>
<SlideOverInfoRow label="Status">

View file

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

View file

@ -1,80 +0,0 @@
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
variant="day"
label={group.label}
count={group.totalCount ?? group.items.length}
count={group.items.length}
isToday={group.key === tableData.todayKey}
onAddWo={() => tableData.startDayDraft(group.key, group.date)}
/>

View file

@ -16,7 +16,6 @@ import { PMTypeCell } from "./cells/pm-type-cell";
import { StatusCell } from "./cells/status-cell";
import { TypeCell } from "./cells/type-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 { WoTableInlineIdentityCells } from "./wo-table-inline-identity-cells";
@ -194,7 +193,14 @@ export function WoTableInlineRowCells({
tech={draft.tech}
techPhone={draft.techPhone}
vendors={vendors}
onSave={(p) => onPatch(toVendorTablePatch(p))}
onSave={(p) =>
onPatch({
vendorId: p.vendorId,
company: p.company,
tech: p.tech,
techPhone: p.techPhone,
})
}
/>
</td>
<td className="group/cell" style={{ padding: "0 14px" }}>

View file

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

View file

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

View file

@ -12,7 +12,6 @@ import type {
import { isUnassignedQueueReady } from "@/domain/work-orders/utils/week-day-nav-utils";
import { DuplicateWoNumberDialog } from "@/app/(protected)/workorders/_components/list/duplicate-wo-number-dialog";
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 { buildSitePlaceLookups, resolveSitePlace } from "./site-place-lookup";
import { useWoTableInlineCreate } from "./use-wo-table-inline-create";
@ -70,24 +69,6 @@ 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({
tableData,
users,
@ -207,7 +188,7 @@ export function WoTable({
return (
<>
<WoTableShell footer={WoTableDayFooter({ tableData })}>
<WoTableShell>
<table
style={{
width: "100%",

View file

@ -1,72 +0,0 @@
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,15 +1,10 @@
import { useMemo } from "react";
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 { 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 { currentWeekMonday } from "@/domain/work-orders/utils/list-week-utils";
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
import { deriveDayFields } from "@/domain/work-orders/utils/table-day-utils";
import {
pageSelectedDayGroups,
shouldShowDayPagination,
} from "@/domain/work-orders/utils/day-pagination";
import {
buildSelectedDayGroup,
isDayScopedSearchEmpty,
@ -56,7 +51,7 @@ export function useWorkOrdersTableData(filters: FiltersReturn) {
advActive,
isFetching,
});
const { displayRows, ...localUi } = local;
const { displayRows } = local;
const advResults = useMemo(() => (advActive ? displayRows : []), [advActive, displayRows]);
@ -90,27 +85,16 @@ export function useWorkOrdersTableData(filters: FiltersReturn) {
: [buildSelectedDayGroup(filters.weekMonday, filters.selectedDayIso, 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 displayGroups = pagedDay.groups;
const dayTotalCount = pagedDay.totalCount;
const displayGroups = dayGroups;
const dayVisibleCount = displayGroups[0]?.items.length ?? 0;
const noSearchResults =
!advActive &&
isDayScopedSearchEmpty({
searchActive,
unassignedCount: unassigned.length,
dayItemCount: dayTotalCount,
dayItemCount: dayVisibleCount,
unassignedQueueReady: isUnassignedQueueReady(unassignedLoading, unassignedError),
});
const isEmptyWeek =
@ -143,24 +127,30 @@ export function useWorkOrdersTableData(filters: FiltersReturn) {
noSearchResults,
isEmptyWeek,
todayKey,
...localUi,
draftDayKey: local.draftDayKey,
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,
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,
totalCount: advActive ? (data?.totalCount ?? advResults.length) : dayTotalCount,
totalCount: advActive ? (data?.totalCount ?? advResults.length) : dayVisibleCount,
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

@ -268,7 +268,15 @@ function readHasPendingUplift(
}
function readPrimaryDispatchStatus(item: Record<string, unknown>): string {
return readString(item, "primaryDispatchStatus", "PrimaryDispatchStatus");
return readString(
item,
"primaryDispatchStatus",
"PrimaryDispatchStatus",
"dispatchStatus",
"DispatchStatus",
"vendorDispatchStatus",
"VendorDispatchStatus",
);
}
export function mapWorkOrderTableRow(raw: unknown): WorkOrderTableRow {

View file

@ -42,7 +42,10 @@ export interface WorkOrderBoardDetailInfoApi {
dayGroup?: string | null;
vendorId?: number | null;
vendorName?: string | null;
/** Primary dispatch status when the board contract supplies it (SH-183). */
primaryDispatchStatus?: string | null;
dispatchStatus?: string | null;
vendorDispatchStatus?: string | null;
techName?: string | null;
techPhone?: string | null;
apptDate?: string | null;

View file

@ -184,7 +184,10 @@ export interface WorkOrderBoardRowApi {
dayGroup?: string | null;
vendorId?: number | null;
vendorName?: string | null;
/** Primary dispatch status when the board contract supplies it (SH-183). */
primaryDispatchStatus?: string | null;
dispatchStatus?: string | null;
vendorDispatchStatus?: string | null;
techName?: string | null;
techPhone?: string | null;
apptDate?: string | null;

View file

@ -37,6 +37,7 @@ export interface WorkOrderTableRow {
extraServices: string[];
vendorId: string;
company: string;
/** Primary dispatch status when the board contract supplies it (SH-183). */
primaryDispatchStatus?: string;
tech: string;
techPhone: string;
@ -139,7 +140,6 @@ export type WorkOrderDayGroup = {
label: string;
date: string;
items: WorkOrderTableRow[];
totalCount?: number;
};
export type WorkOrderTablePatch = Partial<WorkOrderTableRow>;

View file

@ -1,80 +0,0 @@
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.
* Board `info.type`, `completion.docStatus`, and `media.length` are the SH-114 sources.
* Board `info.type`, `completion.docStatus`, and `media.length` are the SH-114/SH-183 sources.
*/
export function enrichDetailClosabilityFromBoard(
detail: WorkOrderDetail,

View file

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

View file

@ -7,11 +7,11 @@ import type {
} from "@/domain/work-orders/types/work-order-table-row";
import type { WOType, WorkOrderWizardDraft } from "@/domain/work-orders/types/work-order-wizard";
/** Fields required to evaluate whether a WO may be marked Completed. */
/** Fields required to evaluate whether a WO may be marked Completed (SH-183 ∪ pending uplift). */
export type WoClosabilityInput = {
dispatcherId: string;
dispatcherName: string;
/** Company / vendor assignment — required for Completed. */
/** Company / vendor assignment — required for Completed (SH-183). */
company: string;
/** Board vendor id; assignment is valid with vendorId even when company label is empty. */
vendorId: string;
@ -158,12 +158,14 @@ export function wizardDraftToClosabilityInput(draft: WorkOrderWizardDraft): WoCl
};
}
/** Backend terminal-cancel spellings; dispatch status is a raw API string. */
const INACTIVE_DISPATCH_STATUSES = new Set(["cancelled", "canceled", "refused"]);
export function isInactiveDispatchStatus(status: string | undefined): boolean {
return INACTIVE_DISPATCH_STATUSES.has((status ?? "").trim().toLowerCase());
}
/** Map board row onto closability; inactive primary dispatch is not a live company (SH-183). */
export function tableRowToClosabilityInput(wo: WorkOrderTableRow): WoClosabilityInput {
const inactive = isInactiveDispatchStatus(wo.primaryDispatchStatus);
return {
@ -183,6 +185,7 @@ export function tableRowToClosabilityInput(wo: WorkOrderTableRow): WoClosability
};
}
/** Current vendor assignment — cancelled/refused dispatches do not count as company (SH-183). */
function currentAssignedDispatch(
dispatches: WorkOrderDispatchSummary[],
): WorkOrderDispatchSummary | undefined {

View file

@ -1,192 +0,0 @@
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

@ -120,7 +120,7 @@ describe("StatusCell pending uplift closability", () => {
expect(onChangeStatus).toHaveBeenCalledWith("Completed");
});
it("disables Completed when company is missing", () => {
it("disables Completed when company is missing (SH-183)", () => {
const onChangeStatus = vi.fn();
renderWithProviders(
@ -141,7 +141,7 @@ describe("StatusCell pending uplift closability", () => {
expect(onChangeStatus).not.toHaveBeenCalled();
});
it("allows Completed when vendorId is set even if company label is empty", () => {
it("allows Completed when vendorId is set even if company label is empty (SH-183)", () => {
const onChangeStatus = vi.fn();
renderWithProviders(
@ -159,7 +159,7 @@ describe("StatusCell pending uplift closability", () => {
expect(onChangeStatus).toHaveBeenCalledWith("Completed");
});
it("allows Completed when vendorId has an empty company label and a live dispatch status", () => {
it("allows Completed when vendorId has an empty company label and a live dispatch status (SH-183)", () => {
const onChangeStatus = vi.fn();
renderWithProviders(
@ -178,7 +178,7 @@ describe("StatusCell pending uplift closability", () => {
});
it.each(["Cancelled", "Canceled", "Refused"] as const)(
"disables Completed when vendorId is leftover from a %s dispatch",
"disables Completed when vendorId is leftover from a %s dispatch (SH-183)",
(primaryDispatchStatus) => {
const onChangeStatus = vi.fn();
@ -206,54 +206,7 @@ describe("StatusCell pending uplift closability", () => {
},
);
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", () => {
it("allows Completed when technician is empty if company is set (SH-183)", () => {
const onChangeStatus = vi.fn();
renderWithProviders(

View file

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

View file

@ -13,13 +13,13 @@ vi.mock("@/app/(protected)/workorders/_components/list/table/reschedule-notify",
weekMoveNotify,
}));
function buildTableData(revealScheduledRow = vi.fn()) {
function buildTableData(triggerFlash = vi.fn()) {
return {
dragState: { current: { dragId: null, overId: null } },
setDragViz: vi.fn(),
reorderRows: vi.fn(),
endDrag: vi.fn(),
revealScheduledRow,
triggerFlash,
setSelectedId: vi.fn(),
} 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", () => {
const onNavigateToDay = vi.fn();
const onPatch = vi.fn();
const revealScheduledRow = vi.fn();
const triggerFlash = vi.fn();
const { result } = renderHook(() =>
useWoTableRowHandlers({
tableData: buildTableData(revealScheduledRow),
tableData: buildTableData(triggerFlash),
weekMonday: "2026-05-18",
onNavigateToDay,
onOpenSlideOver: vi.fn(),
@ -46,17 +46,16 @@ describe("useWoTableRowHandlers schedule navigation (SH-240)", () => {
expect(onPatch).toHaveBeenCalledWith("wo-1", applySchedulePatch("2026-05-21", false));
expect(onNavigateToDay).toHaveBeenCalledWith("2026-05-21");
expect(revealScheduledRow).toHaveBeenCalledWith("wo-1");
expect(triggerFlash).toHaveBeenCalledWith("wo-1");
expect(rescheduleNotify).not.toHaveBeenCalled();
});
it("sends cross-week view-it navigation to the target day, not Monday", () => {
const onNavigateToDay = vi.fn();
const revealScheduledRow = vi.fn();
const { result } = renderHook(() =>
useWoTableRowHandlers({
tableData: buildTableData(revealScheduledRow),
tableData: buildTableData(),
weekMonday: "2026-05-18",
onNavigateToDay,
onOpenSlideOver: vi.fn(),
@ -77,7 +76,6 @@ describe("useWoTableRowHandlers schedule navigation (SH-240)", () => {
const { onNavigate } = rescheduleNotify.mock.calls[0][1] as { onNavigate: () => void };
onNavigate();
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", () => {

View file

@ -1,75 +0,0 @@
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

@ -1,31 +0,0 @@
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,7 +43,6 @@ function tableData(
selectedRow: null,
flashId: null,
triggerFlash: vi.fn(),
revealScheduledRow: vi.fn(),
dragViz: null,
setDragViz: vi.fn(),
dragState: { current: { dragId: null, overId: null } },
@ -57,15 +56,6 @@ function tableData(
scopeTotalCount: 0,
totalCount: 0,
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,
} as ReturnTypeOfUseWorkOrdersTableData;
}
@ -89,7 +79,7 @@ function renderTable(data: ReturnTypeOfUseWorkOrdersTableData) {
);
}
describe("WoTable search empty vs Unassigned queue (SH-165/SH-241)", () => {
describe("WoTable search empty vs Unassigned queue (SH-165/SH-240)", () => {
it("shows empty search only after the Unassigned queue has settled empty", () => {
const { getByText, queryByText } = renderTable(tableData());

View file

@ -54,34 +54,11 @@ describe("mapWorkOrderTableRow isAddOn", () => {
describe("mapWorkOrderTableRow primaryDispatchStatus", () => {
it.each([
["primaryDispatchStatus", "Refused"],
["PrimaryDispatchStatus", "Canceled"],
] as const)("maps exact DTO field %s onto primaryDispatchStatus", (key, value) => {
["dispatchStatus", "Canceled"],
["vendorDispatchStatus", "Cancelled"],
] as const)("maps %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");
});
});

View file

@ -1,84 +0,0 @@
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

@ -1,37 +0,0 @@
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

@ -77,7 +77,7 @@ describe("getClosabilityGaps", () => {
expect(getClosabilityGaps(makeInput())).toEqual([]);
});
it("allows company without technician", () => {
it("allows company without technician (SH-183)", () => {
const gaps = getClosabilityGaps(makeInput({ tech: "" }));
expect(gaps).toEqual([]);
expect(gaps).not.toContain("Technician");
@ -88,7 +88,7 @@ describe("getClosabilityGaps", () => {
expect(getClosabilityGaps(makeInput({ company: " ", tech: "Sam" }))).toEqual(["Company"]);
});
it("allows Completed when vendorId is set and company/technician are empty", () => {
it("allows Completed when vendorId is set and company/technician are empty (SH-183)", () => {
expect(getClosabilityGaps(makeInput({ company: "", vendorId: "7", tech: "" }))).toEqual([]);
});
@ -99,7 +99,7 @@ describe("getClosabilityGaps", () => {
});
it.each(["PM", "Reactive", "Emergency", "Overdue", ""] as const)(
"blocks Completed without a company for type %s",
"blocks Completed without a company for type %s (SH-183)",
(type) => {
const gaps = getClosabilityGaps(
makeInput({
@ -309,7 +309,7 @@ describe("detailToClosabilityInput", () => {
expect(getClosabilityGaps(detailToClosabilityInput(detail))).toEqual(["At least 1 photo"]);
});
it("treats vendorId without vendorName as assigned company", () => {
it("treats vendorId without vendorName as assigned company (SH-183)", () => {
const detail = {
assignedTo: "Alice",
completedDate: "2026-07-15",
@ -330,7 +330,7 @@ describe("detailToClosabilityInput", () => {
});
it.each(["Cancelled", "Canceled", "Refused", "CANCELLED", " canceled "])(
"ignores inactive dispatch status %s when resolving assigned company",
"ignores inactive dispatch status %s when resolving assigned company (SH-183)",
(status) => {
const inactiveOnly = {
assignedTo: "Alice",
@ -352,7 +352,7 @@ describe("detailToClosabilityInput", () => {
);
it.each(["Verified", "Completed", "Sent"])(
"treats dispatch status %s as a live company assignment",
"treats dispatch status %s as a live company assignment (SH-183)",
(status) => {
const liveAssignment = {
assignedTo: "Alice",
@ -374,7 +374,7 @@ describe("detailToClosabilityInput", () => {
},
);
it("uses the active dispatch after an inactive cancelled spelling", () => {
it("uses the active dispatch after an inactive cancelled spelling (SH-183)", () => {
const supersededThenCurrent = {
assignedTo: "Alice",
completedDate: "2026-07-15",
@ -413,14 +413,14 @@ describe("tableRowToClosabilityInput", () => {
type: "",
} as WorkOrderTableRow;
it("keeps vendorId as a live assignment when primary dispatch status is absent", () => {
it("keeps vendorId as a live assignment when primary dispatch status is absent (SH-183)", () => {
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",
"clears company assignment when primary dispatch status is %s (SH-183)",
(primaryDispatchStatus) => {
const input = tableRowToClosabilityInput({ ...closableRow, primaryDispatchStatus });
expect(input.company).toBe("");
@ -428,27 +428,6 @@ describe("tableRowToClosabilityInput", () => {
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([]);
});
});
describe("readDetailWoType", () => {