Merge pull request #173 from Sea-Haven-Industries/feat/SH-186-schedule-clear
Some checks are pending
Frontend checks / Build and test (push) Waiting to run
Frontend checks / governance (push) Waiting to run
Frontend checks / Visual regression (push) Waiting to run
Deploy / deploy (push) Waiting to run

feat(work-orders): persist Schedule On clear for SH-186
This commit is contained in:
Arthur Bassi 2026-09-09 18:31:17 -03:00 • committed by GitHub
commit b7d531f554
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
16 changed files with 497 additions and 32 deletions

View file

@ -39,6 +39,9 @@ function SlideOverScheduleOnField({
draft: WorkOrderTableRow;
onDraftChange: (patch: Partial<WorkOrderTableRow>) => void;
}) {
const hasSchedule =
Boolean(draft.scheduledOn) || Boolean(draft.targetWeek) || Boolean(draft.scheduleEnd);
return (
<DateCell
value={draft.scheduledOn}
@ -80,6 +83,20 @@ function SlideOverScheduleOnField({
...deriveDayFields(start),
})
}
onClear={
hasSchedule
? () =>
onDraftChange({
scheduledOn: "",
scheduleWeekOnly: false,
targetWeek: "",
scheduleEnd: "",
schedMode: undefined,
isPastDue: false,
...deriveDayFields(""),
})
: undefined
}
/>
);
}

View file

@ -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) && (
<div className="mb-1.5 flex justify-end">
<button
type="button"
onClick={() => {
onClear?.();
setAnchorEl(null);
}}
className="inline-flex items-center gap-0.5 text-xs font-medium transition-colors"
style={{ color: "var(--color-text-muted)" }}
>
<X size={12} />
Clear
</button>
</div>
)}
<WizardDateModeTabs
modes={modes}
cellMode={cellMode}
@ -144,21 +160,6 @@ export function DateCell({
}}
/>
)}
{Boolean(onClear) && (
<div className="mt-2.5 flex justify-end border-t border-border pt-2">
<button
type="button"
onClick={() => {
onClear?.();
setAnchorEl(null);
}}
className="text-[11.5px] font-medium transition-colors"
style={{ color: "var(--color-text-muted)" }}
>
× Clear
</button>
</div>
)}
</Popover>
</>
);

View file

@ -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,

View file

@ -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?.();
},
});

View file

@ -43,6 +43,9 @@ export function WoTableRowCells({
handlers,
onPatchRow,
}: WoTableRowCellsProps) {
const hasSchedule =
Boolean(row.scheduledOn) || Boolean(row.targetWeek) || Boolean(row.scheduleEnd);
return (
<>
<td
@ -137,6 +140,7 @@ export function WoTableRowCells({
onSave={(date) => 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}
/>
</td>
<td

View file

@ -201,7 +201,7 @@ export function WoTableRow({
forceScheduleHighlight={forceScheduleHighlight}
forceDueDateHighlight={forceDueDateHighlight}
mutationCellStyle={coreLockStyle}
viewOnly={isCoreLocked}
viewOnly={closedForEdit}
canceledLockStyle={canceledLockStyle}
handlers={guardedHandlers}
onPatchRow={patchRow}
@ -212,7 +212,7 @@ export function WoTableRow({
pmTypes={pmTypes}
search={search}
mutationCellStyle={coreLockStyle}
viewOnly={isCoreLocked}
viewOnly={closedForEdit}
canceledLockStyle={canceledLockStyle}
handlers={guardedHandlers}
onPatchRow={patchRow}

View file

@ -19,6 +19,8 @@ export const SLIDE_OVER_EDIT_KEYS = [
"scheduledOn",
"targetWeek",
"scheduleWeekOnly",
"scheduleEnd",
"schedMode",
"dueDate",
"completedDate",
"apptTime",

View file

@ -105,6 +105,30 @@ describe("DateCell — Schedule On range", () => {
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(
<DateCell
value="2026-09-09"
allowWeekMode
onSave={vi.fn()}
onSaveWeek={vi.fn()}
onSaveRange={vi.fn()}
onClear={onClear}
/>,
);
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", () => {

View file

@ -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.",
});
});
});

View file

@ -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(
<SlideOverInfoTabEditView
draft={baseRow({
scheduledOn: currentMonthIso(5),
scheduleEnd: currentMonthIso(20),
schedMode: "range",
scheduleWeekOnly: false,
targetWeek: currentMonthIso(1),
dayGroup: "09/05",
dayLabel: "Sat",
isPastDue: true,
status: "Scheduled",
})}
users={[]}
sites={[]}
vendors={[]}
pmTypes={[]}
allOrders={[]}
onDraftChange={onDraftChange}
/>,
);
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,
}),
);
});
});

View file

@ -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();
});
});

View file

@ -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(
<WizardStepTypeSchedule
draft={baseDraft({ status: "Incomplete" })}
users={[]}
onUpdate={onUpdate}
onPatch={onPatch}
/>,
);
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);
});
});

View file

@ -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(
<table>
<tbody>
<WoTableInlineRow
draft={draft}
draftDayKey="2026-06-01"
users={[]}
sites={[]}
vendors={[]}
pmTypes={[]}
allRows={[]}
onChange={onChange}
onSave={vi.fn()}
onCancel={vi.fn()}
/>
</tbody>
</table>,
{ 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" }));
});
});

View file

@ -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 (
<button type="button" onClick={onClear}>
clear schedule
</button>
);
},
}));
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(
<table>
<tbody>
<tr>
<WoTableRowCells
row={row}
allRows={[row]}
users={[]}
sites={[]}
viewOnly={viewOnly}
handlers={handlers}
onPatchRow={vi.fn()}
/>
</tr>
</tbody>
</table>,
{ 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();
});
});

View file

@ -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,

View file

@ -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> = {}): 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" }];