fix(work-orders): sync addon-indicator with platform-polish parent

[recover] remove malicious eslint payload (was 692598b7)
This commit is contained in:
Arthur Bassi 2026-08-10 17:12:15 -03:00 • committed by arthur.bassi
parent 2cf33ca6c1
commit 8a2bc21845
28 changed files with 944 additions and 76 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

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

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

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

View file

@ -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 (
<span
title="Add-On"
className="inline-flex h-[18px] items-center rounded px-1.5 text-[10px] font-semibold"
style={{
background: "var(--type-addon-bg)",
color: "var(--type-addon-text)",
}}
>
Add-On
</span>
);
}
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 (
<div className="flex items-center gap-1.5">
{editing ? (
<input
autoFocus
inputMode="numeric"
value={draft}
onChange={(e) => 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();
}
}}
<EditableWONumberInput
draft={draft}
invalid={invalid}
onChange={setDraft}
onCommit={commit}
onBlur={handleBlur}
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,
}}
onCancel={cancel}
/>
) : (
<span
@ -204,6 +193,8 @@ export function EditableWONumberCell({
</span>
)}
<AddOnPill when={isAddOn} />
<CounterPill label="Reschedules" count={rescheduleCount} />
<CounterPill label="Carried over" count={carriedOver} />

View file

@ -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 (
<input
autoFocus
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

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

@ -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({
/>
</td>
<td style={{ padding: "0 14px" }}>
<EditableWONumberCell
value={draft.woNumber}
rescheduleCount={0}
currentId="draft"
allOrders={allRows as never[]}
onSave={(v) => onPatch({ woNumber: v })}
/>
</td>
<InlineWoNumberCell draft={draft} allRows={allRows} onPatch={onPatch} />
<td className="group/cell" style={{ padding: "0 14px" }}>
<TypeCell value={draft.type} onSave={(t) => 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,
})
}
/>
</td>

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

@ -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<WOType, ElementType> = {
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,
})
}
/>
</div>
@ -206,6 +217,21 @@ export function WizardStepTypeSchedule({
/>
</div>
</div>
{/* Manual Add-On only when there is no schedule date (cutoff needs a week). */}
{Boolean(!draft.scheduledOn) && (
<label className="flex cursor-pointer items-center gap-2">
<input
type="checkbox"
checked={Boolean(draft.isAddOnOverride)}
onChange={(e) => onUpdate("isAddOnOverride", 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>
)}
</div>
);
}

View file

@ -30,7 +30,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

@ -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<WOType, WorkOrderTypeApi> = {
PM: WORK_ORDER_TYPE_API.PM,
@ -20,8 +21,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,
};
@ -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()) {

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 {
@ -100,11 +101,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;
}
@ -120,12 +160,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 "";
}
@ -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"),

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"> = {
@ -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;

View file

@ -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 = {

View file

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

@ -157,6 +157,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,
@ -169,10 +173,20 @@ export function applySchedulePatch(
...deriveDayFields(""),
};
}
const trimmed = scheduledOn.trim();
if (!trimmed) {
return {
scheduledOn: "",
scheduleWeekOnly: false,
targetWeek: "",
isAddOn: false,
...deriveDayFields(""),
};
}
return {
scheduledOn,
scheduledOn: trimmed,
scheduleWeekOnly: false,
targetWeek: "",
...deriveDayFields(scheduledOn),
...deriveDayFields(trimmed),
};
}

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

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

View file

@ -120,6 +120,7 @@ describe("workOrdersApi.createFromBoard", () => {
workOrderType: 2,
siteCode: "BK5",
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();
@ -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",

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

@ -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<void> {
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 <QueryClientProvider client={client}>{children}</QueryClientProvider>;
};
}
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 }),
),
);
});
});

View file

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

View file

@ -48,6 +48,7 @@ function makeRow(overrides: Partial<WorkOrderTableRow> = {}): WorkOrderTableRow
dueDate: "2026-06-30",
rescheduleCount: 0,
carriedOver: 0,
isAddOn: false,
originalWeek: "",
originalDate: "",
isPastDue: false,

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