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); }); });