From 426889427ccb48ff6995c84243a56801facd57e9 Mon Sep 17 00:00:00 2001 From: Arthur Bassi Date: Tue, 1 Sep 2026 14:16:52 -0300 Subject: [PATCH 1/6] feat(work-orders): show carry-over pill and map day-tab for prior-week WOs Keep source week visible and map weekday tabs without rewriting schedule. --- .../tabs/slide-over-info-tab-edit-view.tsx | 3 + .../table/cells/editable-wo-number-cell.tsx | 22 ++++- .../list/table/cells/wo-counter-pills.tsx | 28 +++++- .../list/table/wo-table-row-cells.tsx | 3 + .../work-orders/utils/carry-over-display.ts | 94 +++++++++++++++++++ .../work-orders/utils/week-day-nav-utils.ts | 14 ++- .../carried-over-counter-pill.test.tsx | 14 +++ ...use-work-orders-list-filters-week.test.tsx | 30 ++++++ .../work-order-table-row-mapper.test.ts | 30 ++++++ .../utils/carry-over-display.test.ts | 63 +++++++++++++ .../utils/week-day-nav-utils.test.ts | 41 ++++++++ 11 files changed, 332 insertions(+), 10 deletions(-) create mode 100644 src/domain/work-orders/utils/carry-over-display.ts create mode 100644 src/test/app/(protected)/workorders/use-work-orders-list-filters-week.test.tsx create mode 100644 src/test/domain/work-orders/utils/carry-over-display.test.ts diff --git a/src/app/(protected)/workorders/_components/detail/tabs/slide-over-info-tab-edit-view.tsx b/src/app/(protected)/workorders/_components/detail/tabs/slide-over-info-tab-edit-view.tsx index dbb11c52..4df44d68 100644 --- a/src/app/(protected)/workorders/_components/detail/tabs/slide-over-info-tab-edit-view.tsx +++ b/src/app/(protected)/workorders/_components/detail/tabs/slide-over-info-tab-edit-view.tsx @@ -50,6 +50,9 @@ export function SlideOverInfoTabEditView({ rescheduleCount={draft.rescheduleCount} carriedOver={draft.carriedOver} originalDate={draft.originalDate} + originalWeek={draft.originalWeek} + scheduledOn={draft.scheduledOn} + status={draft.status} currentId={draft.id} allOrders={allOrders} onSave={(woNumber) => onDraftChange({ woNumber })} diff --git a/src/app/(protected)/workorders/_components/list/table/cells/editable-wo-number-cell.tsx b/src/app/(protected)/workorders/_components/list/table/cells/editable-wo-number-cell.tsx index b71f9c4f..369c1101 100644 --- a/src/app/(protected)/workorders/_components/list/table/cells/editable-wo-number-cell.tsx +++ b/src/app/(protected)/workorders/_components/list/table/cells/editable-wo-number-cell.tsx @@ -13,6 +13,9 @@ type EditableWONumberCellProps = { carriedOver?: number; isAddOn?: boolean; originalDate?: string; + originalWeek?: string; + scheduledOn?: string; + status?: string; q?: string; currentId: string | number; allOrders: WorkOrderTableRow[]; @@ -37,6 +40,9 @@ export function EditableWONumberCell({ carriedOver = 0, isAddOn = false, originalDate, + originalWeek, + scheduledOn, + status, q, currentId, allOrders, @@ -118,7 +124,13 @@ export function EditableWONumberCell({ - + ); } @@ -155,7 +167,13 @@ export function EditableWONumberCell({ )} - + ); } diff --git a/src/app/(protected)/workorders/_components/list/table/cells/wo-counter-pills.tsx b/src/app/(protected)/workorders/_components/list/table/cells/wo-counter-pills.tsx index a98552ae..32c13d49 100644 --- a/src/app/(protected)/workorders/_components/list/table/cells/wo-counter-pills.tsx +++ b/src/app/(protected)/workorders/_components/list/table/cells/wo-counter-pills.tsx @@ -1,5 +1,10 @@ import { RefreshCw } from "lucide-react"; import { Tooltip } from "@mui/material"; +import { + formatCarriedOverHover, + resolveCarryOverSourceWeek, + shouldShowCarriedOverPill, +} from "@/domain/work-orders/utils/carry-over-display"; import { formatRescheduleHover } from "@/domain/work-orders/utils/reschedule-indicator"; const RESCHEDULE_PILL_STYLE = { @@ -46,17 +51,30 @@ export function RescheduleCounterPill({ ); } -export function CarriedOverCounterPill({ count }: { count: number }) { - if (count < 2) { +export function CarriedOverCounterPill({ + count, + originalWeek, + originalDate, + scheduledOn, + status, +}: { + count: number; + originalWeek?: string; + originalDate?: string; + scheduledOn?: string; + status?: string; +}) { + if (!shouldShowCarriedOverPill(count, status)) { return null; } - const label = `Carried over ${count} times`; + const sourceWeek = resolveCarryOverSourceWeek({ originalWeek, originalDate, scheduledOn }); + const hover = formatCarriedOverHover(count, sourceWeek); return ( - + diff --git a/src/app/(protected)/workorders/_components/list/table/wo-table-row-cells.tsx b/src/app/(protected)/workorders/_components/list/table/wo-table-row-cells.tsx index aac347e1..c82a404c 100644 --- a/src/app/(protected)/workorders/_components/list/table/wo-table-row-cells.tsx +++ b/src/app/(protected)/workorders/_components/list/table/wo-table-row-cells.tsx @@ -82,6 +82,9 @@ export function WoTableRowCells({ carriedOver={row.carriedOver} isAddOn={row.isAddOn} originalDate={row.originalDate} + originalWeek={row.originalWeek} + scheduledOn={row.scheduledOn} + status={row.status} q={search} currentId={row.id} allOrders={allRows} diff --git a/src/domain/work-orders/utils/carry-over-display.ts b/src/domain/work-orders/utils/carry-over-display.ts new file mode 100644 index 00000000..ec31b3d8 --- /dev/null +++ b/src/domain/work-orders/utils/carry-over-display.ts @@ -0,0 +1,94 @@ +import { compactWeekLabel } from "@/domain/work-orders/utils/list-week-utils"; +import { formatRescheduleCountLabel } from "@/domain/work-orders/utils/reschedule-indicator"; +import { addDaysIso, mondayOf } from "@/domain/work-orders/utils/wizard-date-utils"; + +const ISO_DATE = /^\d{4}-\d{2}-\d{2}$/; +const WEEKDAY_COUNT = 7; +const FRIDAY_OFFSET = 4; + +export type CarryOverRow = { + scheduledOn: string; + status?: string; + carriedOver?: number; +}; + +export function isTerminalCarryOverStatus(status?: string): boolean { + return status === "Completed" || status === "Canceled"; +} + +export function shouldShowCarriedOverPill(count: number, status?: string): boolean { + return count >= 2 && !isTerminalCarryOverStatus(status); +} + +export function toIsoDate(value?: string): string { + if (!value) { + return ""; + } + const iso = value.slice(0, 10); + return ISO_DATE.test(iso) ? iso : ""; +} + +export function resolveCarryOverSourceWeek(input: { + originalWeek?: string; + originalDate?: string; + scheduledOn?: string; +}): string { + const originalWeek = toIsoDate(input.originalWeek); + if (originalWeek) { + return mondayOf(originalWeek); + } + const originalDate = toIsoDate(input.originalDate); + if (originalDate) { + return mondayOf(originalDate); + } + const scheduledOn = toIsoDate(input.scheduledOn); + return scheduledOn ? mondayOf(scheduledOn) : ""; +} + +export function formatCarriedOverHover(count: number, sourceWeekIso?: string): string { + const times = formatRescheduleCountLabel(count); + const base = `Carried over ${times} — this WO was not completed and moved to the next period`; + const sourceLabel = sourceWeekIso ? compactWeekLabel(sourceWeekIso) : ""; + if (!sourceLabel) { + return base; + } + return `${base}. Source week: ${sourceLabel}.`; +} + +/** Non-terminal WOs scheduled before the viewed week are display carry-overs. */ +export function isCarryOverDisplayRow(row: CarryOverRow, weekMonday: string): boolean { + if (isTerminalCarryOverStatus(row.status)) { + return false; + } + const scheduled = toIsoDate(row.scheduledOn); + return Boolean(scheduled) && scheduled < weekMonday; +} + +export function mapCarryOverDisplayDay(scheduledOn: string, weekMonday: string): string { + const scheduled = toIsoDate(scheduledOn); + if (!scheduled || !ISO_DATE.test(weekMonday)) { + return ""; + } + const sourceMonday = mondayOf(scheduled); + const offsetMs = + new Date(`${scheduled}T12:00:00`).getTime() - new Date(`${sourceMonday}T12:00:00`).getTime(); + const offset = Math.round(offsetMs / 86_400_000); + if (offset < 0 || offset >= WEEKDAY_COUNT) { + return weekMonday; + } + if (offset > FRIDAY_OFFSET) { + return weekMonday; + } + return addDaysIso(weekMonday, offset); +} + +export function rowMatchesCarryOverDay( + row: CarryOverRow, + selectedDayIso: string, + weekMonday: string, +): boolean { + if (!isCarryOverDisplayRow(row, weekMonday)) { + return false; + } + return mapCarryOverDisplayDay(row.scheduledOn, weekMonday) === selectedDayIso; +} 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 073cf231..f2b10778 100644 --- a/src/domain/work-orders/utils/week-day-nav-utils.ts +++ b/src/domain/work-orders/utils/week-day-nav-utils.ts @@ -2,6 +2,7 @@ import type { WorkOrderDayGroup, WorkOrderTableRow, } from "@/domain/work-orders/types/work-order-table-row"; +import { rowMatchesCarryOverDay } from "@/domain/work-orders/utils/carry-over-display"; import { isLiveWeek } from "@/domain/work-orders/utils/list-week-utils"; import { isDateInWeek } from "@/domain/work-orders/utils/table-day-utils"; import { addDaysIso, mondayOf, todayIso } from "@/domain/work-orders/utils/wizard-date-utils"; @@ -65,10 +66,17 @@ export function formatDayTabLabel(iso: string): string { } export function rowMatchesSelectedDay( - row: Pick, + row: Pick, selectedDayIso: string, + weekMonday?: string, ): boolean { - return Boolean(row.scheduledOn) && row.scheduledOn.slice(0, 10) === selectedDayIso; + if (Boolean(row.scheduledOn) && row.scheduledOn.slice(0, 10) === selectedDayIso) { + return true; + } + if (!weekMonday) { + return false; + } + return rowMatchesCarryOverDay(row, selectedDayIso, weekMonday); } export function buildSelectedDayGroup( @@ -83,6 +91,6 @@ export function buildSelectedDayGroup( key: `${month}/${day}`, label: formatDayTabLabel(date), date, - items: scheduled.filter((row) => rowMatchesSelectedDay(row, date)), + items: scheduled.filter((row) => rowMatchesSelectedDay(row, date, weekMonday)), }; } diff --git a/src/test/app/(protected)/workorders/carried-over-counter-pill.test.tsx b/src/test/app/(protected)/workorders/carried-over-counter-pill.test.tsx index 277f4a73..d0059873 100644 --- a/src/test/app/(protected)/workorders/carried-over-counter-pill.test.tsx +++ b/src/test/app/(protected)/workorders/carried-over-counter-pill.test.tsx @@ -18,6 +18,20 @@ describe("CarriedOverCounterPill", () => { expect(pill).toHaveTextContent("3"); }); + it("includes the source week in the accessible name", () => { + renderWithProviders( + , + ); + expect(screen.getByLabelText(/Source week: 5\/4–5\/8/i)).toBeInTheDocument(); + }); + + it("hides after resolution even when the counter remains", () => { + const { container } = renderWithProviders( + , + ); + expect(container).toBeEmptyDOMElement(); + }); + it("is distinguishable from the reschedule pill beside it", () => { renderWithProviders( <> diff --git a/src/test/app/(protected)/workorders/use-work-orders-list-filters-week.test.tsx b/src/test/app/(protected)/workorders/use-work-orders-list-filters-week.test.tsx new file mode 100644 index 00000000..223a9a96 --- /dev/null +++ b/src/test/app/(protected)/workorders/use-work-orders-list-filters-week.test.tsx @@ -0,0 +1,30 @@ +import { act, renderHook } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; +import { useWorkOrdersListFilters } from "@/app/(protected)/workorders/_hooks/use-work-orders-list-filters"; +import { workOrdersApi } from "@/domain/work-orders/api/work-orders-api"; + +vi.mock("@/hooks/use-debounce", () => ({ + useDebounce: (value: T) => value, +})); + +vi.mock("@/domain/work-orders/api/work-orders-api", () => ({ + workOrdersApi: { + patchBoardField: vi.fn(), + getBoard: vi.fn(), + }, +})); + +describe("useWorkOrdersListFilters week navigation", () => { + it("changes the viewed week without patching schedule fields", () => { + const { result } = renderHook(() => useWorkOrdersListFilters()); + + act(() => { + result.current.setWeekMonday("2026-05-18"); + }); + + expect(result.current.weekMonday).toBe("2026-05-18"); + expect(result.current.selectedDayIso).toBe("2026-05-18"); + expect(result.current.boardParams.weekStart).toBe("2026-05-18"); + expect(workOrdersApi.patchBoardField).not.toHaveBeenCalled(); + }); +}); diff --git a/src/test/domain/work-orders/mappers/work-order-table-row-mapper.test.ts b/src/test/domain/work-orders/mappers/work-order-table-row-mapper.test.ts index b0208e84..81b8e3e8 100644 --- a/src/test/domain/work-orders/mappers/work-order-table-row-mapper.test.ts +++ b/src/test/domain/work-orders/mappers/work-order-table-row-mapper.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest"; import { mapWorkOrderTableRow } from "@/domain/work-orders/mappers/work-order-table-row-mapper"; import { WORK_ORDER_TYPE_API } from "@/domain/work-orders/types/work-order-board"; +import { buildSelectedDayGroup } from "@/domain/work-orders/utils/week-day-nav-utils"; describe("mapWorkOrderTableRow isAddOn", () => { it("reads isAddOn from board DTO when present", () => { @@ -137,3 +138,32 @@ describe("mapWorkOrderTableRow primaryDispatchStatus", () => { expect(row.primaryDispatchId).toBe(11); }); }); + +describe("mapWorkOrderTableRow carry-over fields", () => { + it("maps carriedOver and originalWeek without rewriting scheduledOn", () => { + const row = mapWorkOrderTableRow({ + id: 41, + workOrderType: WORK_ORDER_TYPE_API.PM, + scheduledDate: "2026-05-13", + carriedOver: 2, + originalWeek: "2026-05-11", + }); + expect(row.carriedOver).toBe(2); + expect(row.originalWeek).toBe("2026-05-11"); + expect(row.scheduledOn).toBe("2026-05-13"); + }); + + it("shows an unresolved prior-week row on the next week's matching day tab", () => { + const row = mapWorkOrderTableRow({ + id: 42, + workOrderType: WORK_ORDER_TYPE_API.PM, + scheduledDate: "2026-05-13", + lifecycleStatus: 3, + carriedOver: 2, + originalWeek: "2026-05-11", + }); + const group = buildSelectedDayGroup("2026-05-18", "2026-05-20", [row]); + expect(row.status).toBe("Scheduled"); + expect(group.items.map((item) => item.id)).toEqual([42]); + }); +}); diff --git a/src/test/domain/work-orders/utils/carry-over-display.test.ts b/src/test/domain/work-orders/utils/carry-over-display.test.ts new file mode 100644 index 00000000..a52e7be3 --- /dev/null +++ b/src/test/domain/work-orders/utils/carry-over-display.test.ts @@ -0,0 +1,63 @@ +import { describe, expect, it } from "vitest"; +import { + formatCarriedOverHover, + isCarryOverDisplayRow, + mapCarryOverDisplayDay, + resolveCarryOverSourceWeek, + shouldShowCarriedOverPill, +} from "@/domain/work-orders/utils/carry-over-display"; + +describe("carry-over-display", () => { + it("hides the pill below 2 and on terminal statuses", () => { + expect(shouldShowCarriedOverPill(1, "Scheduled")).toBe(false); + expect(shouldShowCarriedOverPill(2, "Scheduled")).toBe(true); + expect(shouldShowCarriedOverPill(3, "Completed")).toBe(false); + expect(shouldShowCarriedOverPill(3, "Canceled")).toBe(false); + }); + + it("formats hover without a source week", () => { + expect(formatCarriedOverHover(2)).toBe( + "Carried over 2 times — this WO was not completed and moved to the next period", + ); + }); + + it("appends the source week label from originalWeek", () => { + expect(formatCarriedOverHover(3, "2026-05-04")).toBe( + "Carried over 3 times — this WO was not completed and moved to the next period. Source week: 5/4–5/8.", + ); + }); + + it("resolves source week from originalWeek, then originalDate, then scheduledOn", () => { + expect(resolveCarryOverSourceWeek({ originalWeek: "2026-05-04" })).toBe("2026-05-04"); + expect(resolveCarryOverSourceWeek({ originalDate: "2026-05-06" })).toBe("2026-05-04"); + expect(resolveCarryOverSourceWeek({ scheduledOn: "2026-05-13" })).toBe("2026-05-11"); + expect(resolveCarryOverSourceWeek({})).toBe(""); + }); + + it("treats non-terminal rows scheduled before the viewed week as carry-over", () => { + expect( + isCarryOverDisplayRow( + { scheduledOn: "2026-05-13", status: "Scheduled", carriedOver: 1 }, + "2026-05-18", + ), + ).toBe(true); + expect( + isCarryOverDisplayRow( + { scheduledOn: "2026-05-20", status: "Scheduled", carriedOver: 2 }, + "2026-05-18", + ), + ).toBe(false); + expect( + isCarryOverDisplayRow( + { scheduledOn: "2026-05-13", status: "Completed", carriedOver: 2 }, + "2026-05-18", + ), + ).toBe(false); + }); + + it("maps the original weekday onto the viewed week and weekends onto Monday", () => { + expect(mapCarryOverDisplayDay("2026-05-13", "2026-05-18")).toBe("2026-05-20"); + expect(mapCarryOverDisplayDay("2026-05-16", "2026-05-18")).toBe("2026-05-18"); + expect(mapCarryOverDisplayDay("2026-05-17", "2026-05-18")).toBe("2026-05-18"); + }); +}); 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 0439dc61..ec6c9bc3 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 @@ -179,3 +179,44 @@ describe("week-day-nav-utils (SH-240)", () => { ).toBe(false); }); }); + +describe("week-day-nav-utils carry-over display", () => { + it("places a prior-week unresolved WO on the same weekday of the viewed week", () => { + const carried = row({ + id: "co", + scheduledOn: "2026-05-13", + dayGroup: "05/13", + status: "Scheduled", + carriedOver: 2, + originalWeek: "2026-05-11", + }); + const nativeWed = row({ + id: "native", + scheduledOn: "2026-05-20", + dayGroup: "05/20", + status: "Scheduled", + }); + + expect(rowMatchesSelectedDay(carried, "2026-05-20", "2026-05-18")).toBe(true); + expect(rowMatchesSelectedDay(carried, "2026-05-18", "2026-05-18")).toBe(false); + + const group = buildSelectedDayGroup("2026-05-18", "2026-05-20", [carried, nativeWed]); + expect(group.items.map((item) => item.id)).toEqual(["co", "native"]); + }); + + it("does not map completed carry-overs onto the viewed week", () => { + const completed = row({ + id: "done", + scheduledOn: "2026-05-13", + status: "Completed", + carriedOver: 2, + }); + expect(rowMatchesSelectedDay(completed, "2026-05-20", "2026-05-18")).toBe(false); + expect(buildSelectedDayGroup("2026-05-18", "2026-05-20", [completed]).items).toEqual([]); + }); + + it("keeps unscheduled rows out of day groups", () => { + const unscheduled = row({ id: "us", scheduledOn: "", scheduleWeekOnly: true, carriedOver: 2 }); + expect(rowMatchesSelectedDay(unscheduled, "2026-05-18", "2026-05-18")).toBe(false); + }); +}); From bc1e7a1fa29eadb96c94ceffbe41299087c6b3df Mon Sep 17 00:00:00 2001 From: Arthur Bassi Date: Wed, 2 Sep 2026 09:47:34 -0300 Subject: [PATCH 2/6] fix(work-orders): keep carry-over rows in day reorder and one-char search --- .../use-work-orders-table-local-state.ts | 8 +++-- .../work-orders/utils/carry-over-display.ts | 15 +++++++++ .../work-orders/utils/table-filter-utils.ts | 3 +- .../utils/carry-over-display.test.ts | 31 +++++++++++++++++++ .../utils/table-filter-utils.test.ts | 22 +++++++++++++ 5 files changed, 76 insertions(+), 3 deletions(-) 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 9401ba1f..fff37a87 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,5 +1,6 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row"; +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 { @@ -62,8 +63,11 @@ export function useWorkOrdersTableLocalState({ const displayRows = useMemo(() => { const apiIds = new Set(apiRows.map((r) => String(r.id))); const extra = localRows.filter((r) => !apiIds.has(String(r.id))); - return applyRowOrderByDay([...apiRows, ...extra.map(enrichTableRow)], rowOrderByDay); - }, [apiRows, localRows, rowOrderByDay]); + const merged = [...apiRows, ...extra.map(enrichTableRow)].map((row) => + applyCarryOverDisplayDayFields(row, weekMonday), + ); + return applyRowOrderByDay(merged, rowOrderByDay); + }, [apiRows, localRows, rowOrderByDay, weekMonday]); const clearRowPatch = useCallback((id: string | number) => { const key = String(id); diff --git a/src/domain/work-orders/utils/carry-over-display.ts b/src/domain/work-orders/utils/carry-over-display.ts index ec31b3d8..dd92b2d3 100644 --- a/src/domain/work-orders/utils/carry-over-display.ts +++ b/src/domain/work-orders/utils/carry-over-display.ts @@ -1,5 +1,6 @@ import { compactWeekLabel } from "@/domain/work-orders/utils/list-week-utils"; import { formatRescheduleCountLabel } from "@/domain/work-orders/utils/reschedule-indicator"; +import { deriveDayFields } from "@/domain/work-orders/utils/table-day-utils"; import { addDaysIso, mondayOf } from "@/domain/work-orders/utils/wizard-date-utils"; const ISO_DATE = /^\d{4}-\d{2}-\d{2}$/; @@ -92,3 +93,17 @@ export function rowMatchesCarryOverDay( } return mapCarryOverDisplayDay(row.scheduledOn, weekMonday) === selectedDayIso; } + +/** Aligns UI day keys with the mapped weekday so reorder stays on the displayed tab. */ +export function applyCarryOverDisplayDayFields< + T extends CarryOverRow & { dayGroup: string; dayLabel: string }, +>(row: T, weekMonday: string): T { + if (!isCarryOverDisplayRow(row, weekMonday)) { + return row; + } + const displayDay = mapCarryOverDisplayDay(row.scheduledOn, weekMonday); + if (!displayDay) { + return row; + } + return { ...row, ...deriveDayFields(displayDay) }; +} diff --git a/src/domain/work-orders/utils/table-filter-utils.ts b/src/domain/work-orders/utils/table-filter-utils.ts index dcda43c5..4f2a045b 100644 --- a/src/domain/work-orders/utils/table-filter-utils.ts +++ b/src/domain/work-orders/utils/table-filter-utils.ts @@ -7,6 +7,7 @@ import type { WorkOrderTableRow, } from "@/domain/work-orders/types/work-order-table-row"; import { ADVANCED_FILTER_PREDICATES } from "@/domain/work-orders/utils/advanced-filter-predicates"; +import { isCarryOverDisplayRow } from "@/domain/work-orders/utils/carry-over-display"; import { isDateInWeek } from "@/domain/work-orders/utils/table-day-utils"; import { weekRange } from "@/domain/work-orders/utils/list-week-utils"; @@ -96,7 +97,7 @@ export function filterByBarAndSearch( if (row.scheduledOn) { if (row.scheduledOn < weekFrom || row.scheduledOn > weekTo) { - return false; + return isCarryOverDisplayRow(row, bar.weekMonday); } } else if (row.targetWeek) { if (!isDateInWeek(row.targetWeek, bar.weekMonday)) { diff --git a/src/test/domain/work-orders/utils/carry-over-display.test.ts b/src/test/domain/work-orders/utils/carry-over-display.test.ts index a52e7be3..00b54dfe 100644 --- a/src/test/domain/work-orders/utils/carry-over-display.test.ts +++ b/src/test/domain/work-orders/utils/carry-over-display.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from "vitest"; import { + applyCarryOverDisplayDayFields, formatCarriedOverHover, isCarryOverDisplayRow, mapCarryOverDisplayDay, @@ -60,4 +61,34 @@ describe("carry-over-display", () => { expect(mapCarryOverDisplayDay("2026-05-16", "2026-05-18")).toBe("2026-05-18"); expect(mapCarryOverDisplayDay("2026-05-17", "2026-05-18")).toBe("2026-05-18"); }); + + it("remaps dayGroup to the viewed weekday without changing scheduledOn", () => { + const row = { + scheduledOn: "2026-05-13", + status: "Scheduled", + dayGroup: "05/13", + dayLabel: "WED · 05/13", + }; + const next = applyCarryOverDisplayDayFields(row, "2026-05-18"); + expect(next.scheduledOn).toBe("2026-05-13"); + expect(next.dayGroup).toBe("05/20"); + expect(next.dayLabel).toBe("WED · 05/20"); + }); + + it("leaves in-week and terminal rows' dayGroup unchanged", () => { + const inWeek = { + scheduledOn: "2026-05-20", + status: "Scheduled", + dayGroup: "05/20", + dayLabel: "WED · 05/20", + }; + const completed = { + scheduledOn: "2026-05-13", + status: "Completed", + dayGroup: "05/13", + dayLabel: "WED · 05/13", + }; + expect(applyCarryOverDisplayDayFields(inWeek, "2026-05-18")).toEqual(inWeek); + expect(applyCarryOverDisplayDayFields(completed, "2026-05-18")).toEqual(completed); + }); }); diff --git a/src/test/domain/work-orders/utils/table-filter-utils.test.ts b/src/test/domain/work-orders/utils/table-filter-utils.test.ts index fb68c6f4..85651d2c 100644 --- a/src/test/domain/work-orders/utils/table-filter-utils.test.ts +++ b/src/test/domain/work-orders/utils/table-filter-utils.test.ts @@ -137,6 +137,28 @@ describe("filterByBarAndSearch", () => { expect(ids).toEqual(["in"]); }); + it("keeps prior-week unresolved carry-over rows that match a one-character search", () => { + const carried = makeRow({ + id: "co", + woNumber: "WO-CARRY", + scheduledOn: "2026-05-13", + status: "Scheduled", + ...deriveDayFields("2026-05-13"), + }); + const completed = makeRow({ + id: "done", + woNumber: "WO-DONE", + scheduledOn: "2026-05-13", + status: "Completed", + ...deriveDayFields("2026-05-13"), + }); + + const ids = filterByBarAndSearch([carried, completed], bar("2026-05-18"), "W").map( + (row) => row.id, + ); + expect(ids).toEqual(["co"]); + }); + it("keeps the union of selected types (OR)", () => { const pm = makeRow({ id: "pm", type: "PM", scheduledOn: "2026-06-03" }); const emergency = makeRow({ id: "em", type: "Emergency", scheduledOn: "2026-06-03" }); From dc178340de024a0e40bef20a54bf4bcb4ad5deb6 Mon Sep 17 00:00:00 2001 From: Arthur Bassi Date: Wed, 2 Sep 2026 11:07:27 -0300 Subject: [PATCH 3/6] 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 4/6] 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 5/6] 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 6/6] 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(); });