fix(work-orders): paginate board and search without loading the full dataset (SH-165)

This commit is contained in:
Arthur Bassi 2026-08-24 18:38:32 -03:00
parent cd092b3137
commit 4782d2e3b0
31 changed files with 541 additions and 502 deletions

View file

@ -12,11 +12,22 @@ Frontend integration contract for weekly board and advanced search filters.
Paths no FE: `API_PATHS.workOrder.board`, `boardSearch`, `lookupsDispatchers`.
## Contrato (board semanal vs search)
| Superfície | Endpoint | O que volta |
| ------------------------------ | ---------------------------------- | ------------------------------------------------------------------------------------------------------------------ |
| Week + Day (AAP) | `GET /board?weekStart&weekEnd` | Só WOs com data (ou `ScheduleWeekOnly` + `TargetWeek`) **nessa semana**. `unscheduled: []`. Sem `page`/`pageSize`. |
| Contagens do board | `counts.total` / `counts.returned` | Scheduled da semana (antes/depois do search da barra). Nunca `0` se há rows. |
| Lista flat | `GET /board/search` | Envelope paginado. `page` 0-based. `pageSize` default 100, **max 200**. |
| Fila sem data / sem dispatcher | `GET /board/search` | Filtros (`dispatchers=__unassigned__`, statuses, janela Custom). **Não** entra no `/board`. |
O AAP não tem pin Unassigned na tabela. Dated sem assignee entram no dia. Undated só em Advanced Filters / filtro Dispatcher.
## Quando usar qual endpoint
| Modo FE | Endpoint | Quando |
| --------------------------------------- | -------------------------- | ------------------------------------------------ |
| Barra principal (`advApplied === null`) | `GET /board` | Semana + dispatchers + tipo segment + search |
| Barra principal (`advApplied === null`) | `GET /board` | Semana agendada + dispatchers + tipo + search |
| Filtros avançados aplicados | `GET /board/search` | Todos os filtros avançados + paginação/ordenação |
| Lookup dispatchers | `GET /lookups/dispatchers` | Popular multi-select de dispatchers |
@ -43,7 +54,7 @@ Paths no FE: `API_PATHS.workOrder.board`, `boardSearch`, `lookupsDispatchers`.
| FE state | API param | Regra |
| ---------------- | --------------------- | ------------------------------------------- |
| `page` | `page` | **0-based** (primeira página = `0`) |
| `pageSize` | `pageSize` | Default `100`, max `100` |
| `pageSize` | `pageSize` | Default `100`, max `200` |
| `sortBy` | `sortBy` | `scheduledDate` \| `woNumber` \| `dueDate` |
| `sortDir` | `sortDir` | `asc` \| `desc` |
| `search` | `search` | `>= 2` chars |
@ -99,7 +110,9 @@ These facet keys are handled on `GET /board/search` in `shoc-backend` `origin/de
- `IsAddOn` is owned by migration `20260730150000_WoIsAddOn` (legacy type-7 backfill).
- `20260813193000_SH121_BoardSearchFacets` is a **no-op** so both PRs can land without a duplicate column.
- Pagination matches this contract: `page` is 0-based; `pageSize` default/max is 100.
- Pagination matches this contract: `page` is 0-based; `pageSize` default is 100, max is 200.
- `GET /board` returns only scheduled-in-week rows. `unscheduled` is always `[]`. Undated / global Unassigned live on `GET /board/search`.
- `counts.total` / `counts.returned` are scheduled-in-week (before/after the bar search).
- Aveta (`avetaOnly`) remains omitted until `avetaRequired` is confirmed.
### `datePreset`

View file

@ -43,7 +43,15 @@ async function mockWorkOrderBoard(page: Page) {
async (route) => {
const url = route.request().url();
if (url.includes("workorders/board/search")) {
await fulfillJson(route, { items: [], totalCount: 0, page: 1, pageSize: 100 });
const page = Number(new URL(url).searchParams.get("page") ?? "0");
const pageSize = Number(new URL(url).searchParams.get("pageSize") ?? "50");
const items = wednesday.slice(page * pageSize, page * pageSize + pageSize);
await fulfillJson(route, {
items,
totalCount: 1778,
page,
pageSize,
});
return;
}
if (url.includes("workorders/board") && route.request().method() === "GET") {
@ -79,7 +87,7 @@ test.describe("work order per-day pagination (SH-241)", () => {
await mockWorkOrderBoard(page);
});
test("changes rows-per-page, hides on global filter, and resets on day or week change", async ({
test("changes rows-per-page, keeps search pager on advanced filters, and resets on day or week change", async ({
page,
}) => {
const pager = page.getByTestId("wo-day-pagination");
@ -88,35 +96,37 @@ test.describe("work order per-day pagination (SH-241)", () => {
timeout: 20_000,
});
await expect(pager).toBeVisible();
await expect(page.getByText("Page 1 of 2")).toBeVisible();
await expect(pager.getByText("Page 1 of 2")).toBeVisible();
await expect(page.getByTestId("wo-day-pagination-next")).toBeEnabled();
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 pager.getByLabel("Rows per page").selectOption("25");
await expect(pager.getByText("Page 1 of 3")).toBeVisible();
await expect(page.getByTestId("wo-day-pagination-next")).toBeEnabled();
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 page.getByTestId("wo-day-pagination-next").click();
await expect(pager.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(pager.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 expect(page.getByTestId("wo-day-pagination").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 expect(pager).toBeVisible();
await expect(pager.getByText("RESULTS · 1778 items")).toBeVisible();
await expect(pager.getByText("Page 1 of 36")).toBeVisible();
await page.getByRole("button", { name: "Clear all" }).click();
await expect(page.getByTestId("wo-day-pagination")).toBeVisible();

View file

@ -13,6 +13,8 @@ export function readTotalCount(record: Record<string, unknown>, fallback: number
return (
(typeof record.totalCount === "number" ? record.totalCount : undefined) ??
(typeof record.TotalCount === "number" ? record.TotalCount : undefined) ??
(typeof record.total === "number" ? record.total : undefined) ??
(typeof record.Total === "number" ? record.Total : undefined) ??
(typeof record.Count === "number" ? record.Count : undefined) ??
fallback
);

View file

@ -3,6 +3,7 @@ import { NavArrowButton } from "@/components/ui/nav-arrow-button";
import { Text } from "@/components/ui/text";
import {
DAY_PAGE_SIZE_OPTIONS,
asDayPageSize,
formatDayPaginationSummary,
type DayPageSize,
} from "@/domain/work-orders/utils/day-pagination";
@ -16,6 +17,7 @@ type WoDayPaginationFooterProps = {
onPageSizeChange: (pageSize: DayPageSize) => void;
onPrev: () => void;
onNext: () => void;
testId?: string;
};
export function WoDayPaginationFooter({
@ -27,6 +29,7 @@ export function WoDayPaginationFooter({
onPageSizeChange,
onPrev,
onNext,
testId = "wo-day-pagination",
}: WoDayPaginationFooterProps) {
const canPrev = pageIndex > 0;
const canNext = pageIndex < pageCount - 1;
@ -37,7 +40,7 @@ export function WoDayPaginationFooter({
<nav
className="flex shrink-0 items-center justify-between gap-3"
aria-label="Day pagination"
data-testid="wo-day-pagination"
data-testid={testId}
>
<Text variant="caption" tone="muted">
{summary}
@ -51,7 +54,7 @@ export function WoDayPaginationFooter({
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)}
onChange={(event) => onPageSizeChange(asDayPageSize(Number(event.target.value)))}
>
{DAY_PAGE_SIZE_OPTIONS.map((option) => (
<option key={option} value={option}>
@ -67,7 +70,7 @@ export function WoDayPaginationFooter({
<Text variant="caption">{pageLabel}</Text>
<NavArrowButton
aria-label="Next page"
data-testid="wo-day-pagination-next"
data-testid={`${testId}-next`}
disabled={!canNext}
onClick={onNext}
>

View file

@ -1,11 +1,6 @@
import { Fragment, type ReactNode } from "react";
import { Text } from "@/components/ui/text";
import {
UNASSIGNED_DAY_KEY,
type WorkOrderTableRow,
} from "@/domain/work-orders/types/work-order-table-row";
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
import type { ReturnTypeOfUseWorkOrdersTableData } from "./wo-table-types";
import { WO_TABLE_COLS } from "./wo-table-cols";
import { WoTableSectionHeader } from "./wo-table-section-header";
type WoTableBodyProps = {
@ -19,60 +14,8 @@ export function WoTableBody({ tableData, renderRow, renderInlineRow }: WoTableBo
return <tbody>{tableData.advResults.map((row) => renderRow(row))}</tbody>;
}
const unassignedRows = tableData.unassigned ?? [];
const showUnassignedLoading =
tableData.unassignedOpen &&
unassignedRows.length === 0 &&
!tableData.unassignedError &&
tableData.unassignedLoading;
const showUnassignedEmpty =
tableData.unassignedOpen &&
unassignedRows.length === 0 &&
!tableData.unassignedError &&
!tableData.unassignedLoading;
return (
<tbody>
<WoTableSectionHeader
variant="unassigned"
label="Unassigned"
count={unassignedRows.length}
collapsed={!tableData.unassignedOpen}
onToggleCollapse={() => tableData.setUnassignedOpen((v) => !v)}
/>
{tableData.unassignedOpen && (
<>
{renderInlineRow(UNASSIGNED_DAY_KEY)}
{Boolean(tableData.unassignedError) && (
<tr>
<td colSpan={WO_TABLE_COLS.length} style={{ padding: "8px 14px" }}>
<Text variant="error" tone="error">
{tableData.unassignedError?.message ?? "Unable to load unassigned work orders."}
</Text>
</td>
</tr>
)}
{showUnassignedLoading && (
<tr aria-busy="true">
<td colSpan={WO_TABLE_COLS.length} style={{ padding: "8px 14px" }}>
<Text variant="feedback" tone="muted">
Loading unassigned work orders…
</Text>
</td>
</tr>
)}
{showUnassignedEmpty && (
<tr>
<td colSpan={WO_TABLE_COLS.length} style={{ padding: "8px 14px" }}>
<Text variant="feedback" tone="muted">
No unassigned work orders.
</Text>
</td>
</tr>
)}
{unassignedRows.map((row) => renderRow(row))}
</>
)}
{tableData.displayGroups.map((group) => (
<Fragment key={group.key}>
<WoTableSectionHeader

View file

@ -20,7 +20,7 @@ export function WoTableShell({
<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="relative z-10 shrink-0 border-t border-border px-3 py-2">{footer}</div>
)}
</div>
</div>

View file

@ -9,7 +9,6 @@ import type {
WorkOrderTablePatch,
WorkOrderTableRow,
} from "@/domain/work-orders/types/work-order-table-row";
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";
@ -71,7 +70,7 @@ function WoTableEmptyShell({ message, actionLabel, onAction }: WoTableEmptyShell
);
}
function WoTableDayFooter({ tableData }: { tableData: ReturnTypeOfUseWorkOrdersTableData }) {
function WoTablePager({ tableData }: { tableData: ReturnTypeOfUseWorkOrdersTableData }) {
if (!tableData.showDayPagination) {
return undefined;
}
@ -193,10 +192,7 @@ export function WoTable({
);
}
const showSearchEmpty =
!tableData.advActive &&
tableData.noSearchResults &&
isUnassignedQueueReady(tableData.unassignedLoading, tableData.unassignedError);
const showSearchEmpty = !tableData.advActive && tableData.noSearchResults;
if (showSearchEmpty) {
return (
@ -210,7 +206,7 @@ export function WoTable({
return (
<>
<WoTableShell footer={WoTableDayFooter({ tableData })}>
<WoTableShell footer={WoTablePager({ tableData })}>
<table
style={{
width: "100%",

View file

@ -43,7 +43,6 @@ export function WoTableSkeleton({
>
<WoTableHead />
<tbody>
<DaySkeletonSection label="Unassigned" rows={1} />
<DaySkeletonSection label={formatDayTabLabel(dayIso)} rows={rowsPerDay} />
</tbody>
</table>

View file

@ -1,29 +1,22 @@
import { useCallback, useMemo } from "react";
import { useMemo } from "react";
import type { useWorkOrdersListFilters } from "@/app/(protected)/workorders/_hooks/use-work-orders-list-filters";
import { buildResolvedBoardSearchParams } from "@/app/(protected)/workorders/_hooks/build-resolved-board-search-params";
import {
useWorkOrdersBoard,
useWorkOrdersBoardSearch,
useWorkOrdersUnassignedQueue,
} from "@/domain/work-orders/use-cases/use-work-orders-list";
import {
useWorkOrderLocations,
useWorkOrderVendors,
} from "@/domain/work-orders/use-cases/use-work-order-detail";
import type { WorkOrderBoardListResult } from "@/domain/work-orders/types/work-order-board";
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
import type { UseQueryResult } from "@tanstack/react-query";
type FiltersReturn = ReturnType<typeof useWorkOrdersListFilters>;
const EMPTY_UNASSIGNED_QUEUE: WorkOrderTableRow[] = [];
export type WorkOrdersBoardSource = {
advActive: boolean;
data: WorkOrderBoardListResult | undefined;
unassignedQueue: WorkOrderTableRow[];
unassignedError: Error | null;
unassignedLoading: boolean;
isLoading: boolean;
isFetching: boolean;
error: Error | null;
@ -74,35 +67,13 @@ export function useWorkOrdersBoardSource(filters: FiltersReturn): WorkOrdersBoar
error: searchError,
refetch: refetchSearch,
} = useWorkOrdersBoardSearch(resolvedSearchParams ?? { datePreset: "ThisWeek" }, advActive);
const {
data: queueData,
isLoading: queueLoading,
isFetching: queueFetching,
error: queueError,
refetch: refetchQueue,
} = useWorkOrdersUnassignedQueue(!advActive);
const refetchBoardWithQueue: UseQueryResult<WorkOrderBoardListResult, Error>["refetch"] =
useCallback(
async (options) => {
const [boardResult] = await Promise.all([refetchBoard(options), refetchQueue()]);
return boardResult;
},
[refetchBoard, refetchQueue],
);
return {
advActive,
data: advActive ? searchData : boardData,
unassignedQueue: advActive ? EMPTY_UNASSIGNED_QUEUE : (queueData ?? EMPTY_UNASSIGNED_QUEUE),
unassignedError: advActive ? null : (queueError ?? null),
// SH-165: the pinned queue has its own first-load state. Folding it into
// isFetching only made "still paging board/search" indistinguishable from
// "zero unassigned", so the pin announced an empty queue while loading.
unassignedLoading: advActive ? false : queueLoading,
isLoading: advActive ? searchLoading : boardLoading,
isFetching: advActive ? searchFetching : boardFetching || queueFetching,
isFetching: advActive ? searchFetching : boardFetching,
error: advActive ? searchError : boardError,
refetch: advActive ? refetchSearch : refetchBoardWithQueue,
refetch: advActive ? refetchSearch : refetchBoard,
};
}

View file

@ -2,6 +2,7 @@ import { useCallback, useEffect, useState } from "react";
import {
DEFAULT_DAY_PAGE_SIZE,
findItemPageIndex,
totalPageCount,
type DayPageSize,
} from "@/domain/work-orders/utils/day-pagination";
@ -44,6 +45,8 @@ export function useWorkOrdersDayPagination({
onRevealed(revealedId);
}, [pendingRevealId, dayItems, pageSize, onRevealed]);
const pageCount = totalPageCount(dayItems.length, pageSize);
const setPageSize = useCallback((next: DayPageSize) => {
setPageSizeState(next);
setPage(0);
@ -54,8 +57,8 @@ export function useWorkOrdersDayPagination({
}, []);
const goToNextPage = useCallback(() => {
setPage((current) => current + 1);
}, []);
setPage((current) => Math.min(current + 1, Math.max(0, pageCount - 1)));
}, [pageCount]);
const revealRow = useCallback((id: string | number) => {
setPendingRevealId(id);

View file

@ -10,10 +10,8 @@ import {
countAdvancedFilters,
getInitialAdvancedFilters,
} from "@/domain/work-orders/utils/advanced-filters-utils";
import {
BOARD_SEARCH_MAX_PAGE_SIZE,
toBoardQueryParams,
} from "@/domain/work-orders/utils/board-query-params";
import { toBoardQueryParams } from "@/domain/work-orders/utils/board-query-params";
import { asDayPageSize, DEFAULT_DAY_PAGE_SIZE } from "@/domain/work-orders/utils/day-pagination";
import { compactWeekLabel, currentWeekMonday } from "@/domain/work-orders/utils/list-week-utils";
import {
firstWeekday,
@ -27,7 +25,7 @@ export function useWorkOrdersListFilters() {
const [advOpen, setAdvOpen] = useState(false);
const [advApplied, setAdvApplied] = useState<WorkOrderAdvancedFilters | null>(null);
const [page, setPage] = useState(0);
const [pageSize, setPageSize] = useState(BOARD_SEARCH_MAX_PAGE_SIZE);
const [pageSize, setPageSizeState] = useState(DEFAULT_DAY_PAGE_SIZE);
const [sortBy, setSortBy] = useState("");
const [sortDir, setSortDir] = useState<"asc" | "desc" | "">("");
const [weekMonday, setWeekMondayState] = useState(currentWeekMonday);
@ -67,9 +65,7 @@ export function useWorkOrdersListFilters() {
setPage(0);
};
const setSelectedDayIso = (value: string) => {
setSelectedDayIsoState(value);
};
const setSelectedDayIso = setSelectedDayIsoState;
const navigateToDay = (dateIso: string) => {
const tab = scheduledDayTab(dateIso);
@ -110,6 +106,11 @@ export function useWorkOrdersListFilters() {
setPage(0);
};
const setPageSize = (next: number) => {
setPageSizeState(asDayPageSize(next));
setPage(0);
};
const handleSort = (column: string) => {
if (sortBy === column) {
setSortDir((current) => {

View file

@ -7,19 +7,16 @@ 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 {
pickBoardPager,
pageSelectedDayGroups,
shouldShowDayPagination,
type DayPageSize,
} from "@/domain/work-orders/utils/day-pagination";
import {
buildSelectedDayGroup,
isDayScopedSearchEmpty,
isUnassignedQueueReady,
} from "@/domain/work-orders/utils/week-day-nav-utils";
import {
composeBoardSections,
filterByBarAndSearch,
mergeWeeklyAndUnassignedQueue,
rowIdSet,
splitScheduledUnscheduled,
} from "@/domain/work-orders/utils/table-filter-utils";
@ -28,30 +25,16 @@ export type { DragVizState } from "@/app/(protected)/workorders/_hooks/use-work-
type FiltersReturn = ReturnType<typeof useWorkOrdersListFilters>;
const EMPTY_WEEKLY_ITEMS: WorkOrderTableRow[] = [];
const EMPTY_UNASSIGNED: WorkOrderTableRow[] = [];
export function useWorkOrdersTableData(filters: FiltersReturn) {
const {
advActive,
data,
unassignedQueue,
unassignedError,
unassignedLoading,
isLoading,
isFetching,
error,
refetch,
} = useWorkOrdersBoardSource(filters);
const { advActive, data, isLoading, isFetching, error, refetch } =
useWorkOrdersBoardSource(filters);
const weeklyItems = data?.items ?? EMPTY_WEEKLY_ITEMS;
const items = useMemo(
() => mergeWeeklyAndUnassignedQueue(weeklyItems, unassignedQueue),
[weeklyItems, unassignedQueue],
);
const weeklyIds = useMemo(() => rowIdSet(weeklyItems), [weeklyItems]);
const queueIds = useMemo(() => rowIdSet(unassignedQueue), [unassignedQueue]);
const local = useWorkOrdersTableLocalState({
items,
items: weeklyItems,
weekMonday: filters.weekMonday,
advActive,
isFetching,
@ -70,18 +53,12 @@ export function useWorkOrdersTableData(filters: FiltersReturn) {
return displayRows;
}, [displayRows, filters.barFilters, filters.search, advActive]);
const { unassigned, scheduled, unscheduled } = useMemo(() => {
const { scheduled, unscheduled } = useMemo(() => {
if (advActive) {
const split = splitScheduledUnscheduled(advResults);
return { unassigned: [], ...split };
return splitScheduledUnscheduled(advResults);
}
return composeBoardSections({
displayRows,
weekRows: filtered,
weeklyIds,
queueIds,
});
}, [advActive, advResults, displayRows, filtered, weeklyIds, queueIds]);
return splitScheduledUnscheduled(filtered);
}, [advActive, advResults, filtered]);
const dayGroups = useMemo(
() =>
@ -105,16 +82,16 @@ export function useWorkOrdersTableData(filters: FiltersReturn) {
const searchActive = filters.search.trim() !== "";
const displayGroups = pagedDay.groups;
const dayTotalCount = pagedDay.totalCount;
const dayVisibleCount = pagedDay.groups[0]?.items.length ?? 0;
const noSearchResults =
!advActive &&
isDayScopedSearchEmpty({
searchActive,
unassignedCount: unassigned.length,
unassignedCount: 0,
dayItemCount: dayTotalCount,
unassignedQueueReady: isUnassignedQueueReady(unassignedLoading, unassignedError),
unassignedQueueReady: true,
});
const isEmptyWeek =
!advActive && scheduled.length === 0 && unassigned.length === 0 && !searchActive;
const isEmptyWeek = !advActive && scheduled.length === 0 && !searchActive;
const todayKey =
filters.weekMonday === currentWeekMonday()
@ -126,6 +103,27 @@ export function useWorkOrdersTableData(filters: FiltersReturn) {
[displayRows, local.selectedId],
);
const scopeTotalCount = advActive ? (data?.totalCount ?? advResults.length) : dayTotalCount;
const listTotalCount = advActive ? advResults.length : dayVisibleCount;
const pager = pickBoardPager({
advActive,
isLoading,
noSearchResults,
advResultCount: advResults.length,
search: {
page: filters.page,
pageSize: filters.pageSize,
totalCount: scopeTotalCount,
},
day: {
pageSize: pagination.pageSize,
pageIndex: pagedDay.pageIndex,
pageCount: pagedDay.pageCount,
itemCount: dayTotalCount,
footerLabel: displayGroups[0]?.label ?? "",
},
});
return {
rows: displayRows,
isLoading,
@ -134,9 +132,10 @@ export function useWorkOrdersTableData(filters: FiltersReturn) {
refetch,
advActive,
advResults,
unassigned,
unassignedError,
unassignedLoading,
unassigned: EMPTY_UNASSIGNED,
unassignedTotalCount: 0,
unassignedError: null,
unassignedLoading: false,
unscheduled,
displayGroups,
searchActive,
@ -145,22 +144,28 @@ export function useWorkOrdersTableData(filters: FiltersReturn) {
todayKey,
...localUi,
selectedRow,
scopeTotalCount: data?.totalCount ?? 0,
totalCount: advActive ? (data?.totalCount ?? advResults.length) : dayTotalCount,
scopeTotalCount,
totalCount: listTotalCount,
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,
showDayPagination: pager.show,
dayPageSize: pager.pageSize,
dayPageIndex: pager.pageIndex,
dayPageCount: pager.pageCount,
dayItemCount: pager.itemCount,
dayFooterLabel: pager.footerLabel,
setDayPageSize: (next: DayPageSize) => {
if (advActive) {
filters.setPageSize(next);
return;
}
pagination.setPageSize(next);
},
goToPrevDayPage: advActive
? () => filters.setPage(Math.max(0, filters.page - 1))
: pagination.goToPrevPage,
goToNextDayPage: advActive
? () => filters.setPage(Math.min(filters.page + 1, Math.max(0, pager.pageCount - 1)))
: pagination.goToNextPage,
revealScheduledRow: pagination.revealRow,
};
}

View file

@ -29,7 +29,6 @@ export default function WorkOrdersListPage() {
<Box className="flex h-full flex-col overflow-hidden p-2.5">
<PageHeader
surface
eyebrow="OPERATIONS"
title="Work Orders"
belowTitle={
<DispatcherFilter
@ -68,7 +67,7 @@ export default function WorkOrdersListPage() {
{filters.advApplied != null && (
<WorkOrdersAdvancedFiltersBanner
applied={filters.advApplied}
totalCount={tableData.totalCount}
totalCount={tableData.scopeTotalCount}
sites={page.locations}
users={page.users}
onClearChip={filters.clearAdvancedFilterChip}

View file

@ -18,8 +18,10 @@ export function NavArrowButton({
type={type}
disabled={disabled}
className={cn(
"flex size-8 shrink-0 items-center justify-center rounded-md border border-border bg-card transition-colors disabled:cursor-not-allowed",
disabled ? "text-border" : "text-muted-foreground",
"flex size-8 shrink-0 items-center justify-center rounded-md border border-border bg-card transition-colors",
disabled
? "cursor-not-allowed text-border"
: "cursor-pointer text-muted-foreground hover:bg-(--color-bg-muted) hover:text-foreground",
className,
)}
{...props}

View file

@ -1,29 +1,11 @@
import { workOrdersApi } from "@/domain/work-orders/api/work-orders-api";
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
import type { WorkOrderBoardListResult } from "@/domain/work-orders/types/work-order-board";
import { toUnassignedQueueQueryParams } from "@/domain/work-orders/utils/board-query-params";
const UNASSIGNED_QUEUE_MAX_PAGES = 50;
/** Exhausts GET /board/search pages for the global Unassigned pin (SH-165). */
export async function fetchUnassignedBoardRows(): Promise<WorkOrderTableRow[]> {
const rows: WorkOrderTableRow[] = [];
let page = 0;
let totalCount = Number.POSITIVE_INFINITY;
while (rows.length < totalCount) {
if (page >= UNASSIGNED_QUEUE_MAX_PAGES) {
throw new Error(
"Unable to load the unassigned queue: search result set exceeded the safety page limit.",
);
}
const result = await workOrdersApi.searchBoard(toUnassignedQueueQueryParams(page));
rows.push(...result.items);
totalCount = result.totalCount;
if (result.items.length === 0) {
break;
}
page += 1;
}
return rows;
/** One GET /board/search page for Dispatcher=Unassigned / Advanced Filters. */
export async function fetchUnassignedBoardPage(
page = 0,
pageSize = 50,
): Promise<WorkOrderBoardListResult> {
return workOrdersApi.searchBoard(toUnassignedQueueQueryParams(page, pageSize));
}

View file

@ -153,17 +153,17 @@ export const workOrdersApi = {
const scheduled = mapWorkOrderTableRows(payload.scheduled ?? []);
const unscheduled = mapWorkOrderTableRows(payload.unscheduled ?? []);
const returnedCount = payload.counts?.returned ?? scheduled.length;
const totalCount = payload.counts?.total ?? returnedCount;
const returnedCount = payload.counts?.returned || scheduled.length;
const totalCount = payload.counts?.total || scheduled.length;
return {
items: [...unscheduled, ...scheduled],
items: scheduled,
scheduled,
unscheduled,
totalCount,
returnedCount,
page: 1,
pageSize: scheduled.length + unscheduled.length,
pageSize: scheduled.length,
weekStart: payload.weekStart,
weekEnd: payload.weekEnd,
};

View file

@ -1,5 +1,4 @@
import { keepPreviousData, useQuery, type UseQueryResult } from "@tanstack/react-query";
import { fetchUnassignedBoardRows } from "@/domain/work-orders/api/fetch-unassigned-board-rows";
import { workOrdersApi } from "@/domain/work-orders/api/work-orders-api";
import type {
WorkOrdersListParams,
@ -11,7 +10,6 @@ import type {
WorkOrderBoardQueryParams,
WorkOrderBoardSearchQueryParams,
} from "@/domain/work-orders/types/work-order-board";
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
import { queryKeys } from "@/infra/query-key/query-key";
export function useWorkOrdersList(
@ -48,17 +46,6 @@ export function useWorkOrdersBoardSearch(
});
}
export function useWorkOrdersUnassignedQueue(
enabled = true,
): UseQueryResult<WorkOrderTableRow[], Error> {
return useQuery({
queryKey: queryKeys.workOrders.unassignedQueue(),
queryFn: fetchUnassignedBoardRows,
enabled,
placeholderData: keepPreviousData,
});
}
export function useWorkOrderUsers(): UseQueryResult<WorkOrderUser[], Error> {
return useQuery({
queryKey: queryKeys.workOrders.dispatchers(),

View file

@ -18,7 +18,7 @@ import { weekRange } from "@/domain/work-orders/utils/list-week-utils";
import type { CompletionDocFilter } from "@/domain/work-orders/types/work-order-advanced-filters";
import type { WizardWOStatus, WOType } from "@/domain/work-orders/types/work-order-wizard";
export const BOARD_SEARCH_MAX_PAGE_SIZE = 100;
export const BOARD_SEARCH_MAX_PAGE_SIZE = 200;
/** Wide Custom window so applied advanced search is not pinned to ThisWeek. */
export const ADVANCED_SEARCH_ALL_WEEKS_FROM = "2000-01-01";
@ -246,10 +246,13 @@ export function woTypeFilterIsOverdue(type: string): boolean {
}
/** Week-independent GET /board/search params for the pinned Unassigned queue. */
export function toUnassignedQueueQueryParams(page = 0): WorkOrderBoardSearchQueryParams {
export function toUnassignedQueueQueryParams(
page = 0,
pageSize = BOARD_SEARCH_MAX_PAGE_SIZE,
): WorkOrderBoardSearchQueryParams {
return {
page,
pageSize: BOARD_SEARCH_MAX_PAGE_SIZE,
pageSize: Math.min(pageSize || BOARD_SEARCH_MAX_PAGE_SIZE, BOARD_SEARCH_MAX_PAGE_SIZE),
dispatchers: [BOARD_DISPATCHER_UNASSIGNED],
statuses: [...NON_TERMINAL_LIFECYCLE_STATUSES],
datePreset: "Custom",

View file

@ -31,13 +31,24 @@ export function findItemPageIndex<T extends { id: string | number }>(
return pageIndexForItem(index, pageSize);
}
export function totalPageCount(totalCount: number, pageSize: number): number {
const size = pageSize > 0 ? pageSize : DEFAULT_DAY_PAGE_SIZE;
return Math.max(1, Math.ceil(Math.max(0, totalCount) / size) || 1);
}
export function asDayPageSize(value: number): DayPageSize {
return (DAY_PAGE_SIZE_OPTIONS as readonly number[]).includes(value)
? (value as DayPageSize)
: DEFAULT_DAY_PAGE_SIZE;
}
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 pageCount = totalPageCount(items.length, size);
const page = Math.min(Math.max(0, pageIndex), pageCount - 1);
const start = page * size;
return {
@ -47,6 +58,8 @@ export function paginateItems<T>(
};
}
export const SEARCH_PAGER_FOOTER_LABEL = "Results";
export function shouldShowDayPagination(input: {
flatListMode: boolean;
isLoading: boolean;
@ -55,6 +68,70 @@ export function shouldShowDayPagination(input: {
return !input.flatListMode && !input.isLoading && !input.noSearchResults;
}
export function shouldShowSearchPagination(input: {
isLoading: boolean;
hasResults: boolean;
}): boolean {
return !input.isLoading && input.hasResults;
}
export function resolveSearchPager(input: { page: number; pageSize: number; totalCount: number }): {
pageSize: DayPageSize;
pageIndex: number;
pageCount: number;
itemCount: number;
} {
const pageSize = asDayPageSize(input.pageSize);
const pageCount = totalPageCount(input.totalCount, pageSize);
return {
pageSize,
pageIndex: Math.min(Math.max(0, input.page), pageCount - 1),
pageCount,
itemCount: input.totalCount,
};
}
export function pickBoardPager(input: {
advActive: boolean;
isLoading: boolean;
noSearchResults: boolean;
advResultCount: number;
search: { page: number; pageSize: number; totalCount: number };
day: {
pageSize: DayPageSize;
pageIndex: number;
pageCount: number;
itemCount: number;
footerLabel: string;
};
}): {
show: boolean;
pageSize: DayPageSize;
pageIndex: number;
pageCount: number;
itemCount: number;
footerLabel: string;
} {
if (input.advActive) {
return {
show: shouldShowSearchPagination({
isLoading: input.isLoading,
hasResults: input.advResultCount > 0,
}),
...resolveSearchPager(input.search),
footerLabel: SEARCH_PAGER_FOOTER_LABEL,
};
}
return {
show: shouldShowDayPagination({
flatListMode: false,
isLoading: input.isLoading,
noSearchResults: input.noSearchResults,
}),
...input.day,
};
}
export function formatDayPaginationSummary(dayLabel: string, itemCount: number): string {
const label = dayLabel.trim().toUpperCase();
const noun = itemCount === 1 ? "item" : "items";

View file

@ -17,7 +17,6 @@ export const queryKeys = {
[...queryKeys.workOrders.all, "board", params ?? {}] as const,
boardSearch: (params?: WorkOrderBoardSearchQueryParams) =>
[...queryKeys.workOrders.all, "boardSearch", params ?? {}] as const,
unassignedQueue: () => [...queryKeys.workOrders.all, "unassignedQueue"] as const,
byLocation: (locationId: string | number, search = "") =>
[...queryKeys.workOrders.all, "byLocation", locationId, search] as const,
detail: (id: string | number) => [...queryKeys.workOrders.all, "detail", id] as const,

View file

@ -39,12 +39,18 @@ describe("handlePaginatedResponse", () => {
});
});
it("handles plain arrays", () => {
const result = handlePaginatedResponse([{ id: 1 }], { page: 1, pageSize: 10 });
it("reads total as an alias of totalCount", () => {
const result = handlePaginatedResponse<{ id: number }>(
{
items: [{ id: 1 }],
total: 1778,
page: 0,
pageSize: 50,
},
{ page: 0, pageSize: 50 },
);
expect(result.items).toEqual([{ id: 1 }]);
expect(result.totalCount).toBe(1);
expect(result.page).toBe(1);
expect(result.pageSize).toBe(10);
expect(result.totalCount).toBe(1778);
expect(result.items).toHaveLength(1);
});
});

View file

@ -5,11 +5,12 @@ import { DEFAULT_DAY_PAGE_SIZE } from "@/domain/work-orders/utils/day-pagination
describe("useWorkOrdersDayPagination (SH-241)", () => {
const onRevealed = vi.fn();
const manyItems = Array.from({ length: 60 }, (_, index) => ({ id: `wo-${index}` }));
const base = {
weekMonday: "2026-08-17",
selectedDayIso: "2026-08-17",
advActive: false,
dayItems: [] as { id: string }[],
dayItems: manyItems,
onRevealed,
};
@ -58,12 +59,19 @@ describe("useWorkOrdersDayPagination (SH-241)", () => {
expect(result.current.page).toBe(0);
});
it("does not advance past the last page", () => {
const { result } = renderHook(() => useWorkOrdersDayPagination(base));
act(() => result.current.goToNextPage());
act(() => result.current.goToNextPage());
expect(result.current.page).toBe(1);
});
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 } },
{ initialProps: { ...base, dayItems: [] as { id: string }[], onRevealed: revealed } },
);
act(() => result.current.revealRow("wo-55"));

View file

@ -23,6 +23,7 @@ describe("WoDayPaginationFooter (SH-241)", () => {
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();
expect(screen.getByRole("button", { name: "Next page" })).toBeEnabled();
await user.selectOptions(screen.getByLabelText("Rows per page"), "25");
expect(onPageSizeChange).toHaveBeenCalledWith(25);
await user.click(screen.getByRole("button", { name: "Next page" }));

View file

@ -0,0 +1,163 @@
import { fireEvent, screen } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import { WoTable } from "@/app/(protected)/workorders/_components/list/table/wo-table";
import type { ReturnTypeOfUseWorkOrdersTableData } from "@/app/(protected)/workorders/_components/list/table/wo-table-types";
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
import { renderWithProviders } from "@/test/test-utils";
vi.mock("@/domain/locations/use-cases/use-location-sites", () => ({
useLocationSites: () => ({ data: [] }),
}));
vi.mock("@/app/(protected)/workorders/_components/list/table/use-wo-table-inline-create", () => ({
useWoTableInlineCreate: () => ({ handleInlineSave: vi.fn(), saving: false }),
}));
function makeRow(overrides: Partial<WorkOrderTableRow> = {}): WorkOrderTableRow {
return {
id: "1",
woNumber: "24991",
site: "CLV2",
type: "PM",
severity: null,
dispatcherId: "",
dispatcherName: "",
scheduledOn: "",
completedDate: "",
targetWeek: "",
dayGroup: "",
dayLabel: "",
location: "Cleveland, OH",
locationId: "",
pm: "Backflow",
serviceNotes: "",
extraServices: [],
company: "",
tech: "",
techPhone: "",
apptStart: "",
apptEnd: "",
apptTime: "—",
status: "Incomplete",
docStatus: "No",
pocName: "",
pocPhone: "",
pocNotes: "",
dueDate: "",
rescheduleCount: 0,
carriedOver: 0,
isAddOn: false,
originalWeek: "",
originalDate: "",
isPastDue: false,
scheduleWeekOnly: false,
mediaCount: 0,
title: "",
priority: "",
vendorId: "",
primaryDispatchId: null,
rowVersion: "",
dispatchRowVersion: "",
flagColor: null,
additionalContacts: [],
...overrides,
};
}
function tableData(
overrides: Partial<ReturnTypeOfUseWorkOrdersTableData> = {},
): ReturnTypeOfUseWorkOrdersTableData {
const pageRows = [
makeRow({ id: "wo-0", woNumber: "WO-0" }),
makeRow({ id: "wo-49", woNumber: "WO-49" }),
];
return {
rows: pageRows,
isLoading: false,
isFetching: false,
error: null,
refetch: vi.fn(),
advActive: true,
advResults: pageRows,
unassigned: [],
unassignedError: null,
unassignedLoading: false,
unscheduled: [],
displayGroups: [],
searchActive: false,
noSearchResults: false,
isEmptyWeek: false,
todayKey: null,
draftDayKey: null,
draft: { locationId: "", site: "" },
setDraft: vi.fn(),
unassignedOpen: true,
setUnassignedOpen: vi.fn(),
unschedOpen: true,
setUnschedOpen: vi.fn(),
selectedId: null,
setSelectedId: vi.fn(),
selectedRow: null,
flashId: null,
triggerFlash: vi.fn(),
revealScheduledRow: vi.fn(),
dragViz: { dragId: null, overId: null },
setDragViz: vi.fn(),
dragState: { current: { dragId: null, overId: null } },
patchRow: vi.fn(),
clearRowPatch: vi.fn(),
reorderRows: vi.fn(),
endDrag: vi.fn(),
startDayDraft: vi.fn(),
cancelDraft: vi.fn(),
addRow: vi.fn(),
scopeTotalCount: 1778,
totalCount: 2,
search: "",
showDayPagination: true,
dayPageSize: 50,
dayPageIndex: 0,
dayPageCount: 36,
dayItemCount: 1778,
dayFooterLabel: "Results",
setDayPageSize: vi.fn(),
goToPrevDayPage: vi.fn(),
goToNextDayPage: vi.fn(),
...overrides,
} as ReturnTypeOfUseWorkOrdersTableData;
}
describe("WoTable advanced-search pagination (SH-165)", () => {
it("pages the flat Unassigned search without a pin or a 1778-row dump", () => {
const goToNextDayPage = vi.fn();
const data = tableData({ goToNextDayPage });
renderWithProviders(
<WoTable
tableData={data}
users={[]}
sites={[]}
vendors={[]}
pmTypes={[]}
weekMonday="2026-05-18"
onNavigateToDay={vi.fn()}
onOpenSlideOver={vi.fn()}
onOpenMedia={vi.fn()}
onPatch={vi.fn()}
/>,
{ withAuth: false },
);
expect(screen.queryByRole("button", { name: /Unassigned/ })).not.toBeInTheDocument();
expect(screen.getByText("WO-0")).toBeInTheDocument();
expect(screen.getByText("WO-49")).toBeInTheDocument();
expect(screen.queryByText("WO-50")).not.toBeInTheDocument();
expect(screen.getByText("RESULTS · 1778 items")).toBeInTheDocument();
expect(screen.getByText("Page 1 of 36")).toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: "Next page" }));
expect(goToNextDayPage).toHaveBeenCalled();
expect(screen.getByRole("button", { name: "Next page" })).toBeEnabled();
expect(screen.getByRole("button", { name: "Previous page" })).toBeDisabled();
});
});

View file

@ -89,36 +89,12 @@ function renderTable(data: ReturnTypeOfUseWorkOrdersTableData) {
);
}
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());
describe("WoTable search empty (SH-241)", () => {
it("shows empty search when the selected day has no matches", () => {
const { getByText, queryByRole } = renderTable(tableData());
expect(getByText("No work orders match your search")).toBeInTheDocument();
expect(queryByText(/Loading unassigned work orders/)).not.toBeInTheDocument();
});
it("keeps Unassigned loading visible instead of a premature empty search", () => {
const { getByText, queryByText } = renderTable(
tableData({
noSearchResults: true,
unassignedLoading: true,
}),
);
expect(queryByText("No work orders match your search")).not.toBeInTheDocument();
expect(getByText(/Loading unassigned work orders/)).toBeInTheDocument();
});
it("keeps Unassigned error visible instead of hiding it behind empty search", () => {
const { getByText, queryByText } = renderTable(
tableData({
noSearchResults: true,
unassignedError: new Error("queue unavailable"),
}),
);
expect(queryByText("No work orders match your search")).not.toBeInTheDocument();
expect(getByText("queue unavailable")).toBeInTheDocument();
expect(queryByRole("button", { name: /Unassigned/ })).not.toBeInTheDocument();
});
it("shows empty advanced-filter copy with Adjust filters", () => {

View file

@ -1,17 +1,12 @@
import { useMemo, useState } from "react";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { useState } from "react";
import { describe, expect, it, vi } from "vitest";
import { AssignedToCell } from "@/app/(protected)/workorders/_components/list/table/cells/assigned-to-cell";
import { WoTableBody } from "@/app/(protected)/workorders/_components/list/table/wo-table-body";
import type { ReturnTypeOfUseWorkOrdersTableData } from "@/app/(protected)/workorders/_components/list/table/wo-table-types";
import { expandTablePatchToBoardFieldOps } from "@/domain/work-orders/mappers/work-order-board-patch-mapper";
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
import {
buildDayGroups,
composeBoardSections,
rowIdSet,
} from "@/domain/work-orders/utils/table-filter-utils";
function makeRow(overrides: Partial<WorkOrderTableRow> = {}): WorkOrderTableRow {
return {
@ -115,7 +110,7 @@ function baseTableData(
const DISPATCHER = { id: "sarah", name: "Sarah Chen", initials: "SC", color: "#3366cc" };
function UnassignedAssignHarness({
function DayAssignHarness({
initialRow,
patchBoardField,
}: {
@ -125,49 +120,38 @@ function UnassignedAssignHarness({
payload: { field: string; value?: string | null },
) => Promise<WorkOrderTableRow>;
}) {
const [rows, setRows] = useState([initialRow]);
const weeklyIds = rowIdSet(rows);
const sections = useMemo(
() =>
composeBoardSections({
displayRows: rows,
weekRows: rows,
weeklyIds,
queueIds: weeklyIds,
}),
[rows, weeklyIds],
);
const [row, setRow] = useState(initialRow);
const tableData = baseTableData({
unassigned: sections.unassigned,
unscheduled: sections.unscheduled,
displayGroups: buildDayGroups(sections.scheduled),
displayGroups: [
{
key: "06/02",
label: "TUE · 06/02",
date: "2026-06-02",
items: [row],
totalCount: 1,
},
],
});
return (
<table>
<WoTableBody
tableData={tableData}
renderRow={(row) => (
<tr key={String(row.id)} data-testid={`row-${row.id}`}>
<td>{row.woNumber}</td>
renderRow={(item) => (
<tr key={String(item.id)} data-testid={`row-${item.id}`}>
<td>{item.woNumber}</td>
<td>
<AssignedToCell
dispatcherId={row.dispatcherId}
dispatcherId={item.dispatcherId}
users={[DISPATCHER]}
highlight={!row.dispatcherId}
highlight={!item.dispatcherId}
onSave={async (id, name) => {
const ops = expandTablePatchToBoardFieldOps({
dispatcherId: id,
dispatcherName: name,
});
const patched = await patchBoardField(row.id, ops[0]!);
setRows((current) =>
current.map((item) =>
String(item.id) === String(row.id)
? { ...patched, dispatcherId: id, dispatcherName: name }
: item,
),
);
const patched = await patchBoardField(item.id, ops[0]!);
setRow({ ...patched, dispatcherId: id, dispatcherName: name });
}}
/>
</td>
@ -179,25 +163,9 @@ function UnassignedAssignHarness({
);
}
describe("WoTableBody Unassigned queue (SH-165)", () => {
it("renders Unassigned above the selected day without Unscheduled (SH-240)", () => {
describe("WoTableBody weekly board (AAP day view)", () => {
it("renders the selected day and keeps dated-unassigned inline, with no Unassigned pin", () => {
const datedUnassigned = makeRow({ id: "u-dated", woNumber: "24991" });
const undatedUnassigned = makeRow({
id: "us1",
woNumber: "25021",
scheduledOn: "",
dayGroup: "",
dayLabel: "",
});
const undatedAssigned = makeRow({
id: "us-assigned",
woNumber: "25022",
dispatcherId: "sarah",
dispatcherName: "Sarah",
scheduledOn: "",
dayGroup: "",
dayLabel: "",
});
const datedAssigned = makeRow({
id: "day-assigned",
woNumber: "25023",
@ -205,142 +173,19 @@ describe("WoTableBody Unassigned queue (SH-165)", () => {
dispatcherName: "Sarah",
});
const tableData = baseTableData({
unassigned: [datedUnassigned, undatedUnassigned],
unscheduled: [undatedAssigned],
displayGroups: [
{
key: "06/02",
label: "TUE · 06/02",
date: "2026-06-02",
items: [datedAssigned],
},
],
});
render(
<table>
<WoTableBody
tableData={tableData}
renderRow={(row) => (
<tr key={String(row.id)} data-testid={`row-${row.id}`}>
<td>{row.woNumber}</td>
</tr>
)}
renderInlineRow={() => null}
/>
</table>,
);
const unassignedHeader = screen.getByRole("button", {
name: "Unassigned, 2 work orders, expanded",
});
const dayLabel = screen.getByText("TUE · 06/02");
expect(unassignedHeader.compareDocumentPosition(screen.getByTestId("row-u-dated"))).toBe(
Node.DOCUMENT_POSITION_FOLLOWING,
);
expect(unassignedHeader.compareDocumentPosition(dayLabel)).toBe(
Node.DOCUMENT_POSITION_FOLLOWING,
);
expect(screen.queryByRole("button", { name: /Unscheduled/ })).not.toBeInTheDocument();
expect(screen.getByTestId("row-u-dated")).toBeInTheDocument();
expect(screen.getByTestId("row-us1")).toBeInTheDocument();
expect(screen.queryByTestId("row-us-assigned")).not.toBeInTheDocument();
expect(screen.getByTestId("row-day-assigned")).toBeInTheDocument();
expect(screen.getAllByTestId(/row-/)).toHaveLength(3);
});
it("collapses and expands the Unassigned section via the disclosure control", async () => {
const user = userEvent.setup();
const setUnassignedOpen = vi.fn();
const tableData = baseTableData({
unassigned: [makeRow({ id: "u1", woNumber: "24991" })],
setUnassignedOpen,
});
render(
<table>
<WoTableBody
tableData={tableData}
renderRow={(row) => (
<tr key={String(row.id)} data-testid={`row-${row.id}`}>
<td>{row.woNumber}</td>
</tr>
)}
renderInlineRow={() => null}
/>
</table>,
);
await user.click(screen.getByRole("button", { name: "Unassigned, 1 work order, expanded" }));
expect(setUnassignedOpen).toHaveBeenCalledWith(expect.any(Function));
});
it("renders a scheduled unassigned WO from outside the selected week in the pin", () => {
const outOfWeek = makeRow({
id: "out-week",
woNumber: "26001",
scheduledOn: "2026-07-15",
dayGroup: "07/15",
dayLabel: "WED · 07/15",
});
render(
<table>
<WoTableBody
tableData={baseTableData({ unassigned: [outOfWeek] })}
renderRow={(row) => (
<tr key={String(row.id)} data-testid={`row-${row.id}`}>
<td>{row.woNumber}</td>
</tr>
)}
renderInlineRow={() => null}
/>
</table>,
);
expect(screen.getByTestId("row-out-week")).toBeInTheDocument();
expect(screen.queryByText("WED · 07/15")).not.toBeInTheDocument();
});
it("distinguishes a still-loading queue from an empty one", () => {
render(
<table>
<WoTableBody
tableData={baseTableData({ unassignedLoading: true })}
renderRow={() => null}
renderInlineRow={() => null}
/>
</table>,
);
expect(screen.getByText(/Loading unassigned work orders/)).toBeInTheDocument();
expect(screen.queryByText("No unassigned work orders.")).not.toBeInTheDocument();
});
it("announces an empty queue only once loading has settled", () => {
render(
<table>
<WoTableBody
tableData={baseTableData({ unassignedLoading: false })}
renderRow={() => null}
renderInlineRow={() => null}
/>
</table>,
);
expect(screen.getByText("No unassigned work orders.")).toBeInTheDocument();
expect(screen.queryByText(/Loading unassigned work orders/)).not.toBeInTheDocument();
});
it("keeps the Unassigned pin visible when the queue query fails", () => {
render(
<table>
<WoTableBody
tableData={baseTableData({
unassignedError: new Error("queue unavailable"),
displayGroups: [
{
key: "06/02",
label: "TUE · 06/02",
date: "2026-06-02",
items: [datedUnassigned, datedAssigned],
totalCount: 2,
},
],
})}
renderRow={(row) => (
<tr key={String(row.id)} data-testid={`row-${row.id}`}>
@ -352,32 +197,15 @@ describe("WoTableBody Unassigned queue (SH-165)", () => {
</table>,
);
expect(screen.getByText("queue unavailable")).toBeInTheDocument();
expect(screen.getByText("TUE · 06/02")).toBeInTheDocument();
expect(screen.getByText("2 WOs")).toBeInTheDocument();
expect(screen.queryByRole("button", { name: /Unassigned/ })).not.toBeInTheDocument();
expect(screen.queryByRole("button", { name: /Unscheduled/ })).not.toBeInTheDocument();
expect(screen.getByTestId("row-u-dated")).toBeInTheDocument();
expect(screen.getByTestId("row-day-assigned")).toBeInTheDocument();
});
it("keeps the Unassigned section visible with an empty state when the queue is empty", () => {
render(
<table>
<WoTableBody
tableData={baseTableData()}
renderRow={(row) => (
<tr key={String(row.id)} data-testid={`row-${row.id}`}>
<td>{row.woNumber}</td>
</tr>
)}
renderInlineRow={() => null}
/>
</table>,
);
expect(
screen.getByRole("button", { name: "Unassigned, 0 work orders, expanded" }),
).toBeInTheDocument();
expect(screen.getByText("No unassigned work orders.")).toBeInTheDocument();
expect(screen.queryByTestId(/row-/)).not.toBeInTheDocument();
});
it("removes a pinned row immediately after assigning through the board PATCH path", async () => {
it("assigns a dated-unassigned row in the day group without a pin section", async () => {
const user = userEvent.setup();
const pinned = makeRow({
id: "u-pin",
@ -391,17 +219,14 @@ describe("WoTableBody Unassigned queue (SH-165)", () => {
return { ...pinned, dispatcherId: DISPATCHER.id, dispatcherName: DISPATCHER.name };
});
render(<UnassignedAssignHarness initialRow={pinned} patchBoardField={patchBoardField} />);
render(<DayAssignHarness initialRow={pinned} patchBoardField={patchBoardField} />);
expect(screen.getByTestId("row-u-pin")).toBeInTheDocument();
await user.click(screen.getByRole("button", { name: /Select/ }));
await user.click(screen.getByRole("button", { name: /Sarah Chen/ }));
expect(patchBoardField).toHaveBeenCalled();
expect(
screen.getByRole("button", { name: "Unassigned, 0 work orders, expanded" }),
).toBeInTheDocument();
expect(screen.getByText("No unassigned work orders.")).toBeInTheDocument();
expect(screen.queryByRole("button", { name: /Unassigned/ })).not.toBeInTheDocument();
expect(screen.getByTestId("row-u-pin")).toBeInTheDocument();
expect(screen.getByText("TUE · 06/02")).toBeInTheDocument();
});

View file

@ -107,6 +107,7 @@ describe("Work Orders dispatcher filter layout (SH-242)", () => {
);
expect(weekAndDay.contains(dispatcher)).toBe(false);
expect(weekAndDay.contains(week)).toBe(true);
expect(screen.getByText("0 of 0")).toBeInTheDocument();
expect(weekAndDay.contains(screen.getByTestId("wo-type-filter-trigger"))).toBe(true);
expect(screen.queryByText("0 of 0")).not.toBeInTheDocument();
});
});

View file

@ -10,43 +10,39 @@ vi.mock("@/domain/work-orders/api/work-orders-api", () => ({
},
}));
import { fetchUnassignedBoardRows } from "@/domain/work-orders/api/fetch-unassigned-board-rows";
import { fetchUnassignedBoardPage } from "@/domain/work-orders/api/fetch-unassigned-board-rows";
function pageResult(items: Pick<WorkOrderTableRow, "id">[], totalCount: number) {
function pageResult(items: Pick<WorkOrderTableRow, "id">[], totalCount: number, page = 0) {
return {
items,
scheduled: [],
unscheduled: [],
totalCount,
returnedCount: items.length,
page: 0,
page,
pageSize: BOARD_SEARCH_MAX_PAGE_SIZE,
};
}
describe("fetchUnassignedBoardRows", () => {
describe("fetchUnassignedBoardPage", () => {
beforeEach(() => {
searchBoard.mockReset();
});
it("pages until totalCount is exhausted", async () => {
const first = Array.from({ length: BOARD_SEARCH_MAX_PAGE_SIZE }, (_, index) => ({
id: `a-${index}`,
}));
const second = [{ id: "b-0" }];
searchBoard
.mockResolvedValueOnce(pageResult(first, 101))
.mockResolvedValueOnce(pageResult(second, 101));
it("fetches a single search page instead of exhausting totalCount", async () => {
const first = Array.from({ length: 50 }, (_, index) => ({ id: `a-${index}` }));
searchBoard.mockResolvedValueOnce(pageResult(first, 1778, 0));
const rows = await fetchUnassignedBoardRows();
const result = await fetchUnassignedBoardPage(0, 50);
expect(searchBoard).toHaveBeenCalledTimes(2);
expect(searchBoard).toHaveBeenCalledTimes(1);
expect(searchBoard.mock.calls[0]?.[0]).toMatchObject({
page: 0,
pageSize: 50,
dispatchers: ["__unassigned__"],
datePreset: "Custom",
});
expect(searchBoard.mock.calls[1]?.[0]).toMatchObject({ page: 1 });
expect(rows).toHaveLength(101);
expect(result.items).toHaveLength(50);
expect(result.totalCount).toBe(1778);
});
});

View file

@ -245,6 +245,7 @@ describe("workOrdersApi.getBoard", () => {
searchParams: expect.any(URLSearchParams),
}),
);
expect(result.items).toHaveLength(1);
expect(result.returnedCount).toBe(1);
expect(result.totalCount).toBe(2);
expect(result.unscheduled).toHaveLength(1);
@ -253,6 +254,30 @@ describe("workOrdersApi.getBoard", () => {
expect(result.scheduled[0]?.apptTime).toBe("07:00 – 09:00");
expect(result.scheduled[0]?.type).toBe("Reactive");
});
it("does not dump unscheduled rows into items when counts are zero", async () => {
apiGet.mockResolvedValue({
weekStart: "2026-08-24",
weekEnd: "2026-08-28",
counts: { returned: 0, total: 0 },
unscheduled: Array.from({ length: 5 }, (_, index) => ({
id: index + 1,
woNumber: `0000000000${index + 1}`,
lifecycleStatusLabel: "Incomplete",
})),
scheduled: [],
});
const result = await workOrdersApi.getBoard({
weekStart: "2026-08-24",
weekEnd: "2026-08-28",
});
expect(result.items).toEqual([]);
expect(result.unscheduled).toHaveLength(5);
expect(result.totalCount).toBe(0);
expect(result.returnedCount).toBe(0);
});
});
describe("workOrdersApi.searchBoard", () => {

View file

@ -71,7 +71,7 @@ describe("toBoardQueryParams", () => {
expect(params.overdue).toBe(true);
});
it("omits types when filter is All", () => {
it("omits types when the set is empty", () => {
const params = toBoardQueryParams(baseBar, "");
expect(params.types).toBeUndefined();
expect(params.overdue).toBeUndefined();
@ -90,6 +90,16 @@ describe("toBoardSearchQueryParams", () => {
expect(params.dateTo).toBe("2099-12-31");
});
it("forwards chrome pageSize 50 instead of the search max", () => {
const params = toBoardSearchQueryParams({
filters: { ...EMPTY_ADVANCED_FILTERS },
page: 1,
pageSize: 50,
});
expect(params.page).toBe(1);
expect(params.pageSize).toBe(50);
});
it("maps date presets to PascalCase API values", () => {
const params = toBoardSearchQueryParams({
filters: { ...EMPTY_ADVANCED_FILTERS, dateRange: "last-3-months" },
@ -297,7 +307,7 @@ describe("toBoardSearchQueryParams", () => {
filters: { ...EMPTY_ADVANCED_FILTERS },
pageSize: 500,
});
expect(params.pageSize).toBe(100);
expect(params.pageSize).toBe(200);
});
});

View file

@ -2,17 +2,22 @@ import { describe, expect, it } from "vitest";
import {
DAY_PAGE_SIZE_OPTIONS,
DEFAULT_DAY_PAGE_SIZE,
asDayPageSize,
findItemPageIndex,
formatDayPaginationSummary,
pageIndexForItem,
paginateItems,
pickBoardPager,
shouldShowDayPagination,
shouldShowSearchPagination,
} 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]);
expect(asDayPageSize(200)).toBe(200);
expect(asDayPageSize(13)).toBe(DEFAULT_DAY_PAGE_SIZE);
});
it("paginates 60 items at 50 per page", () => {
@ -70,6 +75,34 @@ describe("day pagination utils (SH-241)", () => {
).toBe(false);
});
it("shows search pagination when advanced filters return rows", () => {
expect(shouldShowSearchPagination({ isLoading: false, hasResults: true })).toBe(true);
expect(shouldShowSearchPagination({ isLoading: true, hasResults: true })).toBe(false);
expect(shouldShowSearchPagination({ isLoading: false, hasResults: false })).toBe(false);
});
it("pages a 1778-row search at 50 per page without dumping the universe", () => {
const pager = pickBoardPager({
advActive: true,
isLoading: false,
noSearchResults: false,
advResultCount: 50,
search: { page: 0, pageSize: 50, totalCount: 1778 },
day: {
pageSize: 50,
pageIndex: 0,
pageCount: 1,
itemCount: 0,
footerLabel: "Mon · 08/17",
},
});
expect(pager.show).toBe(true);
expect(pager.itemCount).toBe(1778);
expect(pager.pageCount).toBe(36);
expect(pager.pageIndex).toBe(0);
expect(pager.footerLabel).toBe("Results");
});
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);