From b4d2d69abe6e4750254f1fbf79aa5942d0394e05 Mon Sep 17 00:00:00 2001 From: Codex Review Integration Date: Wed, 16 Sep 2026 13:32:44 -0300 Subject: [PATCH 1/4] fix(work-orders): keep Due Date on or after Schedule On --- .../tabs/slide-over-info-tab-edit-view.tsx | 8 + .../list/table/cells/date-cell.tsx | 4 + .../table/cells/due-date-calendar-dialog.tsx | 3 + .../list/table/cells/due-date-cell.tsx | 12 +- .../list/table/use-wo-table-row-handlers.ts | 18 +- .../list/table/wo-table-inline-row-cells.tsx | 8 + .../list/table/wo-table-row-cells.tsx | 8 +- .../_components/list/table/wo-table-row.tsx | 19 +- .../_components/wizard/mini-calendar.tsx | 10 +- .../_components/wizard/wizard-date-field.tsx | 4 + .../wizard/wizard-due-date-field.tsx | 20 +- .../wizard/wizard-step-type-schedule.tsx | 8 + .../work-orders/utils/due-date-floor.ts | 27 +++ .../due-date-schedule-floor.test.tsx | 192 ++++++++++++++++++ .../work-orders/utils/due-date-floor.test.ts | 29 +++ 15 files changed, 348 insertions(+), 22 deletions(-) create mode 100644 src/domain/work-orders/utils/due-date-floor.ts create mode 100644 src/test/app/(protected)/workorders/due-date-schedule-floor.test.tsx create mode 100644 src/test/domain/work-orders/utils/due-date-floor.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 c5955f15..fc12a91e 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 @@ -19,6 +19,10 @@ import type { WizardWOStatus } from "@/domain/work-orders/types/work-order-wizar import { deriveDayFields } from "@/domain/work-orders/utils/table-day-utils"; import { toVendorTablePatch } from "@/domain/work-orders/utils/vendor-assignment-patch"; import { DocBadge } from "./slide-over-doc-badge"; +import { + clearDueDateBeforeSchedule, + resolveDueDateFloor, +} from "@/domain/work-orders/utils/due-date-floor"; type SlideOverInfoTabEditViewProps = { draft: WorkOrderTableRow; @@ -52,6 +56,7 @@ function SlideOverScheduleOnField({ schedMode={draft.schedMode} onSave={(scheduledOn) => onDraftChange({ + ...clearDueDateBeforeSchedule(draft.dueDate, scheduledOn), scheduledOn, scheduleWeekOnly: false, targetWeek: scheduledOn ? "" : draft.targetWeek, @@ -63,6 +68,7 @@ function SlideOverScheduleOnField({ } onSaveWeek={(weekStart) => onDraftChange({ + ...clearDueDateBeforeSchedule(draft.dueDate, weekStart), scheduledOn: "", targetWeek: weekStart, scheduleWeekOnly: true, @@ -74,6 +80,7 @@ function SlideOverScheduleOnField({ } onSaveRange={(start, end) => onDraftChange({ + ...clearDueDateBeforeSchedule(draft.dueDate, start), scheduledOn: start, scheduleEnd: end, schedMode: "range", @@ -175,6 +182,7 @@ export function SlideOverInfoTabEditView({ onDraftChange({ dueDate })} /> diff --git a/src/app/(protected)/workorders/_components/list/table/cells/date-cell.tsx b/src/app/(protected)/workorders/_components/list/table/cells/date-cell.tsx index 12b5a42e..5c76c5a6 100644 --- a/src/app/(protected)/workorders/_components/list/table/cells/date-cell.tsx +++ b/src/app/(protected)/workorders/_components/list/table/cells/date-cell.tsx @@ -25,6 +25,8 @@ type DateCellProps = { onClear?: () => void; /** Screen-reader-only status appended inside the button so the accessible name keeps the visible date and adds durable state. */ srStatusSuffix?: string; + /** Earliest selectable day in the date calendar (SH-356). */ + minDate?: string; }; export function DateCell({ @@ -41,6 +43,7 @@ export function DateCell({ onSaveRange, onClear, srStatusSuffix, + minDate, }: DateCellProps) { const [anchorEl, setAnchorEl] = useState(null); const open = Boolean(anchorEl); @@ -154,6 +157,7 @@ export function DateCell({ ) : ( { onSave(d); setAnchorEl(null); diff --git a/src/app/(protected)/workorders/_components/list/table/cells/due-date-calendar-dialog.tsx b/src/app/(protected)/workorders/_components/list/table/cells/due-date-calendar-dialog.tsx index a47b8e14..3f5d4915 100644 --- a/src/app/(protected)/workorders/_components/list/table/cells/due-date-calendar-dialog.tsx +++ b/src/app/(protected)/workorders/_components/list/table/cells/due-date-calendar-dialog.tsx @@ -8,6 +8,7 @@ type DueDateCalendarDialogProps = { onOpenChange: (open: boolean) => void; value: string; onSave: (iso: string) => void; + minDate?: string; }; /** @@ -19,6 +20,7 @@ export function DueDateCalendarDialog({ onOpenChange, value, onSave, + minDate, }: DueDateCalendarDialogProps) { return ( { onSave(iso); onOpenChange(false); diff --git a/src/app/(protected)/workorders/_components/list/table/cells/due-date-cell.tsx b/src/app/(protected)/workorders/_components/list/table/cells/due-date-cell.tsx index 1ce90d18..5f09b0a4 100644 --- a/src/app/(protected)/workorders/_components/list/table/cells/due-date-cell.tsx +++ b/src/app/(protected)/workorders/_components/list/table/cells/due-date-cell.tsx @@ -16,6 +16,8 @@ type DueDateCellProps = { * read by colour alone. */ pastDue?: boolean; + /** Schedule On floor: earlier days are not selectable (SH-356). */ + minDate?: string; onSave: (date: string) => void; }; @@ -29,6 +31,7 @@ export function DueDateCell({ q, highlight, pastDue = false, + minDate, onSave, }: DueDateCellProps) { const [open, setOpen] = useState(false); @@ -40,6 +43,7 @@ export function DueDateCell({ q={q} highlight={highlight} onSave={onSave} + minDate={minDate} srStatusSuffix={pastDue ? "Past due" : undefined} /> ); @@ -87,7 +91,13 @@ export function DueDateCell({ style={{ color: "var(--muted-foreground)" }} /> - + ); } diff --git a/src/app/(protected)/workorders/_components/list/table/use-wo-table-row-handlers.ts b/src/app/(protected)/workorders/_components/list/table/use-wo-table-row-handlers.ts index ae33d713..a8fc4673 100644 --- a/src/app/(protected)/workorders/_components/list/table/use-wo-table-row-handlers.ts +++ b/src/app/(protected)/workorders/_components/list/table/use-wo-table-row-handlers.ts @@ -9,6 +9,7 @@ import { shouldNotifyCrossWeek } from "@/domain/work-orders/utils/should-notify- import type { ReturnTypeOfUseWorkOrdersTableData } from "./wo-table-types"; import { rescheduleNotify, weekMoveNotify } from "./reschedule-notify"; import type { WoTableRowHandlers } from "./wo-table-row"; +import { clearDueDateBeforeSchedule } from "@/domain/work-orders/utils/due-date-floor"; function revealOrNotifyScheduledDay(args: { id: string | number; @@ -101,8 +102,11 @@ export function useWoTableRowHandlers({ onPatch: (id, patch, options) => { onPatch(id, patch, options); }, - onScheduleDate: (id: string | number, date: string) => { - onPatch(id, applySchedulePatch(date, false)); + onScheduleDate: (id: string | number, date: string, dueDate?: string) => { + onPatch(id, { + ...applySchedulePatch(date, false), + ...clearDueDateBeforeSchedule(dueDate, date), + }); revealOrNotifyScheduledDay({ id, date, @@ -111,8 +115,11 @@ export function useWoTableRowHandlers({ revealScheduledRow, }); }, - onScheduleRange: (id: string | number, start: string, end: string) => { - onPatch(id, applyScheduleRangePatch(start, end)); + onScheduleRange: (id: string | number, start: string, end: string, dueDate?: string) => { + onPatch(id, { + ...applyScheduleRangePatch(start, end), + ...clearDueDateBeforeSchedule(dueDate, start), + }); revealOrNotifyScheduledDay({ id, date: start, @@ -121,7 +128,7 @@ export function useWoTableRowHandlers({ revealScheduledRow, }); }, - onScheduleWeek: (id: string | number, week: string) => { + onScheduleWeek: (id: string | number, week: string, dueDate?: string) => { const isClear = week === ""; onPatch( id, @@ -135,6 +142,7 @@ export function useWoTableRowHandlers({ schedMode: "week", dayGroup: "", dayLabel: "", + ...clearDueDateBeforeSchedule(dueDate, week), }, ); weekMoveNotify(week, { diff --git a/src/app/(protected)/workorders/_components/list/table/wo-table-inline-row-cells.tsx b/src/app/(protected)/workorders/_components/list/table/wo-table-inline-row-cells.tsx index ebd484f4..581f3287 100644 --- a/src/app/(protected)/workorders/_components/list/table/wo-table-inline-row-cells.tsx +++ b/src/app/(protected)/workorders/_components/list/table/wo-table-inline-row-cells.tsx @@ -23,6 +23,10 @@ import { VendorCell } from "./cells/vendor-cell"; import { toVendorTablePatch } from "@/domain/work-orders/utils/vendor-assignment-patch"; import { toInlineDraftRow } from "./to-inline-draft-row"; import { WoTableInlineIdentityCells } from "./wo-table-inline-identity-cells"; +import { + clearDueDateBeforeSchedule, + resolveDueDateFloor, +} from "@/domain/work-orders/utils/due-date-floor"; type WoTableInlineRowCellsProps = { draft: WorkOrderTableDraft; @@ -93,6 +97,7 @@ function InlineScheduleOnCell({ onSave={(date) => onPatch( withAutoScheduleStatus(draft, { + ...clearDueDateBeforeSchedule(draft.dueDate, date), scheduledOn: date, scheduleWeekOnly: false, targetWeek: "", @@ -105,6 +110,7 @@ function InlineScheduleOnCell({ onSaveWeek={(week) => onPatch( withAutoScheduleStatus(draft, { + ...clearDueDateBeforeSchedule(draft.dueDate, week), scheduledOn: "", targetWeek: week, scheduleWeekOnly: true, @@ -117,6 +123,7 @@ function InlineScheduleOnCell({ onSaveRange={(start, end) => onPatch( withAutoScheduleStatus(draft, { + ...clearDueDateBeforeSchedule(draft.dueDate, start), scheduledOn: start, scheduleEnd: end, schedMode: "range", @@ -200,6 +207,7 @@ export function WoTableInlineRowCells({ onPatch({ dueDate: date })} /> 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 072fe8fa..1cd88e6b 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 @@ -16,6 +16,7 @@ import { EditableWONumberCell } from "./cells/editable-wo-number-cell"; import { SiteCell } from "./cells/site-cell"; import { TypeCell } from "./cells/type-cell"; import type { WoTableRowHandlers } from "./wo-table-row"; +import { resolveDueDateFloor } from "@/domain/work-orders/utils/due-date-floor"; type WoTableRowCellsProps = { row: WorkOrderTableRow; @@ -146,9 +147,9 @@ export function WoTableRowCells({ targetWeek={row.targetWeek} scheduleEnd={row.scheduleEnd} schedMode={row.schedMode} - onSave={(date) => handlers.onScheduleDate(row.id, date)} - onSaveWeek={(week) => handlers.onScheduleWeek(row.id, week)} - onSaveRange={(start, end) => handlers.onScheduleRange(row.id, start, end)} + onSave={(date) => handlers.onScheduleDate(row.id, date, row.dueDate)} + onSaveWeek={(week) => handlers.onScheduleWeek(row.id, week, row.dueDate)} + onSaveRange={(start, end) => handlers.onScheduleRange(row.id, start, end, row.dueDate)} onClear={hasSchedule && !viewOnly ? () => handlers.onScheduleWeek(row.id, "") : undefined} /> @@ -163,6 +164,7 @@ export function WoTableRowCells({ q={search} highlight={forceDueDateHighlight || row.isPastDue} pastDue={row.isPastDue} + minDate={resolveDueDateFloor(row)} onSave={(date) => onPatchRow({ dueDate: date })} /> 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 1abf21e5..8a7814ec 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 @@ -36,9 +36,10 @@ export type WoTableRowHandlers = { patch: Partial, options?: WorkOrderTablePatchOptions, ) => void; - onScheduleDate: (id: string | number, date: string) => void; - onScheduleWeek: (id: string | number, week: string) => void; - onScheduleRange: (id: string | number, start: string, end: string) => void; + /** `dueDate` is the row's current Due Date, cleared when the new schedule passes it (SH-356). */ + onScheduleDate: (id: string | number, date: string, dueDate?: string) => void; + onScheduleWeek: (id: string | number, week: string, dueDate?: string) => void; + onScheduleRange: (id: string | number, start: string, end: string, dueDate?: string) => void; onDuplicateFound?: (existing: WorkOrderTableRow) => void; onOpenSlideOver: (row: WorkOrderTableRow) => void; onRequestComplete?: (row: WorkOrderTableRow) => void; @@ -77,17 +78,17 @@ function guardScheduleHandlers( if (!canApplyWorkOrderTablePatch(row.status, patch)) return; handlers.onPatch(id, patch, options); }, - onScheduleDate: (id, date) => { + onScheduleDate: (id, date, dueDate) => { if (!canApplyWorkOrderTablePatch(row.status, { scheduledOn: date })) return; - handlers.onScheduleDate(id, date); + handlers.onScheduleDate(id, date, dueDate); }, - onScheduleWeek: (id, week) => { + onScheduleWeek: (id, week, dueDate) => { if (!canApplyWorkOrderTablePatch(row.status, { targetWeek: week })) return; - handlers.onScheduleWeek(id, week); + handlers.onScheduleWeek(id, week, dueDate); }, - onScheduleRange: (id, start, end) => { + onScheduleRange: (id, start, end, dueDate) => { if (!canApplyWorkOrderTablePatch(row.status, { scheduledOn: start })) return; - handlers.onScheduleRange(id, start, end); + handlers.onScheduleRange(id, start, end, dueDate); }, }; } diff --git a/src/app/(protected)/workorders/_components/wizard/mini-calendar.tsx b/src/app/(protected)/workorders/_components/wizard/mini-calendar.tsx index e9aa9dab..5b494167 100644 --- a/src/app/(protected)/workorders/_components/wizard/mini-calendar.tsx +++ b/src/app/(protected)/workorders/_components/wizard/mini-calendar.tsx @@ -12,6 +12,8 @@ type MiniCalendarProps = { range?: boolean; rangeStart?: string; rangeEnd?: string; + /** Days before this ISO date are disabled (SH-356 Due Date floor). */ + minDate?: string; }; export function MiniCalendar({ @@ -20,9 +22,11 @@ export function MiniCalendar({ range, rangeStart = "", rangeEnd = "", + minDate = "", }: MiniCalendarProps) { const initRaw = range ? rangeStart || value : value; - const init = /^\d{4}-\d{2}-\d{2}$/.test(initRaw) ? initRaw : todayIso(); + const fallback = minDate > todayIso() ? minDate : todayIso(); + const init = /^\d{4}-\d{2}-\d{2}$/.test(initRaw) ? initRaw : fallback; const [iy, im] = init.split("-").map(Number); const [viewY, setViewY] = useState(iy); const [viewM, setViewM] = useState(im); @@ -94,6 +98,7 @@ export function MiniCalendar({ } const iso = `${viewY}-${pad2(viewM)}-${pad2(day)}`; const isToday = iso === today; + const beforeMin = Boolean(minDate) && iso < minDate; const { bg, fg, radius, filled } = getMiniCalendarCellStyle({ iso, value, @@ -107,8 +112,9 @@ export function MiniCalendar({ - + ); } diff --git a/src/app/(protected)/workorders/_components/wizard/wizard-step-type-schedule.tsx b/src/app/(protected)/workorders/_components/wizard/wizard-step-type-schedule.tsx index f4df71a3..400ad771 100644 --- a/src/app/(protected)/workorders/_components/wizard/wizard-step-type-schedule.tsx +++ b/src/app/(protected)/workorders/_components/wizard/wizard-step-type-schedule.tsx @@ -29,6 +29,10 @@ import { import { WizardSeverityPicker } from "@/app/(protected)/workorders/_components/wizard/wizard-severity-picker"; import { CheckCircle2, Clock, Flame, Users, Wrench, Zap } from "lucide-react"; import type { ElementType } from "react"; +import { + clearDueDateBeforeSchedule, + resolveDueDateFloor, +} from "@/domain/work-orders/utils/due-date-floor"; const WO_TYPE_ICONS: Record = { PM: Wrench, @@ -165,6 +169,7 @@ function WizardTypeScheduleDates({ draft, onUpdate, onPatch }: WizardTypeSchedul onChange={(iso) => onPatch( withAutoScheduleStatus(draft, { + ...clearDueDateBeforeSchedule(draft.dueDate, iso), scheduledOn: iso, scheduleWeekOnly: false, scheduleEnd: "", @@ -177,6 +182,7 @@ function WizardTypeScheduleDates({ draft, onUpdate, onPatch }: WizardTypeSchedul onChangeWeek={(w) => onPatch( withAutoScheduleStatus(draft, { + ...clearDueDateBeforeSchedule(draft.dueDate, w), scheduledOn: w, scheduleWeekOnly: Boolean(w), scheduleEnd: "", @@ -189,6 +195,7 @@ function WizardTypeScheduleDates({ draft, onUpdate, onPatch }: WizardTypeSchedul onChangeRange={(s, e) => onPatch( withAutoScheduleStatus(draft, { + ...clearDueDateBeforeSchedule(draft.dueDate, s), scheduledOn: s, scheduleEnd: e, schedMode: "range", @@ -211,6 +218,7 @@ function WizardTypeScheduleDates({ draft, onUpdate, onPatch }: WizardTypeSchedul onUpdate("dueDate", iso)} /> diff --git a/src/domain/work-orders/utils/due-date-floor.ts b/src/domain/work-orders/utils/due-date-floor.ts new file mode 100644 index 00000000..95f8eb54 --- /dev/null +++ b/src/domain/work-orders/utils/due-date-floor.ts @@ -0,0 +1,27 @@ +/** + * SH-356: a Due Date can never be earlier than Schedule On. + * + * The floor is the concrete Schedule On day, the start of a date range, or — for a + * week-only schedule (SH-110) — the first day of the target week. + */ +export function resolveDueDateFloor(schedule: { + scheduledOn?: string | null; + targetWeek?: string | null; +}): string { + return schedule.scheduledOn?.trim() || schedule.targetWeek?.trim() || ""; +} + +export function isDueDateBeforeFloor(dueDate: string | null | undefined, floor: string): boolean { + return Boolean(dueDate && floor && dueDate < floor); +} + +/** + * Patch fragment for a Schedule On change: clears an existing Due Date that the new + * schedule would leave in the past. Never bumps it to a guessed value. + */ +export function clearDueDateBeforeSchedule( + dueDate: string | null | undefined, + nextFloor: string, +): { dueDate?: string } { + return isDueDateBeforeFloor(dueDate, nextFloor) ? { dueDate: "" } : {}; +} diff --git a/src/test/app/(protected)/workorders/due-date-schedule-floor.test.tsx b/src/test/app/(protected)/workorders/due-date-schedule-floor.test.tsx new file mode 100644 index 00000000..32c1092e --- /dev/null +++ b/src/test/app/(protected)/workorders/due-date-schedule-floor.test.tsx @@ -0,0 +1,192 @@ +import { fireEvent, render, renderHook, screen, within } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; +import { SlideOverInfoTabEditView } from "@/app/(protected)/workorders/_components/detail/tabs/slide-over-info-tab-edit-view"; +import { WoTableInlineRow } from "@/app/(protected)/workorders/_components/list/table/wo-table-inline-row"; +import { useWoTableRowHandlers } from "@/app/(protected)/workorders/_components/list/table/use-wo-table-row-handlers"; +import { MiniCalendar } from "@/app/(protected)/workorders/_components/wizard/mini-calendar"; +import { WizardStepTypeSchedule } from "@/app/(protected)/workorders/_components/wizard/wizard-step-type-schedule"; +import { EMPTY_TABLE_DRAFT } from "@/domain/work-orders/types/work-order-table-row"; +import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row"; +import { EMPTY_WIZARD_DRAFT } from "@/domain/work-orders/types/work-order-wizard"; +import { pad2, todayIso } from "@/domain/work-orders/utils/wizard-date-utils"; +import { renderWithProviders } from "@/test/test-utils"; + +vi.mock("@/app/(protected)/workorders/_components/list/table/cells/site-cell", () => ({ + SiteCell: () => null, +})); +vi.mock( + "@/app/(protected)/workorders/_components/list/table/cells/editable-wo-number-cell", + () => ({ EditableWONumberCell: () => null }), +); +vi.mock("@/app/(protected)/workorders/_components/list/table/cells/type-cell", () => ({ + TypeCell: () => null, +})); +vi.mock("@/app/(protected)/workorders/_components/list/table/cells/assigned-to-cell", () => ({ + AssignedToCell: () => null, +})); +vi.mock("@/app/(protected)/workorders/_components/list/table/cells/appt-time-cell", () => ({ + ApptTimeCell: () => null, +})); +vi.mock("@/app/(protected)/workorders/_components/list/table/cells/pm-type-cell", () => ({ + PMTypeCell: () => null, +})); +vi.mock("@/app/(protected)/workorders/_components/list/table/cells/vendor-cell", () => ({ + VendorCell: () => null, +})); +vi.mock("@/app/(protected)/workorders/_components/list/table/cells/status-cell", () => ({ + StatusCell: () => null, +})); +vi.mock("@/app/(protected)/workorders/_components/list/table/reschedule-notify", () => ({ + rescheduleNotify: vi.fn(), + weekMoveNotify: vi.fn(), +})); + +function day(n: number): string { + const [y, m] = todayIso().split("-").map(Number); + return `${y}-${pad2(m)}-${pad2(n)}`; +} + +function rowByLabel(label: string) { + return screen.getByText(label, { selector: "span,label,div" }).closest("div")!; +} + +describe("Due Date cannot be before Schedule On (SH-356)", () => { + it("disables calendar days before the floor", () => { + render(); + + expect(screen.getByRole("button", { name: "9" })).toBeDisabled(); + expect(screen.getByRole("button", { name: "10" })).toBeEnabled(); + expect(screen.getByRole("button", { name: "11" })).toBeEnabled(); + }); + + describe("slide-over edit", () => { + const draft = { scheduledOn: day(10), dueDate: day(12) } as Partial; + + function renderSlideOver(onDraftChange = vi.fn()) { + renderWithProviders( + , + ); + return onDraftChange; + } + + it("disables Due Date days before Schedule On", async () => { + renderSlideOver(); + fireEvent.click(within(rowByLabel("Due Date")).getByRole("button")); + + expect(await screen.findByRole("button", { name: "9" })).toBeDisabled(); + expect(screen.getByRole("button", { name: "10" })).toBeEnabled(); + }); + + it("clears the Due Date when Schedule On moves past it", async () => { + const onDraftChange = renderSlideOver(); + fireEvent.click(within(rowByLabel("Schedule On")).getAllByRole("button")[0]); + fireEvent.click(await screen.findByRole("button", { name: "15" })); + + expect(onDraftChange).toHaveBeenCalledWith( + expect.objectContaining({ scheduledOn: day(15), dueDate: "" }), + ); + }); + }); + + it("wizard: clears Due Date on a later Schedule On and floors the Due Date picker", async () => { + const onPatch = vi.fn(); + render( + , + ); + + fireEvent.click(within(rowByLabel("Due Date").parentElement!).getAllByRole("button").at(-1)!); + expect(await screen.findByRole("button", { name: "9" })).toBeDisabled(); + fireEvent.keyDown(document.activeElement ?? document.body, { key: "Escape" }); + + const scheduleBlock = rowByLabel("Schedule On").parentElement!; + fireEvent.click(within(scheduleBlock).getAllByRole("button").at(-1)!); + const calendars = await screen.findAllByRole("button", { name: "15" }); + fireEvent.click(calendars.at(-1)!); + + expect(onPatch).toHaveBeenCalledWith( + expect.objectContaining({ scheduledOn: day(15), dueDate: "" }), + ); + }); + + it("inline creation row: clears Due Date on a later Schedule On", async () => { + const onChange = vi.fn(); + renderWithProviders( + + + + +
, + { withAuth: false }, + ); + + const cells = screen.getAllByRole("cell"); + const dateButtons = cells.flatMap((cell) => within(cell).queryAllByRole("button")); + const scheduleButton = dateButtons.find( + (b) => b.textContent?.includes(day(10).slice(5).replace("-", "/")) ?? false, + ); + fireEvent.click(scheduleButton ?? dateButtons[0]); + fireEvent.click(await screen.findByRole("button", { name: "15" })); + + expect(onChange).toHaveBeenCalledWith( + expect.objectContaining({ scheduledOn: day(15), dueDate: "" }), + ); + }); + + it("board row: sends the Due Date clear in the same schedule patch", () => { + const onPatch = vi.fn(); + const { result } = renderHook(() => + useWoTableRowHandlers({ + tableData: { + dragState: { current: { dragId: null, overId: null } }, + setDragViz: vi.fn(), + reorderRows: vi.fn(), + endDrag: vi.fn(), + revealScheduledRow: vi.fn(), + setSelectedId: vi.fn(), + } as never, + weekMonday: "2026-05-18", + onNavigateToDay: vi.fn(), + onOpenSlideOver: vi.fn(), + onOpenMedia: vi.fn(), + onPatch, + onScheduleHighlight: vi.fn(), + }), + ); + + result.current.onScheduleDate("wo-1", "2026-05-21", "2026-05-20"); + expect(onPatch).toHaveBeenLastCalledWith("wo-1", expect.objectContaining({ dueDate: "" })); + + result.current.onScheduleRange("wo-1", "2026-05-19", "2026-05-22", "2026-05-20"); + expect(onPatch.mock.lastCall?.[1]).not.toHaveProperty("dueDate"); + + result.current.onScheduleWeek("wo-1", "2026-05-25", "2026-05-20"); + expect(onPatch).toHaveBeenLastCalledWith( + "wo-1", + expect.objectContaining({ targetWeek: "2026-05-25", dueDate: "" }), + ); + }); +}); diff --git a/src/test/domain/work-orders/utils/due-date-floor.test.ts b/src/test/domain/work-orders/utils/due-date-floor.test.ts new file mode 100644 index 00000000..b4c52c1b --- /dev/null +++ b/src/test/domain/work-orders/utils/due-date-floor.test.ts @@ -0,0 +1,29 @@ +import { describe, expect, it } from "vitest"; +import { + clearDueDateBeforeSchedule, + isDueDateBeforeFloor, + resolveDueDateFloor, +} from "@/domain/work-orders/utils/due-date-floor"; + +describe("due date floor (SH-356)", () => { + it("uses the concrete or range-start Schedule On, else the target week's first day", () => { + expect(resolveDueDateFloor({ scheduledOn: "2026-09-10", targetWeek: "" })).toBe("2026-09-10"); + expect(resolveDueDateFloor({ scheduledOn: "", targetWeek: "2026-09-14" })).toBe("2026-09-14"); + expect(resolveDueDateFloor({ scheduledOn: "", targetWeek: "" })).toBe(""); + expect(resolveDueDateFloor({ scheduledOn: null, targetWeek: undefined })).toBe(""); + }); + + it("flags only a Due Date strictly before the floor", () => { + expect(isDueDateBeforeFloor("2026-09-09", "2026-09-10")).toBe(true); + expect(isDueDateBeforeFloor("2026-09-10", "2026-09-10")).toBe(false); + expect(isDueDateBeforeFloor("", "2026-09-10")).toBe(false); + expect(isDueDateBeforeFloor("2026-09-09", "")).toBe(false); + }); + + it("clears (never bumps) a Due Date the new schedule passes", () => { + expect(clearDueDateBeforeSchedule("2026-09-12", "2026-09-15")).toEqual({ dueDate: "" }); + expect(clearDueDateBeforeSchedule("2026-09-12", "2026-09-12")).toEqual({}); + expect(clearDueDateBeforeSchedule("2026-09-20", "2026-09-15")).toEqual({}); + expect(clearDueDateBeforeSchedule("2026-09-12", "")).toEqual({}); + }); +}); From fdc275ab9a26af658b2dc8bd9f430b0f8117479f Mon Sep 17 00:00:00 2001 From: Codex Review Integration Date: Wed, 16 Sep 2026 13:35:12 -0300 Subject: [PATCH 2/4] test(work-orders): cover the Due Date floor in the wizard --- .../due-date-schedule-floor.spec.ts | 80 +++++++++++++++++++ 1 file changed, 80 insertions(+) create mode 100644 e2e/work-orders/due-date-schedule-floor.spec.ts diff --git a/e2e/work-orders/due-date-schedule-floor.spec.ts b/e2e/work-orders/due-date-schedule-floor.spec.ts new file mode 100644 index 00000000..afd181e9 --- /dev/null +++ b/e2e/work-orders/due-date-schedule-floor.spec.ts @@ -0,0 +1,80 @@ +import { expect, test, type Route } from "@playwright/test"; + +const FROZEN_NOW = "2026-08-19T15:00:00.000Z"; + +const TEST_USER = { + token: "due-date-floor-e2e-token", + expiration: "2030-01-01T00:00:00.000Z", + email: "admin@seahavenind.com", + userRoles: "Admin", + phoneNumber: "5551234567", + fullname: "Work Order E2E Admin", + id: "1", +}; + +async function fulfillJson(route: Route, body: unknown, status = 200) { + await route.fulfill({ status, contentType: "application/json", body: JSON.stringify(body) }); +} + +test.describe("Due Date floor in the Create WO wizard (SH-356)", () => { + test.setTimeout(60_000); + + test("disables days before Schedule On and clears Due Date when Schedule On passes it", async ({ + page, + }) => { + await page.clock.install({ time: new Date(FROZEN_NOW) }); + await page.clock.resume(); + await page.addInitScript((user) => { + localStorage.setItem("auth", JSON.stringify(user)); + const style = document.createElement("style"); + style.textContent = ".tsqd-parent-container { display: none !important; }"; + document.documentElement.appendChild(style); + }, TEST_USER); + await page.route( + (url) => url.pathname.startsWith("/api/"), + async (route) => { + if (route.request().method() !== "GET") { + await fulfillJson(route, {}, 500); + return; + } + await fulfillJson(route, { + data: [], + items: [], + weekStart: "2026-08-17", + weekEnd: "2026-08-21", + counts: { returned: 0, total: 0 }, + unscheduled: [], + scheduled: [], + }); + }, + ); + + await page.goto("/workorders"); + await page.getByRole("button", { name: "New WO" }).click(); + await expect(page.getByRole("heading", { name: "Type & schedule" })).toBeVisible({ + timeout: 20_000, + }); + + await page.getByRole("button", { name: /Pick a date/ }).click(); + const dateTab = page.getByRole("button", { name: "Date", exact: true }); + if (await dateTab.isVisible()) { + await dateTab.click(); + } + await page.getByRole("button", { name: "20", exact: true }).click(); + + await page.getByRole("button", { name: /No due date/ }).click(); + await expect(page.getByRole("button", { name: "19", exact: true })).toBeDisabled(); + await expect(page.getByRole("button", { name: "20", exact: true })).toBeEnabled(); + await page.getByRole("button", { name: "21", exact: true }).click(); + await expect(page.getByRole("button", { name: /No due date/ })).toHaveCount(0); + + await page.getByRole("button", { name: /Aug 20/ }).click(); + const dateTabAgain = page.getByRole("button", { name: "Date", exact: true }); + if (await dateTabAgain.isVisible()) { + await dateTabAgain.click(); + } + await page.getByRole("button", { name: "25", exact: true }).click(); + + await expect(page.getByRole("button", { name: /No due date/ })).toBeVisible(); + }); +}); From 49be7ffbc6116667ec608405b2d6e7c1d31e8729 Mon Sep 17 00:00:00 2001 From: Codex Review Integration Date: Wed, 16 Sep 2026 13:59:22 -0300 Subject: [PATCH 3/4] test(work-orders): avoid unsupported array at helper --- .../workorders/due-date-schedule-floor.test.tsx | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/src/test/app/(protected)/workorders/due-date-schedule-floor.test.tsx b/src/test/app/(protected)/workorders/due-date-schedule-floor.test.tsx index 32c1092e..e6c41d50 100644 --- a/src/test/app/(protected)/workorders/due-date-schedule-floor.test.tsx +++ b/src/test/app/(protected)/workorders/due-date-schedule-floor.test.tsx @@ -107,14 +107,16 @@ describe("Due Date cannot be before Schedule On (SH-356)", () => { />, ); - fireEvent.click(within(rowByLabel("Due Date").parentElement!).getAllByRole("button").at(-1)!); + const dueDateButtons = within(rowByLabel("Due Date").parentElement!).getAllByRole("button"); + fireEvent.click(dueDateButtons[dueDateButtons.length - 1]); expect(await screen.findByRole("button", { name: "9" })).toBeDisabled(); fireEvent.keyDown(document.activeElement ?? document.body, { key: "Escape" }); const scheduleBlock = rowByLabel("Schedule On").parentElement!; - fireEvent.click(within(scheduleBlock).getAllByRole("button").at(-1)!); + const scheduleButtons = within(scheduleBlock).getAllByRole("button"); + fireEvent.click(scheduleButtons[scheduleButtons.length - 1]); const calendars = await screen.findAllByRole("button", { name: "15" }); - fireEvent.click(calendars.at(-1)!); + fireEvent.click(calendars[calendars.length - 1]); expect(onPatch).toHaveBeenCalledWith( expect.objectContaining({ scheduledOn: day(15), dueDate: "" }), From 96a066e98fda98eb92890fcbcea31fe22e1ea34e Mon Sep 17 00:00:00 2001 From: Codex Review Integration Date: Wed, 16 Sep 2026 14:30:39 -0300 Subject: [PATCH 4/4] fix(workorders): satisfy SH-356 maintainability gate --- .../list/table/use-wo-table-row-handlers.ts | 54 ++++++++++++------- 1 file changed, 35 insertions(+), 19 deletions(-) diff --git a/src/app/(protected)/workorders/_components/list/table/use-wo-table-row-handlers.ts b/src/app/(protected)/workorders/_components/list/table/use-wo-table-row-handlers.ts index a8fc4673..acea0710 100644 --- a/src/app/(protected)/workorders/_components/list/table/use-wo-table-row-handlers.ts +++ b/src/app/(protected)/workorders/_components/list/table/use-wo-table-row-handlers.ts @@ -32,6 +32,35 @@ function revealOrNotifyScheduledDay(args: { revealScheduledRow(id); } +type DragHandlersArgs = Pick< + ReturnTypeOfUseWorkOrdersTableData, + "dragState" | "setDragViz" | "reorderRows" | "endDrag" +>; + +function createDragHandlers({ dragState, setDragViz, reorderRows, endDrag }: DragHandlersArgs) { + return { + onDragStart: (id: string | number) => { + dragState.current = { dragId: id, overId: null }; + setDragViz({ dragId: id, overId: null }); + }, + onDragOver: (id: string | number) => { + const did = dragState.current.dragId; + if (did && did !== id && dragState.current.overId !== id) { + dragState.current.overId = id; + setDragViz((v) => ({ ...v, overId: id })); + } + }, + onDrop: () => { + const { dragId, overId } = dragState.current; + if (dragId && overId) { + reorderRows(dragId, overId); + } + endDrag(); + }, + onDragEnd: endDrag, + }; +} + type UseWoTableRowHandlersOptions = { tableData: ReturnTypeOfUseWorkOrdersTableData; weekMonday: string; @@ -161,25 +190,12 @@ export function useWoTableRowHandlers({ onRequestComplete, onPastDueAttempt: (id: string | number) => onScheduleHighlight(id), onReorder: reorderRows, - onDragStart: (id: string | number) => { - dragStateRef.current = { dragId: id, overId: null }; - setDragViz({ dragId: id, overId: null }); - }, - onDragOver: (id: string | number) => { - const did = dragStateRef.current.dragId; - if (did && did !== id && dragStateRef.current.overId !== id) { - dragStateRef.current.overId = id; - setDragViz((v) => ({ ...v, overId: id })); - } - }, - onDrop: () => { - const { dragId, overId } = dragStateRef.current; - if (dragId && overId) { - reorderRows(dragId, overId); - } - endDrag(); - }, - onDragEnd: endDrag, + ...createDragHandlers({ + dragState: dragStateRef, + setDragViz, + reorderRows, + endDrag, + }), }), [ tableData,