From f48342f268d83d233b1b03c00f38367c7118f20d Mon Sep 17 00:00:00 2001 From: Codex Review Integration Date: Wed, 16 Sep 2026 11:01:06 -0300 Subject: [PATCH 01/13] test(work-orders): guard SH-235 severity tooltip and escalation toast rules --- .../workorders/save-existing-row-type.test.ts | 10 ++++ .../workorders/severity-badge.test.tsx | 47 +++++++++++++++++++ .../utils/severity-escalation.test.ts | 15 ++++++ 3 files changed, 72 insertions(+) create mode 100644 src/test/app/(protected)/workorders/severity-badge.test.tsx diff --git a/src/test/app/(protected)/workorders/save-existing-row-type.test.ts b/src/test/app/(protected)/workorders/save-existing-row-type.test.ts index 0ca3f3c9..86b6020c 100644 --- a/src/test/app/(protected)/workorders/save-existing-row-type.test.ts +++ b/src/test/app/(protected)/workorders/save-existing-row-type.test.ts @@ -42,6 +42,16 @@ describe("saveExistingRowType", () => { expect(onPatch.mock.calls[0]![2]).toBeUndefined(); }); + it("does not warn when moving between SEV 3–5", () => { + const onPatch = vi.fn(); + + saveExistingRowType(onPatch, { ...row, severity: 5 }, "Reactive", 3); + + expect(toastWarn).not.toHaveBeenCalled(); + expect(onPatch).toHaveBeenCalledWith(7, { type: "Reactive", severity: 3 }); + expect(onPatch.mock.calls[0]![2]).toBeUndefined(); + }); + it("does not warn when clearing severity on a PM correction", () => { const onPatch = vi.fn(); diff --git a/src/test/app/(protected)/workorders/severity-badge.test.tsx b/src/test/app/(protected)/workorders/severity-badge.test.tsx new file mode 100644 index 00000000..2beb69ef --- /dev/null +++ b/src/test/app/(protected)/workorders/severity-badge.test.tsx @@ -0,0 +1,47 @@ +import { render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { describe, expect, it, vi } from "vitest"; +import { SeverityBadge } from "@/app/(protected)/workorders/_components/list/table/cells/severity-badge"; + +describe("SeverityBadge", () => { + it("shows description, Accept/Respond deadline, and after-hours on hover", async () => { + const user = userEvent.setup(); + render(); + + await user.hover(screen.getByText("SEV 1")); + + const tooltip = await screen.findByRole("tooltip"); + expect(tooltip).toHaveTextContent( + "Business Critical Function Down · Accept: 15 min · Respond: 2 hrs · After hours: Yes", + ); + expect(tooltip).not.toHaveTextContent(/Quote|Tech Notes|Invoice|Turnaround/i); + + await user.unhover(screen.getByText("SEV 1")); + await waitFor(() => expect(screen.queryByRole("tooltip")).not.toBeInTheDocument()); + }); + + it("transcribes the SEV table row for the given severity", async () => { + const user = userEvent.setup(); + render(); + + await user.hover(screen.getByText("SEV 3")); + + expect(await screen.findByRole("tooltip")).toHaveTextContent( + "Group Productivity Impaired · Accept: 2 hrs · Respond: 8 hrs (next day if after hours) · After hours: Approval required", + ); + }); + + it("is a button only when it can reopen the severity control", async () => { + const onClick = vi.fn(); + const user = userEvent.setup(); + const { rerender } = render(); + expect(screen.queryByRole("button")).not.toBeInTheDocument(); + + rerender(); + const badge = screen.getByRole("button"); + expect(badge).toHaveTextContent("SEV 2"); + await user.click(badge); + + expect(onClick).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/test/domain/work-orders/utils/severity-escalation.test.ts b/src/test/domain/work-orders/utils/severity-escalation.test.ts index 812e40f9..22c2cc9a 100644 --- a/src/test/domain/work-orders/utils/severity-escalation.test.ts +++ b/src/test/domain/work-orders/utils/severity-escalation.test.ts @@ -18,6 +18,21 @@ describe("shouldAnnounceSeverityEscalation", () => { expect(shouldAnnounceSeverityEscalation(2, 3)).toBe(false); }); + it("announces when severity rises from SEV 3–5 into SEV 2", () => { + expect(shouldAnnounceSeverityEscalation(5, 2)).toBe(true); + expect(shouldAnnounceSeverityEscalation(4, 2)).toBe(true); + }); + + it("does not announce moves between SEV 3–5", () => { + expect(shouldAnnounceSeverityEscalation(5, 3)).toBe(false); + expect(shouldAnnounceSeverityEscalation(4, 3)).toBe(false); + expect(shouldAnnounceSeverityEscalation(3, 5)).toBe(false); + }); + + it("does not announce SEV 1 to SEV 2 (already critical)", () => { + expect(shouldAnnounceSeverityEscalation(1, 2)).toBe(false); + }); + it("does not announce SEV 2 to SEV 1 (already critical)", () => { expect(shouldAnnounceSeverityEscalation(2, 1)).toBe(false); }); From bd69350a7ce0f2ff4558aa24e2c3927f60c56003 Mon Sep 17 00:00:00 2001 From: Codex Review Integration Date: Wed, 16 Sep 2026 11:13:17 -0300 Subject: [PATCH 02/13] feat(work-orders): render inline create cells as fields with required asterisks Wrap every inline creation cell in the bordered field box used by Site and WO #, top-aligned like the prototype inline row. Show a persistent asterisk on the empty WO # placeholder, the Site label in the create site dialog, and the POC name and phone inputs in the create site dialog and wizard. --- .../table/cells/editable-wo-number-cell.tsx | 27 +++- .../table/cells/site-dialog-poc-section.tsx | 3 + .../list/table/cells/site-dialog.tsx | 3 +- .../table/wo-table-inline-identity-cells.tsx | 1 + .../table/wo-table-inline-row-actions.tsx | 2 +- .../list/table/wo-table-inline-row-cells.tsx | 54 +++++--- .../list/table/wo-table-inline-row.tsx | 20 +-- .../_components/poc/poc-contact-fields.tsx | 17 +++ .../wizard-step-location-poc-fields.tsx | 1 + ...-table-inline-row-required-fields.test.tsx | 121 ++++++++++++++++++ 10 files changed, 216 insertions(+), 33 deletions(-) create mode 100644 src/test/app/(protected)/workorders/wo-table-inline-row-required-fields.test.tsx 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 369c1101..39744598 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 @@ -22,6 +22,8 @@ type EditableWONumberCellProps = { onSave: (v: string) => void; onDuplicateFound?: (existing: WorkOrderTableRow) => void; locked?: boolean; + /** Empty-state label for create flows; renders with a persistent required asterisk. */ + requiredPlaceholder?: string; }; async function resolveDuplicateWoNumber( @@ -34,6 +36,28 @@ async function resolveDuplicateWoNumber( return remote && String(remote.id) !== String(currentId) ? remote : undefined; } +function EmptyWoNumberLabel({ + value, + q, + requiredPlaceholder, +}: { + value: string; + q?: string; + requiredPlaceholder?: string; +}) { + if (value) { + return ; + } + if (!requiredPlaceholder) { + return "-"; + } + return ( + + {requiredPlaceholder} * + + ); +} + export function EditableWONumberCell({ value, rescheduleCount, @@ -49,6 +73,7 @@ export function EditableWONumberCell({ onSave, onDuplicateFound, locked = false, + requiredPlaceholder, }: EditableWONumberCellProps) { const [editing, setEditing] = useState(false); const [draft, setDraft] = useState(value); @@ -162,7 +187,7 @@ export function EditableWONumberCell({ padding: "2px 4px", }} > - {value ? : "-"} + )} diff --git a/src/app/(protected)/workorders/_components/list/table/cells/site-dialog-poc-section.tsx b/src/app/(protected)/workorders/_components/list/table/cells/site-dialog-poc-section.tsx index 48ecbec6..bdccff61 100644 --- a/src/app/(protected)/workorders/_components/list/table/cells/site-dialog-poc-section.tsx +++ b/src/app/(protected)/workorders/_components/list/table/cells/site-dialog-poc-section.tsx @@ -7,6 +7,7 @@ type SiteDialogPocSectionProps = { pocNotes: string; additionalContacts: WorkOrderAdditionalContact[]; showPrimaryErrors?: boolean; + primaryRequired?: boolean; onPocNameChange: (value: string) => void; onPocPhoneChange: (value: string) => void; onPocNotesChange: (value: string) => void; @@ -19,6 +20,7 @@ export function SiteDialogPocSection({ pocNotes, additionalContacts, showPrimaryErrors = false, + primaryRequired = false, onPocNameChange, onPocPhoneChange, onPocNotesChange, @@ -31,6 +33,7 @@ export function SiteDialogPocSection({ pocNotes={pocNotes} additionalContacts={additionalContacts} showPrimaryErrors={showPrimaryErrors} + primaryRequired={primaryRequired} onPocNameChange={onPocNameChange} onPocPhoneChange={onPocPhoneChange} onPocNotesChange={onPocNotesChange} diff --git a/src/app/(protected)/workorders/_components/list/table/cells/site-dialog.tsx b/src/app/(protected)/workorders/_components/list/table/cells/site-dialog.tsx index 239c883e..06c5e6bb 100644 --- a/src/app/(protected)/workorders/_components/list/table/cells/site-dialog.tsx +++ b/src/app/(protected)/workorders/_components/list/table/cells/site-dialog.tsx @@ -86,7 +86,7 @@ export function SiteDialog({
- Site + Site onPatch({ woNumber: v })} onDuplicateFound={onDuplicateFound} + requiredPlaceholder="Insert WO #" /> +
- + ); } 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 07/13] 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 08/13] 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 09/13] 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, From 8774b2e0a668c63179c9cda07f721b1913a18a71 Mon Sep 17 00:00:00 2001 From: Codex Review Integration Date: Wed, 16 Sep 2026 15:23:24 -0300 Subject: [PATCH 10/13] fix(work-orders): render PM extra media in the gallery (SH-371) Co-Authored-By: Claude Opus 5 (1M context) --- .../detail/tabs/slide-over-completion-tab.tsx | 8 +-- .../detail/tabs/slide-over-extras-tab.tsx | 15 +++++- .../detail/use-slide-over-media-state.ts | 10 ++-- .../utils/partition-slide-over-media.ts | 10 ++++ .../slide-over-extras-aveta.test.tsx | 36 ++++++++++++++ .../use-slide-over-media-state.test.tsx | 49 +++++++++++++++++++ .../utils/partition-slide-over-media.test.ts | 30 ++++++++++++ 7 files changed, 147 insertions(+), 11 deletions(-) diff --git a/src/app/(protected)/workorders/_components/detail/tabs/slide-over-completion-tab.tsx b/src/app/(protected)/workorders/_components/detail/tabs/slide-over-completion-tab.tsx index 290ec603..c44465b3 100644 --- a/src/app/(protected)/workorders/_components/detail/tabs/slide-over-completion-tab.tsx +++ b/src/app/(protected)/workorders/_components/detail/tabs/slide-over-completion-tab.tsx @@ -6,7 +6,7 @@ import type { UploadableMediaCategory } from "@/domain/work-orders/mappers/work- import type { WorkOrderBoardCompletion } from "@/domain/work-orders/types/work-order-board-detail"; import type { MediaFile } from "@/domain/work-orders/types/work-order-media"; import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row"; -import type { WOType } from "@/domain/work-orders/types/work-order-wizard"; +import { hasCompletionMediaGallery } from "@/domain/work-orders/utils/partition-slide-over-media"; import { formatMediaSizeLimit, isWorkOrderFileTooLarge, @@ -16,10 +16,6 @@ import { SlideOverCompletionMedia } from "./slide-over-completion-media"; import { SlideOverCompletionReadonlyStatus } from "./slide-over-completion-readonly-status"; import { SlideOverCompletionSignoffs } from "./slide-over-completion-signoffs"; -function isMediaWO(type: WOType | ""): boolean { - return type === "Emergency" || type === "Reactive"; -} - type SlideOverCompletionTabProps = { row: WorkOrderTableRow; isReadOnly: boolean; @@ -56,7 +52,7 @@ export function SlideOverCompletionTab({ const templateUrl = completion?.template?.templateUrl; const signOffAttachment = completion?.signOffAttachment; - if (isMediaWO(row.type)) { + if (hasCompletionMediaGallery(row.type)) { return ( = { other: Paperclip, }; +const FILETYPE_LABEL: Record = { + image: "Photo", + video: "Video", + pdf: "PDF", + doc: "Document", + other: "File", +}; + const EXTRA_DOC_PURPOSES: Array> = [ "Extra", "Aveta", @@ -92,8 +100,13 @@ function ExtraDocRow({ const purpose = file.purpose === "Aveta" || isAveta ? "Aveta" : "Extra"; return ( -
+
(() => loadOwnership(workOrderId), @@ -116,16 +118,16 @@ export function useSlideOverMediaState({ useMediaUploadSession(detailForRow, persistCompletionUpload); const completionMedia = useMemo( - () => uploads.filter((item) => isCompletionSurfaceMedia(item, ownership)), - [uploads, ownership], + () => uploads.filter((item) => isCompletionSurfaceMedia(item, ownership, completionHasGallery)), + [uploads, ownership, completionHasGallery], ); const extrasUploads = useMemo( () => boardMediaItemsToMediaFiles(detailForRow?.media ?? []).filter((item) => - isExtrasSurfaceMedia(item, ownership), + isExtrasSurfaceMedia(item, ownership, completionHasGallery), ), - [detailForRow?.media, ownership], + [detailForRow?.media, ownership, completionHasGallery], ); const hasLocalUploading = uploads.some((item) => item.uploadStatus === "uploading"); diff --git a/src/domain/work-orders/utils/partition-slide-over-media.ts b/src/domain/work-orders/utils/partition-slide-over-media.ts index 5de1e4ad..7092c696 100644 --- a/src/domain/work-orders/utils/partition-slide-over-media.ts +++ b/src/domain/work-orders/utils/partition-slide-over-media.ts @@ -12,6 +12,11 @@ const EMPTY_OWNERSHIP: MediaSurfaceOwnership = { extraOwnedIds: new Set(), }; +/** Only Reactive and Emergency work orders render a media gallery on the Completion tab. */ +export function hasCompletionMediaGallery(type: string | null | undefined): boolean { + return type === "Emergency" || type === "Reactive"; +} + function isPhotoOrVideo(item: MediaFile): boolean { return item.fileType === "image" || item.fileType === "video"; } @@ -24,20 +29,25 @@ function isInFlight(item: MediaFile): boolean { export function isCompletionSurfaceMedia( item: MediaFile, ownership: MediaSurfaceOwnership = EMPTY_OWNERSHIP, + completionHasGallery = true, ): boolean { if (isInFlight(item)) return true; if (COMPLETION_PURPOSES.has(item.purpose ?? "")) return true; + if (!completionHasGallery && item.purpose === "Extra") return false; if (ownership.completionOwnedIds.has(item.id)) return true; if (ownership.extraOwnedIds.has(item.id)) return false; return item.purpose === "Extra" && isPhotoOrVideo(item); } +/** Without a Completion gallery, every Extra item (including photos/videos) belongs to Extras. */ export function isExtrasSurfaceMedia( item: MediaFile, ownership: MediaSurfaceOwnership = EMPTY_OWNERSHIP, + completionHasGallery = true, ): boolean { if (item.purpose === "Aveta") return true; if (item.purpose !== "Extra") return false; + if (!completionHasGallery) return true; if (ownership.completionOwnedIds.has(item.id)) return false; if (ownership.extraOwnedIds.has(item.id)) return true; return !isPhotoOrVideo(item); diff --git a/src/test/app/(protected)/workorders/slide-over-extras-aveta.test.tsx b/src/test/app/(protected)/workorders/slide-over-extras-aveta.test.tsx index f788e250..ba674966 100644 --- a/src/test/app/(protected)/workorders/slide-over-extras-aveta.test.tsx +++ b/src/test/app/(protected)/workorders/slide-over-extras-aveta.test.tsx @@ -161,4 +161,40 @@ describe("SlideOverExtrasTab Aveta", () => { expect(screen.getByRole("button", { name: "safety-form.pdf" })).toBeDisabled(); }); + + it("labels each extra item with its type and category", () => { + const sitePhoto: MediaFile = { + id: "m3", + name: "site.jpg", + fileType: "image", + url: "blob:3", + category: "Photo", + purpose: "Extra", + }; + const walkthrough: MediaFile = { + id: "m4", + name: "walkthrough.mp4", + fileType: "video", + url: "blob:4", + category: "Video", + purpose: "Extra", + }; + + renderWithProviders( + , + ); + + expect(screen.getByRole("group", { name: "Photo, Extra: site.jpg" })).toBeInTheDocument(); + expect( + screen.getByRole("group", { name: "Video, Extra: walkthrough.mp4" }), + ).toBeInTheDocument(); + expect( + screen.getByRole("group", { name: "PDF, Aveta: aveta-clearance.pdf" }), + ).toBeInTheDocument(); + }); }); diff --git a/src/test/app/(protected)/workorders/use-slide-over-media-state.test.tsx b/src/test/app/(protected)/workorders/use-slide-over-media-state.test.tsx index 87a74de5..dab9e3bd 100644 --- a/src/test/app/(protected)/workorders/use-slide-over-media-state.test.tsx +++ b/src/test/app/(protected)/workorders/use-slide-over-media-state.test.tsx @@ -278,4 +278,53 @@ describe("useSlideOverMediaState completion filter", () => { expect(remounted.result.current.completionMedia).toHaveLength(0); expect(remounted.result.current.extrasUploads.map((item) => item.id)).toEqual(["10"]); }); + + it("shows Extra photos and videos in Extra Docs on PM work orders", () => { + const detail = detailWithMedia( + [ + { + id: "10", + category: "Extra", + url: "https://cdn.example.com/site.jpg", + fileName: "site.jpg", + uploadedAt: "2026-07-15T18:00:00.0000000Z", + isLegacy: false, + }, + { + id: "11", + category: "Extra", + url: "https://cdn.example.com/walkthrough.mp4", + fileName: "walkthrough.mp4", + uploadedAt: "2026-07-15T18:00:00.0000000Z", + isLegacy: false, + }, + { + id: "4", + category: "Extra", + url: "https://cdn.example.com/extra.pdf", + fileName: "extra.pdf", + uploadedAt: "2026-07-15T18:00:00.0000000Z", + isLegacy: false, + }, + ], + { id: 200, type: "PM" }, + ); + + const { result } = renderHook(() => + useSlideOverMediaState({ + detailForRow: detail, + uploadMediaMutation: idleMutation(), + deleteMediaMutation: idleMutation(), + categorizeMediaMutation: idleMutation(), + }), + ); + + expect(result.current.completionMedia).toHaveLength(0); + expect(result.current.extrasUploads.map((item) => item.id)).toEqual(["10", "11", "4"]); + expect(result.current.extrasUploads.map((item) => item.fileType)).toEqual([ + "image", + "video", + "pdf", + ]); + }); }); diff --git a/src/test/domain/work-orders/utils/partition-slide-over-media.test.ts b/src/test/domain/work-orders/utils/partition-slide-over-media.test.ts index 077eff57..1970db61 100644 --- a/src/test/domain/work-orders/utils/partition-slide-over-media.test.ts +++ b/src/test/domain/work-orders/utils/partition-slide-over-media.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from "vitest"; import type { MediaFile } from "@/domain/work-orders/types/work-order-media"; import { + hasCompletionMediaGallery, isCompletionSurfaceMedia, isExtrasSurfaceMedia, } from "@/domain/work-orders/utils/partition-slide-over-media"; @@ -76,4 +77,33 @@ describe("partition-slide-over-media", () => { }), ).toBe(true); }); + + it("renders a Completion gallery only for Reactive and Emergency work orders", () => { + expect(hasCompletionMediaGallery("Reactive")).toBe(true); + expect(hasCompletionMediaGallery("Emergency")).toBe(true); + expect(hasCompletionMediaGallery("PM")).toBe(false); + expect(hasCompletionMediaGallery("Overdue")).toBe(false); + expect(hasCompletionMediaGallery("")).toBe(false); + }); + + it("puts Extra photos and videos on Extra Docs when Completion has no gallery", () => { + const photo = file({ id: "10", fileType: "image", purpose: "Extra" }); + const video = file({ id: "11", fileType: "video", purpose: "Extra", category: "Video" }); + + for (const item of [photo, video]) { + expect(isCompletionSurfaceMedia(item, undefined, false)).toBe(false); + expect(isExtrasSurfaceMedia(item, undefined, false)).toBe(true); + } + }); + + it("ignores stale completion ownership when Completion has no gallery", () => { + const photo = file({ id: "10", fileType: "image", purpose: "Extra" }); + const ownership = { + completionOwnedIds: new Set(["10"]), + extraOwnedIds: new Set(), + }; + + expect(isCompletionSurfaceMedia(photo, ownership, false)).toBe(false); + expect(isExtrasSurfaceMedia(photo, ownership, false)).toBe(true); + }); }); From 2b2b24f2e33ed4e5e4e96f33597eecac92b92c6a Mon Sep 17 00:00:00 2001 From: Codex Review Integration Date: Wed, 16 Sep 2026 15:42:01 -0300 Subject: [PATCH 11/13] fix(work-orders): clarify search scope with advanced filters --- .../list/work-orders-list-header.tsx | 7 +++++-- .../_hooks/use-work-orders-board-source.ts | 2 +- src/app/(protected)/workorders/index.tsx | 1 + .../work-orders-search-scope.test.tsx | 19 +++++++++++++++++-- 4 files changed, 24 insertions(+), 5 deletions(-) diff --git a/src/app/(protected)/workorders/_components/list/work-orders-list-header.tsx b/src/app/(protected)/workorders/_components/list/work-orders-list-header.tsx index 247e3674..9bbad8fc 100644 --- a/src/app/(protected)/workorders/_components/list/work-orders-list-header.tsx +++ b/src/app/(protected)/workorders/_components/list/work-orders-list-header.tsx @@ -13,6 +13,8 @@ type WorkOrdersListHeaderProps = { /** Omit until wizard shell slice mounts New WO. */ onNewWorkOrder?: () => void; activeFilterCount: number; + /** True while Advanced Filters are applied; results are global and the keyword box is inert. */ + advancedFiltersActive?: boolean; isFetching?: boolean; }; @@ -23,6 +25,7 @@ export function WorkOrdersListHeader({ onOpenAdvancedFilters, onNewWorkOrder, activeFilterCount, + advancedFiltersActive = false, isFetching = false, }: WorkOrdersListHeaderProps) { return ( @@ -34,8 +37,8 @@ export function WorkOrdersListHeader({ placeholder="Search this week…" aria-label="Search work orders" /> - {/* SH-194: keyword search is scoped to the active week; make that scope visible. */} - {search.trim() !== "" && Boolean(weekLabel) && ( + {/* Keyword search is scoped to the active week; make that scope visible. */} + {!advancedFiltersActive && search.trim() !== "" && Boolean(weekLabel) && ( buildResolvedBoardSearchParams({ advApplied: filters.advApplied, - // SH-194: Advanced Filters are global by design; the week-scoped keyword box has no effect. + // Advanced Filters are global by design; the week-scoped keyword box has no effect. search: "", page: filters.page, pageSize: filters.pageSize, diff --git a/src/app/(protected)/workorders/index.tsx b/src/app/(protected)/workorders/index.tsx index 3e7d3856..e12e040d 100644 --- a/src/app/(protected)/workorders/index.tsx +++ b/src/app/(protected)/workorders/index.tsx @@ -48,6 +48,7 @@ export default function WorkOrdersListPage() { onOpenAdvancedFilters={() => filters.setAdvOpen(true)} onNewWorkOrder={() => page.setWizardOpen(true)} activeFilterCount={filters.activeFilterCount} + advancedFiltersActive={filters.advApplied != null} isFetching={tableData.isFetching && !tableData.isLoading} /> } diff --git a/src/test/app/(protected)/workorders/work-orders-search-scope.test.tsx b/src/test/app/(protected)/workorders/work-orders-search-scope.test.tsx index 5904821f..dbf95538 100644 --- a/src/test/app/(protected)/workorders/work-orders-search-scope.test.tsx +++ b/src/test/app/(protected)/workorders/work-orders-search-scope.test.tsx @@ -47,7 +47,7 @@ function renderBoardSource() { return renderHook(() => useBoardWithFilters(), { wrapper }); } -describe("SH-194 keyword search scope indicator", () => { +describe("keyword search scope indicator", () => { it("shows no scope badge while the search box is empty", () => { renderWithProviders( { expect(screen.getByText("Searching within 5/4–5/8")).toBeInTheDocument(); }); + + it("hides the scope label while Advanced Filters are active", () => { + renderWithProviders( + , + { withAuth: false }, + ); + + expect(screen.queryByText(/searching within/i)).not.toBeInTheDocument(); + }); }); -describe("SH-194 keyword search vs Advanced Filters scope", () => { +describe("keyword search vs Advanced Filters scope", () => { beforeEach(() => { searchBoard.mockReset().mockResolvedValue(EMPTY_RESULT); getBoard.mockReset().mockResolvedValue(EMPTY_RESULT); From 4e54aa7cfe2b8bf09e941ef644d736a1af58cc53 Mon Sep 17 00:00:00 2001 From: Codex Review Integration Date: Wed, 16 Sep 2026 16:56:26 -0300 Subject: [PATCH 12/13] chore(work-orders): remove ticket keys from completion docs --- .../workorders/_hooks/use-work-orders-list-page.ts | 2 +- .../work-orders/utils/completion-doc-auto-complete.ts | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/app/(protected)/workorders/_hooks/use-work-orders-list-page.ts b/src/app/(protected)/workorders/_hooks/use-work-orders-list-page.ts index 08de1f5e..182a98e5 100644 --- a/src/app/(protected)/workorders/_hooks/use-work-orders-list-page.ts +++ b/src/app/(protected)/workorders/_hooks/use-work-orders-list-page.ts @@ -118,7 +118,7 @@ export function useWorkOrdersListPage() { return; } try { - // SH-341: generation is the one automatic completion — no confirmation dialog. + // Generation is the one automatic completion — no confirmation dialog. await patchFieldAsync(row.id, { status: "Completed" }, saved.row); } catch { // The document is saved; the table mutation already surfaced the status error and diff --git a/src/domain/work-orders/utils/completion-doc-auto-complete.ts b/src/domain/work-orders/utils/completion-doc-auto-complete.ts index 2337c8bd..e27f0318 100644 --- a/src/domain/work-orders/utils/completion-doc-auto-complete.ts +++ b/src/domain/work-orders/utils/completion-doc-auto-complete.ts @@ -6,9 +6,9 @@ import { } from "@/domain/work-orders/utils/wo-closability"; /** - * SH-115 / SH-341: completion is manual with confirmation, except that generating the - * completion document completes the WO — but only when the SH-114 closability gate passes - * on the persisted row. Not-required (NN) and drafts never complete; Past Due rows must be + * Completion is manual with confirmation, except that generating the completion document + * completes the WO — but only when the closability gate passes on the persisted row. + * Not-required (NN) and drafts never complete; Past Due rows must be * rescheduled before any status change, so they are left for the dispatcher. */ export function shouldCompleteAfterCompletionDoc( From 04f8b51eab8750fb9c850fead635aa31f052b446 Mon Sep 17 00:00:00 2001 From: Codex Review Integration Date: Wed, 16 Sep 2026 19:17:28 -0300 Subject: [PATCH 13/13] refactor(work-orders): use Tailwind tokens for search scope label Swap the inline fontSize/color on the week-scope status label for the text-(length:--text-xs) and text-muted-foreground classes already used across the header, so it tracks future token changes. --text-xs is 11px and text-muted-foreground resolves to --muted-foreground, so rendering is unchanged. --- .../workorders/_components/list/work-orders-list-header.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/app/(protected)/workorders/_components/list/work-orders-list-header.tsx b/src/app/(protected)/workorders/_components/list/work-orders-list-header.tsx index 9bbad8fc..3f52ea0f 100644 --- a/src/app/(protected)/workorders/_components/list/work-orders-list-header.tsx +++ b/src/app/(protected)/workorders/_components/list/work-orders-list-header.tsx @@ -41,8 +41,8 @@ export function WorkOrdersListHeader({ {!advancedFiltersActive && search.trim() !== "" && Boolean(weekLabel) && ( Searching within {weekLabel}