Merge branch 'dev' into feature/wo-advanced-filters-parity

This commit is contained in:
Alexandre Brandizzi 2026-08-18 17:46:53 -03:00 • committed by GitHub
commit 2c10d2b7cb
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
40 changed files with 870 additions and 143 deletions

View file

@ -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.

View file

@ -187,6 +187,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.

View file

@ -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 (
<label className="flex cursor-pointer items-center gap-1">
<input
type="checkbox"
checked={checked}
onChange={(event) => onChange(event.target.checked)}
aria-label="Mark as Add-On"
/>
<span className="text-[10px]" style={{ color: "var(--color-text-secondary)" }}>
Add-On
</span>
</label>
);
}

View file

@ -41,5 +41,6 @@ export function buildInlineDraftCreateInput(
pocNotes: draft.pocNotes,
scheduleWeekOnly,
targetWeek: draft.targetWeek || "",
isAddOnOverride: draft.isAddOnOverride,
};
}

View file

@ -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 WoNumberEditor({
draft,
invalid,
isChecking,
onDraftChange,
onCommit,
onCancel,
onBlur,
}: WoNumberEditorProps) {
return (
<input
autoFocus
disabled={isChecking}
inputMode="numeric"
value={draft}
onChange={(e) => 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();
function AddOnPill({ when }: { when: boolean }) {
if (!when) {
return null;
}
}}
onBlur={onBlur}
placeholder="12345678901"
aria-label="Work order number"
aria-invalid={invalid}
className="font-mono focus:outline-none"
return (
<span
title="Add-On"
className="inline-flex h-[18px] items-center rounded px-1.5 text-[10px] font-semibold"
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,
background: "var(--type-addon-bg)",
color: "var(--type-addon-text)",
}}
/>
>
Add-On
</span>
);
}
@ -104,6 +68,7 @@ export function EditableWONumberCell({
value,
rescheduleCount,
carriedOver = 0,
isAddOn = false,
q,
currentId,
allOrders,
@ -169,14 +134,14 @@ export function EditableWONumberCell({
return (
<div className="flex items-center gap-1.5">
{editing ? (
<WoNumberEditor
<EditableWONumberInput
draft={draft}
invalid={invalid}
isChecking={isChecking}
onDraftChange={setDraft}
onChange={setDraft}
onCommit={commit}
onCancel={cancel}
onBlur={handleBlur}
onCancel={cancel}
/>
) : (
<span
@ -196,6 +161,7 @@ export function EditableWONumberCell({
{value ? <HighlightText text={value} q={q} /> : "-"}
</span>
)}
<AddOnPill when={isAddOn} />
<CounterPill label="Reschedules" count={rescheduleCount} />
<CounterPill label="Carried over" count={carriedOver} />
</div>

View file

@ -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 (
<input
autoFocus
disabled={isChecking}
inputMode="numeric"
value={draft}
onChange={(event) => 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,
}}
/>
);
}

View file

@ -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.
</Text>
<button
@ -97,7 +97,7 @@ export function StatusCell({
}}
className="mt-2 block text-[11px] font-medium text-[var(--color-primary)]"
>
Update Due Date →
Update Schedule On →
</button>
</div>
</div>

View file

@ -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<WorkOrderTableDraft>) => void;
};
export function InlineWoNumberCell({ draft, allRows, onPatch }: InlineWoNumberCellProps) {
const isUnscheduled = Boolean(!draft.scheduledOn && !draft.targetWeek);
return (
<td style={{ padding: "0 14px" }}>
<div className="flex flex-col gap-1">
<EditableWONumberCell
value={draft.woNumber}
rescheduleCount={0}
isAddOn={Boolean(draft.isAddOnOverride && isUnscheduled)}
currentId="draft"
allOrders={allRows as never[]}
onSave={(woNumber) => onPatch({ woNumber })}
/>
<AddOnOverrideToggle
checked={draft.isAddOnOverride}
visible={isUnscheduled}
onChange={(isAddOnOverride) => onPatch({ isAddOnOverride })}
/>
</div>
</td>
);
}

View file

@ -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 (
<>
<td
@ -73,14 +76,22 @@ export function WoTableInlineIdentityCells({
style={{ padding: "8px 6px 0", verticalAlign: "top", whiteSpace: "nowrap" }}
>
<InlineCreateField showError={woShowError} error="Work order number is required">
<div className="flex flex-col gap-1">
<EditableWONumberCell
value={draft.woNumber}
rescheduleCount={0}
isAddOn={Boolean(draft.isAddOnOverride && isUnscheduled)}
currentId="draft"
allOrders={allRows as never[]}
onSave={(v) => onPatch({ woNumber: v })}
onDuplicateFound={onDuplicateFound}
/>
<AddOnOverrideToggle
checked={draft.isAddOnOverride}
visible={isUnscheduled}
onChange={(isAddOnOverride) => onPatch({ isAddOnOverride })}
/>
</div>
</InlineCreateField>
</td>
</>

View file

@ -63,6 +63,56 @@ function InlineMutedDashCell() {
);
}
function InlineScheduleOnCell({
draft,
weekDefault,
onPatch,
}: {
draft: WorkOrderTableDraft;
weekDefault: boolean;
onPatch: (patch: Partial<WorkOrderTableDraft>) => void;
}) {
const hasSchedule = Boolean(draft.scheduledOn) || Boolean(draft.targetWeek);
return (
<td className="group/cell" style={{ padding: "0 14px" }}>
<DateCell
value={draft.scheduledOn}
allowWeekMode
targetWeek={draft.targetWeek}
defaultMode={weekDefault ? "week" : "date"}
highlight={!draft.scheduledOn && !draft.targetWeek}
onSave={(date) =>
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
}
/>
</td>
);
}
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 (
<>
@ -112,23 +161,7 @@ export function WoTableInlineRowCells({
/>
</td>
<td className="group/cell" style={{ padding: "0 14px" }}>
<DateCell
value={draft.scheduledOn}
allowWeekMode
targetWeek={draft.targetWeek}
defaultMode={weekDefault ? "week" : "date"}
onSave={(date) => onPatch({ scheduledOn: date, scheduleWeekOnly: false, targetWeek: "" })}
onSaveWeek={(week) =>
onPatch({ scheduledOn: "", targetWeek: week, scheduleWeekOnly: true })
}
onClear={
hasSchedule
? () => onPatch({ scheduledOn: "", targetWeek: "", scheduleWeekOnly: false })
: undefined
}
/>
</td>
<InlineScheduleOnCell draft={draft} weekDefault={weekDefault} onPatch={onPatch} />
<td className="group/cell" style={{ padding: "0 14px" }}>
<DueDateCell

View file

@ -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}

View file

@ -4,7 +4,6 @@ export const WO_TABLE_TYPE_TEXT: Record<WOType, string> = {
Emergency: "var(--destructive)",
Reactive: "#e0701a",
PM: "var(--primary)",
"Add-On": "#7c3aed",
Overdue: "#9F1239",
};

View file

@ -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<WOType, ElementType> = {
PM: Wrench,
Reactive: Zap,
Emergency: Flame,
"Add-On": Plus,
Overdue: Clock,
};
@ -146,18 +145,7 @@ function WizardTypeScheduleDates({ draft, onUpdate, onPatch }: WizardTypeSchedul
<div>
<WizLabelRow
optional
onClear={
draft.scheduledOn !== "" || draft.scheduleWeekOnly || draft.scheduleEnd
? () =>
onPatch({
scheduledOn: "",
scheduleWeekOnly: false,
scheduleEnd: "",
schedMode: undefined,
targetWeek: "",
})
: undefined
}
onClear={hasWizardSchedule(draft) ? () => onPatch(clearSchedulePatch()) : undefined}
>
Schedule On
</WizLabelRow>
@ -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<WorkOrderWizardDraft> {
return {
scheduledOn: "",
scheduleWeekOnly: false,
scheduleEnd: "",
schedMode: undefined,
targetWeek: "",
isAddOnOverride: false,
};
}
function WizardAddOnOverrideField({
checked,
onChange,
}: {
checked: boolean;
onChange: (value: boolean) => void;
}) {
return (
<label className="flex cursor-pointer items-center gap-2">
<input
type="checkbox"
checked={checked}
onChange={(e) => onChange(e.target.checked)}
aria-label="Mark as Add-On"
/>
<Text as="p" variant="description" sx={{ fontSize: 13, margin: 0 }}>
Mark as Add-On
</Text>
</label>
);
}
export function WizardStepTypeSchedule({
draft,
users,
@ -310,6 +338,13 @@ export function WizardStepTypeSchedule({
</div>
<WizardTypeScheduleDates draft={draft} onUpdate={onUpdate} onPatch={onPatch} />
{Boolean(!hasWizardSchedule(draft)) && (
<WizardAddOnOverrideField
checked={Boolean(draft.isAddOnOverride)}
onChange={(value) => onUpdate("isAddOnOverride", value)}
/>
)}
</div>
);
}

View file

@ -31,7 +31,6 @@ const TYPE_TO_PRIORITY: Record<WOType, string> = {
Emergency: "Sev 1",
Reactive: "Sev 2",
Overdue: "Sev 2",
"Add-On": "Sev 3",
PM: "Sev 3",
};

View file

@ -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<WOType, WorkOrderTypeApi> = {
PM: WORK_ORDER_TYPE_API.PM,
@ -27,8 +28,6 @@ const WO_TYPE_TO_API: Record<WOType, WorkOrderTypeApi> = {
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) {

View file

@ -40,6 +40,8 @@ const LOCAL_ONLY_KEYS = new Set<keyof WorkOrderTablePatch>([
"isPastDue",
"originalWeek",
"originalDate",
/** Optimistic overlay only — backend owns isAddOn after schedule ops (SH-184). */
"isAddOn",
]);
export function isLocalOnlyTablePatch(patch: WorkOrderTablePatch): boolean {

View file

@ -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 {
@ -101,11 +102,50 @@ function readRowVersion(record: Record<string, unknown>, ...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<string, unknown>): 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<string, unknown>): 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<string, unknown>): 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;
}
@ -121,12 +161,12 @@ function readWoType(record: Record<string, unknown>): 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 "";
}
@ -264,6 +304,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"),

View file

@ -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<number, WOType | ""> = {
[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<number, WizardWOStatus | "Canceled"> = {
@ -72,6 +72,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;
@ -133,6 +138,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;

View file

@ -42,6 +42,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;
@ -82,6 +87,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 = {
@ -110,6 +117,7 @@ export const EMPTY_TABLE_DRAFT: WorkOrderTableDraft = {
pocNotes: "",
scheduleWeekOnly: false,
targetWeek: "",
isAddOnOverride: false,
};
export type WorkOrderDayGroup = {

View file

@ -2,7 +2,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];
@ -56,6 +57,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 = {
@ -86,6 +93,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. */
@ -140,11 +148,6 @@ export const WO_TYPE_META: Record<WOType, { desc: string; bg: string; fg: string
bg: "var(--type-emergency-bg)",
fg: "var(--type-emergency-text)",
},
"Add-On": {
desc: "Extra scope on an existing visit",
bg: "var(--type-addon-bg)",
fg: "var(--type-addon-text)",
},
Overdue: {
desc: "No client response — due date passed",
bg: "var(--type-overdue-bg)",

View file

@ -197,6 +197,10 @@ export function maybeAutoSchedulePatch(row: WorkOrderTableRow): WorkOrderTablePa
return {};
}
/**
* Build a schedule table patch. Clearing the schedule optimistically clears
* `isAddOn`; day/week moves leave `isAddOn` for the board PATCH response (SH-184).
*/
export function applySchedulePatch(
scheduledOn: string,
scheduleWeekOnly: boolean,
@ -209,10 +213,20 @@ export function applySchedulePatch(
...deriveDayFields(""),
};
}
const trimmed = scheduledOn.trim();
if (!trimmed) {
return {
scheduledOn,
scheduledOn: "",
scheduleWeekOnly: false,
targetWeek: "",
...deriveDayFields(scheduledOn),
isAddOn: false,
...deriveDayFields(""),
};
}
return {
scheduledOn: trimmed,
scheduleWeekOnly: false,
targetWeek: "",
...deriveDayFields(trimmed),
};
}

View file

@ -12,18 +12,18 @@ import { weekRange } from "@/domain/work-orders/utils/list-week-utils";
const TERMINAL_STATUSES = new Set(["Completed", "Canceled", "Done", "Cancelled"]);
export function computeIsPastDue(row: WorkOrderTableRow): boolean {
if (!row.dueDate || TERMINAL_STATUSES.has(row.status)) {
if (!row.scheduledOn || TERMINAL_STATUSES.has(row.status)) {
return false;
}
const today = new Date().toISOString().slice(0, 10);
return row.dueDate < today;
return row.scheduledOn.slice(0, 10) < today;
}
export function enrichTableRow(row: WorkOrderTableRow): WorkOrderTableRow {
return {
...row,
// Recompute from dueDate (same date-only rule as BE). Avoid sticky
// `apiTrue || compute` so a future Due Date clears Past Due immediately.
// Recompute from Schedule On (SH-185). Avoid sticky `apiTrue || compute`
// so a future Schedule On clears Past Due immediately.
isPastDue: computeIsPastDue(row),
};
}

View file

@ -0,0 +1,66 @@
import { addDaysIso, mondayOf } from "@/domain/work-orders/utils/wizard-date-utils";
const ISO_DATE = /^\d{4}-\d{2}-\d{2}$/;
/**
* Saturday of the week prior to the scheduledOn week (ISO date only).
* Cutoff instant is end of that day (23:59:59 local).
*
* SH-184: backend recalculates `isAddOn` whenever scheduling moves across weeks
* or is cleared, and owns audit. This helper is only a create-time FE hint.
*/
export function getAddonCutoffIso(scheduledOn: string): string | null {
const trimmed = scheduledOn.trim();
if (!ISO_DATE.test(trimmed)) {
return null;
}
return addDaysIso(mondayOf(trimmed), -2);
}
/**
* Preview whether Add-On would apply for a create-time schedule + createdAt.
* Returns null when scheduledOn is missing — callers may use a manual override.
*
* Not a freeze: after create (and on every schedule patch/clear) the backend is
* authoritative; FE must consume the returned `isAddOn` (SH-184).
*/
export function previewIsAddOnFromCutoff(input: {
createdAt: Date | string | number;
scheduledOn: string;
}): boolean | null {
const cutoffIso = getAddonCutoffIso(input.scheduledOn);
if (!cutoffIso) {
return null;
}
const createdAt = input.createdAt instanceof Date ? input.createdAt : new Date(input.createdAt);
if (Number.isNaN(createdAt.getTime())) {
return null;
}
const cutoff = new Date(`${cutoffIso}T23:59:59`);
return createdAt.getTime() > 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);
}

View file

@ -72,6 +72,7 @@ function baseRow(overrides: Partial<WorkOrderTableRow> = {}): WorkOrderTableRow
rowVersion: "",
dispatchRowVersion: "",
flagColor: null,
isAddOn: false,
};
return {
...defaults,

View file

@ -84,6 +84,7 @@ function baseRow(): WorkOrderTableRow {
rowVersion: "ROW-VER==",
dispatchRowVersion: "",
flagColor: null,
isAddOn: false,
};
}

View file

@ -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(<StatusCell wo={pastDueRow} onChangeStatus={vi.fn()} onOpenSlideOver={vi.fn()} />);
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();
});
});

View file

@ -59,6 +59,7 @@ function baseRow(overrides: Partial<WorkOrderTableRow> = {}): WorkOrderTableRow
rowVersion: "",
dispatchRowVersion: "",
flagColor: null,
isAddOn: false,
};
return {
...defaults,

View file

@ -49,6 +49,7 @@ function baseInfo(overrides: Partial<WorkOrderTableRow> = {}): WorkOrderTableRow
rowVersion: "1",
dispatchRowVersion: "",
flagColor: null,
isAddOn: false,
...overrides,
};
}

View file

@ -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,
});
});

View file

@ -42,6 +42,7 @@ const unscheduledRow: WorkOrderTableRow = {
dueDate: "2026-07-15",
rescheduleCount: 0,
carriedOver: 0,
isAddOn: false,
originalWeek: "",
originalDate: "",
isPastDue: false,

View file

@ -92,6 +92,7 @@ function baseRow(overrides: Partial<WorkOrderTableRow> = {}): WorkOrderTableRow
rowVersion: "v1",
dispatchRowVersion: "",
flagColor: null,
isAddOn: false,
...overrides,
};
}

View file

@ -100,6 +100,7 @@ const canceledRow: WorkOrderTableRow = {
dueDate: "",
rescheduleCount: 0,
carriedOver: 0,
isAddOn: false,
originalWeek: "",
originalDate: "",
isPastDue: false,

View file

@ -121,6 +121,7 @@ describe("workOrdersApi.createFromBoard", () => {
siteCode: "BK5",
lifecycleStatus: 1,
woNumber: "12345",
isAddOn: false,
});
});
});

View file

@ -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",
});
@ -130,6 +181,8 @@ describe("mapWizardDraftToBoardCreatePayload", () => {
scheduledDate: "2026-06-25",
isAddOn: true,
vendorId: 5,
apptDate: "2026-06-25",

View file

@ -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);
});
});

View file

@ -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<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
@ -94,6 +97,7 @@ const BASE_ROW: WorkOrderTableRow = {
rowVersion: "AAAAAAA=",
dispatchRowVersion: "",
flagColor: null,
isAddOn: false,
};
describe("useWorkOrderTableMutations completion-doc persistence", () => {
@ -208,3 +212,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 }),
),
);
});
});

View file

@ -40,6 +40,7 @@ function baseRow(overrides: Partial<WorkOrderTableRow> = {}): WorkOrderTableRow
dueDate: "2026-07-20",
rescheduleCount: 0,
carriedOver: 0,
isAddOn: false,
originalWeek: "",
originalDate: "",
isPastDue: false,

View file

@ -73,6 +73,7 @@ function row(overrides: Partial<WorkOrderTableRow> = {}): WorkOrderTableRow {
rowVersion: "rv1",
dispatchRowVersion: "",
flagColor: null,
isAddOn: false,
...overrides,
};
}

View file

@ -50,6 +50,7 @@ function makeRow(overrides: Partial<WorkOrderTableRow> = {}): WorkOrderTableRow
dueDate: "2026-06-30",
rescheduleCount: 0,
carriedOver: 0,
isAddOn: false,
originalWeek: "",
originalDate: "",
isPastDue: false,
@ -155,43 +156,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({
@ -204,19 +205,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",
}),
),
@ -225,11 +226,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",
}),
@ -237,11 +238,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",
}),

View file

@ -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);
});
});