Merge pull request #169 from Sea-Haven-Industries/feat/SH-310-schedule-on-range
Some checks failed
Frontend checks / Build and test (push) Has been cancelled
Frontend checks / governance (push) Has been cancelled
Frontend checks / Visual regression (push) Has been cancelled
Deploy / deploy (push) Has been cancelled

fix(work-orders): restore Range tab on Schedule On for inline and slide-over
This commit is contained in:
Arthur Bassi 2026-09-04 18:07:18 -03:00 • committed by GitHub
commit a766dc9c50
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
23 changed files with 702 additions and 101 deletions

View file

@ -16,6 +16,7 @@ import type {
} from "@/domain/work-orders/types/work-order";
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
import type { WizardWOStatus } from "@/domain/work-orders/types/work-order-wizard";
import { deriveDayFields } from "@/domain/work-orders/utils/table-day-utils";
import { toVendorTablePatch } from "@/domain/work-orders/utils/vendor-assignment-patch";
import { DocBadge } from "./slide-over-doc-badge";
@ -31,6 +32,58 @@ type SlideOverInfoTabEditViewProps = {
onRequestComplete?: () => void;
};
function SlideOverScheduleOnField({
draft,
onDraftChange,
}: {
draft: WorkOrderTableRow;
onDraftChange: (patch: Partial<WorkOrderTableRow>) => void;
}) {
return (
<DateCell
value={draft.scheduledOn}
allowWeekMode
targetWeek={draft.targetWeek}
defaultMode={draft.scheduleWeekOnly ? "week" : "date"}
scheduleEnd={draft.scheduleEnd}
schedMode={draft.schedMode}
onSave={(scheduledOn) =>
onDraftChange({
scheduledOn,
scheduleWeekOnly: false,
targetWeek: scheduledOn ? "" : draft.targetWeek,
scheduleEnd: "",
schedMode: "date",
isPastDue: false,
...deriveDayFields(scheduledOn),
})
}
onSaveWeek={(weekStart) =>
onDraftChange({
scheduledOn: "",
targetWeek: weekStart,
scheduleWeekOnly: true,
scheduleEnd: "",
schedMode: "week",
isPastDue: false,
...deriveDayFields(""),
})
}
onSaveRange={(start, end) =>
onDraftChange({
scheduledOn: start,
scheduleEnd: end,
schedMode: "range",
scheduleWeekOnly: false,
targetWeek: "",
isPastDue: false,
...deriveDayFields(start),
})
}
/>
);
}
export function SlideOverInfoTabEditView({
draft,
users,
@ -99,23 +152,7 @@ export function SlideOverInfoTabEditView({
/>
</SlideOverInfoRow>
<SlideOverInfoRow label="Schedule On">
<DateCell
value={draft.scheduledOn}
allowWeekMode
targetWeek={draft.targetWeek}
defaultMode={draft.scheduleWeekOnly ? "week" : "date"}
onSave={(scheduledOn) =>
onDraftChange({ scheduledOn, scheduleWeekOnly: false, isPastDue: false })
}
onSaveWeek={(weekStart) =>
onDraftChange({
scheduledOn: "",
targetWeek: weekStart,
scheduleWeekOnly: true,
isPastDue: false,
})
}
/>
<SlideOverScheduleOnField draft={draft} onDraftChange={onDraftChange} />
</SlideOverInfoRow>
<SlideOverInfoRow label="Due Date">
<DueDateCell

View file

@ -48,6 +48,8 @@ export function buildInlineDraftCreateInput(
additionalContacts: draft.additionalContacts ?? [],
scheduleWeekOnly,
targetWeek: draft.targetWeek || "",
scheduleEnd: draft.scheduleEnd || "",
schedMode: draft.schedMode,
isAddOnOverride: draft.isAddOnOverride,
};
}

View file

@ -0,0 +1,60 @@
import { CalendarDays, Calendar as CalendarIcon, CalendarRange } from "lucide-react";
import { fmtDateRange, fmtWeekOf } from "@/domain/work-orders/utils/wizard-date-utils";
import { HighlightText } from "./highlight-text";
type DateCellValueArgs = {
showRange: boolean;
showWeek: boolean;
value: string;
scheduleEnd: string;
targetWeek: string;
q?: string;
highlight?: boolean;
};
export function DateCellValue({
showRange,
showWeek,
value,
scheduleEnd,
targetWeek,
q,
highlight,
}: DateCellValueArgs) {
if (showRange) {
return (
<>
<CalendarRange size={11} className="shrink-0 text-[var(--color-text-muted)]" />
<span className="truncate text-[13px] text-[var(--color-text-secondary)]">
{fmtDateRange(value, scheduleEnd)}
</span>
</>
);
}
if (showWeek) {
return (
<>
<CalendarDays size={10} className="shrink-0 text-[var(--color-text-muted)]" />
<span className="truncate text-[11px] font-medium text-[var(--color-text-muted)]">
{fmtWeekOf(targetWeek)}
</span>
</>
);
}
return (
<>
<CalendarIcon
size={11}
className="shrink-0"
style={{ color: highlight ? "var(--primary)" : "var(--color-text-muted)" }}
/>
{value ? (
<span className="truncate text-[13px] text-[var(--color-text-secondary)]">
<HighlightText text={value.replace(/^\d{4}-/, "")} q={q} />
</span>
) : (
<span className="text-[13px] text-[var(--color-text-muted)]">—</span>
)}
</>
);
}

View file

@ -1,10 +1,13 @@
import { useState } from "react";
import { CalendarDays, Calendar as CalendarIcon, ChevronDown } from "lucide-react";
import { ChevronDown } 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";
import { WeekPicker } from "@/app/(protected)/workorders/_components/wizard/week-picker";
import { fmtWeekOf } from "@/domain/work-orders/utils/wizard-date-utils";
import { HighlightText } from "./highlight-text";
import { useWizardDateFieldState } from "@/app/(protected)/workorders/_components/wizard/use-wizard-date-field-state";
import { WizardDateModeTabs } from "@/app/(protected)/workorders/_components/wizard/wizard-date-mode-tabs";
import { WizardDateRangePanel } from "@/app/(protected)/workorders/_components/wizard/wizard-date-range-panel";
import type { WizardSchedMode } from "@/domain/work-orders/types/work-order-wizard";
type DateCellProps = {
value: string;
@ -15,6 +18,9 @@ type DateCellProps = {
targetWeek?: string;
onSaveWeek?: (weekStart: string) => void;
defaultMode?: "week" | "date";
scheduleEnd?: string;
schedMode?: WizardSchedMode;
onSaveRange?: (start: string, end: string) => void;
/** When set, shows Clear in the popover so schedule can be removed (e.g. day-group → Unscheduled). */
onClear?: () => void;
/** Screen-reader-only status appended inside the button so the accessible name keeps the visible date and adds durable state. */
@ -30,14 +36,34 @@ export function DateCell({
targetWeek = "",
onSaveWeek,
defaultMode,
scheduleEnd = "",
schedMode,
onSaveRange,
onClear,
srStatusSuffix,
}: DateCellProps) {
const [anchorEl, setAnchorEl] = useState<HTMLElement | null>(null);
const [cellMode, setCellMode] = useState<"week" | "date">(
allowWeekMode ? (defaultMode ?? (!value && targetWeek ? "week" : "date")) : "date",
const open = Boolean(anchorEl);
const allowRange = Boolean(allowWeekMode && onSaveRange);
const { cellMode, setCellMode, rangeStart, rangeEnd, handleRangeClick } = useWizardDateFieldState(
{
open,
value,
scheduleEnd,
allowRange,
allowWeekMode,
targetWeek,
schedMode,
defaultMode,
},
);
const showWeek = allowWeekMode && !value && !!targetWeek;
const showWeek = Boolean(allowWeekMode && !value && targetWeek);
const showRange = Boolean(allowRange && schedMode === "range" && value && scheduleEnd);
const modes: WizardSchedMode[] = allowRange
? ["week", "date", "range"]
: allowWeekMode
? ["week", "date"]
: [];
return (
<>
@ -60,29 +86,15 @@ export function DateCell({
}
>
<span className="flex min-w-0 flex-1 items-center gap-1.5 overflow-hidden">
{showWeek ? (
<>
<CalendarDays size={10} className="shrink-0 text-[var(--color-text-muted)]" />
<span className="truncate text-[11px] font-medium text-[var(--color-text-muted)]">
{fmtWeekOf(targetWeek)}
</span>
</>
) : (
<>
<CalendarIcon
size={11}
className="shrink-0"
style={{ color: highlight ? "var(--primary)" : "var(--color-text-muted)" }}
/>
{value ? (
<span className="truncate text-[13px] text-[var(--color-text-secondary)]">
<HighlightText text={value.replace(/^\d{4}-/, "")} q={q} />
</span>
) : (
<span className="text-[13px] text-[var(--color-text-muted)]">—</span>
)}
</>
)}
<DateCellValue
showRange={showRange}
showWeek={showWeek}
value={value}
scheduleEnd={scheduleEnd}
targetWeek={targetWeek}
q={q}
highlight={highlight}
/>
</span>
{Boolean(srStatusSuffix) && <span className="sr-only">{srStatusSuffix}</span>}
<ChevronDown
@ -92,36 +104,19 @@ export function DateCell({
/>
</button>
<Popover
open={Boolean(anchorEl)}
open={open}
anchorEl={anchorEl}
onClose={() => setAnchorEl(null)}
anchorOrigin={{ vertical: "bottom", horizontal: "left" }}
onClick={(e) => e.stopPropagation()}
slotProps={{ paper: { className: "rounded-lg border border-border p-3" } }}
>
{Boolean(allowWeekMode) && (
<div className="mb-2.5 flex items-center gap-1.5">
{(["week", "date"] as const).map((m) => {
const active = cellMode === m;
return (
<button
key={m}
type="button"
onClick={() => setCellMode(m)}
className="rounded-full border px-3 text-[11.5px] font-medium"
style={{
height: 26,
background: active ? "var(--color-primary-tint)" : "var(--color-bg-surface)",
borderColor: active ? "var(--color-primary)" : "var(--color-border)",
color: active ? "var(--color-primary)" : "var(--color-text-secondary)",
}}
>
{m === "week" ? "Week" : "Specific date"}
</button>
);
})}
</div>
)}
<WizardDateModeTabs
modes={modes}
cellMode={cellMode}
allowRange={allowRange}
onSelect={setCellMode}
/>
{allowWeekMode && cellMode === "week" ? (
<WeekPicker
selected={!value ? targetWeek : ""}
@ -130,6 +125,16 @@ export function DateCell({
setAnchorEl(null);
}}
/>
) : allowRange && cellMode === "range" ? (
<WizardDateRangePanel
rangeStart={rangeStart}
rangeEnd={rangeEnd}
onRangeClick={handleRangeClick}
onDone={(start, end) => {
onSaveRange?.(start, end);
setAnchorEl(null);
}}
/>
) : (
<MiniCalendar
value={value}

View file

@ -18,6 +18,8 @@ export function toInlineDraftRow(draft: WorkOrderTableDraft) {
dispatcherName: "",
scheduledOn: draft.scheduledOn,
scheduleWeekOnly: draft.scheduleWeekOnly,
scheduleEnd: draft.scheduleEnd,
schedMode: draft.schedMode,
vendorId: draft.vendorId,
completedDate: "",
dueDate: draft.dueDate,

View file

@ -2,6 +2,7 @@ import { useMemo } from "react";
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
import {
applySchedulePatch,
applyScheduleRangePatch,
type WorkOrderTablePatchFn,
} from "@/domain/work-orders/use-cases/use-work-order-table-mutations";
import { shouldNotifyCrossWeek } from "@/domain/work-orders/utils/should-notify-cross-week";
@ -9,6 +10,27 @@ import type { ReturnTypeOfUseWorkOrdersTableData } from "./wo-table-types";
import { rescheduleNotify, weekMoveNotify } from "./reschedule-notify";
import type { WoTableRowHandlers } from "./wo-table-row";
function revealOrNotifyScheduledDay(args: {
id: string | number;
date: string;
weekMonday: string;
onNavigateToDay: (dateIso: string) => void;
revealScheduledRow: (id: string | number) => void;
}) {
const { id, date, weekMonday, onNavigateToDay, revealScheduledRow } = args;
if (shouldNotifyCrossWeek(date, weekMonday)) {
rescheduleNotify(date, {
onNavigate: () => {
onNavigateToDay(date);
revealScheduledRow(id);
},
});
return;
}
onNavigateToDay(date);
revealScheduledRow(id);
}
type UseWoTableRowHandlersOptions = {
tableData: ReturnTypeOfUseWorkOrdersTableData;
weekMonday: string;
@ -81,23 +103,31 @@ export function useWoTableRowHandlers({
},
onScheduleDate: (id: string | number, date: string) => {
onPatch(id, applySchedulePatch(date, false));
if (shouldNotifyCrossWeek(date, weekMonday)) {
rescheduleNotify(date, {
onNavigate: () => {
onNavigateToDay(date);
revealScheduledRow(id);
},
});
return;
}
onNavigateToDay(date);
revealScheduledRow(id);
revealOrNotifyScheduledDay({
id,
date,
weekMonday,
onNavigateToDay,
revealScheduledRow,
});
},
onScheduleRange: (id: string | number, start: string, end: string) => {
onPatch(id, applyScheduleRangePatch(start, end));
revealOrNotifyScheduledDay({
id,
date: start,
weekMonday,
onNavigateToDay,
revealScheduledRow,
});
},
onScheduleWeek: (id: string | number, week: string) => {
onPatch(id, {
scheduledOn: "",
targetWeek: week,
scheduleWeekOnly: true,
scheduleEnd: "",
schedMode: "week",
dayGroup: "",
dayLabel: "",
});

View file

@ -78,7 +78,8 @@ function InlineScheduleOnCell({
weekDefault: boolean;
onPatch: (patch: Partial<WorkOrderTableDraft>) => void;
}) {
const hasSchedule = Boolean(draft.scheduledOn) || Boolean(draft.targetWeek);
const hasSchedule =
Boolean(draft.scheduledOn) || Boolean(draft.targetWeek) || Boolean(draft.scheduleEnd);
return (
<td className="group/cell" style={{ padding: "0 14px" }}>
<DateCell
@ -87,12 +88,16 @@ function InlineScheduleOnCell({
targetWeek={draft.targetWeek}
defaultMode={weekDefault ? "week" : "date"}
highlight={!draft.scheduledOn && !draft.targetWeek}
scheduleEnd={draft.scheduleEnd}
schedMode={draft.schedMode}
onSave={(date) =>
onPatch(
withAutoScheduleStatus(draft, {
scheduledOn: date,
scheduleWeekOnly: false,
targetWeek: "",
scheduleEnd: "",
schedMode: "date",
isAddOnOverride: false,
}),
)
@ -103,6 +108,20 @@ function InlineScheduleOnCell({
scheduledOn: "",
targetWeek: week,
scheduleWeekOnly: true,
scheduleEnd: "",
schedMode: "week",
isAddOnOverride: false,
}),
)
}
onSaveRange={(start, end) =>
onPatch(
withAutoScheduleStatus(draft, {
scheduledOn: start,
scheduleEnd: end,
schedMode: "range",
scheduleWeekOnly: false,
targetWeek: "",
isAddOnOverride: false,
}),
)
@ -115,6 +134,8 @@ function InlineScheduleOnCell({
scheduledOn: "",
targetWeek: "",
scheduleWeekOnly: false,
scheduleEnd: "",
schedMode: undefined,
isAddOnOverride: false,
}),
)

View file

@ -129,8 +129,11 @@ export function WoTableRowCells({
highlight={forceScheduleHighlight}
allowWeekMode
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)}
/>
</td>
<td

View file

@ -38,6 +38,7 @@ export type WoTableRowHandlers = {
) => void;
onScheduleDate: (id: string | number, date: string) => void;
onScheduleWeek: (id: string | number, week: string) => void;
onScheduleRange: (id: string | number, start: string, end: string) => void;
onDuplicateFound?: (existing: WorkOrderTableRow) => void;
onOpenSlideOver: (row: WorkOrderTableRow) => void;
onRequestComplete?: (row: WorkOrderTableRow) => void;
@ -66,6 +67,31 @@ type WoTableRowProps = {
handlers: WoTableRowHandlers;
};
function guardScheduleHandlers(
row: WorkOrderTableRow,
handlers: WoTableRowHandlers,
): WoTableRowHandlers {
return {
...handlers,
onPatch: (id, patch, options) => {
if (!canApplyWorkOrderTablePatch(row.status, patch)) return;
handlers.onPatch(id, patch, options);
},
onScheduleDate: (id, date) => {
if (!canApplyWorkOrderTablePatch(row.status, { scheduledOn: date })) return;
handlers.onScheduleDate(id, date);
},
onScheduleWeek: (id, week) => {
if (!canApplyWorkOrderTablePatch(row.status, { targetWeek: week })) return;
handlers.onScheduleWeek(id, week);
},
onScheduleRange: (id, start, end) => {
if (!canApplyWorkOrderTablePatch(row.status, { scheduledOn: start })) return;
handlers.onScheduleRange(id, start, end);
},
};
}
export function WoTableRow({
row,
allRows,
@ -88,21 +114,7 @@ export function WoTableRow({
const isCoreLocked = isWorkOrderCoreLocked(row.status);
const closedForEdit = isCanceled || isCoreLocked;
const guardedHandlers: WoTableRowHandlers = {
...handlers,
onPatch: (id, patch, options) => {
if (!canApplyWorkOrderTablePatch(row.status, patch)) return;
handlers.onPatch(id, patch, options);
},
onScheduleDate: (id, date) => {
if (!canApplyWorkOrderTablePatch(row.status, { scheduledOn: date })) return;
handlers.onScheduleDate(id, date);
},
onScheduleWeek: (id, week) => {
if (!canApplyWorkOrderTablePatch(row.status, { targetWeek: week })) return;
handlers.onScheduleWeek(id, week);
},
};
const guardedHandlers = guardScheduleHandlers(row, handlers);
const patchRow = (patch: Partial<WorkOrderTableRow>) => {
if (!canApplyWorkOrderTablePatch(row.status, patch)) return;

View file

@ -9,6 +9,7 @@ type UseWizardDateFieldStateArgs = {
allowWeekMode?: boolean;
targetWeek: string;
schedMode?: WizardSchedMode;
defaultMode?: "week" | "date";
};
function resolveInitialMode({
@ -17,9 +18,10 @@ function resolveInitialMode({
allowWeekMode,
value,
targetWeek,
defaultMode,
}: Pick<
UseWizardDateFieldStateArgs,
"allowRange" | "schedMode" | "allowWeekMode" | "value" | "targetWeek"
"allowRange" | "schedMode" | "allowWeekMode" | "value" | "targetWeek" | "defaultMode"
>): WizardSchedMode {
if (allowRange && schedMode === "range") {
return "range";
@ -27,6 +29,9 @@ function resolveInitialMode({
if (allowWeekMode && !value && targetWeek) {
return "week";
}
if (allowWeekMode && defaultMode) {
return defaultMode;
}
return "date";
}
@ -39,6 +44,7 @@ export function useWizardDateFieldState({
allowWeekMode,
targetWeek,
schedMode,
defaultMode,
}: UseWizardDateFieldStateArgs) {
const initialMode = resolveInitialMode({
allowRange,
@ -46,6 +52,7 @@ export function useWizardDateFieldState({
allowWeekMode,
value,
targetWeek,
defaultMode,
});
const [cellMode, setCellMode] = useState<WizardSchedMode>(initialMode);

View file

@ -43,6 +43,9 @@ const LOCAL_ONLY_KEYS = new Set<keyof WorkOrderTablePatch>([
/** Optimistic overlay only — backend owns isAddOn after schedule ops (SH-184). */
"isAddOn",
"primaryDispatchStatus",
/** Client-only Schedule On range overlay — API persists range start as scheduledDate. */
"scheduleEnd",
"schedMode",
]);
export function isLocalOnlyTablePatch(patch: WorkOrderTablePatch): boolean {

View file

@ -1,5 +1,5 @@
import type { WorkOrderAdditionalContact } from "./work-order-additional-contact";
import type { WizardWOStatus, WOType } from "./work-order-wizard";
import type { WizardSchedMode, WizardWOStatus, WOType } from "./work-order-wizard";
import type { WorkOrderSeverity } from "./work-order-severity";
import type { UpliftSummary } from "./work-order-uplift";
@ -65,6 +65,10 @@ export interface WorkOrderTableRow {
originalDate: string;
isPastDue: boolean;
scheduleWeekOnly: boolean;
/** Optimistic range end; API persists only `scheduledOn` (range start). */
scheduleEnd?: string;
/** Last Schedule On mode used in this session. */
schedMode?: WizardSchedMode;
mediaCount: number;
title: string;
priority: string;
@ -105,6 +109,8 @@ export interface WorkOrderTableDraft {
additionalContacts: WorkOrderAdditionalContact[];
scheduleWeekOnly: boolean;
targetWeek: string;
scheduleEnd?: string;
schedMode?: WizardSchedMode;
/** Manual Add-On override when schedule is empty at inline create. */
isAddOnOverride: boolean;
}
@ -137,6 +143,8 @@ export const EMPTY_TABLE_DRAFT: WorkOrderTableDraft = {
additionalContacts: [],
scheduleWeekOnly: false,
targetWeek: "",
scheduleEnd: "",
schedMode: undefined,
isAddOnOverride: false,
};

View file

@ -220,6 +220,8 @@ export function applySchedulePatch(
scheduledOn: "",
targetWeek: scheduledOn,
scheduleWeekOnly: true,
scheduleEnd: "",
schedMode: "week",
...deriveDayFields(""),
};
}
@ -229,6 +231,8 @@ export function applySchedulePatch(
scheduledOn: "",
scheduleWeekOnly: false,
targetWeek: "",
scheduleEnd: "",
schedMode: undefined,
isAddOn: false,
...deriveDayFields(""),
};
@ -237,6 +241,17 @@ export function applySchedulePatch(
scheduledOn: trimmed,
scheduleWeekOnly: false,
targetWeek: "",
scheduleEnd: "",
schedMode: "date",
...deriveDayFields(trimmed),
};
}
/** Range lands on the first day; end/mode stay optimistic (no board API field). */
export function applyScheduleRangePatch(start: string, end: string): WorkOrderTablePatch {
return {
...applySchedulePatch(start, false),
scheduleEnd: end,
schedMode: "range",
};
}

View file

@ -93,6 +93,31 @@ describe("buildInlineDraftCreateInput", () => {
expect(payload).not.toHaveProperty("assignTo");
});
it("maps range mode so create persists the start as scheduledDate", () => {
const draft = {
...EMPTY_TABLE_DRAFT,
site: "BK5",
locationId: "9",
woNumber: "25007",
pocName: "Jane",
pocPhone: "555-0100",
scheduledOn: "2026-06-08",
scheduleEnd: "2026-06-12",
schedMode: "range" as const,
scheduleWeekOnly: false,
};
const input = buildInlineDraftCreateInput(draft, [], [{ id: 9, name: "BK5 · Dallas, TX" }]);
const payload = mapWizardDraftToBoardCreatePayload(input);
expect(input.schedMode).toBe("range");
expect(input.scheduleEnd).toBe("2026-06-12");
expect(payload.scheduledDate).toBe("2026-06-08");
expect(payload.scheduleWeekOnly).toBeUndefined();
expect(payload.targetWeek).toBeUndefined();
expect(payload).not.toHaveProperty("scheduleEnd");
});
it("promotes Pending to Scheduled when create has a concrete date", () => {
const draft = {
...EMPTY_TABLE_DRAFT,

View file

@ -0,0 +1,120 @@
import { fireEvent, screen } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import { DateCell } from "@/app/(protected)/workorders/_components/list/table/cells/date-cell";
import { DueDateCell } from "@/app/(protected)/workorders/_components/list/table/cells/due-date-cell";
import { pad2, todayIso } from "@/domain/work-orders/utils/wizard-date-utils";
import { renderWithProviders } from "@/test/test-utils";
function currentMonthIso(day: number): string {
const [y, m] = todayIso().split("-").map(Number);
return `${y}-${pad2(m)}-${pad2(day)}`;
}
async function openRangeTab(triggerName: RegExp | string) {
fireEvent.click(screen.getByRole("button", { name: triggerName }));
const rangeTab = await screen.findByRole("button", { name: "Range" });
fireEvent.click(rangeTab);
}
describe("DateCell — Schedule On range", () => {
it("shows Week, Date, and Range when onSaveRange is provided", async () => {
renderWithProviders(
<DateCell
value=""
allowWeekMode
onSave={vi.fn()}
onSaveWeek={vi.fn()}
onSaveRange={vi.fn()}
/>,
);
fireEvent.click(screen.getByRole("button", { name: "—" }));
expect(await screen.findByRole("button", { name: "Week" })).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Date" })).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Range" })).toBeInTheDocument();
expect(screen.queryByRole("button", { name: "Specific date" })).not.toBeInTheDocument();
}, 10_000);
it("keeps Week / Specific date and hides Range without onSaveRange", async () => {
renderWithProviders(<DateCell value="" allowWeekMode onSave={vi.fn()} onSaveWeek={vi.fn()} />);
fireEvent.click(screen.getByRole("button", { name: "—" }));
expect(await screen.findByRole("button", { name: "Week" })).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Specific date" })).toBeInTheDocument();
expect(screen.queryByRole("button", { name: "Range" })).not.toBeInTheDocument();
});
it("submits start and end via Done, including inverted clicks", async () => {
const onSaveRange = vi.fn();
renderWithProviders(
<DateCell
value=""
allowWeekMode
onSave={vi.fn()}
onSaveWeek={vi.fn()}
onSaveRange={onSaveRange}
/>,
);
await openRangeTab("—");
fireEvent.click(screen.getByRole("button", { name: "20" }));
fireEvent.click(screen.getByRole("button", { name: "5" }));
fireEvent.click(screen.getByRole("button", { name: /^done$/i }));
expect(onSaveRange).toHaveBeenCalledWith(currentMonthIso(5), currentMonthIso(20));
});
it("does not submit until both ends are picked", async () => {
const onSaveRange = vi.fn();
renderWithProviders(
<DateCell
value=""
allowWeekMode
onSave={vi.fn()}
onSaveWeek={vi.fn()}
onSaveRange={onSaveRange}
/>,
);
await openRangeTab("—");
fireEvent.click(screen.getByRole("button", { name: "5" }));
expect(screen.getByRole("button", { name: /^done$/i })).toBeDisabled();
expect(onSaveRange).not.toHaveBeenCalled();
});
it("does not persist when switching tabs without confirming", async () => {
const onSave = vi.fn();
const onSaveRange = vi.fn();
renderWithProviders(
<DateCell
value=""
allowWeekMode
onSave={onSave}
onSaveWeek={vi.fn()}
onSaveRange={onSaveRange}
/>,
);
await openRangeTab("—");
fireEvent.click(screen.getByRole("button", { name: "5" }));
fireEvent.click(screen.getByRole("button", { name: "Date" }));
expect(onSave).not.toHaveBeenCalled();
expect(onSaveRange).not.toHaveBeenCalled();
});
});
describe("DueDateCell — no Schedule On range tabs", () => {
it("does not offer Week / Date / Range on a PM due date", () => {
renderWithProviders(<DueDateCell type="PM" value="2026-07-15" onSave={vi.fn()} />);
fireEvent.click(screen.getByRole("button", { name: /07-15/ }));
expect(screen.queryByRole("button", { name: "Week" })).not.toBeInTheDocument();
expect(screen.queryByRole("button", { name: "Range" })).not.toBeInTheDocument();
expect(screen.queryByRole("button", { name: "Specific date" })).not.toBeInTheDocument();
});
});

View file

@ -0,0 +1,124 @@
import { fireEvent, screen } 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 type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
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,
}));
function currentMonthIso(day: number): string {
const [y, m] = todayIso().split("-").map(Number);
return `${y}-${pad2(m)}-${pad2(day)}`;
}
function baseRow(overrides: Partial<WorkOrderTableRow> = {}): WorkOrderTableRow {
return {
id: 42,
woNumber: "WO-42",
site: "SEA1",
type: "PM",
severity: null,
dispatcherId: "u1",
dispatcherName: "Dispatcher",
scheduledOn: "",
completedDate: "",
targetWeek: "",
dayGroup: "",
dayLabel: "",
location: "Seattle",
locationId: "loc-1",
pm: "HVAC",
serviceNotes: "",
extraServices: [],
company: "Vendor Co",
tech: "Tech",
techPhone: "",
vendorId: "v1",
apptStart: "",
apptEnd: "",
apptTime: "",
status: "Incomplete",
docStatus: "No",
pocName: "",
pocPhone: "",
pocNotes: "",
additionalContacts: [],
dueDate: "",
rescheduleCount: 0,
carriedOver: 0,
isAddOn: false,
originalWeek: "",
originalDate: "",
isPastDue: false,
scheduleWeekOnly: false,
mediaCount: 0,
title: "",
priority: "",
primaryDispatchId: null,
rowVersion: "v1",
dispatchRowVersion: "",
flagColor: null,
...overrides,
};
}
describe("SlideOverInfoTabEditView — Schedule On range", () => {
it("patches range start/end and schedMode from the DateCell", async () => {
const onDraftChange = vi.fn();
renderWithProviders(
<SlideOverInfoTabEditView
draft={baseRow()}
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: "Range" }));
fireEvent.click(screen.getByRole("button", { name: "5" }));
fireEvent.click(screen.getByRole("button", { name: "20" }));
fireEvent.click(screen.getByRole("button", { name: /^done$/i }));
expect(onDraftChange).toHaveBeenCalledWith(
expect.objectContaining({
scheduledOn: currentMonthIso(5),
scheduleEnd: currentMonthIso(20),
schedMode: "range",
scheduleWeekOnly: false,
targetWeek: "",
}),
);
}, 10_000);
});

View file

@ -1,7 +1,10 @@
import { renderHook } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { useWoTableRowHandlers } from "@/app/(protected)/workorders/_components/list/table/use-wo-table-row-handlers";
import { applySchedulePatch } from "@/domain/work-orders/use-cases/use-work-order-table-mutations";
import {
applySchedulePatch,
applyScheduleRangePatch,
} from "@/domain/work-orders/use-cases/use-work-order-table-mutations";
const { rescheduleNotify, weekMoveNotify } = vi.hoisted(() => ({
rescheduleNotify: vi.fn(),
@ -25,6 +28,11 @@ function buildTableData(revealScheduledRow = vi.fn()) {
}
describe("useWoTableRowHandlers schedule navigation (SH-240)", () => {
beforeEach(() => {
rescheduleNotify.mockClear();
weekMoveNotify.mockClear();
});
it("selects the scheduled weekday in the same week so the row stays visible", () => {
const onNavigateToDay = vi.fn();
const onPatch = vi.fn();
@ -80,6 +88,34 @@ describe("useWoTableRowHandlers schedule navigation (SH-240)", () => {
expect(revealScheduledRow).toHaveBeenCalledWith("wo-1");
});
it("lands a range on the start day and reveals the row", () => {
const onNavigateToDay = vi.fn();
const onPatch = vi.fn();
const revealScheduledRow = vi.fn();
const { result } = renderHook(() =>
useWoTableRowHandlers({
tableData: buildTableData(revealScheduledRow),
weekMonday: "2026-05-18",
onNavigateToDay,
onOpenSlideOver: vi.fn(),
onOpenMedia: vi.fn(),
onPatch,
onScheduleHighlight: vi.fn(),
}),
);
result.current.onScheduleRange("wo-1", "2026-05-21", "2026-05-23");
expect(onPatch).toHaveBeenCalledWith(
"wo-1",
applyScheduleRangePatch("2026-05-21", "2026-05-23"),
);
expect(onNavigateToDay).toHaveBeenCalledWith("2026-05-21");
expect(revealScheduledRow).toHaveBeenCalledWith("wo-1");
expect(rescheduleNotify).not.toHaveBeenCalled();
});
it("opens Advanced Filters for week-only moves instead of the removed Unscheduled section", () => {
const onNavigateToDay = vi.fn();
const onOpenAdvancedFilters = vi.fn();

View file

@ -0,0 +1,58 @@
import { createRef } from "react";
import { fireEvent, screen } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import { WoTableInlineRowCells } from "@/app/(protected)/workorders/_components/list/table/wo-table-inline-row-cells";
import { EMPTY_TABLE_DRAFT } from "@/domain/work-orders/types/work-order-table-row";
import { pad2, todayIso } from "@/domain/work-orders/utils/wizard-date-utils";
import { renderWithProviders } from "@/test/test-utils";
function currentMonthIso(day: number): string {
const [y, m] = todayIso().split("-").map(Number);
return `${y}-${pad2(m)}-${pad2(day)}`;
}
describe("WoTableInlineRowCells — Schedule On range", () => {
it("patches schedMode range with start and end", async () => {
const onPatch = vi.fn();
renderWithProviders(
<table>
<tbody>
<tr>
<WoTableInlineRowCells
draft={EMPTY_TABLE_DRAFT}
weekDefault={false}
users={[]}
sites={[]}
vendors={[]}
pmTypes={[]}
allRows={[]}
siteCellRef={createRef()}
woCellRef={createRef()}
siteShowError={false}
siteError=""
woShowError={false}
onPatch={onPatch}
/>
</tr>
</tbody>
</table>,
);
const scheduleButtons = screen.getAllByRole("button", { name: "—" });
fireEvent.click(scheduleButtons[0]);
fireEvent.click(await screen.findByRole("button", { name: "Range" }));
fireEvent.click(screen.getByRole("button", { name: "5" }));
fireEvent.click(screen.getByRole("button", { name: "20" }));
fireEvent.click(screen.getByRole("button", { name: /^done$/i }));
expect(onPatch).toHaveBeenCalledWith(
expect.objectContaining({
scheduledOn: currentMonthIso(5),
scheduleEnd: currentMonthIso(20),
schedMode: "range",
scheduleWeekOnly: false,
targetWeek: "",
}),
);
}, 10_000);
});

View file

@ -65,6 +65,7 @@ const handlers: WoTableRowHandlers = {
onPatch: vi.fn(),
onScheduleDate: vi.fn(),
onScheduleWeek: vi.fn(),
onScheduleRange: vi.fn(),
onOpenSlideOver: vi.fn(),
onOpenCompDoc: vi.fn(),
onOpenUplifts: vi.fn(),

View file

@ -65,6 +65,7 @@ function handlers(overrides: Partial<WoTableRowHandlers> = {}): WoTableRowHandle
onPatch: vi.fn(),
onScheduleDate: vi.fn(),
onScheduleWeek: vi.fn(),
onScheduleRange: vi.fn(),
onOpenSlideOver: vi.fn(),
onReorder: vi.fn(),
onDragStart: vi.fn(),

View file

@ -64,6 +64,7 @@ const handlers: WoTableRowHandlers = {
onPatch: vi.fn(),
onScheduleDate: vi.fn(),
onScheduleWeek: vi.fn(),
onScheduleRange: vi.fn(),
onOpenSlideOver: vi.fn(),
onOpenCompDoc: vi.fn(),
onOpenUplifts: vi.fn(),

View file

@ -30,6 +30,10 @@ describe("isLocalOnlyTablePatch", () => {
it("treats primaryDispatchStatus overlay as local-only", () => {
expect(isLocalOnlyTablePatch({ primaryDispatchStatus: "" })).toBe(true);
});
it("treats scheduleEnd and schedMode as local-only overlays", () => {
expect(isLocalOnlyTablePatch({ scheduleEnd: "2026-06-12", schedMode: "range" })).toBe(true);
});
});
describe("expandTablePatchToBoardFieldOps", () => {
@ -123,6 +127,21 @@ describe("expandTablePatchToBoardFieldOps", () => {
]);
});
it("does not emit scheduleEnd or schedMode as board fields", () => {
const ops = expandTablePatchToBoardFieldOps({
scheduleWeekOnly: false,
scheduledOn: "2026-06-08",
targetWeek: "",
scheduleEnd: "2026-06-12",
schedMode: "range",
});
expect(ops).toEqual([
{ field: "scheduleWeekOnly", value: "false" },
{ field: "scheduledDate", value: "2026-06-08" },
{ field: "targetWeek", value: "" },
]);
});
it("emits additionalContacts as serialized JSON for board PATCH", () => {
expect(
expandTablePatchToBoardFieldOps({

View file

@ -26,6 +26,7 @@ vi.mock("react-toastify", () => ({
import { mapWorkOrderTableRow } from "@/domain/work-orders/mappers/work-order-table-row-mapper";
import {
applySchedulePatch,
applyScheduleRangePatch,
useWorkOrderTableMutations,
} from "@/domain/work-orders/use-cases/use-work-order-table-mutations";
import { queryKeys } from "@/infra/query-key/query-key";
@ -275,6 +276,16 @@ describe("applySchedulePatch isAddOn optimistic clear", () => {
expect(applySchedulePatch("2026-07-15", false).isAddOn).toBeUndefined();
expect(applySchedulePatch("2026-07-13", true).isAddOn).toBeUndefined();
});
it("keeps range end/mode optimistic while landing on the start day", () => {
expect(applyScheduleRangePatch("2026-06-08", "2026-06-12")).toMatchObject({
scheduledOn: "2026-06-08",
scheduleEnd: "2026-06-12",
schedMode: "range",
scheduleWeekOnly: false,
targetWeek: "",
});
});
});
describe("useWorkOrderTableMutations schedule isAddOn from BE", () => {