From 8a2bc218456ff1d288e695d81e4c1d7c7b0bb4c0 Mon Sep 17 00:00:00 2001
From: Arthur Bassi <201806768+bassi-arthurr@users.noreply.github.com>
Date: Mon, 10 Aug 2026 17:12:15 -0300
Subject: [PATCH] fix(work-orders): sync addon-indicator with platform-polish
parent
[recover] remove malicious eslint payload (was 692598b7)
---
docs/work-orders/addon-indicator.md | 35 ++
docs/work-orders/board-search-api.md | 5 +
.../list/table/add-on-override-toggle.tsx | 25 ++
.../table/build-inline-draft-create-input.ts | 1 +
.../table/cells/editable-wo-number-cell.tsx | 73 ++--
.../table/cells/editable-wo-number-input.tsx | 53 +++
.../list/table/inline-wo-number-cell.tsx | 33 ++
.../list/table/wo-table-inline-row-cells.tsx | 28 +-
.../list/table/wo-table-row-cells.tsx | 1 +
.../list/table/wo-table-type-style.ts | 1 -
.../wizard/wizard-step-type-schedule.tsx | 34 +-
.../mappers/wizard-draft-mapper.ts | 1 -
.../mappers/work-order-board-mapper.ts | 12 +-
.../mappers/work-order-board-patch-mapper.ts | 2 +
.../mappers/work-order-table-row-mapper.ts | 49 ++-
.../work-orders/types/work-order-board.ts | 9 +-
.../work-orders/types/work-order-table-row.ts | 8 +
.../work-orders/types/work-order-wizard.ts | 15 +-
.../use-work-order-table-mutations.ts | 18 +-
.../work-orders/utils/wo-addon-cutoff.ts | 66 ++++
...ork-orders-board-core-affordances.test.tsx | 1 +
.../work-orders/api/work-orders-api.test.ts | 1 +
.../mappers/work-order-board-mapper.test.ts | 63 +++-
.../work-order-table-row-mapper.test.ts | 52 +++
.../use-work-order-table-mutations.test.tsx | 344 ++++++++++++++++++
.../utils/slide-over-draft.test.ts | 1 +
.../utils/table-filter-utils.test.ts | 1 +
.../work-orders/utils/wo-addon-cutoff.test.ts | 88 +++++
28 files changed, 944 insertions(+), 76 deletions(-)
create mode 100644 docs/work-orders/addon-indicator.md
create mode 100644 src/app/(protected)/workorders/_components/list/table/add-on-override-toggle.tsx
create mode 100644 src/app/(protected)/workorders/_components/list/table/cells/editable-wo-number-input.tsx
create mode 100644 src/app/(protected)/workorders/_components/list/table/inline-wo-number-cell.tsx
create mode 100644 src/domain/work-orders/utils/wo-addon-cutoff.ts
create mode 100644 src/test/domain/work-orders/mappers/work-order-table-row-mapper.test.ts
create mode 100644 src/test/domain/work-orders/use-cases/use-work-order-table-mutations.test.tsx
create mode 100644 src/test/domain/work-orders/utils/wo-addon-cutoff.test.ts
diff --git a/docs/work-orders/addon-indicator.md b/docs/work-orders/addon-indicator.md
new file mode 100644
index 00000000..9a193b5a
--- /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 53c2f381..cdf14e52 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
@@ -37,5 +37,6 @@ export function buildInlineDraftCreateInput(
pocNotes: draft.pocNotes,
scheduleWeekOnly: draft.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 ce4b3462..fcd181fd 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
@@ -4,6 +4,7 @@ import { toast } from "react-toastify";
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
+import { EditableWONumberInput } from "./editable-wo-number-input";
import { HighlightText } from "./highlight-text";
type EditableWONumberCellProps = {
@@ -13,6 +14,8 @@ type EditableWONumberCellProps = {
carriedOver?: number;
+ isAddOn?: boolean;
+
q?: string;
currentId: string | number;
@@ -46,6 +49,25 @@ function CounterPill({ label, count }: { label: string; count: number }) {
);
}
+function AddOnPill({ when }: { when: boolean }) {
+ if (!when) {
+ return null;
+ }
+
+ return (
+
+ Add-On
+
+ );
+}
+
export function EditableWONumberCell({
value,
@@ -53,6 +75,8 @@ export function EditableWONumberCell({
carriedOver = 0,
+ isAddOn = false,
+
q,
currentId,
@@ -137,48 +161,13 @@ export function EditableWONumberCell({
return (
{editing ? (
-
setDraft(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();
-
- commit();
- } else if (e.key === "Escape") {
- e.preventDefault();
-
- cancel();
- }
- }}
+
) : (
)}
+
+
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..972ce9c4
--- /dev/null
+++ b/src/app/(protected)/workorders/_components/list/table/cells/editable-wo-number-input.tsx
@@ -0,0 +1,53 @@
+type EditableWONumberInputProps = {
+ draft: string;
+ invalid: boolean;
+ onChange: (value: string) => void;
+ onCommit: () => void;
+ onBlur?: () => void;
+ onCancel: () => void;
+};
+
+export function EditableWONumberInput({
+ draft,
+ invalid,
+ 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/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-row-cells.tsx b/src/app/(protected)/workorders/_components/list/table/wo-table-inline-row-cells.tsx
index 2357be2e..0d76d00d 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
@@ -8,12 +8,12 @@ import { AssignedToCell } from "./cells/assigned-to-cell";
import { ApptTimeCell } from "./cells/appt-time-cell";
import { DateCell } from "./cells/date-cell";
import { DueDateCell } from "./cells/due-date-cell";
-import { EditableWONumberCell } from "./cells/editable-wo-number-cell";
import { PMTypeCell } from "./cells/pm-type-cell";
import { SiteCell } from "./cells/site-cell";
import { StatusCell } from "./cells/status-cell";
import { TypeCell } from "./cells/type-cell";
import { VendorCell } from "./cells/vendor-cell";
+import { InlineWoNumberCell } from "./inline-wo-number-cell";
import { toInlineDraftRow } from "./to-inline-draft-row";
type WoTableInlineRowCellsProps = {
@@ -67,15 +67,7 @@ export function WoTableInlineRowCells({
/>
-
- onPatch({ woNumber: v })}
- />
- |
+
onPatch({ type: t })} />
@@ -99,9 +91,21 @@ export function WoTableInlineRowCells({
targetWeek={draft.targetWeek}
defaultMode={weekDefault ? "week" : "date"}
highlight={!draft.scheduledOn && !draft.targetWeek}
- onSave={(date) => onPatch({ scheduledOn: date, scheduleWeekOnly: false, targetWeek: "" })}
+ onSave={(date) =>
+ onPatch({
+ scheduledOn: date,
+ scheduleWeekOnly: false,
+ targetWeek: "",
+ isAddOnOverride: false,
+ })
+ }
onSaveWeek={(week) =>
- onPatch({ scheduledOn: "", targetWeek: week, scheduleWeekOnly: true })
+ onPatch({
+ scheduledOn: "",
+ targetWeek: week,
+ scheduleWeekOnly: true,
+ 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 40f97933..28ea7a4a 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
@@ -71,6 +71,7 @@ export function WoTableRowCells({
value={row.woNumber}
rescheduleCount={row.rescheduleCount}
carriedOver={row.carriedOver}
+ isAddOn={row.isAddOn}
q={search}
currentId={row.id}
allOrders={allRows}
diff --git a/src/app/(protected)/workorders/_components/list/table/wo-table-type-style.ts b/src/app/(protected)/workorders/_components/list/table/wo-table-type-style.ts
index 23819e2a..d64eab65 100644
--- a/src/app/(protected)/workorders/_components/list/table/wo-table-type-style.ts
+++ b/src/app/(protected)/workorders/_components/list/table/wo-table-type-style.ts
@@ -4,7 +4,6 @@ export const WO_TABLE_TYPE_TEXT: Record = {
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 51c6f0d3..7227e8d5 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
@@ -14,14 +14,13 @@ import {
WizLabel,
WizLabelRow,
} from "@/app/(protected)/workorders/_components/wizard/wizard-labels";
-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,
};
@@ -186,8 +185,20 @@ export function WizardStepTypeSchedule({
targetWeek={draft.scheduleWeekOnly ? draft.scheduledOn : ""}
placeholder="Pick a date"
allowWeekMode
- onChange={(iso) => onPatch({ scheduledOn: iso, scheduleWeekOnly: false })}
- onChangeWeek={(w) => onPatch({ scheduledOn: w, scheduleWeekOnly: Boolean(w) })}
+ onChange={(iso) =>
+ onPatch({
+ scheduledOn: iso,
+ scheduleWeekOnly: false,
+ isAddOnOverride: false,
+ })
+ }
+ onChangeWeek={(w) =>
+ onPatch({
+ scheduledOn: w,
+ scheduleWeekOnly: Boolean(w),
+ isAddOnOverride: false,
+ })
+ }
/>
@@ -206,6 +217,21 @@ export function WizardStepTypeSchedule({
/>
+
+ {/* Manual Add-On only when there is no schedule date (cutoff needs a week). */}
+ {Boolean(!draft.scheduledOn) && (
+
+ )}
);
}
diff --git a/src/domain/work-orders/mappers/wizard-draft-mapper.ts b/src/domain/work-orders/mappers/wizard-draft-mapper.ts
index d15215c4..6d6775a6 100644
--- a/src/domain/work-orders/mappers/wizard-draft-mapper.ts
+++ b/src/domain/work-orders/mappers/wizard-draft-mapper.ts
@@ -30,7 +30,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 1fbdb364..2036151c 100644
--- a/src/domain/work-orders/mappers/work-order-board-mapper.ts
+++ b/src/domain/work-orders/mappers/work-order-board-mapper.ts
@@ -12,6 +12,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,
@@ -20,8 +21,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,
};
@@ -96,6 +95,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.woNumber.trim()) {
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 a12162c2..63d50eb7 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
@@ -2,6 +2,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 {
@@ -100,11 +101,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;
}
@@ -120,12 +160,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 "";
}
@@ -261,6 +301,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 021fe1c0..bac85127 100644
--- a/src/domain/work-orders/types/work-order-board.ts
+++ b/src/domain/work-orders/types/work-order-board.ts
@@ -41,11 +41,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 = {
@@ -70,6 +70,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;
@@ -127,6 +132,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 4bcb4b60..510b5d6a 100644
--- a/src/domain/work-orders/types/work-order-table-row.ts
+++ b/src/domain/work-orders/types/work-order-table-row.ts
@@ -37,6 +37,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;
@@ -76,6 +81,8 @@ export interface WorkOrderTableDraft {
pocNotes: string;
scheduleWeekOnly: boolean;
targetWeek: string;
+ /** Manual Add-On override when schedule is empty at inline create. */
+ isAddOnOverride: boolean;
}
export const EMPTY_TABLE_DRAFT: WorkOrderTableDraft = {
@@ -103,6 +110,7 @@ export const EMPTY_TABLE_DRAFT: WorkOrderTableDraft = {
pocNotes: "",
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 38cec358..2add447a 100644
--- a/src/domain/work-orders/types/work-order-wizard.ts
+++ b/src/domain/work-orders/types/work-order-wizard.ts
@@ -1,4 +1,5 @@
-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];
@@ -50,6 +51,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 = {
@@ -79,6 +86,7 @@ export const EMPTY_WIZARD_DRAFT: WorkOrderWizardDraft = {
targetWeek: "",
scheduleEnd: "",
schedMode: undefined,
+ isAddOnOverride: false,
};
/** WO number is optional; when provided must be digits only, 1–20 characters. */
@@ -124,11 +132,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/work-orders-board-core-affordances.test.tsx b/src/test/app/(protected)/workorders/work-orders-board-core-affordances.test.tsx
index 004e96d3..3ab80e4b 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
@@ -98,6 +98,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 af480a06..f641daa6 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
@@ -120,6 +120,7 @@ describe("workOrdersApi.createFromBoard", () => {
workOrderType: 2,
siteCode: "BK5",
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 3d8034a7..d5b04393 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();
@@ -55,9 +53,68 @@ describe("mapWizardDraftToBoardCreatePayload", () => {
workOrderType: 2,
siteCode: "BK5",
+
+ isAddOn: false,
});
});
+ 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 full wizard payload with structured service and notes fields", () => {
const payload = mapWizardDraftToBoardCreatePayload({
...EMPTY_WIZARD_DRAFT,
@@ -114,6 +171,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
new file mode 100644
index 00000000..6ce07630
--- /dev/null
+++ b/src/test/domain/work-orders/use-cases/use-work-order-table-mutations.test.tsx
@@ -0,0 +1,344 @@
+import type { ReactNode } from "react";
+import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
+import { act, renderHook, waitFor } from "@testing-library/react";
+import { beforeEach, describe, expect, it, vi } from "vitest";
+import type {
+ CompletionDocStatus,
+ WorkOrderTableRow,
+} from "@/domain/work-orders/types/work-order-table-row";
+
+const getById = vi.fn();
+const update = vi.fn();
+const patchBoardField = vi.fn();
+
+vi.mock("@/domain/work-orders/api/work-orders-api", () => ({
+ workOrdersApi: {
+ getById: (...args: unknown[]) => getById(...args),
+ update: (...args: unknown[]) => update(...args),
+ patchBoardField: (...args: unknown[]) => patchBoardField(...args),
+ },
+}));
+
+vi.mock("react-toastify", () => ({ toast: { error: vi.fn() } }));
+
+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));
+}
+
+function makeWrapper() {
+ const client = new QueryClient({
+ defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
+ });
+ return function Wrapper({ children }: { children: ReactNode }) {
+ return {children};
+ };
+}
+
+type ServerState = {
+ status: string;
+ scheduledDate: string;
+ completedDate: string;
+ docStatus: CompletionDocStatus;
+};
+
+const BASE_SERVER: ServerState = {
+ status: "Open",
+ scheduledDate: "2026-06-01",
+ completedDate: "",
+ docStatus: "No",
+};
+
+const BASE_ROW = {
+ id: 42,
+ woNumber: "WO-42",
+ site: "SEA1",
+ type: "PM",
+ dispatcherId: "d1",
+ dispatcherName: "Dispatcher",
+ scheduledOn: "2026-06-01",
+ completedDate: "",
+ targetWeek: "",
+ dayGroup: "",
+ dayLabel: "",
+ location: "Seattle",
+ locationId: "loc-1",
+ pm: "HVAC",
+ serviceNotes: "",
+ extraServices: [],
+ vendorId: "",
+ company: "",
+ tech: "",
+ techPhone: "",
+ apptStart: "",
+ apptEnd: "",
+ apptTime: "",
+ status: "Incomplete",
+ docStatus: "No",
+ pocName: "",
+ pocPhone: "",
+ pocNotes: "",
+ dueDate: "",
+ rescheduleCount: 0,
+ carriedOver: 0,
+ originalWeek: "",
+ originalDate: "",
+ isPastDue: false,
+ scheduleWeekOnly: false,
+ mediaCount: 0,
+ title: "PM — SEA1",
+ priority: "Sev 3",
+ primaryDispatchId: null,
+ rowVersion: "AAAAAAA=",
+ dispatchRowVersion: "",
+ flagColor: null,
+ isAddOn: false,
+} as WorkOrderTableRow;
+
+describe("useWorkOrderTableMutations completion-doc persistence", () => {
+ let serverState: ServerState;
+ let events: string[];
+ let rowState: WorkOrderTableRow;
+
+ beforeEach(() => {
+ getById.mockReset();
+ update.mockReset();
+ patchBoardField.mockReset();
+ serverState = { ...BASE_SERVER };
+ rowState = { ...BASE_ROW };
+ events = [];
+
+ patchBoardField.mockImplementation(
+ async (
+ _id: unknown,
+ payload: { field: string; value?: string | null; workOrderVersion: string },
+ ) => {
+ events.push(`boardPatch:${payload.field}:start`);
+ await delay(5);
+ if (payload.field === "docStatus" && typeof payload.value === "string") {
+ const docStatus =
+ payload.value === "NotNeeded" ? "NN" : (payload.value as CompletionDocStatus);
+ serverState = { ...serverState, docStatus };
+ rowState = {
+ ...rowState,
+ docStatus,
+ rowVersion: `${payload.workOrderVersion}-next`,
+ };
+ }
+ if (payload.field === "completedDate" && typeof payload.value === "string") {
+ serverState = { ...serverState, completedDate: payload.value };
+ rowState = {
+ ...rowState,
+ completedDate: payload.value,
+ rowVersion: `${payload.workOrderVersion}-date`,
+ };
+ }
+ events.push(`boardPatch:${payload.field}:end`);
+ return { ...rowState };
+ },
+ );
+ });
+
+ it("persists docStatus via board PATCH with NotNeeded for UI NN", async () => {
+ const onPatch = vi.fn();
+ const { result } = renderHook(
+ () =>
+ useWorkOrderTableMutations({
+ onPatch,
+ clearPatch: () => undefined,
+ getRow: () => rowState,
+ }),
+ { wrapper: makeWrapper() },
+ );
+
+ act(() => {
+ result.current.patchField(42, { docStatus: "NN" });
+ });
+
+ await waitFor(() => expect(events).toContain("boardPatch:docStatus:end"));
+
+ expect(patchBoardField).toHaveBeenCalledWith(42, {
+ field: "docStatus",
+ value: "NotNeeded",
+ workOrderVersion: "AAAAAAA=",
+ });
+ expect(update).not.toHaveBeenCalled();
+ expect(getById).not.toHaveBeenCalled();
+ expect(serverState.docStatus).toBe("NN");
+ });
+
+ it("serializes board docStatus and completedDate via PATCH without racing or EditWorkorder", async () => {
+ const onPatch = vi.fn();
+ const { result } = renderHook(
+ () =>
+ useWorkOrderTableMutations({
+ onPatch,
+ clearPatch: () => undefined,
+ getRow: () => rowState,
+ }),
+ { wrapper: makeWrapper() },
+ );
+
+ act(() => {
+ result.current.patchField(42, { docStatus: "Yes" });
+ result.current.patchField(42, { completedDate: "2026-07-20" });
+ });
+
+ await waitFor(() => expect(events).toContain("boardPatch:completedDate:end"));
+ await waitFor(() => expect(events).toContain("boardPatch:docStatus:end"));
+
+ expect(events).toEqual([
+ "boardPatch:docStatus:start",
+ "boardPatch:docStatus:end",
+ "boardPatch:completedDate:start",
+ "boardPatch:completedDate:end",
+ ]);
+
+ expect(getById).not.toHaveBeenCalled();
+ expect(update).not.toHaveBeenCalled();
+ expect(patchBoardField).toHaveBeenCalledWith(
+ 42,
+ expect.objectContaining({ field: "completedDate", value: "2026-07-20" }),
+ );
+
+ expect(serverState.docStatus).toBe("Yes");
+ expect(serverState.completedDate).toBe("2026-07-20");
+ expect(serverState.scheduledDate).toBe(BASE_SERVER.scheduledDate);
+ 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 25788b12..04ca3026 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
@@ -38,6 +38,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/table-filter-utils.test.ts b/src/test/domain/work-orders/utils/table-filter-utils.test.ts
index 4ff5dc93..706f8ec2 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
@@ -48,6 +48,7 @@ function makeRow(overrides: Partial = {}): WorkOrderTableRow
dueDate: "2026-06-30",
rescheduleCount: 0,
carriedOver: 0,
+ isAddOn: false,
originalWeek: "",
originalDate: "",
isPastDue: false,
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);
+ });
+});