From dc178340de024a0e40bef20a54bf4bcb4ad5deb6 Mon Sep 17 00:00:00 2001 From: Arthur Bassi Date: Wed, 2 Sep 2026 11:07:27 -0300 Subject: [PATCH 1/4] feat(work-orders): persist board row order locally and support keyboard reorder Keep same-day WO order across refresh and sign-out in the same browser. Add keyboard reorder on the row grip. --- .../table/wo-table-row-reorder-handle.tsx | 48 ++++++ .../_components/list/table/wo-table-row.tsx | 20 ++- .../_hooks/use-work-orders-table-data.ts | 4 + .../use-work-orders-table-local-state.ts | 31 +++- .../utils/board-row-order-storage.ts | 66 ++++++- ...rk-orders-table-local-state-order.test.tsx | 161 ++++++++++++++++++ .../wo-table-row-reorder-keyboard.test.tsx | 133 +++++++++++++++ .../utils/board-row-order-storage.test.ts | 48 ++++++ 8 files changed, 485 insertions(+), 26 deletions(-) create mode 100644 src/app/(protected)/workorders/_components/list/table/wo-table-row-reorder-handle.tsx create mode 100644 src/test/app/(protected)/workorders/use-work-orders-table-local-state-order.test.tsx create mode 100644 src/test/app/(protected)/workorders/wo-table-row-reorder-keyboard.test.tsx diff --git a/src/app/(protected)/workorders/_components/list/table/wo-table-row-reorder-handle.tsx b/src/app/(protected)/workorders/_components/list/table/wo-table-row-reorder-handle.tsx new file mode 100644 index 00000000..1a934b33 --- /dev/null +++ b/src/app/(protected)/workorders/_components/list/table/wo-table-row-reorder-handle.tsx @@ -0,0 +1,48 @@ +import { GripVertical } from "lucide-react"; + +type WoTableRowReorderHandleProps = { + disabled: boolean; + isDragging: boolean; + woNumber: string; + onArrowReorder: (delta: -1 | 1) => void; +}; + +function gripCursor(disabled: boolean, isDragging: boolean): string { + if (disabled) { + return "default"; + } + return isDragging ? "grabbing" : "grab"; +} + +export function WoTableRowReorderHandle({ + disabled, + isDragging, + woNumber, + onArrowReorder, +}: WoTableRowReorderHandleProps) { + return ( + + ); +} diff --git a/src/app/(protected)/workorders/_components/list/table/wo-table-row.tsx b/src/app/(protected)/workorders/_components/list/table/wo-table-row.tsx index e9ab8450..6e6dcd79 100644 --- a/src/app/(protected)/workorders/_components/list/table/wo-table-row.tsx +++ b/src/app/(protected)/workorders/_components/list/table/wo-table-row.tsx @@ -1,4 +1,3 @@ -import { GripVertical } from "lucide-react"; import type { LocationOption, VendorDropdownItem, @@ -7,6 +6,7 @@ import type { import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row"; import type { WorkOrderTablePatchOptions } from "@/domain/work-orders/use-cases/use-work-order-table-mutations"; import type { DragVizState } from "@/app/(protected)/workorders/_hooks/use-work-orders-table-data"; +import { neighborIdForReorder } from "@/domain/work-orders/utils/board-row-order-storage"; import { canApplyWorkOrderTablePatch, canMutateWorkOrderFlag, @@ -16,6 +16,7 @@ import { import { FlagCell } from "./cells/flag-cell"; import { WoTableRowActions } from "./wo-table-row-actions"; import { WoTableRowCells } from "./wo-table-row-cells"; +import { WoTableRowReorderHandle } from "./wo-table-row-reorder-handle"; import { WoTableRowServiceCells } from "./wo-table-row-service-cells"; import { MUTATION_CELL_STYLE, @@ -155,15 +156,16 @@ export function WoTableRow({ style={{ padding: "0 0 0 6px", boxShadow: resolveFirstCellShadow(row) }} onClick={(e) => e.stopPropagation()} > - { + const overId = neighborIdForReorder(allRows, row, delta); + if (overId == null) return; + handlers.onReorder(row.id, overId); }} - > - - + /> e.stopPropagation()}> >>({}); const [localRows, setLocalRows] = useState([]); @@ -51,8 +55,19 @@ export function useWorkOrdersTableLocalState({ const ui = useWorkOrdersTableUiState(weekMonday); useEffect(() => { - setRowOrderByDay(loadBoardRowOrder(weekMonday)); - }, [weekMonday]); + setRowOrderByDay(loadBoardRowOrder(weekMonday, ownerId)); + }, [ownerId, weekMonday]); + + useEffect(() => { + const storageKey = boardRowOrderStorageKey(weekMonday, ownerId); + const onStorage = (event: StorageEvent) => { + if (event.key !== storageKey) return; + setRowOrderByDay(loadBoardRowOrder(weekMonday, ownerId)); + toast.info("Board order was updated in another session. Refreshed."); + }; + window.addEventListener("storage", onStorage); + return () => window.removeEventListener("storage", onStorage); + }, [ownerId, weekMonday]); const apiRows = useMemo( () => items.map((row) => enrichTableRow({ ...row, ...patches[String(row.id)] })), @@ -104,13 +119,13 @@ export function useWorkOrdersTableLocalState({ if (!previousDayGroup || previousDayGroup === nextDayGroup) return; setRowOrderByDay((prev) => { - const next = removeIdFromRowOrder(prev, key); + const next = moveIdToDayOrder(prev, key, nextDayGroup); if (next === prev) return prev; - saveBoardRowOrder(weekMonday, next); + saveBoardRowOrder(weekMonday, next, ownerId); return next; }); }, - [advActive, displayRows, weekMonday, localRows], + [advActive, displayRows, localRows, ownerId, weekMonday], ); const reorderRows = useCallback( @@ -130,11 +145,11 @@ export function useWorkOrdersTableLocalState({ setRowOrderByDay((prev) => { const next = { ...prev, [dayGroup]: mergeDayOrder(prev[dayGroup], reorderedVisible) }; - if (!advActive) saveBoardRowOrder(weekMonday, next); + if (!advActive) saveBoardRowOrder(weekMonday, next, ownerId); return next; }); }, - [advActive, displayRows, weekMonday], + [advActive, displayRows, ownerId, weekMonday], ); const { triggerFlash } = ui; diff --git a/src/domain/work-orders/utils/board-row-order-storage.ts b/src/domain/work-orders/utils/board-row-order-storage.ts index 0e9f4307..cac42107 100644 --- a/src/domain/work-orders/utils/board-row-order-storage.ts +++ b/src/domain/work-orders/utils/board-row-order-storage.ts @@ -2,7 +2,12 @@ export type BoardRowOrderByDay = Record; const STORAGE_PREFIX = "shoc:wo-board-row-order:v1:"; -export function boardRowOrderStorageKey(weekMonday: string): string { +export function boardRowOrderStorageKey(weekMonday: string, ownerId = ""): string { + const owner = ownerId.trim() || "local"; + return `${STORAGE_PREFIX}${owner}:${weekMonday}`; +} + +export function legacyBoardRowOrderStorageKey(weekMonday: string): string { return `${STORAGE_PREFIX}${weekMonday}`; } @@ -17,13 +22,9 @@ function isBoardRowOrderByDay(value: unknown): value is BoardRowOrderByDay { return Object.values(value).every(isStringArray); } -export function loadBoardRowOrder(weekMonday: string): BoardRowOrderByDay { - if (typeof window === "undefined" || !weekMonday) { - return {}; - } - +function readStoredOrder(key: string): BoardRowOrderByDay { try { - const raw = localStorage.getItem(boardRowOrderStorageKey(weekMonday)); + const raw = localStorage.getItem(key); if (!raw) { return {}; } @@ -34,13 +35,29 @@ export function loadBoardRowOrder(weekMonday: string): BoardRowOrderByDay { } } -export function saveBoardRowOrder(weekMonday: string, orderByDay: BoardRowOrderByDay): void { +export function loadBoardRowOrder(weekMonday: string, ownerId = ""): BoardRowOrderByDay { + if (typeof window === "undefined" || !weekMonday) { + return {}; + } + + const scoped = readStoredOrder(boardRowOrderStorageKey(weekMonday, ownerId)); + if (Object.keys(scoped).length) { + return scoped; + } + return readStoredOrder(legacyBoardRowOrderStorageKey(weekMonday)); +} + +export function saveBoardRowOrder( + weekMonday: string, + orderByDay: BoardRowOrderByDay, + ownerId = "", +): void { if (typeof window === "undefined" || !weekMonday) { return; } try { - localStorage.setItem(boardRowOrderStorageKey(weekMonday), JSON.stringify(orderByDay)); + localStorage.setItem(boardRowOrderStorageKey(weekMonday, ownerId), JSON.stringify(orderByDay)); } catch { // Quota exceeded or storage blocked — keep in-memory order only. } @@ -105,6 +122,20 @@ export function applyRowOrderByDay( + rows: T[], + row: T, + delta: -1 | 1, +): string | number | null { + const dayRows = rows.filter((item) => item.dayGroup === row.dayGroup); + const index = dayRows.findIndex((item) => String(item.id) === String(row.id)); + if (index < 0) { + return null; + } + return dayRows[index + delta]?.id ?? null; +} + export function reorderIdsInList(ids: string[], dragId: string, overId: string): string[] | null { const dragIdx = ids.indexOf(dragId); const overIdx = ids.indexOf(overId); @@ -138,6 +169,23 @@ export function removeIdFromRowOrder( return changed ? next : orderByDay; } +/** Drop the id from every day, then append it to the destination day. */ +export function moveIdToDayOrder( + orderByDay: BoardRowOrderByDay, + id: string, + nextDayGroup: string, +): BoardRowOrderByDay { + if (!nextDayGroup) { + return removeIdFromRowOrder(orderByDay, id); + } + const without = removeIdFromRowOrder(orderByDay, id); + const dest = without[nextDayGroup] ?? []; + if (dest.includes(id)) { + return without; + } + return { ...without, [nextDayGroup]: [...dest, id] }; +} + /** * Merge a visible day's new order into stored order: visible ids take the new sequence, * ids that were stored but not currently visible stay after the visible block (preserving diff --git a/src/test/app/(protected)/workorders/use-work-orders-table-local-state-order.test.tsx b/src/test/app/(protected)/workorders/use-work-orders-table-local-state-order.test.tsx new file mode 100644 index 00000000..5a1bc5e4 --- /dev/null +++ b/src/test/app/(protected)/workorders/use-work-orders-table-local-state-order.test.tsx @@ -0,0 +1,161 @@ +import { act, renderHook, waitFor } from "@testing-library/react"; +import { afterEach, describe, expect, it } from "vitest"; +import { useWorkOrdersTableLocalState } from "@/app/(protected)/workorders/_hooks/use-work-orders-table-local-state"; +import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row"; +import { + boardRowOrderStorageKey, + loadBoardRowOrder, + saveBoardRowOrder, +} from "@/domain/work-orders/utils/board-row-order-storage"; + +const WEEK = "2026-07-13"; + +function makeRow(overrides: Partial = {}): WorkOrderTableRow { + return { + id: "1", + woNumber: "WO-1", + site: "BK5", + type: "PM", + severity: null, + dispatcherId: "u1", + dispatcherName: "Alex", + scheduledOn: "2026-07-15", + completedDate: "", + targetWeek: "2026-07-13", + dayGroup: "07/15", + dayLabel: "WED · 07/15", + location: "Lobby", + locationId: "loc-1", + pm: "HVAC", + serviceNotes: "", + extraServices: [], + vendorId: "7", + company: "Acme", + tech: "Sam", + techPhone: "", + apptStart: "", + apptEnd: "", + apptTime: "", + status: "Scheduled", + docStatus: "No", + pocName: "", + pocPhone: "", + pocNotes: "", + dueDate: "", + rescheduleCount: 0, + carriedOver: 0, + originalWeek: "", + originalDate: "", + isPastDue: false, + scheduleWeekOnly: false, + isAddOn: false, + mediaCount: 0, + title: "", + priority: "", + primaryDispatchId: null, + rowVersion: "v1", + dispatchRowVersion: "", + flagColor: null, + additionalContacts: [], + ...overrides, + }; +} + +function hookParams( + items: WorkOrderTableRow[], + overrides: Partial<{ advActive: boolean; isFetching: boolean }> = {}, +) { + return { + items, + weekMonday: WEEK, + advActive: false, + isFetching: false, + ownerId: "u1", + ...overrides, + }; +} + +describe("useWorkOrdersTableLocalState row order", () => { + afterEach(() => { + localStorage.removeItem(boardRowOrderStorageKey(WEEK, "u1")); + localStorage.removeItem(boardRowOrderStorageKey(WEEK)); + }); + + it("reorders within a day and persists when advanced search is off", async () => { + const items = [makeRow({ id: "1" }), makeRow({ id: "2", woNumber: "WO-2" })]; + const { result } = renderHook(() => useWorkOrdersTableLocalState(hookParams(items))); + + await waitFor(() => { + expect(result.current.displayRows.map((row) => String(row.id))).toEqual(["1", "2"]); + }); + + act(() => { + result.current.reorderRows("1", "2"); + }); + + expect(result.current.displayRows.map((row) => String(row.id))).toEqual(["2", "1"]); + expect(loadBoardRowOrder(WEEK, "u1")).toEqual({ "07/15": ["2", "1"] }); + }); + + it("does not write storage while advanced search is active", async () => { + const items = [makeRow({ id: "1" }), makeRow({ id: "2", woNumber: "WO-2" })]; + const { result } = renderHook(() => + useWorkOrdersTableLocalState(hookParams(items, { advActive: true })), + ); + + await waitFor(() => { + expect(result.current.displayRows).toHaveLength(2); + }); + + act(() => { + result.current.reorderRows("1", "2"); + }); + + expect(result.current.displayRows.map((row) => String(row.id))).toEqual(["2", "1"]); + expect(loadBoardRowOrder(WEEK, "u1")).toEqual({}); + }); + + it("applies stored order after load", async () => { + saveBoardRowOrder(WEEK, { "07/15": ["2", "1"] }, "u1"); + const items = [makeRow({ id: "1" }), makeRow({ id: "2", woNumber: "WO-2" })]; + const { result } = renderHook(() => useWorkOrdersTableLocalState(hookParams(items))); + + await waitFor(() => { + expect(result.current.displayRows.map((row) => String(row.id))).toEqual(["2", "1"]); + }); + }); + + it("drops the id from the old day bucket when scheduledOn changes day", async () => { + saveBoardRowOrder(WEEK, { "07/15": ["1", "2"] }, "u1"); + const items = [makeRow({ id: "1" }), makeRow({ id: "2", woNumber: "WO-2" })]; + const { result } = renderHook(() => useWorkOrdersTableLocalState(hookParams(items))); + + await waitFor(() => { + expect(result.current.displayRows).toHaveLength(2); + }); + + act(() => { + result.current.patchRow("1", { scheduledOn: "2026-07-16" }); + }); + + expect(loadBoardRowOrder(WEEK, "u1")).toEqual({ "07/15": ["2"], "07/16": ["1"] }); + }); + + it("reloads stored order when another session writes the same key", async () => { + const items = [makeRow({ id: "1" }), makeRow({ id: "2", woNumber: "WO-2" })]; + const { result } = renderHook(() => useWorkOrdersTableLocalState(hookParams(items))); + + await waitFor(() => { + expect(result.current.displayRows.map((row) => String(row.id))).toEqual(["1", "2"]); + }); + + saveBoardRowOrder(WEEK, { "07/15": ["2", "1"] }, "u1"); + act(() => { + window.dispatchEvent( + new StorageEvent("storage", { key: boardRowOrderStorageKey(WEEK, "u1") }), + ); + }); + + expect(result.current.displayRows.map((row) => String(row.id))).toEqual(["2", "1"]); + }); +}); diff --git a/src/test/app/(protected)/workorders/wo-table-row-reorder-keyboard.test.tsx b/src/test/app/(protected)/workorders/wo-table-row-reorder-keyboard.test.tsx new file mode 100644 index 00000000..3b59148c --- /dev/null +++ b/src/test/app/(protected)/workorders/wo-table-row-reorder-keyboard.test.tsx @@ -0,0 +1,133 @@ +import { fireEvent, screen } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; +import { WoTableRow } from "@/app/(protected)/workorders/_components/list/table/wo-table-row"; +import type { WoTableRowHandlers } from "@/app/(protected)/workorders/_components/list/table/wo-table-row"; +import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row"; +import { renderWithProviders } from "@/test/test-utils"; + +function makeRow(overrides: Partial = {}): WorkOrderTableRow { + return { + id: "1", + woNumber: "WO-1", + site: "BK5", + type: "PM", + severity: null, + dispatcherId: "u1", + dispatcherName: "Alex", + scheduledOn: "2026-07-15", + completedDate: "", + targetWeek: "2026-07-13", + dayGroup: "07/15", + dayLabel: "WED · 07/15", + location: "Lobby", + locationId: "loc-1", + pm: "HVAC", + serviceNotes: "", + extraServices: [], + vendorId: "7", + company: "Acme", + tech: "Sam", + techPhone: "", + apptStart: "", + apptEnd: "", + apptTime: "", + status: "Scheduled", + docStatus: "No", + pocName: "", + pocPhone: "", + pocNotes: "", + dueDate: "", + rescheduleCount: 0, + carriedOver: 0, + originalWeek: "", + originalDate: "", + isPastDue: false, + scheduleWeekOnly: false, + isAddOn: false, + mediaCount: 0, + title: "", + priority: "", + primaryDispatchId: null, + rowVersion: "v1", + dispatchRowVersion: "", + flagColor: null, + additionalContacts: [], + ...overrides, + }; +} + +function handlers(overrides: Partial = {}): WoTableRowHandlers { + return { + onRowClick: vi.fn(), + onView: vi.fn(), + onEdit: vi.fn(), + onOpenMedia: vi.fn(), + onPatch: vi.fn(), + onScheduleDate: vi.fn(), + onScheduleWeek: vi.fn(), + onOpenSlideOver: vi.fn(), + onReorder: vi.fn(), + onDragStart: vi.fn(), + onDragOver: vi.fn(), + onDrop: vi.fn(), + onDragEnd: vi.fn(), + ...overrides, + }; +} + +function renderRow( + row: WorkOrderTableRow, + allRows: WorkOrderTableRow[], + rowHandlers: WoTableRowHandlers, +) { + return renderWithProviders( + + + + +
, + { withAuth: false }, + ); +} + +describe("WoTableRow keyboard reorder", () => { + it("reorders down and up with arrow keys on the grip", () => { + const onReorder = vi.fn(); + const first = makeRow({ id: "1", woNumber: "WO-1" }); + const second = makeRow({ id: "2", woNumber: "WO-2" }); + renderRow(first, [first, second], handlers({ onReorder })); + + fireEvent.keyDown(screen.getByRole("button", { name: "Reorder work order WO-1" }), { + key: "ArrowDown", + }); + expect(onReorder).toHaveBeenCalledWith("1", "2"); + + onReorder.mockClear(); + fireEvent.keyDown(screen.getByRole("button", { name: "Reorder work order WO-1" }), { + key: "ArrowUp", + }); + expect(onReorder).not.toHaveBeenCalled(); + }); + + it("does not reorder a canceled row from the grip", () => { + const onReorder = vi.fn(); + const row = makeRow({ status: "Canceled" }); + renderRow(row, [row], handlers({ onReorder })); + + const grip = screen.getByRole("button", { name: "Reorder work order WO-1" }); + expect(grip).toBeDisabled(); + fireEvent.keyDown(grip, { key: "ArrowDown" }); + expect(onReorder).not.toHaveBeenCalled(); + }); +}); diff --git a/src/test/domain/work-orders/utils/board-row-order-storage.test.ts b/src/test/domain/work-orders/utils/board-row-order-storage.test.ts index 28c326cf..20bab014 100644 --- a/src/test/domain/work-orders/utils/board-row-order-storage.test.ts +++ b/src/test/domain/work-orders/utils/board-row-order-storage.test.ts @@ -5,7 +5,10 @@ import { boardRowOrderStorageKey, loadBoardRowOrder, mergeDayOrder, + moveIdToDayOrder, removeIdFromRowOrder, + neighborIdForReorder, + legacyBoardRowOrderStorageKey, reorderIdsInList, saveBoardRowOrder, } from "@/domain/work-orders/utils/board-row-order-storage"; @@ -47,6 +50,25 @@ describe("applyRowOrderByDay", () => { }); }); +describe("neighborIdForReorder", () => { + const rows = [ + { id: "a", dayGroup: "07/14" }, + { id: "b", dayGroup: "07/14" }, + { id: "c", dayGroup: "07/15" }, + ]; + + it("returns the previous and next id in the same day", () => { + expect(neighborIdForReorder(rows, rows[1]!, -1)).toBe("a"); + expect(neighborIdForReorder(rows, rows[0]!, 1)).toBe("b"); + }); + + it("returns null at the ends of a day or when the row is missing", () => { + expect(neighborIdForReorder(rows, rows[0]!, -1)).toBeNull(); + expect(neighborIdForReorder(rows, rows[2]!, 1)).toBeNull(); + expect(neighborIdForReorder(rows, { id: "x", dayGroup: "07/14" }, 1)).toBeNull(); + }); +}); + describe("reorderIdsInList", () => { it("moves drag id to over index", () => { expect(reorderIdsInList(["a", "b", "c"], "a", "c")).toEqual(["b", "c", "a"]); @@ -84,12 +106,25 @@ describe("removeIdFromRowOrder", () => { }); }); +describe("moveIdToDayOrder", () => { + it("moves an id to the end of the destination day", () => { + expect(moveIdToDayOrder({ "07/15": ["1", "2"], "07/16": ["3"] }, "1", "07/16")).toEqual({ + "07/15": ["2"], + "07/16": ["3", "1"], + }); + }); +}); + describe("loadBoardRowOrder / saveBoardRowOrder", () => { const week = "2026-07-13"; const key = boardRowOrderStorageKey(week); + const ownerKey = boardRowOrderStorageKey(week, "u1"); + const legacyKey = legacyBoardRowOrderStorageKey(week); afterEach(() => { localStorage.removeItem(key); + localStorage.removeItem(ownerKey); + localStorage.removeItem(legacyKey); vi.restoreAllMocks(); }); @@ -99,6 +134,19 @@ describe("loadBoardRowOrder / saveBoardRowOrder", () => { expect(loadBoardRowOrder(week)).toEqual(order); }); + it("reads the legacy unscoped key when the owner key is empty", () => { + const order = { "07/14": ["9"] }; + localStorage.setItem(legacyKey, JSON.stringify(order)); + expect(loadBoardRowOrder(week, "u1")).toEqual(order); + }); + + it("keeps owner-scoped maps isolated", () => { + saveBoardRowOrder(week, { "07/14": ["1"] }, "u1"); + saveBoardRowOrder(week, { "07/14": ["2"] }, "u2"); + expect(loadBoardRowOrder(week, "u1")).toEqual({ "07/14": ["1"] }); + expect(loadBoardRowOrder(week, "u2")).toEqual({ "07/14": ["2"] }); + }); + it("returns empty object for invalid JSON payload", () => { localStorage.setItem(key, "{not-json"); expect(loadBoardRowOrder(week)).toEqual({}); From ddd83ffec16ce3db7686fe63b658dad3e1d95483 Mon Sep 17 00:00:00 2001 From: Arthur Bassi Date: Thu, 3 Sep 2026 13:34:28 -0300 Subject: [PATCH 2/4] fix(work-orders): skip canceled keyboard reorder and persist destination-week order --- .../table/wo-table-row-reorder-handle.tsx | 8 ++ .../_components/list/table/wo-table-row.tsx | 7 +- .../resolve-board-row-order-owner-id.ts | 6 + .../_hooks/use-work-orders-table-data.ts | 107 +++++------------- .../_hooks/use-work-orders-table-day-view.ts | 90 +++++++++++++++ .../use-work-orders-table-local-state.ts | 22 ++-- ...pply-board-row-order-on-schedule-change.ts | 46 ++++++++ .../utils/board-row-order-storage.ts | 46 +++++++- ...rk-orders-table-local-state-order.test.tsx | 37 ++++++ .../wo-table-row-reorder-keyboard.test.tsx | 13 +++ .../utils/board-row-order-storage.test.ts | 37 ++++++ 11 files changed, 329 insertions(+), 90 deletions(-) create mode 100644 src/app/(protected)/workorders/_hooks/resolve-board-row-order-owner-id.ts create mode 100644 src/app/(protected)/workorders/_hooks/use-work-orders-table-day-view.ts create mode 100644 src/domain/work-orders/utils/apply-board-row-order-on-schedule-change.ts diff --git a/src/app/(protected)/workorders/_components/list/table/wo-table-row-reorder-handle.tsx b/src/app/(protected)/workorders/_components/list/table/wo-table-row-reorder-handle.tsx index 1a934b33..f9d67c27 100644 --- a/src/app/(protected)/workorders/_components/list/table/wo-table-row-reorder-handle.tsx +++ b/src/app/(protected)/workorders/_components/list/table/wo-table-row-reorder-handle.tsx @@ -28,9 +28,17 @@ export function WoTableRowReorderHandle({ aria-label={woNumber ? `Reorder work order ${woNumber}` : "Reorder work order"} className="flex items-center justify-center bg-transparent p-0" style={{ + appearance: "none", cursor: gripCursor(disabled, isDragging), color: "var(--color-text-muted)", border: "none", + background: "transparent", + padding: 0, + margin: 0, + lineHeight: 0, + font: "inherit", + minWidth: 0, + minHeight: 0, }} onClick={(event) => event.stopPropagation()} onKeyDown={(event) => { diff --git a/src/app/(protected)/workorders/_components/list/table/wo-table-row.tsx b/src/app/(protected)/workorders/_components/list/table/wo-table-row.tsx index 6e6dcd79..459d775a 100644 --- a/src/app/(protected)/workorders/_components/list/table/wo-table-row.tsx +++ b/src/app/(protected)/workorders/_components/list/table/wo-table-row.tsx @@ -161,7 +161,12 @@ export function WoTableRow({ isDragging={isDragging} woNumber={row.woNumber} onArrowReorder={(delta) => { - const overId = neighborIdForReorder(allRows, row, delta); + const overId = neighborIdForReorder( + allRows, + row, + delta, + (item) => !isWorkOrderFullyLocked(item.status), + ); if (overId == null) return; handlers.onReorder(row.id, overId); }} diff --git a/src/app/(protected)/workorders/_hooks/resolve-board-row-order-owner-id.ts b/src/app/(protected)/workorders/_hooks/resolve-board-row-order-owner-id.ts new file mode 100644 index 00000000..b6b2032f --- /dev/null +++ b/src/app/(protected)/workorders/_hooks/resolve-board-row-order-owner-id.ts @@ -0,0 +1,6 @@ +import { getAuthUser } from "@/lib/auth/auth-storage"; + +export function resolveBoardRowOrderOwnerId(): string { + const user = getAuthUser(); + return user?.id == null ? "" : String(user.id); +} 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 260794cc..2163bcfb 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,26 +1,13 @@ import { useMemo } from "react"; import type { useWorkOrdersListFilters } from "@/app/(protected)/workorders/_hooks/use-work-orders-list-filters"; +import { resolveBoardRowOrderOwnerId } from "@/app/(protected)/workorders/_hooks/resolve-board-row-order-owner-id"; import { useSyncOutOfRangeSearchPage } from "@/app/(protected)/workorders/_hooks/use-sync-out-of-range-search-page"; 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 { useWorkOrdersTableDayView } from "@/app/(protected)/workorders/_hooks/use-work-orders-table-day-view"; import { useWorkOrdersTableLocalState } from "@/app/(protected)/workorders/_hooks/use-work-orders-table-local-state"; -import { getAuthUser } from "@/lib/auth/auth-storage"; -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 { - pickBoardPager, - pageSelectedDayGroups, - type DayPageSize, -} from "@/domain/work-orders/utils/day-pagination"; -import { - buildSelectedDayGroup, - isDayScopedSearchEmpty, -} from "@/domain/work-orders/utils/week-day-nav-utils"; -import { - filterByBarAndSearch, - splitScheduledUnscheduled, -} from "@/domain/work-orders/utils/table-filter-utils"; +import { pickBoardPager, type DayPageSize } from "@/domain/work-orders/utils/day-pagination"; +import { filterByBarAndSearch } from "@/domain/work-orders/utils/table-filter-utils"; export type { DragVizState } from "@/app/(protected)/workorders/_hooks/use-work-orders-table-local-state"; @@ -34,8 +21,7 @@ export function useWorkOrdersTableData(filters: FiltersReturn) { useWorkOrdersBoardSource(filters); const weeklyItems = data?.items ?? EMPTY_WEEKLY_ITEMS; - const user = getAuthUser(); - const ownerId = user?.id == null ? "" : String(user.id); + const ownerId = resolveBoardRowOrderOwnerId(); const local = useWorkOrdersTableLocalState({ items: weeklyItems, @@ -58,57 +44,22 @@ export function useWorkOrdersTableData(filters: FiltersReturn) { return displayRows; }, [displayRows, filters.barFilters, filters.search, advActive]); - const { scheduled, unscheduled } = useMemo(() => { - if (advActive) { - return splitScheduledUnscheduled(advResults); - } - return splitScheduledUnscheduled(filtered); - }, [advActive, advResults, filtered]); - - const dayGroups = useMemo( - () => - advActive - ? [] - : [buildSelectedDayGroup(filters.weekMonday, filters.selectedDayIso, scheduled)], - [advActive, filters.selectedDayIso, filters.weekMonday, scheduled], - ); - const pagination = useWorkOrdersDayPagination({ - weekMonday: filters.weekMonday, - selectedDayIso: filters.selectedDayIso, + const dayView = useWorkOrdersTableDayView({ + filters, advActive, - dayItems: dayGroups[0]?.items ?? EMPTY_WEEKLY_ITEMS, + filtered, + advResults, onRevealed: local.triggerFlash, }); - const pagedDay = useMemo( - () => pageSelectedDayGroups(dayGroups, pagination.page, pagination.pageSize), - [dayGroups, pagination.page, pagination.pageSize], - ); - - const searchActive = filters.search.trim() !== ""; - const displayGroups = pagedDay.groups; - const dayTotalCount = pagedDay.totalCount; - const dayVisibleCount = pagedDay.groups[0]?.items.length ?? 0; - const noSearchResults = - !advActive && - isDayScopedSearchEmpty({ - searchActive, - unassignedCount: 0, - dayItemCount: dayTotalCount, - unassignedQueueReady: true, - }); - const isEmptyWeek = !advActive && scheduled.length === 0 && !searchActive; - - const todayKey = - filters.weekMonday === currentWeekMonday() - ? deriveDayFields(new Date().toISOString().slice(0, 10)).dayGroup - : null; const selectedRow = useMemo( () => displayRows.find((row) => String(row.id) === String(local.selectedId)) ?? null, [displayRows, local.selectedId], ); - const scopeTotalCount = advActive ? (data?.totalCount ?? advResults.length) : dayTotalCount; + const scopeTotalCount = advActive + ? (data?.totalCount ?? advResults.length) + : dayView.dayTotalCount; useSyncOutOfRangeSearchPage({ enabled: advActive, ready: !isLoading, @@ -117,11 +68,11 @@ export function useWorkOrdersTableData(filters: FiltersReturn) { totalCount: scopeTotalCount, setPage: filters.setPage, }); - const listTotalCount = advActive ? advResults.length : dayVisibleCount; + const listTotalCount = advActive ? advResults.length : dayView.dayVisibleCount; const pager = pickBoardPager({ advActive, isLoading, - noSearchResults, + noSearchResults: dayView.noSearchResults, advResultCount: advResults.length, search: { page: filters.page, @@ -129,11 +80,11 @@ export function useWorkOrdersTableData(filters: FiltersReturn) { totalCount: scopeTotalCount, }, day: { - pageSize: pagination.pageSize, - pageIndex: pagedDay.pageIndex, - pageCount: pagedDay.pageCount, - itemCount: dayTotalCount, - footerLabel: displayGroups[0]?.label ?? "", + pageSize: dayView.pagination.pageSize, + pageIndex: dayView.pagedDay.pageIndex, + pageCount: dayView.pagedDay.pageCount, + itemCount: dayView.dayTotalCount, + footerLabel: dayView.displayGroups[0]?.label ?? "", }, }); @@ -149,12 +100,12 @@ export function useWorkOrdersTableData(filters: FiltersReturn) { unassignedTotalCount: 0, unassignedError: null, unassignedLoading: false, - unscheduled, - displayGroups, - searchActive, - noSearchResults, - isEmptyWeek, - todayKey, + unscheduled: dayView.unscheduled, + displayGroups: dayView.displayGroups, + searchActive: dayView.searchActive, + noSearchResults: dayView.noSearchResults, + isEmptyWeek: dayView.isEmptyWeek, + todayKey: dayView.todayKey, ...localUi, selectedRow, scopeTotalCount, @@ -171,14 +122,14 @@ export function useWorkOrdersTableData(filters: FiltersReturn) { filters.setPageSize(next); return; } - pagination.setPageSize(next); + dayView.pagination.setPageSize(next); }, goToPrevDayPage: advActive ? () => filters.setPage(Math.max(0, pager.pageIndex - 1)) - : pagination.goToPrevPage, + : dayView.pagination.goToPrevPage, goToNextDayPage: advActive ? () => filters.setPage(Math.min(pager.pageIndex + 1, Math.max(0, pager.pageCount - 1))) - : pagination.goToNextPage, - revealScheduledRow: pagination.revealRow, + : dayView.pagination.goToNextPage, + revealScheduledRow: dayView.pagination.revealRow, }; } diff --git a/src/app/(protected)/workorders/_hooks/use-work-orders-table-day-view.ts b/src/app/(protected)/workorders/_hooks/use-work-orders-table-day-view.ts new file mode 100644 index 00000000..3f15b3cb --- /dev/null +++ b/src/app/(protected)/workorders/_hooks/use-work-orders-table-day-view.ts @@ -0,0 +1,90 @@ +import { useMemo } from "react"; +import type { useWorkOrdersListFilters } from "@/app/(protected)/workorders/_hooks/use-work-orders-list-filters"; +import { useWorkOrdersDayPagination } from "@/app/(protected)/workorders/_hooks/use-work-orders-day-pagination"; +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 } from "@/domain/work-orders/utils/day-pagination"; +import { + buildSelectedDayGroup, + isDayScopedSearchEmpty, +} from "@/domain/work-orders/utils/week-day-nav-utils"; +import { splitScheduledUnscheduled } from "@/domain/work-orders/utils/table-filter-utils"; + +type FiltersReturn = ReturnType; + +export type UseWorkOrdersTableDayViewParams = { + filters: FiltersReturn; + advActive: boolean; + filtered: WorkOrderTableRow[]; + advResults: WorkOrderTableRow[]; + onRevealed: (id: string | number) => void; +}; + +const EMPTY_WEEKLY_ITEMS: WorkOrderTableRow[] = []; + +export function useWorkOrdersTableDayView({ + filters, + advActive, + filtered, + advResults, + onRevealed, +}: UseWorkOrdersTableDayViewParams) { + const { scheduled, unscheduled } = useMemo(() => { + if (advActive) { + return splitScheduledUnscheduled(advResults); + } + return splitScheduledUnscheduled(filtered); + }, [advActive, advResults, filtered]); + + const dayGroups = useMemo( + () => + advActive + ? [] + : [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, + }); + const pagedDay = useMemo( + () => pageSelectedDayGroups(dayGroups, pagination.page, pagination.pageSize), + [dayGroups, pagination.page, pagination.pageSize], + ); + + const searchActive = filters.search.trim() !== ""; + const displayGroups = pagedDay.groups; + const dayTotalCount = pagedDay.totalCount; + const dayVisibleCount = pagedDay.groups[0]?.items.length ?? 0; + const noSearchResults = + !advActive && + isDayScopedSearchEmpty({ + searchActive, + unassignedCount: 0, + dayItemCount: dayTotalCount, + unassignedQueueReady: true, + }); + const isEmptyWeek = !advActive && scheduled.length === 0 && !searchActive; + const todayKey = + filters.weekMonday === currentWeekMonday() + ? deriveDayFields(new Date().toISOString().slice(0, 10)).dayGroup + : null; + + return { + scheduled, + unscheduled, + displayGroups, + searchActive, + noSearchResults, + isEmptyWeek, + todayKey, + dayTotalCount, + dayVisibleCount, + pagination, + pagedDay, + }; +} diff --git a/src/app/(protected)/workorders/_hooks/use-work-orders-table-local-state.ts b/src/app/(protected)/workorders/_hooks/use-work-orders-table-local-state.ts index 92ce2f98..86110721 100644 --- a/src/app/(protected)/workorders/_hooks/use-work-orders-table-local-state.ts +++ b/src/app/(protected)/workorders/_hooks/use-work-orders-table-local-state.ts @@ -1,14 +1,15 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { toast } from "react-toastify"; import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row"; +import { applyBoardRowOrderOnScheduleChange } from "@/domain/work-orders/utils/apply-board-row-order-on-schedule-change"; import { deriveDayFields } from "@/domain/work-orders/utils/table-day-utils"; import { enrichTableRow } from "@/domain/work-orders/utils/table-filter-utils"; +import { isWorkOrderFullyLocked } from "@/domain/work-orders/utils/work-order-lock"; import { applyRowOrderByDay, boardRowOrderStorageKey, loadBoardRowOrder, mergeDayOrder, - moveIdToDayOrder, reorderIdsInList, saveBoardRowOrder, type BoardRowOrderByDay, @@ -115,15 +116,15 @@ export function useWorkOrdersTableLocalState({ if (patch.scheduledOn === undefined || advActive) return; - const nextDayGroup = deriveDayFields(patch.scheduledOn).dayGroup; - if (!previousDayGroup || previousDayGroup === nextDayGroup) return; - - setRowOrderByDay((prev) => { - const next = moveIdToDayOrder(prev, key, nextDayGroup); - if (next === prev) return prev; - saveBoardRowOrder(weekMonday, next, ownerId); - return next; - }); + setRowOrderByDay((prev) => + applyBoardRowOrderOnScheduleChange(prev, { + weekMonday, + ownerId, + id: key, + scheduledOn: patch.scheduledOn!, + previousDayGroup, + }), + ); }, [advActive, displayRows, localRows, ownerId, weekMonday], ); @@ -135,6 +136,7 @@ export function useWorkOrdersTableLocalState({ const dragRow = displayRows.find((r) => String(r.id) === dragKey); const overRow = displayRows.find((r) => String(r.id) === overKey); if (!dragRow || !overRow || dragRow.dayGroup !== overRow.dayGroup) return; + if (isWorkOrderFullyLocked(dragRow.status) || isWorkOrderFullyLocked(overRow.status)) return; const dayGroup = dragRow.dayGroup; const dayIds = displayRows diff --git a/src/domain/work-orders/utils/apply-board-row-order-on-schedule-change.ts b/src/domain/work-orders/utils/apply-board-row-order-on-schedule-change.ts new file mode 100644 index 00000000..7d467bb8 --- /dev/null +++ b/src/domain/work-orders/utils/apply-board-row-order-on-schedule-change.ts @@ -0,0 +1,46 @@ +import type { BoardRowOrderByDay } from "@/domain/work-orders/utils/board-row-order-storage"; +import { + moveIdToDayOrder, + removeIdFromRowOrder, + saveBoardRowOrder, + transferIdToDestinationWeekOrder, +} from "@/domain/work-orders/utils/board-row-order-storage"; +import { deriveDayFields } from "@/domain/work-orders/utils/table-day-utils"; +import { mondayOf } from "@/domain/work-orders/utils/wizard-date-utils"; + +export type ApplyBoardRowOrderOnScheduleChangeInput = { + weekMonday: string; + ownerId: string; + id: string; + scheduledOn: string; + previousDayGroup?: string; +}; + +export function applyBoardRowOrderOnScheduleChange( + orderByDay: BoardRowOrderByDay, + input: ApplyBoardRowOrderOnScheduleChangeInput, +): BoardRowOrderByDay { + const { weekMonday, ownerId, id, scheduledOn, previousDayGroup } = input; + const nextDayGroup = deriveDayFields(scheduledOn).dayGroup; + if (!previousDayGroup || previousDayGroup === nextDayGroup) { + return orderByDay; + } + + const destinationWeekMonday = mondayOf(scheduledOn.slice(0, 10)); + if (destinationWeekMonday !== weekMonday) { + transferIdToDestinationWeekOrder({ + sourceWeekMonday: weekMonday, + destinationWeekMonday, + ownerId, + id, + nextDayGroup, + }); + return removeIdFromRowOrder(orderByDay, id); + } + + const next = moveIdToDayOrder(orderByDay, id, nextDayGroup); + if (next !== orderByDay) { + saveBoardRowOrder(weekMonday, next, ownerId); + } + return next; +} diff --git a/src/domain/work-orders/utils/board-row-order-storage.ts b/src/domain/work-orders/utils/board-row-order-storage.ts index cac42107..18b7230c 100644 --- a/src/domain/work-orders/utils/board-row-order-storage.ts +++ b/src/domain/work-orders/utils/board-row-order-storage.ts @@ -127,13 +127,20 @@ export function neighborIdForReorder boolean = () => true, ): string | number | null { const dayRows = rows.filter((item) => item.dayGroup === row.dayGroup); const index = dayRows.findIndex((item) => String(item.id) === String(row.id)); if (index < 0) { return null; } - return dayRows[index + delta]?.id ?? null; + for (let cursor = index + delta; cursor >= 0 && cursor < dayRows.length; cursor += delta) { + const candidate = dayRows[cursor]; + if (candidate && isReorderTarget(candidate)) { + return candidate.id; + } + } + return null; } export function reorderIdsInList(ids: string[], dragId: string, overId: string): string[] | null { @@ -170,6 +177,43 @@ export function removeIdFromRowOrder( } /** Drop the id from every day, then append it to the destination day. */ +export type TransferIdToDestinationWeekOrderInput = { + sourceWeekMonday: string; + destinationWeekMonday: string; + ownerId: string; + id: string; + nextDayGroup: string; +}; + +/** Persist a moved row under the destination week when schedule crosses week boundaries. */ +export function transferIdToDestinationWeekOrder({ + sourceWeekMonday, + destinationWeekMonday, + ownerId, + id, + nextDayGroup, +}: TransferIdToDestinationWeekOrderInput): void { + if (!sourceWeekMonday || !destinationWeekMonday || !nextDayGroup) { + return; + } + if (sourceWeekMonday === destinationWeekMonday) { + return; + } + + const sourceOrder = loadBoardRowOrder(sourceWeekMonday, ownerId); + const sourceWithoutId = removeIdFromRowOrder(sourceOrder, id); + if (sourceWithoutId !== sourceOrder) { + saveBoardRowOrder(sourceWeekMonday, sourceWithoutId, ownerId); + } + + const destinationOrder = loadBoardRowOrder(destinationWeekMonday, ownerId); + saveBoardRowOrder( + destinationWeekMonday, + moveIdToDayOrder(destinationOrder, id, nextDayGroup), + ownerId, + ); +} + export function moveIdToDayOrder( orderByDay: BoardRowOrderByDay, id: string, diff --git a/src/test/app/(protected)/workorders/use-work-orders-table-local-state-order.test.tsx b/src/test/app/(protected)/workorders/use-work-orders-table-local-state-order.test.tsx index 5a1bc5e4..f99a0ea8 100644 --- a/src/test/app/(protected)/workorders/use-work-orders-table-local-state-order.test.tsx +++ b/src/test/app/(protected)/workorders/use-work-orders-table-local-state-order.test.tsx @@ -79,6 +79,7 @@ describe("useWorkOrdersTableLocalState row order", () => { afterEach(() => { localStorage.removeItem(boardRowOrderStorageKey(WEEK, "u1")); localStorage.removeItem(boardRowOrderStorageKey(WEEK)); + localStorage.removeItem(boardRowOrderStorageKey("2026-07-20", "u1")); }); it("reorders within a day and persists when advanced search is off", async () => { @@ -97,6 +98,25 @@ describe("useWorkOrdersTableLocalState row order", () => { expect(loadBoardRowOrder(WEEK, "u1")).toEqual({ "07/15": ["2", "1"] }); }); + it("does not persist a reorder onto a canceled row", async () => { + const items = [ + makeRow({ id: "1" }), + makeRow({ id: "2", woNumber: "WO-2", status: "Canceled" }), + ]; + const { result } = renderHook(() => useWorkOrdersTableLocalState(hookParams(items))); + + await waitFor(() => { + expect(result.current.displayRows.map((row) => String(row.id))).toEqual(["1", "2"]); + }); + + act(() => { + result.current.reorderRows("1", "2"); + }); + + expect(result.current.displayRows.map((row) => String(row.id))).toEqual(["1", "2"]); + expect(loadBoardRowOrder(WEEK, "u1")).toEqual({}); + }); + it("does not write storage while advanced search is active", async () => { const items = [makeRow({ id: "1" }), makeRow({ id: "2", woNumber: "WO-2" })]; const { result } = renderHook(() => @@ -141,6 +161,23 @@ describe("useWorkOrdersTableLocalState row order", () => { expect(loadBoardRowOrder(WEEK, "u1")).toEqual({ "07/15": ["2"], "07/16": ["1"] }); }); + it("writes cross-week schedule moves under the destination week key", async () => { + saveBoardRowOrder(WEEK, { "07/15": ["1", "2"] }, "u1"); + const items = [makeRow({ id: "1" }), makeRow({ id: "2", woNumber: "WO-2" })]; + const { result } = renderHook(() => useWorkOrdersTableLocalState(hookParams(items))); + + await waitFor(() => { + expect(result.current.displayRows).toHaveLength(2); + }); + + act(() => { + result.current.patchRow("1", { scheduledOn: "2026-07-20" }); + }); + + expect(loadBoardRowOrder(WEEK, "u1")).toEqual({ "07/15": ["2"] }); + expect(loadBoardRowOrder("2026-07-20", "u1")).toEqual({ "07/20": ["1"] }); + }); + it("reloads stored order when another session writes the same key", async () => { const items = [makeRow({ id: "1" }), makeRow({ id: "2", woNumber: "WO-2" })]; const { result } = renderHook(() => useWorkOrdersTableLocalState(hookParams(items))); diff --git a/src/test/app/(protected)/workorders/wo-table-row-reorder-keyboard.test.tsx b/src/test/app/(protected)/workorders/wo-table-row-reorder-keyboard.test.tsx index 3b59148c..13cfd51e 100644 --- a/src/test/app/(protected)/workorders/wo-table-row-reorder-keyboard.test.tsx +++ b/src/test/app/(protected)/workorders/wo-table-row-reorder-keyboard.test.tsx @@ -130,4 +130,17 @@ describe("WoTableRow keyboard reorder", () => { fireEvent.keyDown(grip, { key: "ArrowDown" }); expect(onReorder).not.toHaveBeenCalled(); }); + + it("skips a canceled neighbor when reordering an active row", () => { + const onReorder = vi.fn(); + const first = makeRow({ id: "1", woNumber: "WO-1" }); + const canceled = makeRow({ id: "2", woNumber: "WO-2", status: "Canceled" }); + const third = makeRow({ id: "3", woNumber: "WO-3" }); + renderRow(first, [first, canceled, third], handlers({ onReorder })); + + fireEvent.keyDown(screen.getByRole("button", { name: "Reorder work order WO-1" }), { + key: "ArrowDown", + }); + expect(onReorder).toHaveBeenCalledWith("1", "3"); + }); }); diff --git a/src/test/domain/work-orders/utils/board-row-order-storage.test.ts b/src/test/domain/work-orders/utils/board-row-order-storage.test.ts index 20bab014..63d9f984 100644 --- a/src/test/domain/work-orders/utils/board-row-order-storage.test.ts +++ b/src/test/domain/work-orders/utils/board-row-order-storage.test.ts @@ -11,6 +11,7 @@ import { legacyBoardRowOrderStorageKey, reorderIdsInList, saveBoardRowOrder, + transferIdToDestinationWeekOrder, } from "@/domain/work-orders/utils/board-row-order-storage"; describe("applyDayRowOrder", () => { @@ -67,6 +68,17 @@ describe("neighborIdForReorder", () => { expect(neighborIdForReorder(rows, rows[2]!, 1)).toBeNull(); expect(neighborIdForReorder(rows, { id: "x", dayGroup: "07/14" }, 1)).toBeNull(); }); + + it("skips neighbors that fail isReorderTarget", () => { + const dayRows = [ + { id: "a", dayGroup: "07/14", locked: false }, + { id: "b", dayGroup: "07/14", locked: true }, + { id: "c", dayGroup: "07/14", locked: false }, + ]; + expect(neighborIdForReorder(dayRows, dayRows[0]!, 1, (item) => !item.locked)).toBe("c"); + expect(neighborIdForReorder(dayRows, dayRows[2]!, -1, (item) => !item.locked)).toBe("a"); + expect(neighborIdForReorder(dayRows, dayRows[0]!, 1, () => false)).toBeNull(); + }); }); describe("reorderIdsInList", () => { @@ -115,6 +127,31 @@ describe("moveIdToDayOrder", () => { }); }); +describe("transferIdToDestinationWeekOrder", () => { + const sourceWeek = "2026-07-13"; + const destinationWeek = "2026-07-20"; + const ownerKeySource = boardRowOrderStorageKey(sourceWeek, "u1"); + const ownerKeyDestination = boardRowOrderStorageKey(destinationWeek, "u1"); + + afterEach(() => { + localStorage.removeItem(ownerKeySource); + localStorage.removeItem(ownerKeyDestination); + }); + + it("removes the id from the source week and appends it to the destination week day bucket", () => { + saveBoardRowOrder(sourceWeek, { "07/15": ["1", "2"] }, "u1"); + transferIdToDestinationWeekOrder({ + sourceWeekMonday: sourceWeek, + destinationWeekMonday: destinationWeek, + ownerId: "u1", + id: "1", + nextDayGroup: "07/20", + }); + expect(loadBoardRowOrder(sourceWeek, "u1")).toEqual({ "07/15": ["2"] }); + expect(loadBoardRowOrder(destinationWeek, "u1")).toEqual({ "07/20": ["1"] }); + }); +}); + describe("loadBoardRowOrder / saveBoardRowOrder", () => { const week = "2026-07-13"; const key = boardRowOrderStorageKey(week); From 370b2a68ade061800a03637634afde8ff842fa06 Mon Sep 17 00:00:00 2001 From: Arthur Bassi Date: Thu, 3 Sep 2026 14:42:22 -0300 Subject: [PATCH 3/4] fix(work-orders): align reorder grip button box-model with prior span Reset display, vertical-align, and box-sizing so visual snapshots stay on the approved grip. --- .../_components/list/table/wo-table-row-reorder-handle.tsx | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/app/(protected)/workorders/_components/list/table/wo-table-row-reorder-handle.tsx b/src/app/(protected)/workorders/_components/list/table/wo-table-row-reorder-handle.tsx index f9d67c27..16220c5f 100644 --- a/src/app/(protected)/workorders/_components/list/table/wo-table-row-reorder-handle.tsx +++ b/src/app/(protected)/workorders/_components/list/table/wo-table-row-reorder-handle.tsx @@ -29,6 +29,9 @@ export function WoTableRowReorderHandle({ className="flex items-center justify-center bg-transparent p-0" style={{ appearance: "none", + display: "inline-flex", + boxSizing: "border-box", + verticalAlign: "middle", cursor: gripCursor(disabled, isDragging), color: "var(--color-text-muted)", border: "none", From 9f9aa5c82454acb4a4cb1b7199337e53ac2115b2 Mon Sep 17 00:00:00 2001 From: Arthur Bassi Date: Fri, 4 Sep 2026 13:27:53 -0300 Subject: [PATCH 4/4] fix(work-orders): restore AAP span grip while keeping keyboard reorder --- .../table/wo-table-row-reorder-handle.tsx | 31 ++++--------------- .../wo-table-row-reorder-keyboard.test.tsx | 3 +- 2 files changed, 8 insertions(+), 26 deletions(-) diff --git a/src/app/(protected)/workorders/_components/list/table/wo-table-row-reorder-handle.tsx b/src/app/(protected)/workorders/_components/list/table/wo-table-row-reorder-handle.tsx index 16220c5f..cfd2dcf5 100644 --- a/src/app/(protected)/workorders/_components/list/table/wo-table-row-reorder-handle.tsx +++ b/src/app/(protected)/workorders/_components/list/table/wo-table-row-reorder-handle.tsx @@ -7,13 +7,6 @@ type WoTableRowReorderHandleProps = { onArrowReorder: (delta: -1 | 1) => void; }; -function gripCursor(disabled: boolean, isDragging: boolean): string { - if (disabled) { - return "default"; - } - return isDragging ? "grabbing" : "grab"; -} - export function WoTableRowReorderHandle({ disabled, isDragging, @@ -21,27 +14,15 @@ export function WoTableRowReorderHandle({ onArrowReorder, }: WoTableRowReorderHandleProps) { return ( - + ); } diff --git a/src/test/app/(protected)/workorders/wo-table-row-reorder-keyboard.test.tsx b/src/test/app/(protected)/workorders/wo-table-row-reorder-keyboard.test.tsx index 13cfd51e..836eb62b 100644 --- a/src/test/app/(protected)/workorders/wo-table-row-reorder-keyboard.test.tsx +++ b/src/test/app/(protected)/workorders/wo-table-row-reorder-keyboard.test.tsx @@ -126,7 +126,8 @@ describe("WoTableRow keyboard reorder", () => { renderRow(row, [row], handlers({ onReorder })); const grip = screen.getByRole("button", { name: "Reorder work order WO-1" }); - expect(grip).toBeDisabled(); + expect(grip).toHaveAttribute("aria-disabled", "true"); + expect(grip).toHaveAttribute("tabindex", "-1"); fireEvent.keyDown(grip, { key: "ArrowDown" }); expect(onReorder).not.toHaveBeenCalled(); });