- {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");
+ });
+});