) => 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);
+ });
+});
|