diff --git a/docs/work-orders/addon-indicator.md b/docs/work-orders/addon-indicator.md new file mode 100644 index 00000000..78dcd8ed --- /dev/null +++ b/docs/work-orders/addon-indicator.md @@ -0,0 +1,35 @@ +# Add-On indicator (SH-184) + +Product rule superseding the SH-126 “frozen at create” FE assumption. + +## Authority + +| Concern | Owner | +| ------------------------------------- | -------- | +| Cutoff math & week-boundary recalc | Backend | +| Clear `isAddOn` when schedule cleared | Backend | +| Audit of every recalculation | Backend | +| Display / board overlay | Frontend | + +The frontend **must not** treat `isAddOn` as frozen after create. Create may +send a **hint** (`resolveIsAddOnForCreate` / cutoff preview or unscheduled +manual override). After any schedule patch or clear, FE consumes the +authoritative `isAddOn` returned by the board PATCH (and subsequent board +fetch). + +## FE contract + +``` +Board row / PATCH response: isAddOn: boolean +Create (hint): isAddOn = cutoff preview | manual override +Schedule day/week patch: FE applies returned row.isAddOn (ON↔OFF) +Schedule cleared: FE expects isAddOn === false from BE; + optimistic clear sets isAddOn: false locally +Legacy: workOrderType=AddOn(7) without isAddOn ⇒ isAddOn=true +``` + +## Dependency + +Full backend recalculation + audit may land in a separate BE PR. Until then, +FE still maps and overlays whatever `isAddOn` the API returns so the contract +is correct once BE ships. diff --git a/docs/work-orders/board-search-api.md b/docs/work-orders/board-search-api.md index fde9438b..0051e53a 100644 --- a/docs/work-orders/board-search-api.md +++ b/docs/work-orders/board-search-api.md @@ -143,6 +143,11 @@ Pending (`No`)=2, Uploaded (`Yes`)=1, N/N (`NN`)=3. Body típico: `{ "status": "Error", "message": "..." }` — o FE exibe `message` via `ApiError`. +## Add-On indicator + +See [addon-indicator.md](./addon-indicator.md) (SH-184): backend recalculates +`isAddOn` on schedule move/clear with audit; FE only reflects the API value. + ## Validação UI O side sheet de filtros avançados valida Custom (From/To obrigatórios e `dateTo >= dateFrom`) antes de aplicar, para evitar 400 desnecessário. diff --git a/src/app/(protected)/workorders/_components/list/table/add-on-override-toggle.tsx b/src/app/(protected)/workorders/_components/list/table/add-on-override-toggle.tsx new file mode 100644 index 00000000..d7dc4ada --- /dev/null +++ b/src/app/(protected)/workorders/_components/list/table/add-on-override-toggle.tsx @@ -0,0 +1,25 @@ +type AddOnOverrideToggleProps = { + checked: boolean; + visible: boolean; + onChange: (checked: boolean) => void; +}; + +export function AddOnOverrideToggle({ checked, visible, onChange }: AddOnOverrideToggleProps) { + if (!visible) { + return null; + } + + return ( + + ); +} diff --git a/src/app/(protected)/workorders/_components/list/table/build-inline-draft-create-input.ts b/src/app/(protected)/workorders/_components/list/table/build-inline-draft-create-input.ts index 675feede..68605ce8 100644 --- a/src/app/(protected)/workorders/_components/list/table/build-inline-draft-create-input.ts +++ b/src/app/(protected)/workorders/_components/list/table/build-inline-draft-create-input.ts @@ -42,5 +42,6 @@ export function buildInlineDraftCreateInput( additionalContacts: draft.additionalContacts ?? [], scheduleWeekOnly, targetWeek: draft.targetWeek || "", + isAddOnOverride: draft.isAddOnOverride, }; } diff --git a/src/app/(protected)/workorders/_components/list/table/cells/editable-wo-number-cell.tsx b/src/app/(protected)/workorders/_components/list/table/cells/editable-wo-number-cell.tsx index 83d4297c..bd92636f 100644 --- a/src/app/(protected)/workorders/_components/list/table/cells/editable-wo-number-cell.tsx +++ b/src/app/(protected)/workorders/_components/list/table/cells/editable-wo-number-cell.tsx @@ -3,12 +3,14 @@ import { toast } from "react-toastify"; import { workOrdersApi } from "@/domain/work-orders/api/work-orders-api"; import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row"; import { findDuplicateWoNumberRow } from "@/domain/work-orders/utils/find-duplicate-wo-number-row"; +import { EditableWONumberInput } from "./editable-wo-number-input"; import { HighlightText } from "./highlight-text"; type EditableWONumberCellProps = { value: string; rescheduleCount: number; carriedOver?: number; + isAddOn?: boolean; q?: string; currentId: string | number; allOrders: WorkOrderTableRow[]; @@ -33,60 +35,22 @@ function CounterPill({ label, count }: { label: string; count: number }) { ); } -type WoNumberEditorProps = { - draft: string; - invalid: boolean; - isChecking: boolean; - onDraftChange: (value: string) => void; - onCommit: () => void; - onCancel: () => void; - onBlur: () => void; -}; +function AddOnPill({ when }: { when: boolean }) { + if (!when) { + return null; + } -function WoNumberEditor({ - draft, - invalid, - isChecking, - onDraftChange, - onCommit, - onCancel, - onBlur, -}: WoNumberEditorProps) { return ( - onDraftChange(e.target.value.replace(/\D/g, "").slice(0, 11))} - onFocus={(e) => e.target.select()} - onClick={(e) => e.stopPropagation()} - onKeyDown={(e) => { - if (e.key === "Enter") { - e.preventDefault(); - onCommit(); - } else if (e.key === "Escape") { - e.preventDefault(); - onCancel(); - } - }} - onBlur={onBlur} - placeholder="12345678901" - aria-label="Work order number" - aria-invalid={invalid} - className="font-mono focus:outline-none" + + > + Add-On + ); } @@ -104,6 +68,7 @@ export function EditableWONumberCell({ value, rescheduleCount, carriedOver = 0, + isAddOn = false, q, currentId, allOrders, @@ -169,14 +134,14 @@ export function EditableWONumberCell({ return (
{editing ? ( - ) : ( : "-"} )} +
diff --git a/src/app/(protected)/workorders/_components/list/table/cells/editable-wo-number-input.tsx b/src/app/(protected)/workorders/_components/list/table/cells/editable-wo-number-input.tsx new file mode 100644 index 00000000..f4becca8 --- /dev/null +++ b/src/app/(protected)/workorders/_components/list/table/cells/editable-wo-number-input.tsx @@ -0,0 +1,56 @@ +type EditableWONumberInputProps = { + draft: string; + invalid: boolean; + isChecking?: boolean; + onChange: (value: string) => void; + onCommit: () => void; + onBlur?: () => void; + onCancel: () => void; +}; + +export function EditableWONumberInput({ + draft, + invalid, + isChecking = false, + onChange, + onCommit, + onBlur, + onCancel, +}: EditableWONumberInputProps) { + return ( + onChange(event.target.value.replace(/\D/g, "").slice(0, 11))} + onFocus={(event) => event.target.select()} + onClick={(event) => event.stopPropagation()} + onKeyDown={(event) => { + if (event.key === "Enter") { + event.preventDefault(); + onCommit(); + } else if (event.key === "Escape") { + event.preventDefault(); + onCancel(); + } + }} + onBlur={onBlur ?? onCommit} + placeholder="12345678901" + aria-label="Work order number" + aria-invalid={invalid} + className="font-mono focus:outline-none" + style={{ + fontSize: 13, + fontWeight: 700, + color: "var(--foreground)", + background: "var(--color-bg-surface)", + border: `1px solid ${invalid ? "var(--color-destructive)" : "var(--color-ring)"}`, + boxShadow: `0 0 0 1px ${invalid ? "var(--color-destructive)" : "var(--color-accent)"}`, + borderRadius: 4, + padding: "2px 4px", + width: 120, + }} + /> + ); +} diff --git a/src/app/(protected)/workorders/_components/list/table/cells/status-cell.tsx b/src/app/(protected)/workorders/_components/list/table/cells/status-cell.tsx index d2e4c78a..c5857f1e 100644 --- a/src/app/(protected)/workorders/_components/list/table/cells/status-cell.tsx +++ b/src/app/(protected)/workorders/_components/list/table/cells/status-cell.tsx @@ -49,7 +49,7 @@ export function StatusCell({ setAnchorEl(e.currentTarget); }} - title="This WO missed its deadline. Update the Due Date to unlock status updates." + title="This WO missed its deadline. Update Schedule On to unlock status updates." className="group/cell flex w-full cursor-pointer items-center gap-1.5 whitespace-nowrap leading-none transition-opacity hover:opacity-80" style={{ fontSize: 12.5, fontWeight: 600, color: "var(--status-pastdue-text)" }} > @@ -83,7 +83,7 @@ export function StatusCell({ color: "var(--status-pending-text)", }} > - This work order is past due. Update the Due Date before the status can be updated. + This work order is past due. Update Schedule On before the status can be updated. diff --git a/src/app/(protected)/workorders/_components/list/table/inline-wo-number-cell.tsx b/src/app/(protected)/workorders/_components/list/table/inline-wo-number-cell.tsx new file mode 100644 index 00000000..8b3b196f --- /dev/null +++ b/src/app/(protected)/workorders/_components/list/table/inline-wo-number-cell.tsx @@ -0,0 +1,33 @@ +import type { WorkOrderTableDraft } from "@/domain/work-orders/types/work-order-table-row"; +import { AddOnOverrideToggle } from "./add-on-override-toggle"; +import { EditableWONumberCell } from "./cells/editable-wo-number-cell"; + +type InlineWoNumberCellProps = { + draft: WorkOrderTableDraft; + allRows: { id: string | number; woNumber: string }[]; + onPatch: (patch: Partial) => void; +}; + +export function InlineWoNumberCell({ draft, allRows, onPatch }: InlineWoNumberCellProps) { + const isUnscheduled = Boolean(!draft.scheduledOn && !draft.targetWeek); + + return ( + +
+ onPatch({ woNumber })} + /> + onPatch({ isAddOnOverride })} + /> +
+ + ); +} diff --git a/src/app/(protected)/workorders/_components/list/table/wo-table-inline-identity-cells.tsx b/src/app/(protected)/workorders/_components/list/table/wo-table-inline-identity-cells.tsx index 599d0876..47ca7b46 100644 --- a/src/app/(protected)/workorders/_components/list/table/wo-table-inline-identity-cells.tsx +++ b/src/app/(protected)/workorders/_components/list/table/wo-table-inline-identity-cells.tsx @@ -4,6 +4,7 @@ import type { WorkOrderTableDraft, WorkOrderTableRow, } from "@/domain/work-orders/types/work-order-table-row"; +import { AddOnOverrideToggle } from "./add-on-override-toggle"; import { EditableWONumberCell } from "./cells/editable-wo-number-cell"; import { SiteCell } from "./cells/site-cell"; import { InlineCreateField } from "./inline-create-field"; @@ -35,6 +36,8 @@ export function WoTableInlineIdentityCells({ onPatch, onDuplicateFound, }: WoTableInlineIdentityCellsProps) { + const isUnscheduled = Boolean(!draft.scheduledOn && !draft.targetWeek); + return ( <> - onPatch({ woNumber: v })} - onDuplicateFound={onDuplicateFound} - /> +
+ onPatch({ woNumber: v })} + onDuplicateFound={onDuplicateFound} + /> + onPatch({ isAddOnOverride })} + /> +
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 6669f53f..bcf6e105 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 @@ -63,6 +63,56 @@ function InlineMutedDashCell() { ); } +function InlineScheduleOnCell({ + draft, + weekDefault, + onPatch, +}: { + draft: WorkOrderTableDraft; + weekDefault: boolean; + onPatch: (patch: Partial) => void; +}) { + const hasSchedule = Boolean(draft.scheduledOn) || Boolean(draft.targetWeek); + return ( + + + onPatch({ + scheduledOn: date, + scheduleWeekOnly: false, + targetWeek: "", + isAddOnOverride: false, + }) + } + onSaveWeek={(week) => + onPatch({ + scheduledOn: "", + targetWeek: week, + scheduleWeekOnly: true, + isAddOnOverride: false, + }) + } + onClear={ + hasSchedule + ? () => + onPatch({ + scheduledOn: "", + targetWeek: "", + scheduleWeekOnly: false, + isAddOnOverride: false, + }) + : undefined + } + /> + + ); +} + export function WoTableInlineRowCells({ draft, weekDefault, @@ -81,7 +131,6 @@ export function WoTableInlineRowCells({ onDuplicateFound, }: WoTableInlineRowCellsProps) { const draftRow = toInlineDraftRow(draft); - const hasSchedule = Boolean(draft.scheduledOn) || Boolean(draft.targetWeek); return ( <> @@ -111,23 +160,8 @@ export function WoTableInlineRowCells({ }} /> - - onPatch({ scheduledOn: date, scheduleWeekOnly: false, targetWeek: "" })} - onSaveWeek={(week) => - onPatch({ scheduledOn: "", targetWeek: week, scheduleWeekOnly: true }) - } - onClear={ - hasSchedule - ? () => onPatch({ scheduledOn: "", targetWeek: "", scheduleWeekOnly: false }) - : undefined - } - /> - + + = { Emergency: "var(--destructive)", Reactive: "#e0701a", PM: "var(--primary)", - "Add-On": "#7c3aed", Overdue: "#9F1239", }; diff --git a/src/app/(protected)/workorders/_components/wizard/wizard-step-type-schedule.tsx b/src/app/(protected)/workorders/_components/wizard/wizard-step-type-schedule.tsx index 9d7addd5..2b8f36b7 100644 --- a/src/app/(protected)/workorders/_components/wizard/wizard-step-type-schedule.tsx +++ b/src/app/(protected)/workorders/_components/wizard/wizard-step-type-schedule.tsx @@ -25,14 +25,13 @@ import { WizLabelRow, } from "@/app/(protected)/workorders/_components/wizard/wizard-labels"; import { WizardSeverityPicker } from "@/app/(protected)/workorders/_components/wizard/wizard-severity-picker"; -import { CheckCircle2, Clock, Flame, Plus, Users, Wrench, Zap } from "lucide-react"; +import { CheckCircle2, Clock, Flame, Users, Wrench, Zap } from "lucide-react"; import type { ElementType } from "react"; const WO_TYPE_ICONS: Record = { PM: Wrench, Reactive: Zap, Emergency: Flame, - "Add-On": Plus, Overdue: Clock, }; @@ -146,18 +145,7 @@ function WizardTypeScheduleDates({ draft, onUpdate, onPatch }: WizardTypeSchedul
- onPatch({ - scheduledOn: "", - scheduleWeekOnly: false, - scheduleEnd: "", - schedMode: undefined, - targetWeek: "", - }) - : undefined - } + onClear={hasWizardSchedule(draft) ? () => onPatch(clearSchedulePatch()) : undefined} > Schedule On @@ -175,6 +163,7 @@ function WizardTypeScheduleDates({ draft, onUpdate, onPatch }: WizardTypeSchedul scheduleEnd: "", schedMode: "date", targetWeek: "", + isAddOnOverride: false, }) } onChangeWeek={(w) => @@ -184,6 +173,7 @@ function WizardTypeScheduleDates({ draft, onUpdate, onPatch }: WizardTypeSchedul scheduleEnd: "", schedMode: "week", targetWeek: w, + isAddOnOverride: false, }) } onChangeRange={(s, e) => @@ -193,6 +183,7 @@ function WizardTypeScheduleDates({ draft, onUpdate, onPatch }: WizardTypeSchedul schedMode: "range", scheduleWeekOnly: false, targetWeek: "", + isAddOnOverride: false, }) } /> @@ -225,6 +216,43 @@ type WizardStepTypeScheduleProps = { onDuplicateWoNumber?: (woNumber: string) => void; }; +function hasWizardSchedule(draft: WorkOrderWizardDraft): boolean { + return draft.scheduledOn !== "" || Boolean(draft.scheduleWeekOnly) || Boolean(draft.scheduleEnd); +} + +function clearSchedulePatch(): Partial { + return { + scheduledOn: "", + scheduleWeekOnly: false, + scheduleEnd: "", + schedMode: undefined, + targetWeek: "", + isAddOnOverride: false, + }; +} + +function WizardAddOnOverrideField({ + checked, + onChange, +}: { + checked: boolean; + onChange: (value: boolean) => void; +}) { + return ( + + ); +} + export function WizardStepTypeSchedule({ draft, users, @@ -310,6 +338,13 @@ export function WizardStepTypeSchedule({
+ + {Boolean(!hasWizardSchedule(draft)) && ( + onUpdate("isAddOnOverride", value)} + /> + )} ); } diff --git a/src/domain/work-orders/mappers/wizard-draft-mapper.ts b/src/domain/work-orders/mappers/wizard-draft-mapper.ts index bb1a5d03..d2692d46 100644 --- a/src/domain/work-orders/mappers/wizard-draft-mapper.ts +++ b/src/domain/work-orders/mappers/wizard-draft-mapper.ts @@ -31,7 +31,6 @@ const TYPE_TO_PRIORITY: Record = { Emergency: "Sev 1", Reactive: "Sev 2", Overdue: "Sev 2", - "Add-On": "Sev 3", PM: "Sev 3", }; diff --git a/src/domain/work-orders/mappers/work-order-board-mapper.ts b/src/domain/work-orders/mappers/work-order-board-mapper.ts index bfcb345d..af00a55d 100644 --- a/src/domain/work-orders/mappers/work-order-board-mapper.ts +++ b/src/domain/work-orders/mappers/work-order-board-mapper.ts @@ -19,6 +19,7 @@ import { fmtDateRange } from "@/domain/work-orders/utils/wizard-date-utils"; import { assignWizardScheduleFields } from "@/domain/work-orders/mappers/assign-wizard-schedule-fields"; import { assignWizardVendorApptFields } from "@/domain/work-orders/mappers/assign-wizard-vendor-appt-fields"; import { assignWizardServicePocFields } from "@/domain/work-orders/mappers/assign-wizard-service-poc-fields"; +import { resolveIsAddOnForCreate } from "@/domain/work-orders/utils/wo-addon-cutoff"; const WO_TYPE_TO_API: Record = { PM: WORK_ORDER_TYPE_API.PM, @@ -27,8 +28,6 @@ const WO_TYPE_TO_API: Record = { Reactive: WORK_ORDER_TYPE_API.Reactive, - "Add-On": WORK_ORDER_TYPE_API.AddOn, - Overdue: WORK_ORDER_TYPE_API.Other, }; @@ -103,6 +102,15 @@ export function mapWizardDraftToBoardCreatePayload( workOrderType, siteCode, + + // Range start persists as scheduledDate (prototype hasScheduleDate). Use it for + // the create-time Add-On cutoff hint; week mode uses targetWeek / Monday. + isAddOn: resolveIsAddOnForCreate({ + scheduledOn: draft.scheduleWeekOnly + ? draft.targetWeek?.trim() || draft.scheduledOn + : draft.scheduledOn, + manualOverride: draft.isAddOnOverride, + }), }; if (draft.status) { 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 535ebb16..c3e55a58 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 @@ -40,6 +40,8 @@ const LOCAL_ONLY_KEYS = new Set([ "isPastDue", "originalWeek", "originalDate", + /** Optimistic overlay only — backend owns isAddOn after schedule ops (SH-184). */ + "isAddOn", ]); export function isLocalOnlyTablePatch(patch: WorkOrderTablePatch): boolean { diff --git a/src/domain/work-orders/mappers/work-order-table-row-mapper.ts b/src/domain/work-orders/mappers/work-order-table-row-mapper.ts index 07e33f3a..432e1e2d 100644 --- a/src/domain/work-orders/mappers/work-order-table-row-mapper.ts +++ b/src/domain/work-orders/mappers/work-order-table-row-mapper.ts @@ -3,6 +3,7 @@ import { mapApiStatusToWizard } from "@/domain/work-orders/mappers/wizard-draft- import { DOC_STATUS_API, LIFECYCLE_STATUS_API_TO_FE, + WORK_ORDER_TYPE_API, WORK_ORDER_TYPE_API_TO_FE, } from "@/domain/work-orders/types/work-order-board"; import type { @@ -102,11 +103,50 @@ function readRowVersion(record: Record, ...keys: string[]): str return ""; } -const WO_TYPES_SET = new Set(["PM", "Reactive", "Emergency", "Add-On", "Overdue"]); +const WO_TYPES_SET = new Set(["PM", "Reactive", "Emergency", "Overdue"]); + +function isLegacyAddOnType(record: Record): boolean { + const numeric = + readNumber(record, "workOrderType", "WorkOrderType") ?? readNumber(record, "type", "Type"); + if (numeric === WORK_ORDER_TYPE_API.AddOn) { + return true; + } + const raw = readString( + record, + "workOrderType", + "WorkOrderType", + "type", + "Type", + "woType", + "WoType", + "source", + "Source", + ); + return raw === "AddOn" || raw === "Add-On"; +} + +function readIsAddOn(record: Record): boolean { + for (const key of ["isAddOn", "IsAddOn"] as const) { + if (!(key in record)) continue; + const value = record[key]; + // Explicit boolean is authoritative; null/undefined fall through to legacy. + if (typeof value === "boolean") { + return value; + } + if (value == null) { + break; + } + return Boolean(value); + } + return isLegacyAddOnType(record); +} function readWoType(record: Record): WOType | "" { const numeric = readNumber(record, "workOrderType", "WorkOrderType") ?? readNumber(record, "type", "Type"); + if (numeric === WORK_ORDER_TYPE_API.AddOn) { + return ""; + } if (numeric != null && WORK_ORDER_TYPE_API_TO_FE[numeric]) { return WORK_ORDER_TYPE_API_TO_FE[numeric] as WOType; } @@ -122,12 +162,12 @@ function readWoType(record: Record): WOType | "" { "source", "Source", ); + if (raw === "AddOn" || raw === "Add-On") { + return ""; + } if (WO_TYPES_SET.has(raw)) { return raw as WOType; } - if (raw === "AddOn") { - return "Add-On"; - } return ""; } @@ -266,6 +306,7 @@ export function mapWorkOrderTableRow(raw: unknown): WorkOrderTableRow { dueDate: toDateInputValue(readString(item, "dueDate", "DueDate")), rescheduleCount: readNumber(item, "rescheduleCount", "RescheduleCount") ?? 0, carriedOver: readNumber(item, "carriedOver", "CarriedOver") ?? 0, + isAddOn: readIsAddOn(item), originalWeek: readString(item, "originalWeek", "OriginalWeek"), originalDate: toDateInputValue(readString(item, "originalDate", "OriginalDate")), isPastDue: readBool(item, "isPastDue", "IsPastDue"), diff --git a/src/domain/work-orders/types/work-order-board.ts b/src/domain/work-orders/types/work-order-board.ts index df0fe166..a6a751e0 100644 --- a/src/domain/work-orders/types/work-order-board.ts +++ b/src/domain/work-orders/types/work-order-board.ts @@ -42,11 +42,11 @@ export const LIFECYCLE_STATUS_API = { export type LifecycleStatusApi = (typeof LIFECYCLE_STATUS_API)[keyof typeof LIFECYCLE_STATUS_API]; +/** Maps API type enums to selectable FE types. Legacy AddOn(7) is not a type. */ export const WORK_ORDER_TYPE_API_TO_FE: Record = { [WORK_ORDER_TYPE_API.PM]: "PM", [WORK_ORDER_TYPE_API.Reactive]: "Reactive", [WORK_ORDER_TYPE_API.Emergency]: "Emergency", - [WORK_ORDER_TYPE_API.AddOn]: "Add-On", }; export const LIFECYCLE_STATUS_API_TO_FE: Record = { @@ -73,6 +73,11 @@ export interface WorkOrderBoardCreatePayload { scheduledDate?: string; targetWeek?: string; scheduleWeekOnly?: boolean; + /** + * Create-time Add-On hint only. Backend recalculates on schedule change/clear + * and owns audit (SH-184); FE must consume returned `isAddOn` afterward. + */ + isAddOn?: boolean; vendorId?: number; apptDate?: string; apptTime?: string; @@ -136,6 +141,8 @@ export interface WorkOrderBoardRowApi { woNumber?: string | null; rescheduleCount?: number; carriedOver?: number; + /** Backend-authoritative Add-On indicator (SH-184; recalculated on schedule). */ + isAddOn?: boolean | null; workOrderType?: number | null; isPastDue?: boolean; siteCode?: string | null; 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 c0c9a385..34ac0c35 100644 --- a/src/domain/work-orders/types/work-order-table-row.ts +++ b/src/domain/work-orders/types/work-order-table-row.ts @@ -44,6 +44,11 @@ export interface WorkOrderTableRow { dueDate: string; rescheduleCount: number; carriedOver: number; + /** + * Add-On indicator (not a WO type). Backend-authoritative (SH-184): + * recalculated on schedule week moves / clear; FE only reflects API value. + */ + isAddOn: boolean; originalWeek: string; originalDate: string; isPastDue: boolean; @@ -85,6 +90,8 @@ export interface WorkOrderTableDraft { additionalContacts: WorkOrderAdditionalContact[]; scheduleWeekOnly: boolean; targetWeek: string; + /** Manual Add-On override when schedule is empty at inline create. */ + isAddOnOverride: boolean; } export const EMPTY_TABLE_DRAFT: WorkOrderTableDraft = { @@ -114,6 +121,7 @@ export const EMPTY_TABLE_DRAFT: WorkOrderTableDraft = { additionalContacts: [], scheduleWeekOnly: false, targetWeek: "", + isAddOnOverride: false, }; export type WorkOrderDayGroup = { diff --git a/src/domain/work-orders/types/work-order-wizard.ts b/src/domain/work-orders/types/work-order-wizard.ts index c73c6952..cc4c1ad7 100644 --- a/src/domain/work-orders/types/work-order-wizard.ts +++ b/src/domain/work-orders/types/work-order-wizard.ts @@ -3,7 +3,8 @@ import { typeNeedsSeverity, type WorkOrderSeverity } from "./work-order-severity export type { WorkOrderSeverity } from "./work-order-severity"; -export const WO_TYPES = ["PM", "Reactive", "Emergency", "Add-On", "Overdue"] as const; +/** Selectable business types — Add-On is an indicator (`isAddOn`), not a type. */ +export const WO_TYPES = ["PM", "Reactive", "Emergency", "Overdue"] as const; export type WOType = (typeof WO_TYPES)[number]; @@ -59,6 +60,12 @@ export interface WorkOrderWizardDraft { scheduleEnd?: string; /** Last Schedule On mode used. */ schedMode?: WizardSchedMode; + /** + * Manual Add-On override when `scheduledOn` is empty at create. + * Ignored when a cutoff preview can be computed from schedule. + * Create hint only — backend remains authoritative after schedule patches (SH-184). + */ + isAddOnOverride?: boolean; } export const EMPTY_WIZARD_DRAFT: WorkOrderWizardDraft = { @@ -90,6 +97,7 @@ export const EMPTY_WIZARD_DRAFT: WorkOrderWizardDraft = { targetWeek: "", scheduleEnd: "", schedMode: undefined, + isAddOnOverride: false, }; /** Blank numbers receive an SH provisional number from the backend; nonblank values are alphanumeric. */ @@ -144,11 +152,6 @@ export const WO_TYPE_META: Record cutoff.getTime(); +} + +/** @deprecated Prefer `previewIsAddOnFromCutoff` — name implied a freeze (SH-126). */ +export const computeIsAddOnAtCreation = previewIsAddOnFromCutoff; + +/** + * Resolve create-payload `isAddOn` hint: cutoff preview when scheduled, else + * manual override. Backend may recalculate immediately and on later schedule + * patches/clears; FE must not treat this value as frozen (SH-184). + */ +export function resolveIsAddOnForCreate(input: { + createdAt?: Date | string | number; + scheduledOn: string; + manualOverride?: boolean; +}): boolean { + const computed = previewIsAddOnFromCutoff({ + createdAt: input.createdAt ?? new Date(), + scheduledOn: input.scheduledOn, + }); + if (computed !== null) { + return computed; + } + return Boolean(input.manualOverride); +} diff --git a/src/test/app/(protected)/workorders/comp-doc-dialog-completion-contract.test.tsx b/src/test/app/(protected)/workorders/comp-doc-dialog-completion-contract.test.tsx index ac28864e..3a8fb16b 100644 --- a/src/test/app/(protected)/workorders/comp-doc-dialog-completion-contract.test.tsx +++ b/src/test/app/(protected)/workorders/comp-doc-dialog-completion-contract.test.tsx @@ -73,6 +73,7 @@ function baseRow(overrides: Partial = {}): WorkOrderTableRow rowVersion: "", dispatchRowVersion: "", flagColor: null, + isAddOn: false, }; return { ...defaults, diff --git a/src/test/app/(protected)/workorders/media-upload-modal-api.test.tsx b/src/test/app/(protected)/workorders/media-upload-modal-api.test.tsx index 3faca690..f5c987b6 100644 --- a/src/test/app/(protected)/workorders/media-upload-modal-api.test.tsx +++ b/src/test/app/(protected)/workorders/media-upload-modal-api.test.tsx @@ -85,6 +85,7 @@ function baseRow(): WorkOrderTableRow { rowVersion: "ROW-VER==", dispatchRowVersion: "", flagColor: null, + isAddOn: false, }; } diff --git a/src/test/app/(protected)/workorders/slide-over-info-tab-edit-view-additional-contacts.test.tsx b/src/test/app/(protected)/workorders/slide-over-info-tab-edit-view-additional-contacts.test.tsx index 8093cb29..b1627674 100644 --- a/src/test/app/(protected)/workorders/slide-over-info-tab-edit-view-additional-contacts.test.tsx +++ b/src/test/app/(protected)/workorders/slide-over-info-tab-edit-view-additional-contacts.test.tsx @@ -104,6 +104,7 @@ function baseRow(overrides: Partial = {}): WorkOrderTableRow dueDate: "", rescheduleCount: 0, carriedOver: 0, + isAddOn: false, originalWeek: "", originalDate: "", isPastDue: false, diff --git a/src/test/app/(protected)/workorders/status-cell-past-due-copy.test.tsx b/src/test/app/(protected)/workorders/status-cell-past-due-copy.test.tsx new file mode 100644 index 00000000..961e830c --- /dev/null +++ b/src/test/app/(protected)/workorders/status-cell-past-due-copy.test.tsx @@ -0,0 +1,38 @@ +import { fireEvent, render, screen } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; +import { StatusCell } from "@/app/(protected)/workorders/_components/list/table/cells/status-cell"; +import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row"; + +const pastDueRow = { + id: "wo-1", + status: "Scheduled", + dispatcherId: "", + dispatcherName: "", + company: "", + tech: "", + completedDate: "", + woNumber: "", + pm: "", + docStatus: "", + mediaCount: 0, + type: "PM", + isPastDue: true, + scheduledOn: "2026-06-01", + dueDate: "2026-08-01", +} as WorkOrderTableRow; + +describe("StatusCell Past Due copy (SH-185)", () => { + it("directs the dispatcher to update Schedule On, not Due Date", () => { + render(); + + const trigger = screen.getByRole("button", { name: /past due/i }); + expect(trigger.getAttribute("title") ?? "").toContain("Schedule On"); + expect(trigger.getAttribute("title") ?? "").not.toMatch(/due date/i); + + fireEvent.click(trigger); + + expect(screen.getByText(/update schedule on before the status can be updated/i)).toBeTruthy(); + expect(screen.getByRole("button", { name: /update schedule on/i })).toBeTruthy(); + expect(screen.queryByText(/due date/i)).toBeNull(); + }); +}); diff --git a/src/test/app/(protected)/workorders/use-comp-doc-dialog-address-seed.test.tsx b/src/test/app/(protected)/workorders/use-comp-doc-dialog-address-seed.test.tsx index 970078e4..6c0b4e8e 100644 --- a/src/test/app/(protected)/workorders/use-comp-doc-dialog-address-seed.test.tsx +++ b/src/test/app/(protected)/workorders/use-comp-doc-dialog-address-seed.test.tsx @@ -60,6 +60,7 @@ function baseRow(overrides: Partial = {}): WorkOrderTableRow rowVersion: "", dispatchRowVersion: "", flagColor: null, + isAddOn: false, }; return { ...defaults, diff --git a/src/test/app/(protected)/workorders/use-slide-over-media-state.test.tsx b/src/test/app/(protected)/workorders/use-slide-over-media-state.test.tsx index 3c42695d..06acd120 100644 --- a/src/test/app/(protected)/workorders/use-slide-over-media-state.test.tsx +++ b/src/test/app/(protected)/workorders/use-slide-over-media-state.test.tsx @@ -50,6 +50,7 @@ function baseInfo(overrides: Partial = {}): WorkOrderTableRow rowVersion: "1", dispatchRowVersion: "", flagColor: null, + isAddOn: false, ...overrides, }; } diff --git a/src/test/app/(protected)/workorders/wizard-schedule-date-range.test.tsx b/src/test/app/(protected)/workorders/wizard-schedule-date-range.test.tsx index 7ef8223c..a79ac2e6 100644 --- a/src/test/app/(protected)/workorders/wizard-schedule-date-range.test.tsx +++ b/src/test/app/(protected)/workorders/wizard-schedule-date-range.test.tsx @@ -51,6 +51,7 @@ describe("WizardStepTypeSchedule — Schedule On range selection", () => { schedMode: "range", scheduleWeekOnly: false, targetWeek: "", + isAddOnOverride: false, }); }); @@ -84,6 +85,7 @@ describe("WizardStepTypeSchedule — Schedule On range selection", () => { schedMode: "range", scheduleWeekOnly: false, targetWeek: "", + isAddOnOverride: false, }); }); 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 302f7495..51dce5d3 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 @@ -43,6 +43,7 @@ const unscheduledRow: WorkOrderTableRow = { dueDate: "2026-07-15", rescheduleCount: 0, carriedOver: 0, + isAddOn: false, originalWeek: "", originalDate: "", isPastDue: false, diff --git a/src/test/app/(protected)/workorders/work-order-slide-over-failed-save.test.tsx b/src/test/app/(protected)/workorders/work-order-slide-over-failed-save.test.tsx index 9519a8a1..28f00d9b 100644 --- a/src/test/app/(protected)/workorders/work-order-slide-over-failed-save.test.tsx +++ b/src/test/app/(protected)/workorders/work-order-slide-over-failed-save.test.tsx @@ -93,6 +93,7 @@ function baseRow(overrides: Partial = {}): WorkOrderTableRow rowVersion: "v1", dispatchRowVersion: "", flagColor: null, + isAddOn: false, ...overrides, }; } diff --git a/src/test/app/(protected)/workorders/work-orders-board-core-affordances.test.tsx b/src/test/app/(protected)/workorders/work-orders-board-core-affordances.test.tsx index 77cf959b..06fe1751 100644 --- a/src/test/app/(protected)/workorders/work-orders-board-core-affordances.test.tsx +++ b/src/test/app/(protected)/workorders/work-orders-board-core-affordances.test.tsx @@ -101,6 +101,7 @@ const canceledRow: WorkOrderTableRow = { dueDate: "", rescheduleCount: 0, carriedOver: 0, + isAddOn: false, originalWeek: "", originalDate: "", isPastDue: false, diff --git a/src/test/domain/work-orders/api/work-orders-api.test.ts b/src/test/domain/work-orders/api/work-orders-api.test.ts index b343127a..76e6013a 100644 --- a/src/test/domain/work-orders/api/work-orders-api.test.ts +++ b/src/test/domain/work-orders/api/work-orders-api.test.ts @@ -121,6 +121,7 @@ describe("workOrdersApi.createFromBoard", () => { siteCode: "BK5", lifecycleStatus: 1, woNumber: "12345", + isAddOn: false, }); }); }); diff --git a/src/test/domain/work-orders/mappers/work-order-board-mapper.test.ts b/src/test/domain/work-orders/mappers/work-order-board-mapper.test.ts index 1acb2aa6..eff3b3fc 100644 --- a/src/test/domain/work-orders/mappers/work-order-board-mapper.test.ts +++ b/src/test/domain/work-orders/mappers/work-order-board-mapper.test.ts @@ -33,8 +33,6 @@ describe("mapWizardTypeToApi", () => { expect(mapWizardTypeToApi("Reactive")).toBe(WORK_ORDER_TYPE_API.Reactive); - expect(mapWizardTypeToApi("Add-On")).toBe(WORK_ORDER_TYPE_API.AddOn); - expect(mapWizardTypeToApi("Overdue")).toBe(WORK_ORDER_TYPE_API.Other); expect(mapWizardTypeToApi("")).toBeNull(); @@ -56,18 +54,71 @@ describe("mapWizardDraftToBoardCreatePayload", () => { siteCode: "BK5", + isAddOn: false, + lifecycleStatus: 1, }); }); + it("sets isAddOn from cutoff preview when scheduled (create hint; not frozen)", () => { + const payload = mapWizardDraftToBoardCreatePayload({ + ...EMPTY_WIZARD_DRAFT, + type: "PM", + siteCode: "BK5", + scheduledOn: "2026-07-08", + isAddOnOverride: false, + }); + + // createdAt defaults to now; after prior-Saturday 2026-07-04 for that week → true + expect(payload.isAddOn).toBe(true); + }); + + it("sets isAddOn from manual override when schedule is missing", () => { + const payload = mapWizardDraftToBoardCreatePayload({ + ...EMPTY_WIZARD_DRAFT, + type: "PM", + siteCode: "BK5", + isAddOnOverride: true, + }); + + expect(payload.isAddOn).toBe(true); + }); + + it("derives isAddOn from range start via cutoff (range persists scheduledDate)", () => { + const payload = mapWizardDraftToBoardCreatePayload({ + ...EMPTY_WIZARD_DRAFT, + type: "PM", + siteCode: "BK5", + scheduledOn: "2026-07-08", + scheduleEnd: "2026-07-10", + schedMode: "range", + isAddOnOverride: false, + }); + + expect(payload.scheduledDate).toBe("2026-07-08"); + // createdAt defaults to now; after prior-Saturday 2026-07-04 for that week → true + expect(payload.isAddOn).toBe(true); + }); + + it("uses manual Add-On override when range has no start date", () => { + const payload = mapWizardDraftToBoardCreatePayload({ + ...EMPTY_WIZARD_DRAFT, + type: "PM", + siteCode: "BK5", + scheduledOn: "", + scheduleEnd: "", + schedMode: "range", + isAddOnOverride: true, + }); + + expect(payload.isAddOn).toBe(true); + }); + it("maps wizard status to lifecycleStatus on create", () => { const payload = mapWizardDraftToBoardCreatePayload({ ...EMPTY_WIZARD_DRAFT, - type: "PM", - siteCode: "BK5", - status: "Scheduled", }); @@ -132,6 +183,8 @@ describe("mapWizardDraftToBoardCreatePayload", () => { scheduledDate: "2026-06-25", + isAddOn: true, + vendorId: 5, apptDate: "2026-06-25", diff --git a/src/test/domain/work-orders/mappers/work-order-table-row-mapper.test.ts b/src/test/domain/work-orders/mappers/work-order-table-row-mapper.test.ts new file mode 100644 index 00000000..93123601 --- /dev/null +++ b/src/test/domain/work-orders/mappers/work-order-table-row-mapper.test.ts @@ -0,0 +1,52 @@ +import { describe, expect, it } from "vitest"; + +import { mapWorkOrderTableRow } from "@/domain/work-orders/mappers/work-order-table-row-mapper"; +import { WORK_ORDER_TYPE_API } from "@/domain/work-orders/types/work-order-board"; + +describe("mapWorkOrderTableRow isAddOn", () => { + it("reads isAddOn from board DTO when present", () => { + const row = mapWorkOrderTableRow({ + id: 1, + workOrderType: WORK_ORDER_TYPE_API.PM, + isAddOn: true, + }); + expect(row.type).toBe("PM"); + expect(row.isAddOn).toBe(true); + }); + + it("maps legacy AddOn type to isAddOn without a selectable type", () => { + const row = mapWorkOrderTableRow({ + id: 2, + workOrderType: WORK_ORDER_TYPE_API.AddOn, + }); + expect(row.type).toBe(""); + expect(row.isAddOn).toBe(true); + }); + + it("prefers explicit isAddOn false over legacy AddOn type", () => { + const row = mapWorkOrderTableRow({ + id: 3, + workOrderType: WORK_ORDER_TYPE_API.AddOn, + isAddOn: false, + }); + expect(row.isAddOn).toBe(false); + }); + + it("falls back to legacy AddOn type when isAddOn is null", () => { + const row = mapWorkOrderTableRow({ + id: 5, + workOrderType: WORK_ORDER_TYPE_API.AddOn, + isAddOn: null, + }); + expect(row.isAddOn).toBe(true); + }); + + it("defaults isAddOn to false for normal types", () => { + const row = mapWorkOrderTableRow({ + id: 4, + workOrderType: WORK_ORDER_TYPE_API.Reactive, + }); + expect(row.type).toBe("Reactive"); + expect(row.isAddOn).toBe(false); + }); +}); 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 8297ebb9..9a071310 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 @@ -21,7 +21,10 @@ vi.mock("@/domain/work-orders/api/work-orders-api", () => ({ vi.mock("react-toastify", () => ({ toast: { error: vi.fn() } })); -import { useWorkOrderTableMutations } from "@/domain/work-orders/use-cases/use-work-order-table-mutations"; +import { + applySchedulePatch, + useWorkOrderTableMutations, +} from "@/domain/work-orders/use-cases/use-work-order-table-mutations"; function delay(ms: number): Promise { return new Promise((resolve) => setTimeout(resolve, ms)); @@ -95,6 +98,7 @@ const BASE_ROW: WorkOrderTableRow = { rowVersion: "AAAAAAA=", dispatchRowVersion: "", flagColor: null, + isAddOn: false, }; describe("useWorkOrderTableMutations completion-doc persistence", () => { @@ -209,3 +213,134 @@ describe("useWorkOrderTableMutations completion-doc persistence", () => { expect(serverState.status).toBe(BASE_SERVER.status); }); }); + +describe("applySchedulePatch isAddOn optimistic clear", () => { + it("clears isAddOn when schedule is cleared", () => { + expect(applySchedulePatch("", false)).toMatchObject({ + scheduledOn: "", + targetWeek: "", + scheduleWeekOnly: false, + isAddOn: false, + }); + }); + + it("does not set isAddOn on day or week moves (BE response owns it)", () => { + expect(applySchedulePatch("2026-07-15", false).isAddOn).toBeUndefined(); + expect(applySchedulePatch("2026-07-13", true).isAddOn).toBeUndefined(); + }); +}); + +describe("useWorkOrderTableMutations schedule isAddOn from BE", () => { + let rowState: WorkOrderTableRow; + + beforeEach(() => { + getById.mockReset(); + update.mockReset(); + patchBoardField.mockReset(); + rowState = { ...BASE_ROW, isAddOn: false, scheduledOn: "2026-06-01" }; + }); + + function mockSchedulePatchReturning(isAddOn: boolean) { + patchBoardField.mockImplementation( + async ( + _id: unknown, + payload: { field: string; value?: string | null; workOrderVersion: string }, + ) => { + if (payload.field === "scheduledDate") { + rowState = { + ...rowState, + scheduledOn: typeof payload.value === "string" ? payload.value : "", + isAddOn, + rowVersion: `${payload.workOrderVersion}-sched`, + }; + } else { + rowState = { + ...rowState, + rowVersion: `${payload.workOrderVersion}-noop`, + }; + } + return { ...rowState }; + }, + ); + } + + it("applies isAddOn ON when schedule patch response flips Add-On on", async () => { + mockSchedulePatchReturning(true); + + const onPatch = vi.fn(); + const { result } = renderHook( + () => + useWorkOrderTableMutations({ + onPatch, + clearPatch: () => undefined, + getRow: () => rowState, + }), + { wrapper: makeWrapper() }, + ); + + act(() => { + result.current.patchField(42, applySchedulePatch("2026-07-15", false)); + }); + + await waitFor(() => + expect(onPatch).toHaveBeenCalledWith( + 42, + expect.objectContaining({ scheduledOn: "2026-07-15", isAddOn: true }), + ), + ); + }); + + it("applies isAddOn OFF when schedule patch response flips Add-On off", async () => { + rowState = { ...rowState, isAddOn: true, scheduledOn: "2026-07-15" }; + mockSchedulePatchReturning(false); + + const onPatch = vi.fn(); + const { result } = renderHook( + () => + useWorkOrderTableMutations({ + onPatch, + clearPatch: () => undefined, + getRow: () => rowState, + }), + { wrapper: makeWrapper() }, + ); + + act(() => { + result.current.patchField(42, applySchedulePatch("2026-06-03", false)); + }); + + await waitFor(() => + expect(onPatch).toHaveBeenCalledWith( + 42, + expect.objectContaining({ scheduledOn: "2026-06-03", isAddOn: false }), + ), + ); + }); + + it("applies isAddOn false when schedule clear response clears Add-On", async () => { + rowState = { ...rowState, isAddOn: true, scheduledOn: "2026-07-15" }; + mockSchedulePatchReturning(false); + + const onPatch = vi.fn(); + const { result } = renderHook( + () => + useWorkOrderTableMutations({ + onPatch, + clearPatch: () => undefined, + getRow: () => rowState, + }), + { wrapper: makeWrapper() }, + ); + + act(() => { + result.current.patchField(42, applySchedulePatch("", false)); + }); + + await waitFor(() => + expect(onPatch).toHaveBeenCalledWith( + 42, + expect.objectContaining({ scheduledOn: "", isAddOn: false }), + ), + ); + }); +}); diff --git a/src/test/domain/work-orders/utils/slide-over-draft.test.ts b/src/test/domain/work-orders/utils/slide-over-draft.test.ts index ffd65fc0..c58d8d04 100644 --- a/src/test/domain/work-orders/utils/slide-over-draft.test.ts +++ b/src/test/domain/work-orders/utils/slide-over-draft.test.ts @@ -41,6 +41,7 @@ function baseRow(overrides: Partial = {}): WorkOrderTableRow dueDate: "2026-07-20", rescheduleCount: 0, carriedOver: 0, + isAddOn: false, originalWeek: "", originalDate: "", isPastDue: false, diff --git a/src/test/domain/work-orders/utils/slide-over-failed-save-baseline.test.ts b/src/test/domain/work-orders/utils/slide-over-failed-save-baseline.test.ts index 94d55f68..0b5ec1f6 100644 --- a/src/test/domain/work-orders/utils/slide-over-failed-save-baseline.test.ts +++ b/src/test/domain/work-orders/utils/slide-over-failed-save-baseline.test.ts @@ -74,6 +74,7 @@ function row(overrides: Partial = {}): WorkOrderTableRow { rowVersion: "rv1", dispatchRowVersion: "", flagColor: null, + isAddOn: false, ...overrides, }; } diff --git a/src/test/domain/work-orders/utils/table-filter-utils.test.ts b/src/test/domain/work-orders/utils/table-filter-utils.test.ts index 666d422c..484e108b 100644 --- a/src/test/domain/work-orders/utils/table-filter-utils.test.ts +++ b/src/test/domain/work-orders/utils/table-filter-utils.test.ts @@ -51,6 +51,7 @@ function makeRow(overrides: Partial = {}): WorkOrderTableRow dueDate: "2026-06-30", rescheduleCount: 0, carriedOver: 0, + isAddOn: false, originalWeek: "", originalDate: "", isPastDue: false, @@ -156,43 +157,43 @@ describe("matchesAdvancedFilters", () => { }); describe("computeIsPastDue", () => { - it("is true when dueDate is before today even if scheduledOn is future", () => { + it("is true when scheduledOn is before today even if dueDate is future", () => { expect( computeIsPastDue( makeRow({ - dueDate: shiftIso(-1), - scheduledOn: shiftIso(5), + dueDate: shiftIso(5), + scheduledOn: shiftIso(-1), status: "Scheduled", }), ), ).toBe(true); }); - it("is false when dueDate is future even if scheduledOn is past", () => { + it("is false when scheduledOn is future even if dueDate is past", () => { expect( computeIsPastDue( makeRow({ - dueDate: shiftIso(5), - scheduledOn: shiftIso(-1), + dueDate: shiftIso(-1), + scheduledOn: shiftIso(5), status: "Incomplete", }), ), ).toBe(false); }); - it("is false when dueDate is empty", () => { + it("is false when scheduledOn is empty", () => { expect( computeIsPastDue( makeRow({ - dueDate: "", - scheduledOn: shiftIso(-3), + dueDate: shiftIso(-3), + scheduledOn: "", status: "Scheduled", }), ), ).toBe(false); }); - it("is false for terminal statuses even with past dueDate", () => { + it("is false for terminal statuses even with past scheduledOn", () => { expect( computeIsPastDue( makeRow({ @@ -205,19 +206,19 @@ describe("computeIsPastDue", () => { expect( computeIsPastDue( makeRow({ - dueDate: shiftIso(-2), + scheduledOn: shiftIso(-2), status: "Canceled", }), ), ).toBe(false); }); - it("is false when dueDate is today", () => { + it("is false when scheduledOn is today", () => { expect( computeIsPastDue( makeRow({ - dueDate: todayIso(), - scheduledOn: shiftIso(-1), + dueDate: shiftIso(-1), + scheduledOn: todayIso(), status: "Scheduled", }), ), @@ -226,11 +227,11 @@ describe("computeIsPastDue", () => { }); describe("enrichTableRow", () => { - it("recomputes isPastDue from dueDate and ignores sticky API true", () => { + it("recomputes isPastDue from scheduledOn and ignores sticky API true", () => { const enriched = enrichTableRow( makeRow({ - dueDate: shiftIso(5), - scheduledOn: shiftIso(-1), + dueDate: shiftIso(-1), + scheduledOn: shiftIso(5), isPastDue: true, status: "Scheduled", }), @@ -238,11 +239,11 @@ describe("enrichTableRow", () => { expect(enriched.isPastDue).toBe(false); }); - it("sets isPastDue true from past dueDate even when API flag is false", () => { + it("sets isPastDue true from past scheduledOn even when API flag is false", () => { const enriched = enrichTableRow( makeRow({ - dueDate: shiftIso(-1), - scheduledOn: shiftIso(5), + dueDate: shiftIso(5), + scheduledOn: shiftIso(-1), isPastDue: false, status: "Incomplete", }), diff --git a/src/test/domain/work-orders/utils/wo-addon-cutoff.test.ts b/src/test/domain/work-orders/utils/wo-addon-cutoff.test.ts new file mode 100644 index 00000000..091ab621 --- /dev/null +++ b/src/test/domain/work-orders/utils/wo-addon-cutoff.test.ts @@ -0,0 +1,88 @@ +import { describe, expect, it } from "vitest"; + +import { + computeIsAddOnAtCreation, + getAddonCutoffIso, + previewIsAddOnFromCutoff, + resolveIsAddOnForCreate, +} from "@/domain/work-orders/utils/wo-addon-cutoff"; + +describe("getAddonCutoffIso", () => { + it("returns Saturday of the week prior to the scheduled week", () => { + // Week of Mon 2026-07-06 → prior Saturday 2026-07-04 + expect(getAddonCutoffIso("2026-07-08")).toBe("2026-07-04"); + expect(getAddonCutoffIso("2026-07-06")).toBe("2026-07-04"); + expect(getAddonCutoffIso("2026-07-10")).toBe("2026-07-04"); + }); + + it("returns null for missing or invalid scheduledOn", () => { + expect(getAddonCutoffIso("")).toBeNull(); + expect(getAddonCutoffIso("not-a-date")).toBeNull(); + }); +}); + +describe("previewIsAddOnFromCutoff", () => { + it("returns true when created after prior-Saturday 23:59", () => { + expect( + previewIsAddOnFromCutoff({ + createdAt: "2026-07-05T00:00:00", + scheduledOn: "2026-07-08", + }), + ).toBe(true); + }); + + it("returns false when created on or before prior-Saturday 23:59", () => { + expect( + previewIsAddOnFromCutoff({ + createdAt: "2026-07-04T23:59:59", + scheduledOn: "2026-07-08", + }), + ).toBe(false); + expect( + previewIsAddOnFromCutoff({ + createdAt: "2026-07-03T12:00:00", + scheduledOn: "2026-07-08", + }), + ).toBe(false); + }); + + it("returns null when scheduledOn is missing", () => { + expect( + previewIsAddOnFromCutoff({ + createdAt: new Date("2026-07-05T12:00:00"), + scheduledOn: "", + }), + ).toBeNull(); + }); + + it("keeps computeIsAddOnAtCreation as a deprecated alias", () => { + expect(computeIsAddOnAtCreation).toBe(previewIsAddOnFromCutoff); + }); +}); + +describe("resolveIsAddOnForCreate", () => { + it("uses cutoff preview when scheduledOn is set", () => { + expect( + resolveIsAddOnForCreate({ + createdAt: "2026-07-05T00:00:00", + scheduledOn: "2026-07-08", + manualOverride: false, + }), + ).toBe(true); + }); + + it("uses manual override when scheduledOn is missing", () => { + expect( + resolveIsAddOnForCreate({ + scheduledOn: "", + manualOverride: true, + }), + ).toBe(true); + expect( + resolveIsAddOnForCreate({ + scheduledOn: "", + manualOverride: false, + }), + ).toBe(false); + }); +});