From 426889427ccb48ff6995c84243a56801facd57e9 Mon Sep 17 00:00:00 2001 From: Arthur Bassi Date: Tue, 1 Sep 2026 14:16:52 -0300 Subject: [PATCH 01/11] 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 02/11] 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 03/11] 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 9cc9bc4dfb47fdec65690210c1f1299b13e295b9 Mon Sep 17 00:00:00 2001 From: Arthur Bassi Date: Wed, 2 Sep 2026 15:11:31 -0300 Subject: [PATCH 04/11] feat(work-orders): align inline lifecycle transitions with AAP Use the prototype status list and gates on the board and wizard. Auto-derive Scheduled or Pending from a concrete schedule date. --- .../list/table/cells/status-cell.tsx | 36 ++-- .../list/table/to-inline-draft-row.ts | 2 + .../list/table/wo-table-inline-row-cells.tsx | 2 + .../wizard/wizard-step-vendor-time.tsx | 36 ++-- .../use-work-order-table-mutations.ts | 25 ++- .../utils/wo-lifecycle-transitions.ts | 169 ++++++++++++++++++ .../status-cell-closability.test.tsx | 41 ++++- .../status-cell-past-due-copy.test.tsx | 10 +- .../status-cell-pending-uplift.test.tsx | 9 +- ...zard-step-vendor-time-closability.test.tsx | 39 ++-- .../use-work-order-table-mutations.test.tsx | 70 ++++++++ .../utils/format-board-audit-action.test.ts | 12 ++ .../utils/wo-lifecycle-transitions.test.ts | 125 +++++++++++++ 13 files changed, 504 insertions(+), 72 deletions(-) create mode 100644 src/domain/work-orders/utils/wo-lifecycle-transitions.ts create mode 100644 src/test/domain/work-orders/utils/wo-lifecycle-transitions.test.ts diff --git a/src/app/(protected)/workorders/_components/list/table/cells/status-cell.tsx b/src/app/(protected)/workorders/_components/list/table/cells/status-cell.tsx index d55176bd..a9ee2647 100644 --- a/src/app/(protected)/workorders/_components/list/table/cells/status-cell.tsx +++ b/src/app/(protected)/workorders/_components/list/table/cells/status-cell.tsx @@ -2,16 +2,19 @@ import { useState } from "react"; import { AlertTriangle, Check } from "lucide-react"; import { Popover } from "@mui/material"; import { Text } from "@/components/ui/text"; -import { - ALL_WIZARD_STATUSES, - type WizardWOStatus, -} from "@/domain/work-orders/types/work-order-wizard"; +import type { WizardWOStatus } from "@/domain/work-orders/types/work-order-wizard"; import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row"; import { - formatClosabilityMissingTitle, getClosabilityGaps, tableRowToClosabilityInput, } from "@/domain/work-orders/utils/wo-closability"; +import { + BOARD_INLINE_STATUSES, + PAST_DUE_BADGE_TOOLTIP, + PAST_DUE_POPOVER_BODY, + PAST_DUE_RESCHEDULE_CTA, + getStatusOptionState, +} from "@/domain/work-orders/utils/wo-lifecycle-transitions"; import { HoverChevron } from "./hover-chevron"; import { WoTableStatusLabel } from "./wo-table-status-label"; @@ -24,6 +27,7 @@ type StatusCellProps = { onPastDueAttempt?: () => void; onRequestComplete?: () => void; readOnly?: boolean; + statusOptions?: readonly WizardWOStatus[]; }; function ReadOnlyStatusLabel({ status }: { status: string }) { @@ -64,7 +68,7 @@ function PastDueStatusCell({ onPastDueAttempt?.(); setAnchorEl(e.currentTarget); }} - title="This WO missed its deadline. Update Schedule On to unlock status updates." + title={PAST_DUE_BADGE_TOOLTIP} className="group/cell flex w-full cursor-pointer items-center gap-1.5 whitespace-nowrap leading-none transition-opacity hover:opacity-80" style={{ fontSize: 12.5, fontWeight: 600, color: "var(--status-pastdue-text)" }} > @@ -97,7 +101,7 @@ function PastDueStatusCell({ color: "var(--status-pending-text)", }} > - This work order is past due. Update Schedule On before the status can be updated. + {PAST_DUE_POPOVER_BODY} @@ -124,6 +128,7 @@ export function StatusCell({ onPastDueAttempt, onRequestComplete, readOnly = false, + statusOptions = BOARD_INLINE_STATUSES, }: StatusCellProps) { const [anchorEl, setAnchorEl] = useState(null); const closabilityGaps = rowClosabilityGaps(wo); @@ -164,20 +169,17 @@ export function StatusCell({ onClick={(e) => e.stopPropagation()} slotProps={{ paper: { className: "min-w-[168px] rounded-lg border border-border p-1" } }} > - {ALL_WIZARD_STATUSES.map((status) => { - const isCompletedBlocked = status === "Completed" && closabilityGaps.length > 0; - const missingTitle = isCompletedBlocked - ? formatClosabilityMissingTitle(closabilityGaps) - : undefined; + {statusOptions.map((status) => { + const option = getStatusOptionState(wo, status, closabilityGaps); 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(); }); From 3ed8600beb6c35de8770924148ccb66603b78907 Mon Sep 17 00:00:00 2001 From: Arthur Bassi Date: Fri, 4 Sep 2026 14:31:08 -0300 Subject: [PATCH 11/11] fix(work-orders): promote dated pre-schedule picks to Scheduled Align create, wizard, and board status patches with AAP maybeAutoSchedule. Include the previous status in audit copy when the API sends it. --- .../utils/board-audit-action-rules.ts | 10 +++++- .../utils/wo-lifecycle-transitions.ts | 10 +++--- .../build-inline-draft-create-input.test.ts | 17 +++++++++ .../wizard-schedule-date-range.test.tsx | 2 ++ ...zard-step-vendor-time-closability.test.tsx | 4 +-- .../utils/format-board-audit-action.test.ts | 19 +++++++--- .../utils/wo-lifecycle-transitions.test.ts | 36 +++++++++++++++---- 7 files changed, 78 insertions(+), 20 deletions(-) diff --git a/src/domain/work-orders/utils/board-audit-action-rules.ts b/src/domain/work-orders/utils/board-audit-action-rules.ts index bae79b4b..c5c6a12a 100644 --- a/src/domain/work-orders/utils/board-audit-action-rules.ts +++ b/src/domain/work-orders/utils/board-audit-action-rules.ts @@ -26,11 +26,19 @@ export type AuditActionRuleContext = { export type AuditActionRule = (ctx: AuditActionRuleContext) => string | null; -function tryStatusRule({ fieldKey, actionKey, newValue }: AuditActionRuleContext): string | null { +function tryStatusRule({ + fieldKey, + actionKey, + oldValue, + newValue, +}: AuditActionRuleContext): string | null { const matches = fieldKey.includes("status") || fieldKey === "lifecyclestatus" || actionKey.includes("status"); if (!matches || !newValue.text) return null; + if (oldValue.text) { + return `Changed status from ${wrapAuditValue(oldValue)} to ${wrapAuditValue(newValue)}`; + } return `Changed status to ${wrapAuditValue(newValue)}`; } diff --git a/src/domain/work-orders/utils/wo-lifecycle-transitions.ts b/src/domain/work-orders/utils/wo-lifecycle-transitions.ts index 2a2ba7f5..264344eb 100644 --- a/src/domain/work-orders/utils/wo-lifecycle-transitions.ts +++ b/src/domain/work-orders/utils/wo-lifecycle-transitions.ts @@ -143,8 +143,9 @@ export function maybeAutoSchedulePatch( return {}; } -function patchTouchesSchedule(patch: WorkOrderTablePatch): boolean { +function patchTouchesScheduleOrStatus(patch: WorkOrderTablePatch): boolean { return ( + patch.status !== undefined || patch.scheduledOn !== undefined || patch.scheduleWeekOnly !== undefined || patch.targetWeek !== undefined @@ -155,7 +156,7 @@ export function mergeAutoSchedulePatch( row: WorkOrderTableRow | undefined, patch: WorkOrderTablePatch, ): WorkOrderTablePatch { - if (!row || !patchTouchesSchedule(patch)) { + if (!row || !patchTouchesScheduleOrStatus(patch)) { return patch; } const merged = { @@ -181,13 +182,10 @@ export function withAutoScheduleStatus { expect(payload).not.toHaveProperty("assignTo"); }); + it("promotes Pending to Scheduled when create has a concrete date", () => { + const draft = { + ...EMPTY_TABLE_DRAFT, + site: "BK5", + locationId: "9", + woNumber: "25006", + pocName: "Jane", + pocPhone: "555-0100", + status: "Pending" as const, + scheduledOn: "2026-06-01", + scheduleWeekOnly: false, + }; + + const input = buildInlineDraftCreateInput(draft, [], [{ id: 9, name: "BK5 · Dallas, TX" }]); + expect(input.status).toBe("Scheduled"); + }); + it("demotes Scheduled to Pending when create has no concrete date", () => { const draft = { ...EMPTY_TABLE_DRAFT, diff --git a/src/test/app/(protected)/workorders/wizard-schedule-date-range.test.tsx b/src/test/app/(protected)/workorders/wizard-schedule-date-range.test.tsx index a79ac2e6..11c2d05d 100644 --- a/src/test/app/(protected)/workorders/wizard-schedule-date-range.test.tsx +++ b/src/test/app/(protected)/workorders/wizard-schedule-date-range.test.tsx @@ -52,6 +52,7 @@ describe("WizardStepTypeSchedule — Schedule On range selection", () => { scheduleWeekOnly: false, targetWeek: "", isAddOnOverride: false, + status: "Scheduled", }); }); @@ -86,6 +87,7 @@ describe("WizardStepTypeSchedule — Schedule On range selection", () => { scheduleWeekOnly: false, targetWeek: "", isAddOnOverride: false, + status: "Scheduled", }); }); diff --git a/src/test/app/(protected)/workorders/wizard-step-vendor-time-closability.test.tsx b/src/test/app/(protected)/workorders/wizard-step-vendor-time-closability.test.tsx index 6210a3b2..9280a735 100644 --- a/src/test/app/(protected)/workorders/wizard-step-vendor-time-closability.test.tsx +++ b/src/test/app/(protected)/workorders/wizard-step-vendor-time-closability.test.tsx @@ -77,7 +77,7 @@ describe("WizardStepVendorTime creation statuses (AAP)", () => { expect(onPatch).toHaveBeenCalledWith({ status: "Pending" }); }); - it("keeps Pending when picked on a dated draft", () => { + it("promotes Pending to Scheduled when picked on a dated draft", () => { const onPatch = vi.fn(); render( { fireEvent.click(screen.getByRole("button", { name: /scheduled/i })); fireEvent.click(screen.getByRole("button", { name: /^pending$/i })); - expect(onPatch).toHaveBeenCalledWith({ status: "Pending" }); + expect(onPatch).toHaveBeenCalledWith({ status: "Scheduled" }); }); }); diff --git a/src/test/domain/work-orders/utils/format-board-audit-action.test.ts b/src/test/domain/work-orders/utils/format-board-audit-action.test.ts index 77cbb059..3ba0ad1b 100644 --- a/src/test/domain/work-orders/utils/format-board-audit-action.test.ts +++ b/src/test/domain/work-orders/utils/format-board-audit-action.test.ts @@ -21,19 +21,28 @@ describe("formatBoardAuditAction", () => { oldValue: "Scheduled", newValue: "Incomplete", }), - ).toBe('Changed status to "Incomplete"'); + ).toBe('Changed status from "Scheduled" to "Incomplete"'); }); - it("does not include the previous status in the audit sentence", () => { + it("includes the previous status in the audit sentence when present", () => { const sentence = formatBoardAuditAction({ action: "StatusChanged", fieldName: "LifecycleStatus", oldValue: "Pending", newValue: "En Route", }); - expect(sentence).toBe('Changed status to "En Route"'); - expect(sentence).not.toMatch(/from/i); - expect(sentence).not.toContain("Pending"); + expect(sentence).toBe('Changed status from "Pending" to "En Route"'); + }); + + it("omits from when the previous status is empty", () => { + expect( + formatBoardAuditAction({ + action: "StatusChanged", + fieldName: "LifecycleStatus", + oldValue: "", + newValue: "Scheduled", + }), + ).toBe('Changed status to "Scheduled"'); }); it("formats WeekRolled / CarriedOver with increment", () => { diff --git a/src/test/domain/work-orders/utils/wo-lifecycle-transitions.test.ts b/src/test/domain/work-orders/utils/wo-lifecycle-transitions.test.ts index 4a31e4e7..5e99b7a1 100644 --- a/src/test/domain/work-orders/utils/wo-lifecycle-transitions.test.ts +++ b/src/test/domain/work-orders/utils/wo-lifecycle-transitions.test.ts @@ -11,6 +11,7 @@ import { hasConcreteScheduleDate, maybeAutoSchedulePatch, mergeAutoSchedulePatch, + resolveCreationStatus, withAutoScheduleStatus, } from "@/domain/work-orders/utils/wo-lifecycle-transitions"; import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row"; @@ -102,17 +103,20 @@ describe("wo-lifecycle-transitions", () => { status: "Scheduled", }); expect(mergeAutoSchedulePatch(row, { vendorId: "9" })).toEqual({ vendorId: "9" }); + expect( + mergeAutoSchedulePatch({ ...row, scheduledOn: "2026-06-01" }, { vendorId: "9" }), + ).toEqual({ vendorId: "9" }); }); - it("keeps an explicit wizard status pick on a dated draft", () => { + it("promotes a pre-schedule wizard pick to Scheduled on a dated draft", () => { const draft = { status: "Scheduled", scheduledOn: "2026-06-01", scheduleWeekOnly: false, }; - expect(withAutoScheduleStatus(draft, { status: "Pending" })).toEqual({ status: "Pending" }); + expect(withAutoScheduleStatus(draft, { status: "Pending" })).toEqual({ status: "Scheduled" }); expect(withAutoScheduleStatus(draft, { status: "Incomplete" })).toEqual({ - status: "Incomplete", + status: "Scheduled", }); }); @@ -131,15 +135,15 @@ describe("wo-lifecycle-transitions", () => { }); }); - it("keeps explicit status picks on dated rows", () => { + it("promotes pre-schedule picks on dated rows", () => { const row = { status: "Scheduled", scheduledOn: "2026-06-01", scheduleWeekOnly: false, } as WorkOrderTableRow; - expect(mergeAutoSchedulePatch(row, { status: "Pending" })).toEqual({ status: "Pending" }); + expect(mergeAutoSchedulePatch(row, { status: "Pending" })).toEqual({ status: "Scheduled" }); expect(mergeAutoSchedulePatch(row, { status: "Incomplete" })).toEqual({ - status: "Incomplete", + status: "Scheduled", }); }); @@ -156,6 +160,26 @@ describe("wo-lifecycle-transitions", () => { }); }); + it("promotes a Pending create pick to Scheduled when the draft is dated", () => { + expect( + resolveCreationStatus({ + status: "Pending", + scheduledOn: "2026-06-01", + scheduleWeekOnly: false, + }), + ).toBe("Scheduled"); + }); + + it("demotes Scheduled without a concrete date at create", () => { + expect( + resolveCreationStatus({ + status: "Scheduled", + scheduledOn: "", + scheduleWeekOnly: false, + }), + ).toBe("Pending"); + }); + it("uses AAP completed tooltip hierarchy", () => { expect(formatAapCompletedBlockTitle(["Dispatcher", "Company"])).toBe( "Fill in Assigned To, Vendor before completing this work order.",