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/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-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 4706ce02..0d170a5d 100644 --- a/src/app/(protected)/workorders/_components/list/table/wo-table.tsx +++ b/src/app/(protected)/workorders/_components/list/table/wo-table.tsx @@ -12,6 +12,7 @@ import type { import { isUnassignedQueueReady } from "@/domain/work-orders/utils/week-day-nav-utils"; import { DuplicateWoNumberDialog } from "@/app/(protected)/workorders/_components/list/duplicate-wo-number-dialog"; import { WoTableSkeleton } from "@/app/(protected)/workorders/_components/loading/wo-table-skeleton"; +import { WoDayPaginationFooter } from "./wo-day-pagination-footer"; import type { ReturnTypeOfUseWorkOrdersTableData } from "./wo-table-types"; import { buildSitePlaceLookups, resolveSitePlace } from "./site-place-lookup"; import { useWoTableInlineCreate } from "./use-wo-table-inline-create"; @@ -69,6 +70,24 @@ function WoTableEmptyShell({ message, actionLabel, onAction }: WoTableEmptyShell ); } +function WoTableDayFooter({ tableData }: { tableData: ReturnTypeOfUseWorkOrdersTableData }) { + if (!tableData.showDayPagination) { + return undefined; + } + return ( + + ); +} + export function WoTable({ tableData, users, @@ -188,7 +207,7 @@ export function WoTable({ 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); + }, []); + + const goToPrevPage = useCallback(() => { + setPage((current) => Math.max(0, current - 1)); + }, []); + + const goToNextPage = useCallback(() => { + setPage((current) => current + 1); + }, []); + + const revealRow = useCallback((id: string | number) => { + setPendingRevealId(id); + }, []); + + return { + pageSize, + page, + setPageSize, + goToPrevPage, + goToNextPage, + revealRow, + }; +} 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 f56409f9..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 @@ -1,10 +1,15 @@ import { useMemo } from "react"; import type { useWorkOrdersListFilters } from "@/app/(protected)/workorders/_hooks/use-work-orders-list-filters"; import { useWorkOrdersBoardSource } from "@/app/(protected)/workorders/_hooks/use-work-orders-board-source"; +import { useWorkOrdersDayPagination } from "@/app/(protected)/workorders/_hooks/use-work-orders-day-pagination"; import { useWorkOrdersTableLocalState } from "@/app/(protected)/workorders/_hooks/use-work-orders-table-local-state"; import { currentWeekMonday } from "@/domain/work-orders/utils/list-week-utils"; import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row"; import { deriveDayFields } from "@/domain/work-orders/utils/table-day-utils"; +import { + pageSelectedDayGroups, + shouldShowDayPagination, +} from "@/domain/work-orders/utils/day-pagination"; import { buildSelectedDayGroup, isDayScopedSearchEmpty, @@ -51,7 +56,7 @@ export function useWorkOrdersTableData(filters: FiltersReturn) { advActive, isFetching, }); - const { displayRows } = local; + const { displayRows, ...localUi } = local; const advResults = useMemo(() => (advActive ? displayRows : []), [advActive, displayRows]); @@ -85,16 +90,27 @@ export function useWorkOrdersTableData(filters: FiltersReturn) { : [buildSelectedDayGroup(filters.weekMonday, filters.selectedDayIso, scheduled)], [advActive, filters.selectedDayIso, filters.weekMonday, scheduled], ); + const pagination = useWorkOrdersDayPagination({ + weekMonday: filters.weekMonday, + selectedDayIso: filters.selectedDayIso, + advActive, + dayItems: dayGroups[0]?.items ?? EMPTY_WEEKLY_ITEMS, + onRevealed: local.triggerFlash, + }); + const pagedDay = useMemo( + () => pageSelectedDayGroups(dayGroups, pagination.page, pagination.pageSize), + [dayGroups, pagination.page, pagination.pageSize], + ); const searchActive = filters.search.trim() !== ""; - const displayGroups = dayGroups; - const dayVisibleCount = displayGroups[0]?.items.length ?? 0; + const displayGroups = pagedDay.groups; + const dayTotalCount = pagedDay.totalCount; const noSearchResults = !advActive && isDayScopedSearchEmpty({ searchActive, unassignedCount: unassigned.length, - dayItemCount: dayVisibleCount, + dayItemCount: dayTotalCount, unassignedQueueReady: isUnassignedQueueReady(unassignedLoading, unassignedError), }); const isEmptyWeek = @@ -127,30 +143,24 @@ export function useWorkOrdersTableData(filters: FiltersReturn) { noSearchResults, isEmptyWeek, todayKey, - draftDayKey: local.draftDayKey, - draft: local.draft, - setDraft: local.setDraft, - unassignedOpen: local.unassignedOpen, - setUnassignedOpen: local.setUnassignedOpen, - unschedOpen: local.unschedOpen, - setUnschedOpen: local.setUnschedOpen, - selectedId: local.selectedId, - setSelectedId: local.setSelectedId, + ...localUi, selectedRow, - flashId: local.flashId, - triggerFlash: local.triggerFlash, - dragViz: local.dragViz, - setDragViz: local.setDragViz, - dragState: local.dragState, - patchRow: local.patchRow, - clearRowPatch: local.clearRowPatch, - reorderRows: local.reorderRows, - endDrag: local.endDrag, - startDayDraft: local.startDayDraft, - cancelDraft: local.cancelDraft, - addRow: local.addRow, scopeTotalCount: data?.totalCount ?? 0, - totalCount: advActive ? (data?.totalCount ?? advResults.length) : dayVisibleCount, + totalCount: advActive ? (data?.totalCount ?? advResults.length) : dayTotalCount, search: filters.search, + showDayPagination: shouldShowDayPagination({ + flatListMode: advActive, + isLoading, + noSearchResults, + }), + dayPageSize: pagination.pageSize, + dayPageIndex: pagedDay.pageIndex, + dayPageCount: pagedDay.pageCount, + dayItemCount: pagedDay.totalCount, + dayFooterLabel: displayGroups[0]?.label ?? "", + setDayPageSize: pagination.setPageSize, + goToPrevDayPage: pagination.goToPrevPage, + goToNextDayPage: pagination.goToNextPage, + revealScheduledRow: pagination.revealRow, }; } 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..8caab266 --- /dev/null +++ b/src/domain/work-orders/utils/day-pagination.ts @@ -0,0 +1,80 @@ +import type { WorkOrderDayGroup } from "@/domain/work-orders/types/work-order-table-row"; + +export const DAY_PAGE_SIZE_OPTIONS = [25, 50, 100, 200] as const; +export const DEFAULT_DAY_PAGE_SIZE = 50; + +export type DayPageSize = (typeof DAY_PAGE_SIZE_OPTIONS)[number]; + +export type PaginatedSlice = { + visible: T[]; + pageCount: number; + pageIndex: number; +}; + +export function pageIndexForItem(itemIndex: number, pageSize: number): number { + if (itemIndex < 0) { + return 0; + } + const size = pageSize > 0 ? pageSize : DEFAULT_DAY_PAGE_SIZE; + return Math.floor(itemIndex / size); +} + +export function findItemPageIndex( + 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, + 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-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 new file mode 100644 index 00000000..01e98569 --- /dev/null +++ b/src/test/app/(protected)/workorders/use-work-orders-day-pagination.test.tsx @@ -0,0 +1,75 @@ +import { act, renderHook } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; +import { useWorkOrdersDayPagination } from "@/app/(protected)/workorders/_hooks/use-work-orders-day-pagination"; +import { DEFAULT_DAY_PAGE_SIZE } from "@/domain/work-orders/utils/day-pagination"; + +describe("useWorkOrdersDayPagination (SH-241)", () => { + const onRevealed = vi.fn(); + const base = { + weekMonday: "2026-08-17", + selectedDayIso: "2026-08-17", + advActive: false, + dayItems: [] as { id: string }[], + onRevealed, + }; + + it("defaults to 50 rows per page", () => { + const { result } = renderHook(() => useWorkOrdersDayPagination(base)); + expect(result.current.pageSize).toBe(DEFAULT_DAY_PAGE_SIZE); + expect(result.current.page).toBe(0); + }); + + it("resets to page 1 when switching days", () => { + const { result, rerender } = renderHook( + (props: typeof base) => useWorkOrdersDayPagination(props), + { initialProps: base }, + ); + act(() => result.current.goToNextPage()); + expect(result.current.page).toBe(1); + rerender({ ...base, selectedDayIso: "2026-08-18" }); + expect(result.current.page).toBe(0); + }); + + it("resets to page 1 when the week changes", () => { + const { result, rerender } = renderHook( + (props: typeof base) => useWorkOrdersDayPagination(props), + { initialProps: base }, + ); + act(() => result.current.goToNextPage()); + rerender({ ...base, weekMonday: "2026-08-10" }); + expect(result.current.page).toBe(0); + }); + + it("resets to page 1 when rows-per-page changes", () => { + const { result } = renderHook(() => useWorkOrdersDayPagination(base)); + act(() => result.current.goToNextPage()); + act(() => result.current.setPageSize(25)); + expect(result.current.page).toBe(0); + expect(result.current.pageSize).toBe(25); + }); + + it("resets to page 1 when a global filter is applied", () => { + const { result, rerender } = renderHook( + (props: typeof base) => useWorkOrdersDayPagination(props), + { initialProps: base }, + ); + act(() => result.current.goToNextPage()); + rerender({ ...base, advActive: true }); + expect(result.current.page).toBe(0); + }); + + it("selects the page that contains a revealed row beyond the first page", () => { + const dayItems = Array.from({ length: 60 }, (_, index) => ({ id: `wo-${index}` })); + const revealed = vi.fn(); + const { result, rerender } = renderHook( + (props: typeof base) => useWorkOrdersDayPagination(props), + { initialProps: { ...base, onRevealed: revealed } }, + ); + + act(() => result.current.revealRow("wo-55")); + rerender({ ...base, dayItems, onRevealed: revealed, selectedDayIso: "2026-08-18" }); + + expect(result.current.page).toBe(1); + expect(revealed).toHaveBeenCalledWith("wo-55"); + }); +}); 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/app/(protected)/workorders/wo-table-search-empty.test.tsx b/src/test/app/(protected)/workorders/wo-table-search-empty.test.tsx index 410f0c8d..db7e5685 100644 --- a/src/test/app/(protected)/workorders/wo-table-search-empty.test.tsx +++ b/src/test/app/(protected)/workorders/wo-table-search-empty.test.tsx @@ -43,6 +43,7 @@ function tableData( selectedRow: null, flashId: null, triggerFlash: vi.fn(), + revealScheduledRow: vi.fn(), dragViz: null, setDragViz: vi.fn(), dragState: { current: { dragId: null, overId: null } }, @@ -56,6 +57,15 @@ function tableData( scopeTotalCount: 0, totalCount: 0, search: "no-match", + showDayPagination: false, + dayPageSize: 50, + dayPageIndex: 0, + dayPageCount: 1, + dayItemCount: 0, + dayFooterLabel: "Mon · 05/18", + setDayPageSize: vi.fn(), + goToPrevDayPage: vi.fn(), + goToNextDayPage: vi.fn(), ...overrides, } as ReturnTypeOfUseWorkOrdersTableData; } @@ -79,7 +89,7 @@ function renderTable(data: ReturnTypeOfUseWorkOrdersTableData) { ); } -describe("WoTable search empty vs Unassigned queue (SH-165/SH-240)", () => { +describe("WoTable search empty vs Unassigned queue (SH-165/SH-241)", () => { it("shows empty search only after the Unassigned queue has settled empty", () => { const { getByText, queryByText } = renderTable(tableData()); 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..0a71ba3c --- /dev/null +++ b/src/test/domain/work-orders/utils/day-pagination.test.ts @@ -0,0 +1,84 @@ +import { describe, expect, it } from "vitest"; +import { + DAY_PAGE_SIZE_OPTIONS, + DEFAULT_DAY_PAGE_SIZE, + findItemPageIndex, + formatDayPaginationSummary, + pageIndexForItem, + paginateItems, + shouldShowDayPagination, +} from "@/domain/work-orders/utils/day-pagination"; + +describe("day pagination utils (SH-241)", () => { + it("defaults to 50 and exposes 25/50/100/200", () => { + expect(DEFAULT_DAY_PAGE_SIZE).toBe(50); + expect(DAY_PAGE_SIZE_OPTIONS).toEqual([25, 50, 100, 200]); + }); + + it("paginates 60 items at 50 per page", () => { + const items = Array.from({ length: 60 }, (_, index) => index); + const first = paginateItems(items, 0, 50); + expect(first.visible).toHaveLength(50); + expect(first.pageCount).toBe(2); + const second = paginateItems(items, 1, 50); + expect(second.visible).toEqual(items.slice(50)); + }); + + it("re-paginates immediately when page size changes", () => { + const items = Array.from({ length: 60 }, (_, index) => index); + expect(paginateItems(items, 0, 25).visible).toHaveLength(25); + expect(paginateItems(items, 0, 25).pageCount).toBe(3); + }); + + it("clamps an out-of-range page to the last page", () => { + const paged = paginateItems([1, 2, 3], 9, 2); + expect(paged.pageIndex).toBe(1); + expect(paged.visible).toEqual([3]); + }); + + it("shows pagination in day view even when the page is not full", () => { + expect( + shouldShowDayPagination({ + flatListMode: false, + isLoading: false, + noSearchResults: false, + }), + ).toBe(true); + }); + + it("hides pagination in flat-list / loading / empty search", () => { + expect( + shouldShowDayPagination({ + flatListMode: true, + isLoading: false, + noSearchResults: false, + }), + ).toBe(false); + expect( + shouldShowDayPagination({ + flatListMode: false, + isLoading: true, + noSearchResults: false, + }), + ).toBe(false); + expect( + shouldShowDayPagination({ + flatListMode: false, + isLoading: false, + noSearchResults: true, + }), + ).toBe(false); + }); + + it("maps a row past the first page onto page 2 at the default size", () => { + const items = Array.from({ length: 60 }, (_, index) => ({ id: `wo-${index}` })); + expect(pageIndexForItem(50, 50)).toBe(1); + expect(findItemPageIndex(items, "wo-55", 50)).toBe(1); + expect(findItemPageIndex(items, "missing", 50)).toBeNull(); + }); + + it("formats the AAP footer summary", () => { + expect(formatDayPaginationSummary("Mon · 06/01", 9)).toBe("MON · 06/01 · 9 items"); + expect(formatDayPaginationSummary("Tue · 06/02", 1)).toBe("TUE · 06/02 · 1 item"); + }); +});