From 4782d2e3b0f00334e305e398491179cee14f7351 Mon Sep 17 00:00:00 2001 From: Arthur Bassi Date: Mon, 24 Aug 2026 18:38:32 -0300 Subject: [PATCH] fix(work-orders): paginate board and search without loading the full dataset (SH-165) --- docs/work-orders/board-search-api.md | 19 +- e2e/work-orders/board-day-pagination.spec.ts | 34 ++- src/api/paginated-response-fields.ts | 2 + .../list/table/wo-day-pagination-footer.tsx | 9 +- .../_components/list/table/wo-table-body.tsx | 59 +--- .../_components/list/table/wo-table-shell.tsx | 2 +- .../_components/list/table/wo-table.tsx | 10 +- .../_components/loading/wo-table-skeleton.tsx | 1 - .../_hooks/use-work-orders-board-source.ts | 35 +-- .../_hooks/use-work-orders-day-pagination.ts | 7 +- .../_hooks/use-work-orders-list-filters.ts | 17 +- .../_hooks/use-work-orders-table-data.ts | 115 ++++---- src/app/(protected)/workorders/index.tsx | 3 +- src/components/ui/nav-arrow-button.tsx | 6 +- .../api/fetch-unassigned-board-rows.ts | 32 +-- src/domain/work-orders/api/work-orders-api.ts | 8 +- .../use-cases/use-work-orders-list.ts | 13 - .../work-orders/utils/board-query-params.ts | 9 +- .../work-orders/utils/day-pagination.ts | 79 +++++- src/infra/query-key/query-key.ts | 1 - src/test/api/handle-api-response.test.ts | 18 +- .../use-work-orders-day-pagination.test.tsx | 12 +- .../wo-day-pagination-footer.test.tsx | 1 + .../wo-table-adv-search-pagination.test.tsx | 163 +++++++++++ .../workorders/wo-table-search-empty.test.tsx | 32 +-- .../wo-table-unassigned-queue.test.tsx | 253 +++--------------- .../work-orders-dispatcher-layout.test.tsx | 3 +- .../api/fetch-unassigned-board-rows.test.ts | 28 +- .../work-orders/api/work-orders-api.test.ts | 25 ++ .../utils/board-query-params.test.ts | 14 +- .../work-orders/utils/day-pagination.test.ts | 33 +++ 31 files changed, 541 insertions(+), 502 deletions(-) create mode 100644 src/test/app/(protected)/workorders/wo-table-adv-search-pagination.test.tsx diff --git a/docs/work-orders/board-search-api.md b/docs/work-orders/board-search-api.md index eea2a0bd..f233640e 100644 --- a/docs/work-orders/board-search-api.md +++ b/docs/work-orders/board-search-api.md @@ -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` diff --git a/e2e/work-orders/board-day-pagination.spec.ts b/e2e/work-orders/board-day-pagination.spec.ts index 0bb3b322..46f97a49 100644 --- a/e2e/work-orders/board-day-pagination.spec.ts +++ b/e2e/work-orders/board-day-pagination.spec.ts @@ -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(); diff --git a/src/api/paginated-response-fields.ts b/src/api/paginated-response-fields.ts index 157ffbc4..cfc31b8f 100644 --- a/src/api/paginated-response-fields.ts +++ b/src/api/paginated-response-fields.ts @@ -13,6 +13,8 @@ export function readTotalCount(record: Record, 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 ); diff --git a/src/app/(protected)/workorders/_components/list/table/wo-day-pagination-footer.tsx b/src/app/(protected)/workorders/_components/list/table/wo-day-pagination-footer.tsx index 850c7f55..6f5af206 100644 --- a/src/app/(protected)/workorders/_components/list/table/wo-day-pagination-footer.tsx +++ b/src/app/(protected)/workorders/_components/list/table/wo-day-pagination-footer.tsx @@ -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({