mirror of
https://github.com/Sea-Haven-Industries/shoc-frontend-new.git
synced 2026-10-06 13:32:06 +00:00
feat(work-orders): align Schedule On clear with shared demotion path
Board Clear uses empty-week patch; slide-over Save derives Pending via mergeAutoSchedulePatch.
This commit is contained in:
parent
dee1578efe
commit
c71d9482ec
16 changed files with 475 additions and 105 deletions
|
|
@ -18,7 +18,6 @@ import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-ta
|
||||||
import type { WizardWOStatus } from "@/domain/work-orders/types/work-order-wizard";
|
import type { WizardWOStatus } from "@/domain/work-orders/types/work-order-wizard";
|
||||||
import { deriveDayFields } from "@/domain/work-orders/utils/table-day-utils";
|
import { deriveDayFields } from "@/domain/work-orders/utils/table-day-utils";
|
||||||
import { toVendorTablePatch } from "@/domain/work-orders/utils/vendor-assignment-patch";
|
import { toVendorTablePatch } from "@/domain/work-orders/utils/vendor-assignment-patch";
|
||||||
import { withAutoScheduleStatus } from "@/domain/work-orders/utils/wo-lifecycle-transitions";
|
|
||||||
import { DocBadge } from "./slide-over-doc-badge";
|
import { DocBadge } from "./slide-over-doc-badge";
|
||||||
|
|
||||||
type SlideOverInfoTabEditViewProps = {
|
type SlideOverInfoTabEditViewProps = {
|
||||||
|
|
@ -52,58 +51,50 @@ function SlideOverScheduleOnField({
|
||||||
scheduleEnd={draft.scheduleEnd}
|
scheduleEnd={draft.scheduleEnd}
|
||||||
schedMode={draft.schedMode}
|
schedMode={draft.schedMode}
|
||||||
onSave={(scheduledOn) =>
|
onSave={(scheduledOn) =>
|
||||||
onDraftChange(
|
onDraftChange({
|
||||||
withAutoScheduleStatus(draft, {
|
scheduledOn,
|
||||||
scheduledOn,
|
scheduleWeekOnly: false,
|
||||||
scheduleWeekOnly: false,
|
targetWeek: scheduledOn ? "" : draft.targetWeek,
|
||||||
targetWeek: scheduledOn ? "" : draft.targetWeek,
|
scheduleEnd: "",
|
||||||
scheduleEnd: "",
|
schedMode: "date",
|
||||||
schedMode: "date",
|
isPastDue: false,
|
||||||
isPastDue: false,
|
...deriveDayFields(scheduledOn),
|
||||||
...deriveDayFields(scheduledOn),
|
})
|
||||||
}),
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
onSaveWeek={(weekStart) =>
|
onSaveWeek={(weekStart) =>
|
||||||
onDraftChange(
|
onDraftChange({
|
||||||
withAutoScheduleStatus(draft, {
|
scheduledOn: "",
|
||||||
scheduledOn: "",
|
targetWeek: weekStart,
|
||||||
targetWeek: weekStart,
|
scheduleWeekOnly: true,
|
||||||
scheduleWeekOnly: true,
|
scheduleEnd: "",
|
||||||
scheduleEnd: "",
|
schedMode: "week",
|
||||||
schedMode: "week",
|
isPastDue: false,
|
||||||
isPastDue: false,
|
...deriveDayFields(""),
|
||||||
...deriveDayFields(""),
|
})
|
||||||
}),
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
onSaveRange={(start, end) =>
|
onSaveRange={(start, end) =>
|
||||||
onDraftChange(
|
onDraftChange({
|
||||||
withAutoScheduleStatus(draft, {
|
scheduledOn: start,
|
||||||
scheduledOn: start,
|
scheduleEnd: end,
|
||||||
scheduleEnd: end,
|
schedMode: "range",
|
||||||
schedMode: "range",
|
scheduleWeekOnly: false,
|
||||||
scheduleWeekOnly: false,
|
targetWeek: "",
|
||||||
targetWeek: "",
|
isPastDue: false,
|
||||||
isPastDue: false,
|
...deriveDayFields(start),
|
||||||
...deriveDayFields(start),
|
})
|
||||||
}),
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
onClear={
|
onClear={
|
||||||
hasSchedule
|
hasSchedule
|
||||||
? () =>
|
? () =>
|
||||||
onDraftChange(
|
onDraftChange({
|
||||||
withAutoScheduleStatus(draft, {
|
scheduledOn: "",
|
||||||
scheduledOn: "",
|
scheduleWeekOnly: false,
|
||||||
targetWeek: "",
|
targetWeek: "",
|
||||||
scheduleWeekOnly: false,
|
scheduleEnd: "",
|
||||||
scheduleEnd: "",
|
schedMode: undefined,
|
||||||
schedMode: undefined,
|
isPastDue: false,
|
||||||
isPastDue: false,
|
...deriveDayFields(""),
|
||||||
...deriveDayFields(""),
|
})
|
||||||
}),
|
|
||||||
)
|
|
||||||
: undefined
|
: undefined
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { ChevronDown } from "lucide-react";
|
import { ChevronDown, X } from "lucide-react";
|
||||||
import { Popover } from "@mui/material";
|
import { Popover } from "@mui/material";
|
||||||
import { DateCellValue } from "@/app/(protected)/workorders/_components/list/table/cells/date-cell-value";
|
import { DateCellValue } from "@/app/(protected)/workorders/_components/list/table/cells/date-cell-value";
|
||||||
import { MiniCalendar } from "@/app/(protected)/workorders/_components/wizard/mini-calendar";
|
import { MiniCalendar } from "@/app/(protected)/workorders/_components/wizard/mini-calendar";
|
||||||
|
|
@ -111,6 +111,22 @@ export function DateCell({
|
||||||
onClick={(e) => e.stopPropagation()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
slotProps={{ paper: { className: "rounded-lg border border-border p-3" } }}
|
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
|
<WizardDateModeTabs
|
||||||
modes={modes}
|
modes={modes}
|
||||||
cellMode={cellMode}
|
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>
|
</Popover>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -18,11 +18,15 @@ type NotifyNavigate = {
|
||||||
onNavigate: () => void;
|
onNavigate: () => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
function showRescheduleToast(message: string, onNavigate: () => void) {
|
function showRescheduleToast(
|
||||||
|
message: string,
|
||||||
|
onNavigate: () => void,
|
||||||
|
linkLabel = "Click to view it.",
|
||||||
|
) {
|
||||||
const toastId = toast(
|
const toastId = toast(
|
||||||
createElement(RescheduleToast, {
|
createElement(RescheduleToast, {
|
||||||
message,
|
message,
|
||||||
linkLabel: "Click to view it.",
|
linkLabel,
|
||||||
onNavigate: () => {
|
onNavigate: () => {
|
||||||
toast.dismiss(toastId);
|
toast.dismiss(toastId);
|
||||||
onNavigate();
|
onNavigate();
|
||||||
|
|
@ -35,6 +39,13 @@ function showRescheduleToast(message: string, onNavigate: () => void) {
|
||||||
}
|
}
|
||||||
|
|
||||||
export function weekMoveNotify(weekStart: string, { onNavigate }: NotifyNavigate) {
|
export function weekMoveNotify(weekStart: string, { onNavigate }: NotifyNavigate) {
|
||||||
|
if (!weekStart) {
|
||||||
|
return showRescheduleToast(
|
||||||
|
"Work order moved to Unscheduled.",
|
||||||
|
onNavigate,
|
||||||
|
"View in Advanced Filters.",
|
||||||
|
);
|
||||||
|
}
|
||||||
return showRescheduleToast(
|
return showRescheduleToast(
|
||||||
`Work order moved to Unscheduled · ${fmtWeekOf(weekStart)}.`,
|
`Work order moved to Unscheduled · ${fmtWeekOf(weekStart)}.`,
|
||||||
onNavigate,
|
onNavigate,
|
||||||
|
|
|
||||||
|
|
@ -122,27 +122,26 @@ export function useWoTableRowHandlers({
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
onScheduleWeek: (id: string | number, week: string) => {
|
onScheduleWeek: (id: string | number, week: string) => {
|
||||||
onPatch(id, {
|
const isClear = week === "";
|
||||||
scheduledOn: "",
|
onPatch(
|
||||||
targetWeek: week,
|
id,
|
||||||
scheduleWeekOnly: true,
|
isClear
|
||||||
scheduleEnd: "",
|
? applySchedulePatch("", false)
|
||||||
schedMode: "week",
|
: {
|
||||||
dayGroup: "",
|
scheduledOn: "",
|
||||||
dayLabel: "",
|
targetWeek: week,
|
||||||
});
|
scheduleWeekOnly: true,
|
||||||
|
scheduleEnd: "",
|
||||||
|
schedMode: "week",
|
||||||
|
dayGroup: "",
|
||||||
|
dayLabel: "",
|
||||||
|
},
|
||||||
|
);
|
||||||
weekMoveNotify(week, {
|
weekMoveNotify(week, {
|
||||||
onNavigate: () => {
|
onNavigate: () => {
|
||||||
onNavigateToDay(week);
|
if (!isClear) {
|
||||||
onOpenAdvancedFilters?.();
|
onNavigateToDay(week);
|
||||||
},
|
}
|
||||||
});
|
|
||||||
},
|
|
||||||
onScheduleClear: (id: string | number) => {
|
|
||||||
onPatch(id, applySchedulePatch("", false));
|
|
||||||
weekMoveNotify(weekMonday, {
|
|
||||||
onNavigate: () => {
|
|
||||||
onNavigateToDay(weekMonday);
|
|
||||||
onOpenAdvancedFilters?.();
|
onOpenAdvancedFilters?.();
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -43,6 +43,9 @@ export function WoTableRowCells({
|
||||||
handlers,
|
handlers,
|
||||||
onPatchRow,
|
onPatchRow,
|
||||||
}: WoTableRowCellsProps) {
|
}: WoTableRowCellsProps) {
|
||||||
|
const hasSchedule =
|
||||||
|
Boolean(row.scheduledOn) || Boolean(row.targetWeek) || Boolean(row.scheduleEnd);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<td
|
<td
|
||||||
|
|
@ -137,11 +140,7 @@ export function WoTableRowCells({
|
||||||
onSave={(date) => handlers.onScheduleDate(row.id, date)}
|
onSave={(date) => handlers.onScheduleDate(row.id, date)}
|
||||||
onSaveWeek={(week) => handlers.onScheduleWeek(row.id, week)}
|
onSaveWeek={(week) => handlers.onScheduleWeek(row.id, week)}
|
||||||
onSaveRange={(start, end) => handlers.onScheduleRange(row.id, start, end)}
|
onSaveRange={(start, end) => handlers.onScheduleRange(row.id, start, end)}
|
||||||
onClear={
|
onClear={hasSchedule && !viewOnly ? () => handlers.onScheduleWeek(row.id, "") : undefined}
|
||||||
Boolean(row.scheduledOn) || Boolean(row.targetWeek) || Boolean(row.scheduleEnd)
|
|
||||||
? () => handlers.onScheduleClear(row.id)
|
|
||||||
: undefined
|
|
||||||
}
|
|
||||||
/>
|
/>
|
||||||
</td>
|
</td>
|
||||||
<td
|
<td
|
||||||
|
|
|
||||||
|
|
@ -39,7 +39,6 @@ export type WoTableRowHandlers = {
|
||||||
onScheduleDate: (id: string | number, date: string) => void;
|
onScheduleDate: (id: string | number, date: string) => void;
|
||||||
onScheduleWeek: (id: string | number, week: string) => void;
|
onScheduleWeek: (id: string | number, week: string) => void;
|
||||||
onScheduleRange: (id: string | number, start: string, end: string) => void;
|
onScheduleRange: (id: string | number, start: string, end: string) => void;
|
||||||
onScheduleClear: (id: string | number) => void;
|
|
||||||
onDuplicateFound?: (existing: WorkOrderTableRow) => void;
|
onDuplicateFound?: (existing: WorkOrderTableRow) => void;
|
||||||
onOpenSlideOver: (row: WorkOrderTableRow) => void;
|
onOpenSlideOver: (row: WorkOrderTableRow) => void;
|
||||||
onRequestComplete?: (row: WorkOrderTableRow) => void;
|
onRequestComplete?: (row: WorkOrderTableRow) => void;
|
||||||
|
|
@ -90,10 +89,6 @@ function guardScheduleHandlers(
|
||||||
if (!canApplyWorkOrderTablePatch(row.status, { scheduledOn: start })) return;
|
if (!canApplyWorkOrderTablePatch(row.status, { scheduledOn: start })) return;
|
||||||
handlers.onScheduleRange(id, start, end);
|
handlers.onScheduleRange(id, start, end);
|
||||||
},
|
},
|
||||||
onScheduleClear: (id) => {
|
|
||||||
if (!canApplyWorkOrderTablePatch(row.status, { scheduledOn: "" })) return;
|
|
||||||
handlers.onScheduleClear(id);
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -206,7 +201,7 @@ export function WoTableRow({
|
||||||
forceScheduleHighlight={forceScheduleHighlight}
|
forceScheduleHighlight={forceScheduleHighlight}
|
||||||
forceDueDateHighlight={forceDueDateHighlight}
|
forceDueDateHighlight={forceDueDateHighlight}
|
||||||
mutationCellStyle={coreLockStyle}
|
mutationCellStyle={coreLockStyle}
|
||||||
viewOnly={isCoreLocked}
|
viewOnly={closedForEdit}
|
||||||
canceledLockStyle={canceledLockStyle}
|
canceledLockStyle={canceledLockStyle}
|
||||||
handlers={guardedHandlers}
|
handlers={guardedHandlers}
|
||||||
onPatchRow={patchRow}
|
onPatchRow={patchRow}
|
||||||
|
|
@ -217,7 +212,7 @@ export function WoTableRow({
|
||||||
pmTypes={pmTypes}
|
pmTypes={pmTypes}
|
||||||
search={search}
|
search={search}
|
||||||
mutationCellStyle={coreLockStyle}
|
mutationCellStyle={coreLockStyle}
|
||||||
viewOnly={isCoreLocked}
|
viewOnly={closedForEdit}
|
||||||
canceledLockStyle={canceledLockStyle}
|
canceledLockStyle={canceledLockStyle}
|
||||||
handlers={guardedHandlers}
|
handlers={guardedHandlers}
|
||||||
onPatchRow={patchRow}
|
onPatchRow={patchRow}
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,8 @@ export const SLIDE_OVER_EDIT_KEYS = [
|
||||||
"scheduledOn",
|
"scheduledOn",
|
||||||
"targetWeek",
|
"targetWeek",
|
||||||
"scheduleWeekOnly",
|
"scheduleWeekOnly",
|
||||||
|
"scheduleEnd",
|
||||||
|
"schedMode",
|
||||||
"dueDate",
|
"dueDate",
|
||||||
"completedDate",
|
"completedDate",
|
||||||
"apptTime",
|
"apptTime",
|
||||||
|
|
|
||||||
|
|
@ -105,11 +105,12 @@ describe("DateCell — Schedule On range", () => {
|
||||||
expect(onSave).not.toHaveBeenCalled();
|
expect(onSave).not.toHaveBeenCalled();
|
||||||
expect(onSaveRange).not.toHaveBeenCalled();
|
expect(onSaveRange).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
it("shows Clear when a concrete schedule exists", async () => {
|
|
||||||
|
it("shows Clear above the mode tabs and closes after clearing", async () => {
|
||||||
const onClear = vi.fn();
|
const onClear = vi.fn();
|
||||||
renderWithProviders(
|
renderWithProviders(
|
||||||
<DateCell
|
<DateCell
|
||||||
value="2026-06-01"
|
value="2026-09-09"
|
||||||
allowWeekMode
|
allowWeekMode
|
||||||
onSave={vi.fn()}
|
onSave={vi.fn()}
|
||||||
onSaveWeek={vi.fn()}
|
onSaveWeek={vi.fn()}
|
||||||
|
|
@ -118,10 +119,15 @@ describe("DateCell — Schedule On range", () => {
|
||||||
/>,
|
/>,
|
||||||
);
|
);
|
||||||
|
|
||||||
fireEvent.click(screen.getByRole("button", { name: /06-01/ }));
|
fireEvent.click(screen.getByRole("button", { name: /09-09/ }));
|
||||||
fireEvent.click(await screen.findByRole("button", { name: /clear/i }));
|
|
||||||
|
|
||||||
expect(onClear).toHaveBeenCalledTimes(1);
|
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();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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.",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -121,4 +121,46 @@ describe("SlideOverInfoTabEditView — Schedule On range", () => {
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
}, 10_000);
|
}, 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,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -27,7 +27,7 @@ function buildTableData(revealScheduledRow = vi.fn()) {
|
||||||
} as never;
|
} as never;
|
||||||
}
|
}
|
||||||
|
|
||||||
describe("useWoTableRowHandlers schedule navigation (SH-240)", () => {
|
describe("useWoTableRowHandlers schedule navigation", () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
rescheduleNotify.mockClear();
|
rescheduleNotify.mockClear();
|
||||||
weekMoveNotify.mockClear();
|
weekMoveNotify.mockClear();
|
||||||
|
|
@ -152,7 +152,7 @@ describe("useWoTableRowHandlers schedule navigation (SH-240)", () => {
|
||||||
expect(onOpenAdvancedFilters).toHaveBeenCalled();
|
expect(onOpenAdvancedFilters).toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("clears Schedule On and offers Advanced Filters navigation", () => {
|
it("clears the concrete schedule and opens Advanced Filters from the Unscheduled toast", () => {
|
||||||
const onNavigateToDay = vi.fn();
|
const onNavigateToDay = vi.fn();
|
||||||
const onOpenAdvancedFilters = vi.fn();
|
const onOpenAdvancedFilters = vi.fn();
|
||||||
const onPatch = vi.fn();
|
const onPatch = vi.fn();
|
||||||
|
|
@ -170,17 +170,17 @@ describe("useWoTableRowHandlers schedule navigation (SH-240)", () => {
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
result.current.onScheduleClear("wo-1");
|
result.current.onScheduleWeek("wo-1", "");
|
||||||
|
|
||||||
expect(onPatch).toHaveBeenCalledWith("wo-1", applySchedulePatch("", false));
|
expect(onPatch).toHaveBeenCalledWith("wo-1", applySchedulePatch("", false));
|
||||||
expect(weekMoveNotify).toHaveBeenCalledWith(
|
expect(weekMoveNotify).toHaveBeenCalledWith(
|
||||||
"2026-05-18",
|
"",
|
||||||
expect.objectContaining({ onNavigate: expect.any(Function) }),
|
expect.objectContaining({ onNavigate: expect.any(Function) }),
|
||||||
);
|
);
|
||||||
|
|
||||||
const { onNavigate } = weekMoveNotify.mock.calls[0][1] as { onNavigate: () => void };
|
const { onNavigate } = weekMoveNotify.mock.calls[0][1] as { onNavigate: () => void };
|
||||||
onNavigate();
|
onNavigate();
|
||||||
expect(onNavigateToDay).toHaveBeenCalledWith("2026-05-18");
|
expect(onNavigateToDay).not.toHaveBeenCalled();
|
||||||
expect(onOpenAdvancedFilters).toHaveBeenCalled();
|
expect(onOpenAdvancedFilters).toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -111,4 +111,33 @@ describe("WizardStepTypeSchedule — Schedule On range selection", () => {
|
||||||
expect(screen.getByRole("button", { name: /^done$/i })).toBeDisabled();
|
expect(screen.getByRole("button", { name: /^done$/i })).toBeDisabled();
|
||||||
expect(onPatch).not.toHaveBeenCalled();
|
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);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -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" }));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -7,7 +7,10 @@ import {
|
||||||
resolveSiteCode,
|
resolveSiteCode,
|
||||||
} from "@/domain/work-orders/mappers/work-order-board-mapper";
|
} 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";
|
import { EMPTY_WIZARD_DRAFT } from "@/domain/work-orders/types/work-order-wizard";
|
||||||
|
|
||||||
|
|
@ -262,6 +265,22 @@ describe("mapWizardDraftToBoardCreatePayload", () => {
|
||||||
expect(payload.scheduledDate).toBeUndefined();
|
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", () => {
|
it("prefers explicit targetWeek when scheduleWeekOnly and scheduledOn is empty", () => {
|
||||||
const payload = mapWizardDraftToBoardCreatePayload({
|
const payload = mapWizardDraftToBoardCreatePayload({
|
||||||
...EMPTY_WIZARD_DRAFT,
|
...EMPTY_WIZARD_DRAFT,
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ import {
|
||||||
} from "@/domain/work-orders/utils/slide-over-draft";
|
} from "@/domain/work-orders/utils/slide-over-draft";
|
||||||
import { EMPTY_UPLIFT_SUMMARY } from "@/domain/work-orders/types/work-order-uplift";
|
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 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 {
|
function baseRow(overrides: Partial<WorkOrderTableRow> = {}): WorkOrderTableRow {
|
||||||
return {
|
return {
|
||||||
|
|
@ -97,6 +98,24 @@ describe("slide-over-draft", () => {
|
||||||
expect(isSlideOverDraftDirty(draft, row)).toBe(true);
|
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", () => {
|
it("is not dirty when values match", () => {
|
||||||
const row = baseRow();
|
const row = baseRow();
|
||||||
expect(isSlideOverDraftDirty(row, row)).toBe(false);
|
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)", () => {
|
it("tracks ordered additionalContacts for dirty state and patch (SH-218)", () => {
|
||||||
const row = baseRow();
|
const row = baseRow();
|
||||||
const contacts = [{ name: "Backup", phone: "555-0101", notes: "Gate" }];
|
const contacts = [{ name: "Backup", phone: "555-0101", notes: "Gate" }];
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue