Compare commits

..

14 commits

Author SHA1 Message Date
Codex Review Integration
14ff8c5481 style(work-orders): format SH-114 closability test 2026-08-18 17:56:29 -03:00
Codex Review Integration
4449e49149 fix(work-orders): align detail type mapping with Add-On indicator (SH-114)
Merging dev broke this branch: SH-126/SH-184 made Add-On a backend-authoritative
indicator (isAddOn) and removed it from the WOType union, but
read-detail-closability-fields still accepted "Add-On" and mapped "AddOn" onto
it, so tsc failed on both the mapper and the closability test.

Drop Add-On from the detail type set so a legacy payload normalizes to "", and
cover the normalization directly instead of asserting it through
detailToClosabilityInput, which only forwards the already-typed value.
2026-08-18 17:53:41 -03:00
Alexandre Brandizzi
5ad13d38ca
Merge branch 'dev' into feature/sh-114-wo-closability-gate 2026-08-18 17:46:59 -03:00
Arthur Bassi
d43341f77a
Merge pull request #102 from Sea-Haven-Industries/feature/sh-126-wo-addon-indicator
Some checks are pending
CI / ci (push) Waiting to run
CI / governance (push) Waiting to run
CI / vendor-visual-regression (push) Waiting to run
Deploy / deploy (push) Waiting to run
feat(work-orders): SH-126/SH-184 — Add-On indicator (BE-authoritative)
2026-08-18 11:58:38 -03:00
Arthur Bassi
8d1a3340a8 fix(work-orders): align Past Due overlay and copy to Schedule On (SH-185)
Dispatchers were told to change Due Date, which cannot clear Past Due.
Drive the table overlay from Schedule On and match the 422 contract.
2026-08-18 09:20:18 -03:00
Arthur Bassi
4a95da32d2 chore(work-orders): merge origin/dev and keep SH-184 Add-On with severity gates 2026-08-17 09:36:07 -03:00
Arthur Bassi
d3bfd35d9b chore(work-orders): merge origin/dev and keep SH-184 Add-On with wizard range 2026-08-14 10:06:56 -03:00
arthur.bassi
b39bb0020f chore(work-orders): merge dev into Add-On indicator branch
Integrate SH-189 duplicate WO dialog with SH-184 Add-On indicator:
preserve remote duplicate check, AddOnPill, and inline override toggle.
2026-08-13 13:19:18 -03:00
arthur.bassi
2ef0980273 chore(work-orders): incorporate remote tip into Add-On merge resolution 2026-08-12 16:12:33 -03:00
arthur.bassi
dc5140b495 chore(work-orders): resolve Add-On indicator conflicts with current dev 2026-08-12 16:09:56 -03:00
arthur.bassi
83ae05be27 chore(work-orders): resolve Add-On indicator conflicts with current dev 2026-08-12 16:07:26 -03:00
arthur.bassi
99724f831c fix(work-orders): adapt Add-On indicator unstack onto independently approvable base
Extract inline schedule cell for the maintainability gate, drop completedDate
patch coverage that is not on WorkOrderTableRow on dev, and align range create
expectations with assignWizardScheduleFields.
2026-08-11 15:42:29 -03:00
arthur.bassi
56f007bcf2 style(work-orders): format addon-indicator doc after unstack rebase 2026-08-11 15:13:51 -03:00
Arthur Bassi
8a2bc21845 fix(work-orders): sync addon-indicator with platform-polish parent
[recover] remove malicious eslint payload (was 692598b7)
2026-08-11 15:08:27 -03:00
42 changed files with 897 additions and 149 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

@ -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 AddOnPill({ when }: { when: boolean }) {
if (!when) {
return null;
}
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();
}
}}
onBlur={onBlur}
placeholder="12345678901"
aria-label="Work order number"
aria-invalid={invalid}
className="font-mono focus:outline-none"
<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

@ -54,7 +54,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)" }}
>
@ -88,7 +88,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
@ -102,7 +102,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">
<EditableWONumberCell
value={draft.woNumber}
rescheduleCount={0}
currentId="draft"
allOrders={allRows as never[]}
onSave={(v) => onPatch({ woNumber: v })}
onDuplicateFound={onDuplicateFound}
/>
<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

@ -5,7 +5,9 @@ import {
import type { CompletionDocStatus } from "@/domain/work-orders/types/work-order-table-row";
import type { WOType } from "@/domain/work-orders/types/work-order-wizard";
const DETAIL_WO_TYPES = new Set(["PM", "Reactive", "Emergency", "Add-On", "Overdue"]);
// SH-126/SH-184: Add-On is a backend-authoritative indicator (`isAddOn`), not a
// selectable business type, so it is no longer part of the WOType union.
const DETAIL_WO_TYPES = new Set(["PM", "Reactive", "Emergency", "Overdue"]);
function asRecord(raw: unknown): Record<string, unknown> {
return typeof raw === "object" && raw !== null ? (raw as Record<string, unknown>) : {};
@ -60,9 +62,6 @@ export function readDetailWoType(record: Record<string, unknown>): WOType | "" {
if (DETAIL_WO_TYPES.has(raw)) {
return raw as WOType;
}
if (raw === "AddOn") {
return "Add-On";
}
return "";
}

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: "",
scheduleWeekOnly: false,
targetWeek: "",
isAddOn: false,
...deriveDayFields(""),
};
}
return {
scheduledOn,
scheduledOn: trimmed,
scheduleWeekOnly: false,
targetWeek: "",
...deriveDayFields(scheduledOn),
...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,
@ -157,43 +158,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({
@ -206,19 +207,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",
}),
),
@ -227,11 +228,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",
}),
@ -239,11 +240,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);
});
});

View file

@ -1,5 +1,6 @@
import { describe, expect, it } from "vitest";
import type { WorkOrderDetail } from "@/domain/work-orders/types/work-order-detail";
import { readDetailWoType } from "@/domain/work-orders/mappers/read-detail-closability-fields";
import type { WoClosabilityInput } from "@/domain/work-orders/utils/wo-closability";
import {
detailToClosabilityInput,
@ -169,7 +170,10 @@ describe("detailToClosabilityInput", () => {
internalWONumber: "SH-1",
problem: "HVAC PM",
source: "procurement-integration",
type: "Add-On",
// SH-126/SH-184: Add-On is a backend-authoritative indicator (`isAddOn`), not a
// WOType, so it is no longer a valid value here. Normalization of a legacy
// "Add-On"/"AddOn" payload is covered by readDetailWoType.
type: "",
docStatus: "",
mediaCount: 0,
dispatches: [{ vendorName: "ACME Tech" }],
@ -182,7 +186,7 @@ describe("detailToClosabilityInput", () => {
expect(input.completedDate).toBe("2026-07-15");
expect(input.woNumber).toBe("20260623001");
expect(input.pm).toBe("HVAC PM");
expect(input.type).toBe("Add-On");
expect(input.type).toBe("");
expect(input.docStatus).toBe("");
expect(input.mediaCount).toBe(0);
expect(getClosabilityGaps(input)).not.toContain("Service");
@ -299,3 +303,21 @@ describe("detailToClosabilityInput", () => {
expect(getClosabilityGaps(detailToClosabilityInput(detail))).toEqual(["At least 1 photo"]);
});
});
describe("readDetailWoType", () => {
it.each(["PM", "Reactive", "Emergency", "Overdue"])(
"passes through the business type %s",
(type) => {
expect(readDetailWoType({ workOrderType: type })).toBe(type);
},
);
it.each(["Add-On", "AddOn"])(
"normalizes the legacy Add-On payload %s to empty (SH-126/SH-184)",
(legacy) => {
// Add-On became a backend-authoritative indicator (`isAddOn`) and is no longer a
// selectable business type, so a stale payload must not resolve to a WOType.
expect(readDetailWoType({ workOrderType: legacy })).toBe("");
},
);
});