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 ce70990a..1bc70fae 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 @@ -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) => void; +}) { + return ( + + 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({ /> - - onDraftChange({ scheduledOn, scheduleWeekOnly: false, isPastDue: false }) - } - onSaveWeek={(weekStart) => - onDraftChange({ - scheduledOn: "", - targetWeek: weekStart, - scheduleWeekOnly: true, - isPastDue: false, - }) - } - /> + + + + {fmtDateRange(value, scheduleEnd)} + + + ); + } + if (showWeek) { + return ( + <> + + + {fmtWeekOf(targetWeek)} + + + ); + } + return ( + <> + + {value ? ( + + + + ) : ( + — + )} + + ); +} 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 e0932926..8c709385 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,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(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({ } > - {showWeek ? ( - <> - - - {fmtWeekOf(targetWeek)} - - - ) : ( - <> - - {value ? ( - - - - ) : ( - — - )} - - )} + {Boolean(srStatusSuffix) && {srStatusSuffix}} setAnchorEl(null)} anchorOrigin={{ vertical: "bottom", horizontal: "left" }} onClick={(e) => e.stopPropagation()} slotProps={{ paper: { className: "rounded-lg border border-border p-3" } }} > - {Boolean(allowWeekMode) && ( -
- {(["week", "date"] as const).map((m) => { - const active = cellMode === m; - return ( - - ); - })} -
- )} + {allowWeekMode && cellMode === "week" ? ( + ) : allowRange && cellMode === "range" ? ( + { + onSaveRange?.(start, end); + setAnchorEl(null); + }} + /> ) : ( 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: "", }); diff --git a/src/app/(protected)/workorders/_components/list/table/wo-table-inline-row-cells.tsx b/src/app/(protected)/workorders/_components/list/table/wo-table-inline-row-cells.tsx index cde640c8..ebd484f4 100644 --- a/src/app/(protected)/workorders/_components/list/table/wo-table-inline-row-cells.tsx +++ b/src/app/(protected)/workorders/_components/list/table/wo-table-inline-row-cells.tsx @@ -78,7 +78,8 @@ function InlineScheduleOnCell({ weekDefault: boolean; onPatch: (patch: Partial) => void; }) { - const hasSchedule = Boolean(draft.scheduledOn) || Boolean(draft.targetWeek); + const hasSchedule = + Boolean(draft.scheduledOn) || Boolean(draft.targetWeek) || Boolean(draft.scheduleEnd); return ( 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, }), ) 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 c6fbde16..8d04a227 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 @@ -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)} /> 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) => { if (!canApplyWorkOrderTablePatch(row.status, patch)) return; diff --git a/src/app/(protected)/workorders/_components/wizard/use-wizard-date-field-state.ts b/src/app/(protected)/workorders/_components/wizard/use-wizard-date-field-state.ts index ae48b4b5..1f89ce5a 100644 --- a/src/app/(protected)/workorders/_components/wizard/use-wizard-date-field-state.ts +++ b/src/app/(protected)/workorders/_components/wizard/use-wizard-date-field-state.ts @@ -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(initialMode); diff --git a/src/domain/work-orders/mappers/work-order-board-patch-mapper.ts b/src/domain/work-orders/mappers/work-order-board-patch-mapper.ts index c5714dc6..66338697 100644 --- a/src/domain/work-orders/mappers/work-order-board-patch-mapper.ts +++ b/src/domain/work-orders/mappers/work-order-board-patch-mapper.ts @@ -43,6 +43,9 @@ const LOCAL_ONLY_KEYS = new Set([ /** 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 { diff --git a/src/domain/work-orders/types/work-order-table-row.ts b/src/domain/work-orders/types/work-order-table-row.ts index fa7b7a63..92040fa2 100644 --- a/src/domain/work-orders/types/work-order-table-row.ts +++ b/src/domain/work-orders/types/work-order-table-row.ts @@ -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, }; diff --git a/src/domain/work-orders/use-cases/use-work-order-table-mutations.ts b/src/domain/work-orders/use-cases/use-work-order-table-mutations.ts index f77d44a9..e2ffbd91 100644 --- a/src/domain/work-orders/use-cases/use-work-order-table-mutations.ts +++ b/src/domain/work-orders/use-cases/use-work-order-table-mutations.ts @@ -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", + }; +} diff --git a/src/test/app/(protected)/workorders/build-inline-draft-create-input.test.ts b/src/test/app/(protected)/workorders/build-inline-draft-create-input.test.ts index 674a9b63..593d4631 100644 --- a/src/test/app/(protected)/workorders/build-inline-draft-create-input.test.ts +++ b/src/test/app/(protected)/workorders/build-inline-draft-create-input.test.ts @@ -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, diff --git a/src/test/app/(protected)/workorders/date-cell-schedule-range.test.tsx b/src/test/app/(protected)/workorders/date-cell-schedule-range.test.tsx new file mode 100644 index 00000000..698918c4 --- /dev/null +++ b/src/test/app/(protected)/workorders/date-cell-schedule-range.test.tsx @@ -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( + , + ); + + 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(); + + 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( + , + ); + + 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( + , + ); + + 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( + , + ); + + 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(); + + 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(); + }); +}); 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 new file mode 100644 index 00000000..2fb72672 --- /dev/null +++ b/src/test/app/(protected)/workorders/slide-over-schedule-range.test.tsx @@ -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 { + 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( + , + ); + + 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); +}); 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 0f0ed39a..c4cc0867 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 @@ -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(); diff --git a/src/test/app/(protected)/workorders/wo-table-inline-schedule-range.test.tsx b/src/test/app/(protected)/workorders/wo-table-inline-schedule-range.test.tsx new file mode 100644 index 00000000..a276e515 --- /dev/null +++ b/src/test/app/(protected)/workorders/wo-table-inline-schedule-range.test.tsx @@ -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( + + + + + + +
, + ); + + 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); +}); diff --git a/src/test/app/(protected)/workorders/wo-table-row-completed-lock.test.tsx b/src/test/app/(protected)/workorders/wo-table-row-completed-lock.test.tsx index 2c02e58c..3bbed85f 100644 --- a/src/test/app/(protected)/workorders/wo-table-row-completed-lock.test.tsx +++ b/src/test/app/(protected)/workorders/wo-table-row-completed-lock.test.tsx @@ -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(), diff --git a/src/test/app/(protected)/workorders/wo-table-row-reorder-keyboard.test.tsx b/src/test/app/(protected)/workorders/wo-table-row-reorder-keyboard.test.tsx index 836eb62b..868d7412 100644 --- a/src/test/app/(protected)/workorders/wo-table-row-reorder-keyboard.test.tsx +++ b/src/test/app/(protected)/workorders/wo-table-row-reorder-keyboard.test.tsx @@ -65,6 +65,7 @@ function handlers(overrides: Partial = {}): WoTableRowHandle onPatch: vi.fn(), onScheduleDate: vi.fn(), onScheduleWeek: vi.fn(), + onScheduleRange: vi.fn(), onOpenSlideOver: vi.fn(), onReorder: vi.fn(), onDragStart: vi.fn(), diff --git a/src/test/app/(protected)/workorders/wo-table-unscheduled-highlight.test.tsx b/src/test/app/(protected)/workorders/wo-table-unscheduled-highlight.test.tsx index afa1a42b..645154c8 100644 --- a/src/test/app/(protected)/workorders/wo-table-unscheduled-highlight.test.tsx +++ b/src/test/app/(protected)/workorders/wo-table-unscheduled-highlight.test.tsx @@ -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(), diff --git a/src/test/domain/work-orders/mappers/work-order-board-patch-mapper.test.ts b/src/test/domain/work-orders/mappers/work-order-board-patch-mapper.test.ts index 0cb2c792..0d973632 100644 --- a/src/test/domain/work-orders/mappers/work-order-board-patch-mapper.test.ts +++ b/src/test/domain/work-orders/mappers/work-order-board-patch-mapper.test.ts @@ -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({ diff --git a/src/test/domain/work-orders/use-cases/use-work-order-table-mutations.test.tsx b/src/test/domain/work-orders/use-cases/use-work-order-table-mutations.test.tsx index ed614089..514f8991 100644 --- a/src/test/domain/work-orders/use-cases/use-work-order-table-mutations.test.tsx +++ b/src/test/domain/work-orders/use-cases/use-work-order-table-mutations.test.tsx @@ -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", () => {