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..cfd2dcf5
--- /dev/null
+++ b/src/app/(protected)/workorders/_components/list/table/wo-table-row-reorder-handle.tsx
@@ -0,0 +1,40 @@
+import { GripVertical } from "lucide-react";
+
+type WoTableRowReorderHandleProps = {
+ disabled: boolean;
+ isDragging: boolean;
+ woNumber: string;
+ onArrowReorder: (delta: -1 | 1) => void;
+};
+
+export function WoTableRowReorderHandle({
+ disabled,
+ isDragging,
+ woNumber,
+ onArrowReorder,
+}: WoTableRowReorderHandleProps) {
+ return (
+ event.stopPropagation()}
+ onKeyDown={(event) => {
+ if (disabled || (event.key !== "ArrowUp" && event.key !== "ArrowDown")) {
+ return;
+ }
+ event.preventDefault();
+ event.stopPropagation();
+ onArrowReorder(event.key === "ArrowUp" ? -1 : 1);
+ }}
+ >
+
+
+ );
+}
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..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
@@ -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,21 @@ export function WoTableRow({
style={{ padding: "0 0 0 6px", boxShadow: resolveFirstCellShadow(row) }}
onClick={(e) => e.stopPropagation()}
>
- {
+ const overId = neighborIdForReorder(
+ allRows,
+ row,
+ delta,
+ (item) => !isWorkOrderFullyLocked(item.status),
+ );
+ if (overId == null) return;
+ handlers.onReorder(row.id, overId);
}}
- >
-
-
+ />
e.stopPropagation()}>
{
- 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,
@@ -113,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,
@@ -125,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 ?? "",
},
});
@@ -145,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,
@@ -167,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 fff37a87..9b3b853a 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,13 +1,16 @@
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 { applyCarryOverDisplayDayFields } from "@/domain/work-orders/utils/carry-over-display";
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,
- removeIdFromRowOrder,
reorderIdsInList,
saveBoardRowOrder,
type BoardRowOrderByDay,
@@ -24,6 +27,7 @@ export type UseWorkOrdersTableLocalStateParams = {
weekMonday: string;
advActive: boolean;
isFetching: boolean;
+ ownerId?: string;
};
function usePrunePatchesOnFetchSettle(
@@ -45,6 +49,7 @@ export function useWorkOrdersTableLocalState({
weekMonday,
advActive,
isFetching,
+ ownerId = "",
}: UseWorkOrdersTableLocalStateParams) {
const [patches, setPatches] = useState>>({});
const [localRows, setLocalRows] = useState([]);
@@ -52,8 +57,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,17 +120,17 @@ export function useWorkOrdersTableLocalState({
if (patch.scheduledOn === undefined || advActive) return;
- const nextDayGroup = deriveDayFields(patch.scheduledOn).dayGroup;
- if (!previousDayGroup || previousDayGroup === nextDayGroup) return;
-
- setRowOrderByDay((prev) => {
- const next = removeIdFromRowOrder(prev, key);
- if (next === prev) return prev;
- saveBoardRowOrder(weekMonday, next);
- return next;
- });
+ setRowOrderByDay((prev) =>
+ applyBoardRowOrderOnScheduleChange(prev, {
+ weekMonday,
+ ownerId,
+ id: key,
+ scheduledOn: patch.scheduledOn!,
+ previousDayGroup,
+ }),
+ );
},
- [advActive, displayRows, weekMonday, localRows],
+ [advActive, displayRows, localRows, ownerId, weekMonday],
);
const reorderRows = useCallback(
@@ -124,6 +140,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
@@ -134,11 +151,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/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 0e9f4307..18b7230c 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,27 @@ export function applyRowOrderByDay(
+ rows: T[],
+ row: T,
+ delta: -1 | 1,
+ isReorderTarget: (item: T) => 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;
+ }
+ 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 {
const dragIdx = ids.indexOf(dragId);
const overIdx = ids.indexOf(overId);
@@ -138,6 +176,60 @@ export function removeIdFromRowOrder(
return changed ? next : orderByDay;
}
+/** 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,
+ 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..f99a0ea8
--- /dev/null
+++ b/src/test/app/(protected)/workorders/use-work-orders-table-local-state-order.test.tsx
@@ -0,0 +1,198 @@
+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));
+ localStorage.removeItem(boardRowOrderStorageKey("2026-07-20", "u1"));
+ });
+
+ 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 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(() =>
+ 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("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)));
+
+ 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..836eb62b
--- /dev/null
+++ b/src/test/app/(protected)/workorders/wo-table-row-reorder-keyboard.test.tsx
@@ -0,0 +1,147 @@
+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).toHaveAttribute("aria-disabled", "true");
+ expect(grip).toHaveAttribute("tabindex", "-1");
+ 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 28c326cf..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
@@ -5,9 +5,13 @@ import {
boardRowOrderStorageKey,
loadBoardRowOrder,
mergeDayOrder,
+ moveIdToDayOrder,
removeIdFromRowOrder,
+ neighborIdForReorder,
+ legacyBoardRowOrderStorageKey,
reorderIdsInList,
saveBoardRowOrder,
+ transferIdToDestinationWeekOrder,
} from "@/domain/work-orders/utils/board-row-order-storage";
describe("applyDayRowOrder", () => {
@@ -47,6 +51,36 @@ 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();
+ });
+
+ 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", () => {
it("moves drag id to over index", () => {
expect(reorderIdsInList(["a", "b", "c"], "a", "c")).toEqual(["b", "c", "a"]);
@@ -84,12 +118,50 @@ 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("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);
+ const ownerKey = boardRowOrderStorageKey(week, "u1");
+ const legacyKey = legacyBoardRowOrderStorageKey(week);
afterEach(() => {
localStorage.removeItem(key);
+ localStorage.removeItem(ownerKey);
+ localStorage.removeItem(legacyKey);
vi.restoreAllMocks();
});
@@ -99,6 +171,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({});
|