Compare commits

...

20 commits

Author SHA1 Message Date
Arthur Bassi
4c9f8b7d81 fix(work-orders): treat vendor save as a live company assignment
Clear leftover primary dispatch status on vendor patch so Completed is not
blocked after a new company is chosen. Keep Jira keys out of source comments.
2026-08-20 14:17:20 -03:00
Arthur Bassi
72de2ea78d
Merge branch 'dev' into fix/sh-183-closability-company-not-tech 2026-08-20 13:40:53 -03:00
Alexandre Brandizzi
58bdd6bffa
Merge pull request #117 from Sea-Haven-Industries/feat/sh-249-fe-trades
Some checks are pending
CI / ci (push) Waiting to run
CI / governance (push) Waiting to run
CI / vendor-visual-regression (push) Waiting to run
Deploy / deploy (push) Waiting to run
fix(vendors): gate trades to canonical list, preserve legacy values (SH-249)
2026-08-20 13:38:23 -03:00
Alexandre Brandizzi
ef04008fa2
Merge branch 'dev' into feat/sh-249-fe-trades 2026-08-20 13:25:09 -03:00
Arthur Bassi
a156db70bc
Merge branch 'dev' into fix/sh-183-closability-company-not-tech 2026-08-20 13:24:51 -03:00
Arthur Bassi
f77428ab19 fix(work-orders): map only official primaryDispatchStatus (SH-183)
Drop undocumented board aliases so the Completed gate matches the backend DTO field.
2026-08-20 13:22:12 -03:00
Arthur Bassi
632e00a0ce
Merge pull request #124 from Sea-Haven-Industries/feat/sh-241-wo-day-pagination
Some checks are pending
CI / ci (push) Waiting to run
CI / governance (push) Waiting to run
CI / vendor-visual-regression (push) Waiting to run
Deploy / deploy (push) Waiting to run
feat(work-orders): add per-day board pagination (SH-241)
2026-08-20 11:57:53 -03:00
Alexandre Brandizzi
4d8e86b264
Merge branch 'dev' into feat/sh-249-fe-trades 2026-08-20 11:48:07 -03:00
Arthur Bassi
75c82e90a5
Merge branch 'dev' into feat/sh-241-wo-day-pagination 2026-08-20 11:37:01 -03:00
Arthur Bassi
e6567c2c0a
Merge branch 'dev' into feat/sh-241-wo-day-pagination 2026-08-20 11:26:47 -03:00
Arthur Bassi
91f30304a2 Merge branch 'dev' into feat/sh-241-wo-day-pagination 2026-08-20 11:16:17 -03:00
Arthur Bassi
ee5eae8927 fix(work-orders): page-aware flash and Unassigned search empty (SH-241)
Empty search waits until the Unassigned queue has settled.

Reschedule selects the destination day's page before flashing the row.
2026-08-20 11:00:25 -03:00
Alexandre Brandizzi
22bd392794
Merge branch 'dev' into feat/sh-249-fe-trades 2026-08-20 10:58:29 -03:00
Arthur Bassi
0c514c9bad feat(work-orders): add per-day board pagination (SH-241)
Paginate the selected day's WOs client-side with AAP footer controls.
2026-08-20 10:08:34 -03:00
Alexandre Brandizzi
13d9aa2034
Merge branch 'dev' into feat/sh-249-fe-trades 2026-08-19 10:28:01 -03:00
Codex Review Integration
cccb0e4ca7 refactor(vendors): extract trade rejection message to satisfy length gate (SH-249)
The loading-aware gate pushed VendorTradeSpecialtiesField to 151 lines, one over
the changed-file maintainability cap. Move the rejection-message resolution and
the contract explanation to module scope; no behavior change.
2026-08-18 18:09:23 -03:00
Alexandre Brandizzi
5d18b76143
Merge branch 'dev' into feat/sh-249-fe-trades 2026-08-18 17:46:46 -03:00
Codex Review Integration
64caab1832 fix(vendors): do not fail open while the trades facet is loading (SH-249)
An empty tradeOptions array meant two different things: "facets query still in
flight" and "vocabulary genuinely unavailable". Both enabled fail-open free
text and showed the outage warning, so a normal cold fetch looked like an
outage. Anything committed in that window was added to knownTradesRef and stayed
selectable after the canonical list arrived, permanently undermining the gate.

Expose the facets query isLoading as tradesLoading, thread it to the field as
tradeOptionsLoading, and keep the canonical gate active while loading. The
outage warning now shows only once the query has settled empty.
2026-08-18 17:25:44 -03:00
Codex Review Integration
63e37dd0bb fix(vendors): keep trades live region out of Stack spacing (SH-249)
The always-mounted aria-live feedback region inherited MUI Stack spacing,
adding a permanent gap under the trades input and shifting the approved
Vendor edit drawer layout. vendor-visual-regression caught it.

Move the region outside the spaced Stack and collapse it while silent, so
the live region stays in the DOM for announcements without claiming layout.
Restores the caption to MUI Typography; that swap was cosmetic and out of
scope for SH-249.
2026-08-18 14:27:42 -03:00
Codex Review Integration
df00e56d6e fix(vendors): gate trades to canonical list, preserve legacy values (SH-249) 2026-08-18 13:12:10 -03:00
37 changed files with 1159 additions and 129 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
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

@ -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

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

View file

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

View file

@ -25,6 +25,7 @@ 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;
@ -236,6 +237,7 @@ interface TechnicianRowProps {
onRemove: () => void;
canRemove: boolean;
tradeOptions: string[];
tradeOptionsLoading?: boolean;
showStatus: boolean;
}
@ -246,6 +248,7 @@ function TechnicianRow({
onRemove,
canRemove,
tradeOptions,
tradeOptionsLoading,
showStatus,
}: TechnicianRowProps) {
return (
@ -315,7 +318,12 @@ function TechnicianRow({
)}
/>
</Stack>
<VendorTradeSpecialtiesField control={control} index={index} tradeOptions={tradeOptions} />
<VendorTradeSpecialtiesField
control={control}
index={index}
tradeOptions={tradeOptions}
tradeOptionsLoading={tradeOptionsLoading}
/>
{showStatus && (
<Controller
control={control}
@ -341,11 +349,13 @@ 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" });
@ -389,6 +399,7 @@ function TechniciansFieldArray({
onRemove={() => remove(index)}
canRemove
tradeOptions={tradeOptions}
tradeOptionsLoading={tradeOptionsLoading}
showStatus={showStatus}
/>
))
@ -399,7 +410,13 @@ function TechniciansFieldArray({
}
export function VendorRosterFormFields(props: VendorRosterFormFieldsProps) {
const { control, errors, tradeOptions = [], showTechnicianStatus = true } = props;
const {
control,
errors,
tradeOptions = [],
tradeOptionsLoading = false,
showTechnicianStatus = true,
} = props;
return (
<Stack spacing={3}>
<CompanyFields {...props} />
@ -408,6 +425,7 @@ export function VendorRosterFormFields(props: VendorRosterFormFieldsProps) {
control={control}
errors={errors}
tradeOptions={tradeOptions}
tradeOptionsLoading={tradeOptionsLoading}
showStatus={showTechnicianStatus}
/>
</Stack>

View file

@ -124,6 +124,7 @@ 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 { useState } from "react";
import { useRef, 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,8 +14,43 @@ 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(",")
@ -23,18 +58,59 @@ 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>
@ -47,10 +123,28 @@ 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();
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("");
};
const move = (tradeIndex: number, direction: -1 | 1) => {
@ -64,31 +158,31 @@ export function VendorTradeSpecialtiesField({
return (
<Stack spacing={1} className="mt-2">
<Box className="flex flex-wrap gap-1.5">
{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}`} />}
/>
))
)}
<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}`} />}
/>
))}
</Box>
<Stack direction="row" spacing={1} sx={{ alignItems: "center" }}>
<Autocomplete
freeSolo
options={tradeOptions}
options={selectableTrades}
value={null}
inputValue={tradeInput}
onInputChange={(_event, value, reason) => {
if (reason === "input") setTradeInput(value);
if (reason === "input") {
setTradeInput(value);
setFeedbackMessage(null);
}
}}
onChange={(_event, 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>
);
}

View file

@ -15,6 +15,7 @@ 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 = {
@ -140,14 +141,7 @@ export function SlideOverInfoTabEditView({
tech={draft.tech}
techPhone={draft.techPhone}
vendors={vendors}
onSave={(patch) =>
onDraftChange({
vendorId: patch.vendorId,
company: patch.company,
tech: patch.tech,
techPhone: patch.techPhone,
})
}
onSave={(patch) => onDraftChange(toVendorTablePatch(patch))}
/>
</SlideOverInfoRow>
<SlideOverInfoRow label="Status">

View file

@ -36,7 +36,13 @@ export function useWoTableRowHandlers({
onScheduleHighlight,
onDuplicateFound,
}: UseWoTableRowHandlersOptions): WoTableRowHandlers {
const { dragState: dragStateRef, setDragViz, reorderRows, endDrag, triggerFlash } = tableData;
const {
dragState: dragStateRef,
setDragViz,
reorderRows,
endDrag,
revealScheduledRow,
} = tableData;
return useMemo(
() => ({
@ -77,13 +83,13 @@ export function useWoTableRowHandlers({
rescheduleNotify(date, {
onNavigate: () => {
onNavigateToDay(date);
setTimeout(() => triggerFlash(id), 180);
revealScheduledRow(id);
},
});
return;
}
onNavigateToDay(date);
triggerFlash(id);
revealScheduledRow(id);
},
onScheduleWeek: (id: string | number, week: string) => {
onPatch(id, {
@ -132,7 +138,7 @@ export function useWoTableRowHandlers({
setDragViz,
reorderRows,
endDrag,
triggerFlash,
revealScheduledRow,
weekMonday,
onNavigateToDay,
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
variant="day"
label={group.label}
count={group.items.length}
count={group.totalCount ?? group.items.length}
isToday={group.key === tableData.todayKey}
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 { 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";
@ -193,14 +194,7 @@ export function WoTableInlineRowCells({
tech={draft.tech}
techPhone={draft.techPhone}
vendors={vendors}
onSave={(p) =>
onPatch({
vendorId: p.vendorId,
company: p.company,
tech: p.tech,
techPhone: p.techPhone,
})
}
onSave={(p) => onPatch(toVendorTablePatch(p))}
/>
</td>
<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 { 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 = {
@ -67,14 +68,7 @@ export function WoTableRowServiceCells({
techPhone={row.techPhone}
vendors={vendors}
q={search}
onSave={(p) =>
onPatchRow({
vendorId: p.vendorId,
company: p.company,
tech: p.tech,
techPhone: p.techPhone,
})
}
onSave={(p) => onPatchRow(toVendorTablePatch(p))}
/>
</td>
<td

View file

@ -8,11 +8,20 @@ const CARD_STYLE: React.CSSProperties = {
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 (
<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 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>
</div>
);

View file

@ -12,6 +12,7 @@ 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";
@ -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({
tableData,
users,
@ -188,7 +207,7 @@ export function WoTable({
return (
<>
<WoTableShell>
<WoTableShell footer={WoTableDayFooter({ tableData })}>
<table
style={{
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 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,
@ -51,7 +56,7 @@ export function useWorkOrdersTableData(filters: FiltersReturn) {
advActive,
isFetching,
});
const { displayRows } = local;
const { displayRows, ...localUi } = local;
const advResults = useMemo(() => (advActive ? displayRows : []), [advActive, displayRows]);
@ -85,16 +90,27 @@ 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 = dayGroups;
const dayVisibleCount = displayGroups[0]?.items.length ?? 0;
const displayGroups = pagedDay.groups;
const dayTotalCount = pagedDay.totalCount;
const noSearchResults =
!advActive &&
isDayScopedSearchEmpty({
searchActive,
unassignedCount: unassigned.length,
dayItemCount: dayVisibleCount,
dayItemCount: dayTotalCount,
unassignedQueueReady: isUnassignedQueueReady(unassignedLoading, unassignedError),
});
const isEmptyWeek =
@ -127,30 +143,24 @@ export function useWorkOrdersTableData(filters: FiltersReturn) {
noSearchResults,
isEmptyWeek,
todayKey,
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,
...localUi,
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) : dayVisibleCount,
totalCount: advActive ? (data?.totalCount ?? advResults.length) : dayTotalCount,
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,15 +268,7 @@ function readHasPendingUplift(
}
function readPrimaryDispatchStatus(item: Record<string, unknown>): string {
return readString(
item,
"primaryDispatchStatus",
"PrimaryDispatchStatus",
"dispatchStatus",
"DispatchStatus",
"vendorDispatchStatus",
"VendorDispatchStatus",
);
return readString(item, "primaryDispatchStatus", "PrimaryDispatchStatus");
}
export function mapWorkOrderTableRow(raw: unknown): WorkOrderTableRow {

View file

@ -42,10 +42,7 @@ 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,10 +184,7 @@ 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,7 +37,6 @@ 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;
@ -140,6 +139,7 @@ export type WorkOrderDayGroup = {
label: string;
date: string;
items: WorkOrderTableRow[];
totalCount?: number;
};
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.
* 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(
detail: WorkOrderDetail,

View file

@ -0,0 +1,18 @@
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 (SH-183 ∪ pending uplift). */
/** Fields required to evaluate whether a WO may be marked Completed. */
export type WoClosabilityInput = {
dispatcherId: string;
dispatcherName: string;
/** Company / vendor assignment — required for Completed (SH-183). */
/** Company / vendor assignment — required for Completed. */
company: string;
/** Board vendor id; assignment is valid with vendorId even when company label is empty. */
vendorId: string;
@ -158,14 +158,12 @@ 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 {
@ -185,7 +183,6 @@ 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

@ -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

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

View file

@ -13,6 +13,7 @@ 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(triggerFlash = vi.fn()) {
function buildTableData(revealScheduledRow = vi.fn()) {
return {
dragState: { current: { dragId: null, overId: null } },
setDragViz: vi.fn(),
reorderRows: vi.fn(),
endDrag: vi.fn(),
triggerFlash,
revealScheduledRow,
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 triggerFlash = vi.fn();
const revealScheduledRow = vi.fn();
const { result } = renderHook(() =>
useWoTableRowHandlers({
tableData: buildTableData(triggerFlash),
tableData: buildTableData(revealScheduledRow),
weekMonday: "2026-05-18",
onNavigateToDay,
onOpenSlideOver: vi.fn(),
@ -46,16 +46,17 @@ describe("useWoTableRowHandlers schedule navigation (SH-240)", () => {
expect(onPatch).toHaveBeenCalledWith("wo-1", applySchedulePatch("2026-05-21", false));
expect(onNavigateToDay).toHaveBeenCalledWith("2026-05-21");
expect(triggerFlash).toHaveBeenCalledWith("wo-1");
expect(revealScheduledRow).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(),
tableData: buildTableData(revealScheduledRow),
weekMonday: "2026-05-18",
onNavigateToDay,
onOpenSlideOver: vi.fn(),
@ -76,6 +77,7 @@ 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

@ -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,
flashId: null,
triggerFlash: vi.fn(),
revealScheduledRow: vi.fn(),
dragViz: null,
setDragViz: vi.fn(),
dragState: { current: { dragId: null, overId: null } },
@ -56,6 +57,15 @@ 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;
}
@ -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", () => {
const { getByText, queryByText } = renderTable(tableData());

View file

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

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

@ -77,7 +77,7 @@ describe("getClosabilityGaps", () => {
expect(getClosabilityGaps(makeInput())).toEqual([]);
});
it("allows company without technician (SH-183)", () => {
it("allows company without technician", () => {
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 (SH-183)", () => {
it("allows Completed when vendorId is set and company/technician are empty", () => {
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 (SH-183)",
"blocks Completed without a company for type %s",
(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 (SH-183)", () => {
it("treats vendorId without vendorName as assigned company", () => {
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 (SH-183)",
"ignores inactive dispatch status %s when resolving assigned company",
(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 (SH-183)",
"treats dispatch status %s as a live company assignment",
(status) => {
const liveAssignment = {
assignedTo: "Alice",
@ -374,7 +374,7 @@ describe("detailToClosabilityInput", () => {
},
);
it("uses the active dispatch after an inactive cancelled spelling (SH-183)", () => {
it("uses the active dispatch after an inactive cancelled spelling", () => {
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 (SH-183)", () => {
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 (SH-183)",
"clears company assignment when primary dispatch status is %s",
(primaryDispatchStatus) => {
const input = tableRowToClosabilityInput({ ...closableRow, primaryDispatchStatus });
expect(input.company).toBe("");
@ -428,6 +428,27 @@ 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", () => {