From 0c514c9bad749df1d9e1dc2edb86f6356b688905 Mon Sep 17 00:00:00 2001 From: Arthur Bassi Date: Thu, 20 Aug 2026 10:08:34 -0300 Subject: [PATCH 1/2] feat(work-orders): add per-day board pagination (SH-241) Paginate the selected day's WOs client-side with AAP footer controls. --- e2e/work-orders/board-day-pagination.spec.ts | 124 ++++++++++++++++++ .../list/table/wo-day-pagination-footer.tsx | 80 +++++++++++ .../_components/list/table/wo-table-body.tsx | 2 +- .../_components/list/table/wo-table-shell.tsx | 17 ++- .../_components/list/table/wo-table.tsx | 21 ++- .../_hooks/use-work-orders-day-pagination.ts | 42 ++++++ .../_hooks/use-work-orders-table-data.ts | 59 +++++---- .../work-orders/types/work-order-table-row.ts | 1 + .../work-orders/utils/day-pagination.ts | 60 +++++++++ .../use-work-orders-day-pagination.test.tsx | 57 ++++++++ .../wo-day-pagination-footer.test.tsx | 31 +++++ .../work-orders/utils/day-pagination.test.ts | 75 +++++++++++ 12 files changed, 537 insertions(+), 32 deletions(-) create mode 100644 e2e/work-orders/board-day-pagination.spec.ts create mode 100644 src/app/(protected)/workorders/_components/list/table/wo-day-pagination-footer.tsx create mode 100644 src/app/(protected)/workorders/_hooks/use-work-orders-day-pagination.ts create mode 100644 src/domain/work-orders/utils/day-pagination.ts create mode 100644 src/test/app/(protected)/workorders/use-work-orders-day-pagination.test.tsx create mode 100644 src/test/app/(protected)/workorders/wo-day-pagination-footer.test.tsx create mode 100644 src/test/domain/work-orders/utils/day-pagination.test.ts diff --git a/e2e/work-orders/board-day-pagination.spec.ts b/e2e/work-orders/board-day-pagination.spec.ts new file mode 100644 index 00000000..0bb3b322 --- /dev/null +++ b/e2e/work-orders/board-day-pagination.spec.ts @@ -0,0 +1,124 @@ +import { expect, test, type Page, type Route } from "@playwright/test"; + +const FROZEN_NOW = "2026-08-19T15:00:00.000Z"; + +const TEST_USER = { + token: "wo-day-pagination-e2e-token", + expiration: "2030-01-01T00:00:00.000Z", + email: "admin@seahavenind.com", + userRoles: "Admin", + phoneNumber: "5551234567", + fullname: "Work Order E2E Admin", + id: "1", +}; + +async function fulfillJson(route: Route, body: unknown, status = 200) { + await route.fulfill({ status, contentType: "application/json", body: JSON.stringify(body) }); +} + +function boardRow(id: number, woNumber: string, scheduledDate: string) { + return { + id, + woNumber, + workOrderType: 2, + siteCode: woNumber, + lifecycleStatusLabel: "Scheduled", + scheduledDate, + dispatcherId: "1", + dispatcherName: "Pat Dispatcher", + pm: "HVAC", + }; +} + +async function mockWorkOrderBoard(page: Page) { + const wednesday = Array.from({ length: 60 }, (_, index) => + boardRow(1000 + index, `WO-${1000 + index}`, "2026-08-19"), + ); + const monday = Array.from({ length: 5 }, (_, index) => + boardRow(2000 + index, `MON-${2000 + index}`, "2026-08-17"), + ); + + await page.route( + (url) => url.pathname.startsWith("/api/"), + async (route) => { + const url = route.request().url(); + if (url.includes("workorders/board/search")) { + await fulfillJson(route, { items: [], totalCount: 0, page: 1, pageSize: 100 }); + return; + } + if (url.includes("workorders/board") && route.request().method() === "GET") { + const weekStart = new URL(url).searchParams.get("weekStart") ?? "2026-08-17"; + const scheduled = + weekStart === "2026-08-17" ? [...wednesday, ...monday] : monday.slice(0, 1); + await fulfillJson(route, { + weekStart, + weekEnd: weekStart === "2026-08-17" ? "2026-08-21" : "2026-08-14", + counts: { returned: scheduled.length, total: scheduled.length }, + unscheduled: [], + scheduled, + }); + return; + } + await fulfillJson(route, { data: [], items: [] }); + }, + ); +} + +test.describe("work order per-day pagination (SH-241)", () => { + test.setTimeout(60_000); + + test.beforeEach(async ({ page }) => { + await page.clock.install({ time: new Date(FROZEN_NOW) }); + await page.clock.resume(); + await page.addInitScript((user) => { + localStorage.setItem("auth", JSON.stringify(user)); + const style = document.createElement("style"); + style.textContent = ".tsqd-parent-container { display: none !important; }"; + document.documentElement.appendChild(style); + }, TEST_USER); + await mockWorkOrderBoard(page); + }); + + test("changes rows-per-page, hides on global filter, and resets on day or week change", async ({ + page, + }) => { + const pager = page.getByTestId("wo-day-pagination"); + await page.goto("/workorders"); + await expect(page.getByRole("heading", { name: "Work Orders" })).toBeVisible({ + timeout: 20_000, + }); + await expect(pager).toBeVisible(); + await expect(page.getByText("Page 1 of 2")).toBeVisible(); + await expect(page.getByText("WO-1000").first()).toBeVisible(); + await expect(page.getByText("WO-1050")).toHaveCount(0); + + await page.getByLabel("Rows per page").selectOption("25"); + await expect(page.getByText("Page 1 of 3")).toBeVisible(); + await expect(page.getByText("WO-1025")).toHaveCount(0); + + await page.getByTestId("wo-day-pagination-next").evaluate((node) => { + (node as HTMLButtonElement).click(); + }); + await expect(page.getByText("Page 2 of 3")).toBeVisible(); + await expect(page.getByText("WO-1025").first()).toBeVisible(); + await expect(page.getByText("WO-1000")).toHaveCount(0); + + await page.getByRole("tab", { name: "Mon · 08/17" }).click({ force: true }); + await expect(page.getByText("Page 1 of 1")).toBeVisible(); + await expect(page.getByText("MON-2000").first()).toBeVisible(); + + await page.getByRole("button", { name: "Week 8/17–8/21" }).click({ force: true }); + await page.getByRole("button", { name: /Aug 10/ }).click({ force: true }); + await expect(page.getByTestId("wo-day-pagination")).toBeVisible(); + await expect(page.getByText("Page 1 of")).toBeVisible(); + + await page.getByRole("button", { name: "Advanced Filters" }).click(); + await page.getByRole("button", { name: /Indicators/i }).click(); + await page.getByRole("button", { name: "Rescheduled ≥ 2" }).click(); + await page.getByRole("button", { name: "Apply filters" }).click(); + await expect(pager).toHaveCount(0); + + await page.getByRole("button", { name: "Clear all" }).click(); + await expect(page.getByTestId("wo-day-pagination")).toBeVisible(); + }); +}); 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 new file mode 100644 index 00000000..850c7f55 --- /dev/null +++ b/src/app/(protected)/workorders/_components/list/table/wo-day-pagination-footer.tsx @@ -0,0 +1,80 @@ +import { ChevronLeft, ChevronRight } from "lucide-react"; +import { NavArrowButton } from "@/components/ui/nav-arrow-button"; +import { Text } from "@/components/ui/text"; +import { + DAY_PAGE_SIZE_OPTIONS, + formatDayPaginationSummary, + type DayPageSize, +} from "@/domain/work-orders/utils/day-pagination"; + +type WoDayPaginationFooterProps = { + dayLabel: string; + itemCount: number; + pageSize: DayPageSize; + pageIndex: number; + pageCount: number; + onPageSizeChange: (pageSize: DayPageSize) => void; + onPrev: () => void; + onNext: () => void; +}; + +export function WoDayPaginationFooter({ + dayLabel, + itemCount, + pageSize, + pageIndex, + pageCount, + onPageSizeChange, + onPrev, + onNext, +}: WoDayPaginationFooterProps) { + const canPrev = pageIndex > 0; + const canNext = pageIndex < pageCount - 1; + const pageLabel = `Page ${pageIndex + 1} of ${pageCount}`; + const summary = formatDayPaginationSummary(dayLabel, itemCount); + + return ( + + ); +} diff --git a/src/app/(protected)/workorders/_components/list/table/wo-table-body.tsx b/src/app/(protected)/workorders/_components/list/table/wo-table-body.tsx index 270943a4..fd7b5e20 100644 --- a/src/app/(protected)/workorders/_components/list/table/wo-table-body.tsx +++ b/src/app/(protected)/workorders/_components/list/table/wo-table-body.tsx @@ -78,7 +78,7 @@ export function WoTableBody({ tableData, renderRow, renderInlineRow }: WoTableBo tableData.startDayDraft(group.key, group.date)} /> diff --git a/src/app/(protected)/workorders/_components/list/table/wo-table-shell.tsx b/src/app/(protected)/workorders/_components/list/table/wo-table-shell.tsx index b7aca373..2396b7c8 100644 --- a/src/app/(protected)/workorders/_components/list/table/wo-table-shell.tsx +++ b/src/app/(protected)/workorders/_components/list/table/wo-table-shell.tsx @@ -8,11 +8,20 @@ const CARD_STYLE: React.CSSProperties = { boxShadow: "0 1px 3px rgba(38,34,98,.05)", }; -export function WoTableShell({ children }: { children: React.ReactNode }) { +export function WoTableShell({ + children, + footer, +}: { + children: React.ReactNode; + footer?: React.ReactNode; +}) { return ( -
-
- {children} +
+
+
{children}
+ {Boolean(footer) && ( +
{footer}
+ )}
); diff --git a/src/app/(protected)/workorders/_components/list/table/wo-table.tsx b/src/app/(protected)/workorders/_components/list/table/wo-table.tsx index 0ac11f1a..1bd17eb0 100644 --- a/src/app/(protected)/workorders/_components/list/table/wo-table.tsx +++ b/src/app/(protected)/workorders/_components/list/table/wo-table.tsx @@ -11,6 +11,7 @@ import type { } from "@/domain/work-orders/types/work-order-table-row"; import { DuplicateWoNumberDialog } from "@/app/(protected)/workorders/_components/list/duplicate-wo-number-dialog"; import { WoTableSkeleton } from "@/app/(protected)/workorders/_components/loading/wo-table-skeleton"; +import { WoDayPaginationFooter } from "./wo-day-pagination-footer"; import type { ReturnTypeOfUseWorkOrdersTableData } from "./wo-table-types"; import { buildSitePlaceLookups, resolveSitePlace } from "./site-place-lookup"; import { useWoTableInlineCreate } from "./use-wo-table-inline-create"; @@ -68,6 +69,24 @@ function WoTableEmptyShell({ message, actionLabel, onAction }: WoTableEmptyShell ); } +function WoTableDayFooter({ tableData }: { tableData: ReturnTypeOfUseWorkOrdersTableData }) { + if (!tableData.showDayPagination) { + return undefined; + } + return ( + + ); +} + export function WoTable({ tableData, users, @@ -182,7 +201,7 @@ export function WoTable({ return ( <> - + (DEFAULT_DAY_PAGE_SIZE); + const [page, setPage] = useState(0); + + useEffect(() => { + setPage(0); + }, [weekMonday, selectedDayIso, advActive]); + + const setPageSize = useCallback((next: DayPageSize) => { + setPageSizeState(next); + setPage(0); + }, []); + + const goToPrevPage = useCallback(() => { + setPage((current) => Math.max(0, current - 1)); + }, []); + + const goToNextPage = useCallback(() => { + setPage((current) => current + 1); + }, []); + + return { + pageSize, + page, + setPageSize, + goToPrevPage, + goToNextPage, + }; +} diff --git a/src/app/(protected)/workorders/_hooks/use-work-orders-table-data.ts b/src/app/(protected)/workorders/_hooks/use-work-orders-table-data.ts index b931237e..1d0934f6 100644 --- a/src/app/(protected)/workorders/_hooks/use-work-orders-table-data.ts +++ b/src/app/(protected)/workorders/_hooks/use-work-orders-table-data.ts @@ -1,10 +1,15 @@ import { useMemo } from "react"; import type { useWorkOrdersListFilters } from "@/app/(protected)/workorders/_hooks/use-work-orders-list-filters"; import { useWorkOrdersBoardSource } from "@/app/(protected)/workorders/_hooks/use-work-orders-board-source"; +import { useWorkOrdersDayPagination } from "@/app/(protected)/workorders/_hooks/use-work-orders-day-pagination"; import { useWorkOrdersTableLocalState } from "@/app/(protected)/workorders/_hooks/use-work-orders-table-local-state"; import { currentWeekMonday } from "@/domain/work-orders/utils/list-week-utils"; import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row"; import { deriveDayFields } from "@/domain/work-orders/utils/table-day-utils"; +import { + pageSelectedDayGroups, + shouldShowDayPagination, +} from "@/domain/work-orders/utils/day-pagination"; import { buildSelectedDayGroup, isDayScopedSearchEmpty, @@ -50,7 +55,7 @@ export function useWorkOrdersTableData(filters: FiltersReturn) { advActive, isFetching, }); - const { displayRows } = local; + const { displayRows, ...localUi } = local; const advResults = useMemo(() => (advActive ? displayRows : []), [advActive, displayRows]); @@ -84,16 +89,25 @@ export function useWorkOrdersTableData(filters: FiltersReturn) { : [buildSelectedDayGroup(filters.weekMonday, filters.selectedDayIso, scheduled)], [advActive, filters.selectedDayIso, filters.weekMonday, scheduled], ); + const pagination = useWorkOrdersDayPagination({ + weekMonday: filters.weekMonday, + selectedDayIso: filters.selectedDayIso, + advActive, + }); + const pagedDay = useMemo( + () => pageSelectedDayGroups(dayGroups, pagination.page, pagination.pageSize), + [dayGroups, pagination.page, pagination.pageSize], + ); const searchActive = filters.search.trim() !== ""; - const displayGroups = dayGroups; - const dayVisibleCount = displayGroups[0]?.items.length ?? 0; + const displayGroups = pagedDay.groups; + const dayTotalCount = pagedDay.totalCount; const noSearchResults = !advActive && isDayScopedSearchEmpty({ searchActive, unassignedCount: unassigned.length, - dayItemCount: dayVisibleCount, + dayItemCount: dayTotalCount, }); const isEmptyWeek = !advActive && scheduled.length === 0 && unassigned.length === 0 && !searchActive; @@ -125,30 +139,23 @@ export function useWorkOrdersTableData(filters: FiltersReturn) { noSearchResults, isEmptyWeek, todayKey, - draftDayKey: local.draftDayKey, - draft: local.draft, - setDraft: local.setDraft, - unassignedOpen: local.unassignedOpen, - setUnassignedOpen: local.setUnassignedOpen, - unschedOpen: local.unschedOpen, - setUnschedOpen: local.setUnschedOpen, - selectedId: local.selectedId, - setSelectedId: local.setSelectedId, + ...localUi, selectedRow, - flashId: local.flashId, - triggerFlash: local.triggerFlash, - dragViz: local.dragViz, - setDragViz: local.setDragViz, - dragState: local.dragState, - patchRow: local.patchRow, - clearRowPatch: local.clearRowPatch, - reorderRows: local.reorderRows, - endDrag: local.endDrag, - startDayDraft: local.startDayDraft, - cancelDraft: local.cancelDraft, - addRow: local.addRow, scopeTotalCount: data?.totalCount ?? 0, - totalCount: advActive ? (data?.totalCount ?? advResults.length) : dayVisibleCount, + totalCount: advActive ? (data?.totalCount ?? advResults.length) : dayTotalCount, search: filters.search, + showDayPagination: shouldShowDayPagination({ + flatListMode: advActive, + isLoading, + noSearchResults, + }), + dayPageSize: pagination.pageSize, + dayPageIndex: pagedDay.pageIndex, + dayPageCount: pagedDay.pageCount, + dayItemCount: pagedDay.totalCount, + dayFooterLabel: displayGroups[0]?.label ?? "", + setDayPageSize: pagination.setPageSize, + goToPrevDayPage: pagination.goToPrevPage, + goToNextDayPage: pagination.goToNextPage, }; } diff --git a/src/domain/work-orders/types/work-order-table-row.ts b/src/domain/work-orders/types/work-order-table-row.ts index 4b066382..4594bb49 100644 --- a/src/domain/work-orders/types/work-order-table-row.ts +++ b/src/domain/work-orders/types/work-order-table-row.ts @@ -138,6 +138,7 @@ export type WorkOrderDayGroup = { label: string; date: string; items: WorkOrderTableRow[]; + totalCount?: number; }; export type WorkOrderTablePatch = Partial; diff --git a/src/domain/work-orders/utils/day-pagination.ts b/src/domain/work-orders/utils/day-pagination.ts new file mode 100644 index 00000000..2eb40255 --- /dev/null +++ b/src/domain/work-orders/utils/day-pagination.ts @@ -0,0 +1,60 @@ +import type { WorkOrderDayGroup } from "@/domain/work-orders/types/work-order-table-row"; + +export const DAY_PAGE_SIZE_OPTIONS = [25, 50, 100, 200] as const; +export const DEFAULT_DAY_PAGE_SIZE = 50; + +export type DayPageSize = (typeof DAY_PAGE_SIZE_OPTIONS)[number]; + +export type PaginatedSlice = { + visible: T[]; + pageCount: number; + pageIndex: number; +}; + +export function paginateItems( + items: readonly T[], + pageIndex: number, + pageSize: number, +): PaginatedSlice { + const size = pageSize > 0 ? pageSize : DEFAULT_DAY_PAGE_SIZE; + const pageCount = Math.max(1, Math.ceil(items.length / size) || 1); + const page = Math.min(Math.max(0, pageIndex), pageCount - 1); + const start = page * size; + return { + visible: items.slice(start, start + size), + pageCount, + pageIndex: page, + }; +} + +export function shouldShowDayPagination(input: { + flatListMode: boolean; + isLoading: boolean; + noSearchResults: boolean; +}): boolean { + return !input.flatListMode && !input.isLoading && !input.noSearchResults; +} + +export function formatDayPaginationSummary(dayLabel: string, itemCount: number): string { + const label = dayLabel.trim().toUpperCase(); + const noun = itemCount === 1 ? "item" : "items"; + return `${label} · ${itemCount} ${noun}`; +} + +export function pageSelectedDayGroups( + dayGroups: WorkOrderDayGroup[], + pageIndex: number, + pageSize: number, +): { groups: WorkOrderDayGroup[]; pageIndex: number; pageCount: number; totalCount: number } { + const group = dayGroups[0]; + if (!group) { + return { groups: dayGroups, pageIndex: 0, pageCount: 1, totalCount: 0 }; + } + const slice = paginateItems(group.items, pageIndex, pageSize); + return { + groups: [{ ...group, items: slice.visible, totalCount: group.items.length }], + pageIndex: slice.pageIndex, + pageCount: slice.pageCount, + totalCount: group.items.length, + }; +} diff --git a/src/test/app/(protected)/workorders/use-work-orders-day-pagination.test.tsx b/src/test/app/(protected)/workorders/use-work-orders-day-pagination.test.tsx new file mode 100644 index 00000000..7d4b3935 --- /dev/null +++ b/src/test/app/(protected)/workorders/use-work-orders-day-pagination.test.tsx @@ -0,0 +1,57 @@ +import { act, renderHook } from "@testing-library/react"; +import { describe, expect, it } from "vitest"; +import { useWorkOrdersDayPagination } from "@/app/(protected)/workorders/_hooks/use-work-orders-day-pagination"; +import { DEFAULT_DAY_PAGE_SIZE } from "@/domain/work-orders/utils/day-pagination"; + +describe("useWorkOrdersDayPagination (SH-241)", () => { + const base = { + weekMonday: "2026-08-17", + selectedDayIso: "2026-08-17", + advActive: false, + }; + + it("defaults to 50 rows per page", () => { + const { result } = renderHook(() => useWorkOrdersDayPagination(base)); + expect(result.current.pageSize).toBe(DEFAULT_DAY_PAGE_SIZE); + expect(result.current.page).toBe(0); + }); + + it("resets to page 1 when switching days", () => { + const { result, rerender } = renderHook( + (props: typeof base) => useWorkOrdersDayPagination(props), + { initialProps: base }, + ); + act(() => result.current.goToNextPage()); + expect(result.current.page).toBe(1); + rerender({ ...base, selectedDayIso: "2026-08-18" }); + expect(result.current.page).toBe(0); + }); + + it("resets to page 1 when the week changes", () => { + const { result, rerender } = renderHook( + (props: typeof base) => useWorkOrdersDayPagination(props), + { initialProps: base }, + ); + act(() => result.current.goToNextPage()); + rerender({ ...base, weekMonday: "2026-08-10" }); + expect(result.current.page).toBe(0); + }); + + it("resets to page 1 when rows-per-page changes", () => { + const { result } = renderHook(() => useWorkOrdersDayPagination(base)); + act(() => result.current.goToNextPage()); + act(() => result.current.setPageSize(25)); + expect(result.current.page).toBe(0); + expect(result.current.pageSize).toBe(25); + }); + + it("resets to page 1 when a global filter is applied", () => { + const { result, rerender } = renderHook( + (props: typeof base) => useWorkOrdersDayPagination(props), + { initialProps: base }, + ); + act(() => result.current.goToNextPage()); + rerender({ ...base, advActive: true }); + expect(result.current.page).toBe(0); + }); +}); diff --git a/src/test/app/(protected)/workorders/wo-day-pagination-footer.test.tsx b/src/test/app/(protected)/workorders/wo-day-pagination-footer.test.tsx new file mode 100644 index 00000000..37965136 --- /dev/null +++ b/src/test/app/(protected)/workorders/wo-day-pagination-footer.test.tsx @@ -0,0 +1,31 @@ +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { describe, expect, it, vi } from "vitest"; +import { WoDayPaginationFooter } from "@/app/(protected)/workorders/_components/list/table/wo-day-pagination-footer"; + +describe("WoDayPaginationFooter (SH-241)", () => { + it("changes rows-per-page and navigates pages", async () => { + const user = userEvent.setup(); + const onPageSizeChange = vi.fn(); + const onNext = vi.fn(); + render( + , + ); + expect(screen.getByText("MON · 06/01 · 60 items")).toBeInTheDocument(); + expect(screen.getByText("Page 1 of 2")).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Previous page" })).toBeDisabled(); + await user.selectOptions(screen.getByLabelText("Rows per page"), "25"); + expect(onPageSizeChange).toHaveBeenCalledWith(25); + await user.click(screen.getByRole("button", { name: "Next page" })); + expect(onNext).toHaveBeenCalled(); + }); +}); diff --git a/src/test/domain/work-orders/utils/day-pagination.test.ts b/src/test/domain/work-orders/utils/day-pagination.test.ts new file mode 100644 index 00000000..0a72c026 --- /dev/null +++ b/src/test/domain/work-orders/utils/day-pagination.test.ts @@ -0,0 +1,75 @@ +import { describe, expect, it } from "vitest"; +import { + DAY_PAGE_SIZE_OPTIONS, + DEFAULT_DAY_PAGE_SIZE, + formatDayPaginationSummary, + paginateItems, + shouldShowDayPagination, +} from "@/domain/work-orders/utils/day-pagination"; + +describe("day pagination utils (SH-241)", () => { + it("defaults to 50 and exposes 25/50/100/200", () => { + expect(DEFAULT_DAY_PAGE_SIZE).toBe(50); + expect(DAY_PAGE_SIZE_OPTIONS).toEqual([25, 50, 100, 200]); + }); + + it("paginates 60 items at 50 per page", () => { + const items = Array.from({ length: 60 }, (_, index) => index); + const first = paginateItems(items, 0, 50); + expect(first.visible).toHaveLength(50); + expect(first.pageCount).toBe(2); + const second = paginateItems(items, 1, 50); + expect(second.visible).toEqual(items.slice(50)); + }); + + it("re-paginates immediately when page size changes", () => { + const items = Array.from({ length: 60 }, (_, index) => index); + expect(paginateItems(items, 0, 25).visible).toHaveLength(25); + expect(paginateItems(items, 0, 25).pageCount).toBe(3); + }); + + it("clamps an out-of-range page to the last page", () => { + const paged = paginateItems([1, 2, 3], 9, 2); + expect(paged.pageIndex).toBe(1); + expect(paged.visible).toEqual([3]); + }); + + it("shows pagination in day view even when the page is not full", () => { + expect( + shouldShowDayPagination({ + flatListMode: false, + isLoading: false, + noSearchResults: false, + }), + ).toBe(true); + }); + + it("hides pagination in flat-list / loading / empty search", () => { + expect( + shouldShowDayPagination({ + flatListMode: true, + isLoading: false, + noSearchResults: false, + }), + ).toBe(false); + expect( + shouldShowDayPagination({ + flatListMode: false, + isLoading: true, + noSearchResults: false, + }), + ).toBe(false); + expect( + shouldShowDayPagination({ + flatListMode: false, + isLoading: false, + noSearchResults: true, + }), + ).toBe(false); + }); + + it("formats the AAP footer summary", () => { + expect(formatDayPaginationSummary("Mon · 06/01", 9)).toBe("MON · 06/01 · 9 items"); + expect(formatDayPaginationSummary("Tue · 06/02", 1)).toBe("TUE · 06/02 · 1 item"); + }); +}); From ee5eae89275fc1dd8716c529e1a0cde569f6312a Mon Sep 17 00:00:00 2001 From: Arthur Bassi Date: Thu, 20 Aug 2026 11:00:25 -0300 Subject: [PATCH 2/2] fix(work-orders): page-aware flash and Unassigned search empty (SH-241) Empty search waits until the Unassigned queue has settled. Reschedule selects the destination day's page before flashing the row. --- .../list/table/use-wo-table-row-handlers.ts | 14 +- .../_components/list/table/wo-table.tsx | 8 +- .../_hooks/use-work-orders-day-pagination.ts | 32 ++++- .../_hooks/use-work-orders-table-data.ts | 5 + .../work-orders/utils/day-pagination.ts | 20 +++ .../work-orders/utils/week-day-nav-utils.ts | 15 ++- ...se-wo-table-row-handlers-duplicate.test.ts | 1 + ...use-wo-table-row-handlers-schedule.test.ts | 14 +- .../use-work-orders-day-pagination.test.tsx | 20 ++- .../workorders/wo-table-search-empty.test.tsx | 123 ++++++++++++++++++ .../work-orders/utils/day-pagination.test.ts | 9 ++ .../utils/week-day-nav-utils.test.ts | 43 +++++- 12 files changed, 286 insertions(+), 18 deletions(-) create mode 100644 src/test/app/(protected)/workorders/wo-table-search-empty.test.tsx diff --git a/src/app/(protected)/workorders/_components/list/table/use-wo-table-row-handlers.ts b/src/app/(protected)/workorders/_components/list/table/use-wo-table-row-handlers.ts index aa6b8599..98374174 100644 --- a/src/app/(protected)/workorders/_components/list/table/use-wo-table-row-handlers.ts +++ b/src/app/(protected)/workorders/_components/list/table/use-wo-table-row-handlers.ts @@ -36,7 +36,13 @@ export function useWoTableRowHandlers({ onScheduleHighlight, onDuplicateFound, }: UseWoTableRowHandlersOptions): WoTableRowHandlers { - const { dragState: dragStateRef, setDragViz, reorderRows, endDrag, triggerFlash } = tableData; + const { + dragState: dragStateRef, + setDragViz, + reorderRows, + endDrag, + revealScheduledRow, + } = tableData; return useMemo( () => ({ @@ -77,13 +83,13 @@ export function useWoTableRowHandlers({ rescheduleNotify(date, { onNavigate: () => { onNavigateToDay(date); - setTimeout(() => triggerFlash(id), 180); + revealScheduledRow(id); }, }); return; } onNavigateToDay(date); - triggerFlash(id); + revealScheduledRow(id); }, onScheduleWeek: (id: string | number, week: string) => { onPatch(id, { @@ -132,7 +138,7 @@ export function useWoTableRowHandlers({ setDragViz, reorderRows, endDrag, - triggerFlash, + revealScheduledRow, weekMonday, onNavigateToDay, onOpenAdvancedFilters, diff --git a/src/app/(protected)/workorders/_components/list/table/wo-table.tsx b/src/app/(protected)/workorders/_components/list/table/wo-table.tsx index 1bd17eb0..0d170a5d 100644 --- a/src/app/(protected)/workorders/_components/list/table/wo-table.tsx +++ b/src/app/(protected)/workorders/_components/list/table/wo-table.tsx @@ -9,6 +9,7 @@ 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"; @@ -189,7 +190,12 @@ export function WoTable({ ); } - if (!tableData.advActive && tableData.noSearchResults) { + const showSearchEmpty = + !tableData.advActive && + tableData.noSearchResults && + isUnassignedQueueReady(tableData.unassignedLoading, tableData.unassignedError); + + if (showSearchEmpty) { return ( void; }; export function useWorkOrdersDayPagination({ weekMonday, selectedDayIso, advActive, + dayItems, + onRevealed, }: UseWorkOrdersDayPaginationInput) { const [pageSize, setPageSizeState] = useState(DEFAULT_DAY_PAGE_SIZE); const [page, setPage] = useState(0); + const [pendingRevealId, setPendingRevealId] = useState(null); useEffect(() => { setPage(0); }, [weekMonday, selectedDayIso, advActive]); + useEffect(() => { + if (pendingRevealId == null) { + return; + } + const nextPage = findItemPageIndex(dayItems, pendingRevealId, pageSize); + if (nextPage == null) { + return; + } + setPage(nextPage); + const revealedId = pendingRevealId; + setPendingRevealId(null); + onRevealed(revealedId); + }, [pendingRevealId, dayItems, pageSize, onRevealed]); + const setPageSize = useCallback((next: DayPageSize) => { setPageSizeState(next); setPage(0); @@ -32,11 +57,16 @@ export function useWorkOrdersDayPagination({ setPage((current) => current + 1); }, []); + const revealRow = useCallback((id: string | number) => { + setPendingRevealId(id); + }, []); + return { pageSize, page, setPageSize, goToPrevPage, goToNextPage, + revealRow, }; } diff --git a/src/app/(protected)/workorders/_hooks/use-work-orders-table-data.ts b/src/app/(protected)/workorders/_hooks/use-work-orders-table-data.ts index 1d0934f6..0b02f8a5 100644 --- a/src/app/(protected)/workorders/_hooks/use-work-orders-table-data.ts +++ b/src/app/(protected)/workorders/_hooks/use-work-orders-table-data.ts @@ -13,6 +13,7 @@ import { import { buildSelectedDayGroup, isDayScopedSearchEmpty, + isUnassignedQueueReady, } from "@/domain/work-orders/utils/week-day-nav-utils"; import { composeBoardSections, @@ -93,6 +94,8 @@ export function useWorkOrdersTableData(filters: FiltersReturn) { weekMonday: filters.weekMonday, selectedDayIso: filters.selectedDayIso, advActive, + dayItems: dayGroups[0]?.items ?? EMPTY_WEEKLY_ITEMS, + onRevealed: local.triggerFlash, }); const pagedDay = useMemo( () => pageSelectedDayGroups(dayGroups, pagination.page, pagination.pageSize), @@ -108,6 +111,7 @@ export function useWorkOrdersTableData(filters: FiltersReturn) { searchActive, unassignedCount: unassigned.length, dayItemCount: dayTotalCount, + unassignedQueueReady: isUnassignedQueueReady(unassignedLoading, unassignedError), }); const isEmptyWeek = !advActive && scheduled.length === 0 && unassigned.length === 0 && !searchActive; @@ -157,5 +161,6 @@ export function useWorkOrdersTableData(filters: FiltersReturn) { setDayPageSize: pagination.setPageSize, goToPrevDayPage: pagination.goToPrevPage, goToNextDayPage: pagination.goToNextPage, + revealScheduledRow: pagination.revealRow, }; } diff --git a/src/domain/work-orders/utils/day-pagination.ts b/src/domain/work-orders/utils/day-pagination.ts index 2eb40255..8caab266 100644 --- a/src/domain/work-orders/utils/day-pagination.ts +++ b/src/domain/work-orders/utils/day-pagination.ts @@ -11,6 +11,26 @@ export type PaginatedSlice = { pageIndex: number; }; +export function pageIndexForItem(itemIndex: number, pageSize: number): number { + if (itemIndex < 0) { + return 0; + } + const size = pageSize > 0 ? pageSize : DEFAULT_DAY_PAGE_SIZE; + return Math.floor(itemIndex / size); +} + +export function findItemPageIndex( + items: readonly T[], + id: string | number, + pageSize: number, +): number | null { + const index = items.findIndex((item) => String(item.id) === String(id)); + if (index < 0) { + return null; + } + return pageIndexForItem(index, pageSize); +} + export function paginateItems( items: readonly T[], pageIndex: number, diff --git a/src/domain/work-orders/utils/week-day-nav-utils.ts b/src/domain/work-orders/utils/week-day-nav-utils.ts index 7eea7d4b..073cf231 100644 --- a/src/domain/work-orders/utils/week-day-nav-utils.ts +++ b/src/domain/work-orders/utils/week-day-nav-utils.ts @@ -27,12 +27,25 @@ export function scheduledDayTab(dateIso: string): { weekMonday: string; selected }; } +export function isUnassignedQueueReady( + unassignedLoading: boolean, + unassignedError: Error | null, +): boolean { + return !unassignedLoading && unassignedError == null; +} + export function isDayScopedSearchEmpty(args: { searchActive: boolean; unassignedCount: number; dayItemCount: number; + unassignedQueueReady: boolean; }): boolean { - return args.searchActive && args.unassignedCount === 0 && args.dayItemCount === 0; + return ( + args.searchActive && + args.unassignedQueueReady && + args.unassignedCount === 0 && + args.dayItemCount === 0 + ); } export function initialSelectedDay(weekMonday: string, today = todayIso()): string { diff --git a/src/test/app/(protected)/workorders/use-wo-table-row-handlers-duplicate.test.ts b/src/test/app/(protected)/workorders/use-wo-table-row-handlers-duplicate.test.ts index e9b52a4a..daad10bd 100644 --- a/src/test/app/(protected)/workorders/use-wo-table-row-handlers-duplicate.test.ts +++ b/src/test/app/(protected)/workorders/use-wo-table-row-handlers-duplicate.test.ts @@ -13,6 +13,7 @@ function buildTableData() { endDrag: vi.fn(), setUnschedOpen: vi.fn(), triggerFlash: vi.fn(), + revealScheduledRow: vi.fn(), setSelectedId: vi.fn(), } as never; } diff --git a/src/test/app/(protected)/workorders/use-wo-table-row-handlers-schedule.test.ts b/src/test/app/(protected)/workorders/use-wo-table-row-handlers-schedule.test.ts index afbf434c..0f0ed39a 100644 --- a/src/test/app/(protected)/workorders/use-wo-table-row-handlers-schedule.test.ts +++ b/src/test/app/(protected)/workorders/use-wo-table-row-handlers-schedule.test.ts @@ -13,13 +13,13 @@ vi.mock("@/app/(protected)/workorders/_components/list/table/reschedule-notify", weekMoveNotify, })); -function buildTableData(triggerFlash = vi.fn()) { +function buildTableData(revealScheduledRow = vi.fn()) { return { dragState: { current: { dragId: null, overId: null } }, setDragViz: vi.fn(), reorderRows: vi.fn(), endDrag: vi.fn(), - triggerFlash, + revealScheduledRow, setSelectedId: vi.fn(), } as never; } @@ -28,11 +28,11 @@ describe("useWoTableRowHandlers schedule navigation (SH-240)", () => { it("selects the scheduled weekday in the same week so the row stays visible", () => { const onNavigateToDay = vi.fn(); const onPatch = vi.fn(); - const triggerFlash = vi.fn(); + const revealScheduledRow = vi.fn(); const { result } = renderHook(() => useWoTableRowHandlers({ - tableData: buildTableData(triggerFlash), + tableData: buildTableData(revealScheduledRow), weekMonday: "2026-05-18", onNavigateToDay, onOpenSlideOver: vi.fn(), @@ -46,16 +46,17 @@ describe("useWoTableRowHandlers schedule navigation (SH-240)", () => { expect(onPatch).toHaveBeenCalledWith("wo-1", applySchedulePatch("2026-05-21", false)); expect(onNavigateToDay).toHaveBeenCalledWith("2026-05-21"); - expect(triggerFlash).toHaveBeenCalledWith("wo-1"); + expect(revealScheduledRow).toHaveBeenCalledWith("wo-1"); expect(rescheduleNotify).not.toHaveBeenCalled(); }); it("sends cross-week view-it navigation to the target day, not Monday", () => { const onNavigateToDay = vi.fn(); + const revealScheduledRow = vi.fn(); const { result } = renderHook(() => useWoTableRowHandlers({ - tableData: buildTableData(), + tableData: buildTableData(revealScheduledRow), weekMonday: "2026-05-18", onNavigateToDay, onOpenSlideOver: vi.fn(), @@ -76,6 +77,7 @@ describe("useWoTableRowHandlers schedule navigation (SH-240)", () => { const { onNavigate } = rescheduleNotify.mock.calls[0][1] as { onNavigate: () => void }; onNavigate(); expect(onNavigateToDay).toHaveBeenCalledWith("2026-05-27"); + expect(revealScheduledRow).toHaveBeenCalledWith("wo-1"); }); it("opens Advanced Filters for week-only moves instead of the removed Unscheduled section", () => { diff --git a/src/test/app/(protected)/workorders/use-work-orders-day-pagination.test.tsx b/src/test/app/(protected)/workorders/use-work-orders-day-pagination.test.tsx index 7d4b3935..01e98569 100644 --- a/src/test/app/(protected)/workorders/use-work-orders-day-pagination.test.tsx +++ b/src/test/app/(protected)/workorders/use-work-orders-day-pagination.test.tsx @@ -1,13 +1,16 @@ import { act, renderHook } from "@testing-library/react"; -import { describe, expect, it } from "vitest"; +import { describe, expect, it, vi } from "vitest"; import { useWorkOrdersDayPagination } from "@/app/(protected)/workorders/_hooks/use-work-orders-day-pagination"; import { DEFAULT_DAY_PAGE_SIZE } from "@/domain/work-orders/utils/day-pagination"; describe("useWorkOrdersDayPagination (SH-241)", () => { + const onRevealed = vi.fn(); const base = { weekMonday: "2026-08-17", selectedDayIso: "2026-08-17", advActive: false, + dayItems: [] as { id: string }[], + onRevealed, }; it("defaults to 50 rows per page", () => { @@ -54,4 +57,19 @@ describe("useWorkOrdersDayPagination (SH-241)", () => { rerender({ ...base, advActive: true }); expect(result.current.page).toBe(0); }); + + it("selects the page that contains a revealed row beyond the first page", () => { + const dayItems = Array.from({ length: 60 }, (_, index) => ({ id: `wo-${index}` })); + const revealed = vi.fn(); + const { result, rerender } = renderHook( + (props: typeof base) => useWorkOrdersDayPagination(props), + { initialProps: { ...base, onRevealed: revealed } }, + ); + + act(() => result.current.revealRow("wo-55")); + rerender({ ...base, dayItems, onRevealed: revealed, selectedDayIso: "2026-08-18" }); + + expect(result.current.page).toBe(1); + expect(revealed).toHaveBeenCalledWith("wo-55"); + }); }); diff --git a/src/test/app/(protected)/workorders/wo-table-search-empty.test.tsx b/src/test/app/(protected)/workorders/wo-table-search-empty.test.tsx new file mode 100644 index 00000000..db7e5685 --- /dev/null +++ b/src/test/app/(protected)/workorders/wo-table-search-empty.test.tsx @@ -0,0 +1,123 @@ +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 { 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 tableData( + overrides: Partial = {}, +): ReturnTypeOfUseWorkOrdersTableData { + return { + rows: [], + isLoading: false, + isFetching: false, + error: null, + refetch: vi.fn(), + advActive: false, + advResults: [], + unassigned: [], + unassignedError: null, + unassignedLoading: false, + unscheduled: [], + displayGroups: [{ key: "05/18", label: "Mon · 05/18", date: "2026-05-18", items: [] }], + searchActive: true, + noSearchResults: true, + 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: 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: 0, + totalCount: 0, + search: "no-match", + showDayPagination: false, + dayPageSize: 50, + dayPageIndex: 0, + dayPageCount: 1, + dayItemCount: 0, + dayFooterLabel: "Mon · 05/18", + setDayPageSize: vi.fn(), + goToPrevDayPage: vi.fn(), + goToNextDayPage: vi.fn(), + ...overrides, + } as ReturnTypeOfUseWorkOrdersTableData; +} + +function renderTable(data: ReturnTypeOfUseWorkOrdersTableData) { + return renderWithProviders( + , + { withAuth: false }, + ); +} + +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()); + + 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(); + }); +}); diff --git a/src/test/domain/work-orders/utils/day-pagination.test.ts b/src/test/domain/work-orders/utils/day-pagination.test.ts index 0a72c026..0a71ba3c 100644 --- a/src/test/domain/work-orders/utils/day-pagination.test.ts +++ b/src/test/domain/work-orders/utils/day-pagination.test.ts @@ -2,7 +2,9 @@ import { describe, expect, it } from "vitest"; import { DAY_PAGE_SIZE_OPTIONS, DEFAULT_DAY_PAGE_SIZE, + findItemPageIndex, formatDayPaginationSummary, + pageIndexForItem, paginateItems, shouldShowDayPagination, } from "@/domain/work-orders/utils/day-pagination"; @@ -68,6 +70,13 @@ describe("day pagination utils (SH-241)", () => { ).toBe(false); }); + it("maps a row past the first page onto page 2 at the default size", () => { + const items = Array.from({ length: 60 }, (_, index) => ({ id: `wo-${index}` })); + expect(pageIndexForItem(50, 50)).toBe(1); + expect(findItemPageIndex(items, "wo-55", 50)).toBe(1); + expect(findItemPageIndex(items, "missing", 50)).toBeNull(); + }); + it("formats the AAP footer summary", () => { expect(formatDayPaginationSummary("Mon · 06/01", 9)).toBe("MON · 06/01 · 9 items"); expect(formatDayPaginationSummary("Tue · 06/02", 1)).toBe("TUE · 06/02 · 1 item"); diff --git a/src/test/domain/work-orders/utils/week-day-nav-utils.test.ts b/src/test/domain/work-orders/utils/week-day-nav-utils.test.ts index bc56591d..0439dc61 100644 --- a/src/test/domain/work-orders/utils/week-day-nav-utils.test.ts +++ b/src/test/domain/work-orders/utils/week-day-nav-utils.test.ts @@ -7,6 +7,7 @@ import { formatDayTabLabel, initialSelectedDay, isDayScopedSearchEmpty, + isUnassignedQueueReady, rowMatchesSelectedDay, scheduledDayTab, weekdaysMonFri, @@ -131,16 +132,50 @@ describe("week-day-nav-utils (SH-240)", () => { it("treats search as empty only when the selected day and Unassigned have no hits", () => { expect( - isDayScopedSearchEmpty({ searchActive: true, unassignedCount: 0, dayItemCount: 0 }), + isDayScopedSearchEmpty({ + searchActive: true, + unassignedCount: 0, + dayItemCount: 0, + unassignedQueueReady: true, + }), ).toBe(true); expect( - isDayScopedSearchEmpty({ searchActive: true, unassignedCount: 0, dayItemCount: 1 }), + isDayScopedSearchEmpty({ + searchActive: true, + unassignedCount: 0, + dayItemCount: 1, + unassignedQueueReady: true, + }), ).toBe(false); expect( - isDayScopedSearchEmpty({ searchActive: true, unassignedCount: 2, dayItemCount: 0 }), + isDayScopedSearchEmpty({ + searchActive: true, + unassignedCount: 2, + dayItemCount: 0, + unassignedQueueReady: true, + }), ).toBe(false); expect( - isDayScopedSearchEmpty({ searchActive: false, unassignedCount: 0, dayItemCount: 0 }), + isDayScopedSearchEmpty({ + searchActive: false, + unassignedCount: 0, + dayItemCount: 0, + unassignedQueueReady: true, + }), + ).toBe(false); + }); + + it("does not treat search as empty while the Unassigned queue is loading or failed", () => { + expect(isUnassignedQueueReady(true, null)).toBe(false); + expect(isUnassignedQueueReady(false, new Error("queue unavailable"))).toBe(false); + expect(isUnassignedQueueReady(false, null)).toBe(true); + expect( + isDayScopedSearchEmpty({ + searchActive: true, + unassignedCount: 0, + dayItemCount: 0, + unassignedQueueReady: false, + }), ).toBe(false); }); });