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 1bc70fae..c5955f15 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 @@ -39,6 +39,9 @@ function SlideOverScheduleOnField({ draft: WorkOrderTableRow; onDraftChange: (patch: Partial) => void; }) { + const hasSchedule = + Boolean(draft.scheduledOn) || Boolean(draft.targetWeek) || Boolean(draft.scheduleEnd); + return ( + onDraftChange({ + scheduledOn: "", + scheduleWeekOnly: false, + targetWeek: "", + scheduleEnd: "", + schedMode: undefined, + isPastDue: false, + ...deriveDayFields(""), + }) + : undefined + } /> ); } 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 8c709385..12b5a42e 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 @@ -1,5 +1,5 @@ import { useState } from "react"; -import { ChevronDown } from "lucide-react"; +import { ChevronDown, X } from "lucide-react"; import { Popover } from "@mui/material"; import { DateCellValue } from "@/app/(protected)/workorders/_components/list/table/cells/date-cell-value"; import { MiniCalendar } from "@/app/(protected)/workorders/_components/wizard/mini-calendar"; @@ -111,6 +111,22 @@ export function DateCell({ onClick={(e) => e.stopPropagation()} slotProps={{ paper: { className: "rounded-lg border border-border p-3" } }} > + {Boolean(onClear) && ( +
+ +
+ )} )} - {Boolean(onClear) && ( -
- -
- )} ); diff --git a/src/app/(protected)/workorders/_components/list/table/reschedule-notify.ts b/src/app/(protected)/workorders/_components/list/table/reschedule-notify.ts index 8953af82..1391cf32 100644 --- a/src/app/(protected)/workorders/_components/list/table/reschedule-notify.ts +++ b/src/app/(protected)/workorders/_components/list/table/reschedule-notify.ts @@ -18,11 +18,15 @@ type NotifyNavigate = { onNavigate: () => void; }; -function showRescheduleToast(message: string, onNavigate: () => void) { +function showRescheduleToast( + message: string, + onNavigate: () => void, + linkLabel = "Click to view it.", +) { const toastId = toast( createElement(RescheduleToast, { message, - linkLabel: "Click to view it.", + linkLabel, onNavigate: () => { toast.dismiss(toastId); onNavigate(); @@ -35,6 +39,13 @@ function showRescheduleToast(message: string, onNavigate: () => void) { } export function weekMoveNotify(weekStart: string, { onNavigate }: NotifyNavigate) { + if (!weekStart) { + return showRescheduleToast( + "Work order moved to Unscheduled.", + onNavigate, + "View in Advanced Filters.", + ); + } return showRescheduleToast( `Work order moved to Unscheduled · ${fmtWeekOf(weekStart)}.`, onNavigate, 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 20172baa..ae33d713 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 @@ -122,18 +122,26 @@ export function useWoTableRowHandlers({ }); }, onScheduleWeek: (id: string | number, week: string) => { - onPatch(id, { - scheduledOn: "", - targetWeek: week, - scheduleWeekOnly: true, - scheduleEnd: "", - schedMode: "week", - dayGroup: "", - dayLabel: "", - }); + const isClear = week === ""; + onPatch( + id, + isClear + ? applySchedulePatch("", false) + : { + scheduledOn: "", + targetWeek: week, + scheduleWeekOnly: true, + scheduleEnd: "", + schedMode: "week", + dayGroup: "", + dayLabel: "", + }, + ); weekMoveNotify(week, { onNavigate: () => { - onNavigateToDay(week); + if (!isClear) { + onNavigateToDay(week); + } onOpenAdvancedFilters?.(); }, }); 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 90b16533..01fea44a 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 @@ -43,6 +43,9 @@ export function WoTableRowCells({ handlers, onPatchRow, }: WoTableRowCellsProps) { + const hasSchedule = + Boolean(row.scheduledOn) || Boolean(row.targetWeek) || Boolean(row.scheduleEnd); + return ( <> handlers.onScheduleDate(row.id, date)} onSaveWeek={(week) => handlers.onScheduleWeek(row.id, week)} onSaveRange={(start, end) => handlers.onScheduleRange(row.id, start, end)} + onClear={hasSchedule && !viewOnly ? () => handlers.onScheduleWeek(row.id, "") : undefined} /> { expect(onSave).not.toHaveBeenCalled(); expect(onSaveRange).not.toHaveBeenCalled(); }); + + it("shows Clear above the mode tabs and closes after clearing", async () => { + const onClear = vi.fn(); + renderWithProviders( + , + ); + + fireEvent.click(screen.getByRole("button", { name: /09-09/ })); + + const clear = await screen.findByRole("button", { name: "Clear" }); + const week = screen.getByRole("button", { name: "Week" }); + expect(clear.compareDocumentPosition(week) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy(); + + fireEvent.click(clear); + expect(onClear).toHaveBeenCalledOnce(); + expect(screen.queryByRole("button", { name: "Clear" })).not.toBeInTheDocument(); + }); }); describe("DueDateCell — no Schedule On range tabs", () => { diff --git a/src/test/app/(protected)/workorders/reschedule-notify.test.ts b/src/test/app/(protected)/workorders/reschedule-notify.test.ts new file mode 100644 index 00000000..241d40e2 --- /dev/null +++ b/src/test/app/(protected)/workorders/reschedule-notify.test.ts @@ -0,0 +1,33 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { weekMoveNotify } from "@/app/(protected)/workorders/_components/list/table/reschedule-notify"; + +const { toast } = vi.hoisted(() => { + const notify = Object.assign( + vi.fn((_content: unknown, _options?: unknown) => 1), + { + dismiss: vi.fn(), + }, + ); + return { toast: notify }; +}); + +vi.mock("react-toastify", () => ({ toast })); + +describe("weekMoveNotify", () => { + beforeEach(() => { + toast.mockClear(); + toast.dismiss.mockClear(); + }); + + it("uses the AAP Unscheduled message and Advanced Filters action when clearing", () => { + weekMoveNotify("", { onNavigate: vi.fn() }); + + const content = toast.mock.calls[0][0] as { + props: { message: string; linkLabel: string }; + }; + expect(content.props).toMatchObject({ + message: "Work order moved to Unscheduled.", + linkLabel: "View in Advanced Filters.", + }); + }); +}); diff --git a/src/test/app/(protected)/workorders/slide-over-schedule-range.test.tsx b/src/test/app/(protected)/workorders/slide-over-schedule-range.test.tsx index 2fb72672..0de9f938 100644 --- a/src/test/app/(protected)/workorders/slide-over-schedule-range.test.tsx +++ b/src/test/app/(protected)/workorders/slide-over-schedule-range.test.tsx @@ -121,4 +121,46 @@ describe("SlideOverInfoTabEditView — Schedule On range", () => { }), ); }, 10_000); + + it("clears every schedule field in the draft", async () => { + const onDraftChange = vi.fn(); + renderWithProviders( + , + ); + + const scheduleRow = screen.getByText("Schedule On").closest("div"); + fireEvent.click(scheduleRow!.querySelector("button")!); + fireEvent.click(await screen.findByRole("button", { name: /clear/i })); + + expect(onDraftChange).toHaveBeenCalledWith( + expect.objectContaining({ + scheduledOn: "", + scheduleWeekOnly: false, + targetWeek: "", + scheduleEnd: "", + schedMode: undefined, + dayGroup: "", + dayLabel: "", + isPastDue: false, + }), + ); + }); }); diff --git a/src/test/app/(protected)/workorders/use-wo-table-row-handlers-schedule.test.ts b/src/test/app/(protected)/workorders/use-wo-table-row-handlers-schedule.test.ts index c4cc0867..ff3f0312 100644 --- a/src/test/app/(protected)/workorders/use-wo-table-row-handlers-schedule.test.ts +++ b/src/test/app/(protected)/workorders/use-wo-table-row-handlers-schedule.test.ts @@ -27,7 +27,7 @@ function buildTableData(revealScheduledRow = vi.fn()) { } as never; } -describe("useWoTableRowHandlers schedule navigation (SH-240)", () => { +describe("useWoTableRowHandlers schedule navigation", () => { beforeEach(() => { rescheduleNotify.mockClear(); weekMoveNotify.mockClear(); @@ -151,4 +151,36 @@ describe("useWoTableRowHandlers schedule navigation (SH-240)", () => { expect(onNavigateToDay).toHaveBeenCalledWith("2026-05-25"); expect(onOpenAdvancedFilters).toHaveBeenCalled(); }); + + it("clears the concrete schedule and opens Advanced Filters from the Unscheduled toast", () => { + const onNavigateToDay = vi.fn(); + const onOpenAdvancedFilters = vi.fn(); + const onPatch = vi.fn(); + + const { result } = renderHook(() => + useWoTableRowHandlers({ + tableData: buildTableData(), + weekMonday: "2026-05-18", + onNavigateToDay, + onOpenAdvancedFilters, + onOpenSlideOver: vi.fn(), + onOpenMedia: vi.fn(), + onPatch, + onScheduleHighlight: vi.fn(), + }), + ); + + result.current.onScheduleWeek("wo-1", ""); + + expect(onPatch).toHaveBeenCalledWith("wo-1", applySchedulePatch("", false)); + expect(weekMoveNotify).toHaveBeenCalledWith( + "", + expect.objectContaining({ onNavigate: expect.any(Function) }), + ); + + const { onNavigate } = weekMoveNotify.mock.calls[0][1] as { onNavigate: () => void }; + onNavigate(); + expect(onNavigateToDay).not.toHaveBeenCalled(); + expect(onOpenAdvancedFilters).toHaveBeenCalled(); + }); }); 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 11c2d05d..4d70e893 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 @@ -111,4 +111,33 @@ describe("WizardStepTypeSchedule — Schedule On range selection", () => { expect(screen.getByRole("button", { name: /^done$/i })).toBeDisabled(); expect(onPatch).not.toHaveBeenCalled(); }); + + it("does not promote Incomplete to Scheduled for week-only Schedule On", async () => { + const onUpdate = vi.fn(); + const onPatch = vi.fn(); + + render( + , + ); + + fireEvent.click(screen.getByRole("button", { name: /pick a date/i })); + fireEvent.click(await screen.findByRole("button", { name: "Week" })); + const weekButtons = screen + .getAllByRole("button") + .filter((button) => /–/.test(button.textContent ?? "")); + fireEvent.click(weekButtons[0]); + + expect(onPatch).toHaveBeenCalledWith( + expect.objectContaining({ + scheduleWeekOnly: true, + schedMode: "week", + }), + ); + expect(onPatch.mock.calls.some(([patch]) => patch.status === "Scheduled")).toBe(false); + }); }); diff --git a/src/test/app/(protected)/workorders/wo-table-inline-status-schedule.test.tsx b/src/test/app/(protected)/workorders/wo-table-inline-status-schedule.test.tsx new file mode 100644 index 00000000..38e0014e --- /dev/null +++ b/src/test/app/(protected)/workorders/wo-table-inline-status-schedule.test.tsx @@ -0,0 +1,73 @@ +import { fireEvent, screen } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; +import { WoTableInlineRow } from "@/app/(protected)/workorders/_components/list/table/wo-table-inline-row"; +import { EMPTY_TABLE_DRAFT } from "@/domain/work-orders/types/work-order-table-row"; +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/date-cell", () => ({ + DateCell: () => null, +})); +vi.mock("@/app/(protected)/workorders/_components/list/table/cells/due-date-cell", () => ({ + DueDateCell: () => 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/appt-time-cell", () => ({ + ApptTimeCell: () => null, +})); + +describe("WoTableInlineRow StatusCell with a concrete schedule", () => { + it("keeps Scheduled when Incomplete is picked on a dated draft", () => { + const onChange = vi.fn(); + const draft = { + ...EMPTY_TABLE_DRAFT, + status: "Incomplete" as const, + scheduledOn: "2026-06-01", + scheduleWeekOnly: false, + }; + + renderWithProviders( + + + + +
, + { withAuth: false }, + ); + + fireEvent.click(screen.getByRole("button", { name: /incomplete/i })); + fireEvent.click(screen.getByRole("button", { name: /^pending$/i })); + + expect(onChange).toHaveBeenCalledWith(expect.objectContaining({ status: "Scheduled" })); + }); +}); diff --git a/src/test/app/(protected)/workorders/wo-table-row-schedule-clear.test.tsx b/src/test/app/(protected)/workorders/wo-table-row-schedule-clear.test.tsx new file mode 100644 index 00000000..33dd7d92 --- /dev/null +++ b/src/test/app/(protected)/workorders/wo-table-row-schedule-clear.test.tsx @@ -0,0 +1,119 @@ +import { fireEvent, screen } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; +import { WoTableRowCells } from "@/app/(protected)/workorders/_components/list/table/wo-table-row-cells"; +import type { WoTableRowHandlers } from "@/app/(protected)/workorders/_components/list/table/wo-table-row"; +import type { + WorkOrderTablePatch, + WorkOrderTableRow, +} from "@/domain/work-orders/types/work-order-table-row"; +import { applySchedulePatch } from "@/domain/work-orders/use-cases/use-work-order-table-mutations"; +import { mergeAutoSchedulePatch } from "@/domain/work-orders/utils/wo-lifecycle-transitions"; +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/date-cell", () => ({ + DateCell: ({ onClear }: { onClear?: () => void }) => { + if (!onClear) return null; + return ( + + ); + }, +})); +vi.mock("@/app/(protected)/workorders/_components/list/table/cells/due-date-cell", () => ({ + DueDateCell: () => null, +})); + +function renderScheduledRow( + row: WorkOrderTableRow, + onPersist: (id: string | number, patch: WorkOrderTablePatch) => void, + viewOnly = false, +) { + const handlers = { + onScheduleWeek: (id: string | number, week: string) => { + onPersist(id, mergeAutoSchedulePatch(row, applySchedulePatch(week, false))); + }, + } as unknown as WoTableRowHandlers; + + renderWithProviders( + + + + + + +
, + { withAuth: false }, + ); +} + +describe("WoTableRowCells Schedule On clear", () => { + it.each([ + { scheduledOn: "2026-09-09", targetWeek: "", scheduleEnd: "" }, + { scheduledOn: "", targetWeek: "2026-09-07", scheduleEnd: "" }, + { scheduledOn: "2026-09-09", targetWeek: "", scheduleEnd: "2026-09-11" }, + ])("clears a persisted schedule and derives Pending from Scheduled", (schedule) => { + const onPersist = vi.fn(); + const row = { + id: 42, + status: "Scheduled", + scheduleWeekOnly: Boolean(schedule.targetWeek), + schedMode: schedule.targetWeek ? "week" : schedule.scheduleEnd ? "range" : "date", + ...schedule, + } as WorkOrderTableRow; + + renderScheduledRow(row, onPersist); + fireEvent.click(screen.getByRole("button", { name: "clear schedule" })); + + expect(onPersist).toHaveBeenCalledWith( + 42, + expect.objectContaining({ + scheduledOn: "", + scheduleWeekOnly: false, + targetWeek: "", + scheduleEnd: "", + schedMode: undefined, + status: "Pending", + }), + ); + }); + + it.each(["Completed", "Canceled"])("does not expose Clear for a %s row", (status) => { + const row = { + id: 42, + status, + scheduledOn: "2026-09-09", + targetWeek: "", + scheduleEnd: "", + scheduleWeekOnly: false, + schedMode: "date", + } as WorkOrderTableRow; + + renderScheduledRow(row, vi.fn(), true); + + expect(screen.queryByRole("button", { name: "clear schedule" })).not.toBeInTheDocument(); + }); +}); diff --git a/src/test/domain/work-orders/mappers/work-order-board-mapper.test.ts b/src/test/domain/work-orders/mappers/work-order-board-mapper.test.ts index 26991511..e5d0cd0e 100644 --- a/src/test/domain/work-orders/mappers/work-order-board-mapper.test.ts +++ b/src/test/domain/work-orders/mappers/work-order-board-mapper.test.ts @@ -7,7 +7,10 @@ import { resolveSiteCode, } from "@/domain/work-orders/mappers/work-order-board-mapper"; -import { WORK_ORDER_TYPE_API } from "@/domain/work-orders/types/work-order-board"; +import { + LIFECYCLE_STATUS_API, + WORK_ORDER_TYPE_API, +} from "@/domain/work-orders/types/work-order-board"; import { EMPTY_WIZARD_DRAFT } from "@/domain/work-orders/types/work-order-wizard"; @@ -262,6 +265,22 @@ describe("mapWizardDraftToBoardCreatePayload", () => { expect(payload.scheduledDate).toBeUndefined(); }); + it("keeps Incomplete on week-only create and omits scheduledDate", () => { + const payload = mapWizardDraftToBoardCreatePayload({ + ...EMPTY_WIZARD_DRAFT, + type: "PM", + siteCode: "BK5", + status: "Incomplete", + scheduleWeekOnly: true, + targetWeek: "2026-06-22", + }); + + expect(payload.lifecycleStatus).toBe(LIFECYCLE_STATUS_API.Incomplete); + expect(payload.scheduleWeekOnly).toBe(true); + expect(payload.targetWeek).toBe("2026-06-22"); + expect(payload.scheduledDate).toBeUndefined(); + }); + it("prefers explicit targetWeek when scheduleWeekOnly and scheduledOn is empty", () => { const payload = mapWizardDraftToBoardCreatePayload({ ...EMPTY_WIZARD_DRAFT, diff --git a/src/test/domain/work-orders/utils/slide-over-draft.test.ts b/src/test/domain/work-orders/utils/slide-over-draft.test.ts index ab31f5a5..615e1085 100644 --- a/src/test/domain/work-orders/utils/slide-over-draft.test.ts +++ b/src/test/domain/work-orders/utils/slide-over-draft.test.ts @@ -6,6 +6,7 @@ import { } from "@/domain/work-orders/utils/slide-over-draft"; import { EMPTY_UPLIFT_SUMMARY } from "@/domain/work-orders/types/work-order-uplift"; import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row"; +import { mergeAutoSchedulePatch } from "@/domain/work-orders/utils/wo-lifecycle-transitions"; function baseRow(overrides: Partial = {}): WorkOrderTableRow { return { @@ -97,6 +98,24 @@ describe("slide-over-draft", () => { expect(isSlideOverDraftDirty(draft, row)).toBe(true); }); + it("clears client schedule range overlays in the save patch", () => { + const row = baseRow({ + scheduleEnd: "2026-07-18", + schedMode: "range", + }); + const draft = baseRow({ + scheduledOn: "", + scheduleEnd: "", + schedMode: undefined, + }); + + expect(buildSlideOverPatch(draft, row)).toEqual({ + scheduledOn: "", + scheduleEnd: "", + schedMode: undefined, + }); + }); + it("is not dirty when values match", () => { const row = baseRow(); expect(isSlideOverDraftDirty(row, row)).toBe(false); @@ -116,6 +135,38 @@ describe("slide-over-draft", () => { }); }); + it("derives Pending on persist when a Scheduled draft clears Schedule On", () => { + const baseline = baseRow({ + status: "Scheduled", + scheduledOn: "2026-07-16", + scheduleWeekOnly: false, + targetWeek: "", + scheduleEnd: "", + schedMode: "date", + }); + const draft = baseRow({ + status: "Scheduled", + scheduledOn: "", + scheduleWeekOnly: false, + targetWeek: "", + scheduleEnd: "", + schedMode: undefined, + }); + + const patch = buildSlideOverPatch(draft, baseline); + expect(patch).toEqual({ + scheduledOn: "", + schedMode: undefined, + }); + expect(patch.status).toBeUndefined(); + expect(mergeAutoSchedulePatch(baseline, patch)).toEqual( + expect.objectContaining({ + scheduledOn: "", + status: "Pending", + }), + ); + }); + it("tracks ordered additionalContacts for dirty state and patch (SH-218)", () => { const row = baseRow(); const contacts = [{ name: "Backup", phone: "555-0101", notes: "Gate" }];