mirror of
https://github.com/Sea-Haven-Industries/shoc-frontend-new.git
synced 2026-09-30 06:53:12 +00:00
fix(work-orders): paginate board and search without loading the full dataset (SH-165)
This commit is contained in:
parent
cd092b3137
commit
4782d2e3b0
31 changed files with 541 additions and 502 deletions
|
|
@ -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`
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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%",
|
||||
|
|
|
|||
|
|
@ -43,7 +43,6 @@ export function WoTableSkeleton({
|
|||
>
|
||||
<WoTableHead />
|
||||
<tbody>
|
||||
<DaySkeletonSection label="Unassigned" rows={1} />
|
||||
<DaySkeletonSection label={formatDayTabLabel(dayIso)} rows={rowsPerDay} />
|
||||
</tbody>
|
||||
</table>
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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"));
|
||||
|
|
|
|||
|
|
@ -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" }));
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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", () => {
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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", () => {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue