From 8a2bc218456ff1d288e695d81e4c1d7c7b0bb4c0 Mon Sep 17 00:00:00 2001 From: Arthur Bassi <201806768+bassi-arthurr@users.noreply.github.com> Date: Mon, 10 Aug 2026 17:12:15 -0300 Subject: [PATCH] fix(work-orders): sync addon-indicator with platform-polish parent [recover] remove malicious eslint payload (was 692598b7) --- docs/work-orders/addon-indicator.md | 35 ++ docs/work-orders/board-search-api.md | 5 + .../list/table/add-on-override-toggle.tsx | 25 ++ .../table/build-inline-draft-create-input.ts | 1 + .../table/cells/editable-wo-number-cell.tsx | 73 ++-- .../table/cells/editable-wo-number-input.tsx | 53 +++ .../list/table/inline-wo-number-cell.tsx | 33 ++ .../list/table/wo-table-inline-row-cells.tsx | 28 +- .../list/table/wo-table-row-cells.tsx | 1 + .../list/table/wo-table-type-style.ts | 1 - .../wizard/wizard-step-type-schedule.tsx | 34 +- .../mappers/wizard-draft-mapper.ts | 1 - .../mappers/work-order-board-mapper.ts | 12 +- .../mappers/work-order-board-patch-mapper.ts | 2 + .../mappers/work-order-table-row-mapper.ts | 49 ++- .../work-orders/types/work-order-board.ts | 9 +- .../work-orders/types/work-order-table-row.ts | 8 + .../work-orders/types/work-order-wizard.ts | 15 +- .../use-work-order-table-mutations.ts | 18 +- .../work-orders/utils/wo-addon-cutoff.ts | 66 ++++ ...ork-orders-board-core-affordances.test.tsx | 1 + .../work-orders/api/work-orders-api.test.ts | 1 + .../mappers/work-order-board-mapper.test.ts | 63 +++- .../work-order-table-row-mapper.test.ts | 52 +++ .../use-work-order-table-mutations.test.tsx | 344 ++++++++++++++++++ .../utils/slide-over-draft.test.ts | 1 + .../utils/table-filter-utils.test.ts | 1 + .../work-orders/utils/wo-addon-cutoff.test.ts | 88 +++++ 28 files changed, 944 insertions(+), 76 deletions(-) create mode 100644 docs/work-orders/addon-indicator.md create mode 100644 src/app/(protected)/workorders/_components/list/table/add-on-override-toggle.tsx create mode 100644 src/app/(protected)/workorders/_components/list/table/cells/editable-wo-number-input.tsx create mode 100644 src/app/(protected)/workorders/_components/list/table/inline-wo-number-cell.tsx create mode 100644 src/domain/work-orders/utils/wo-addon-cutoff.ts create mode 100644 src/test/domain/work-orders/mappers/work-order-table-row-mapper.test.ts create mode 100644 src/test/domain/work-orders/use-cases/use-work-order-table-mutations.test.tsx create mode 100644 src/test/domain/work-orders/utils/wo-addon-cutoff.test.ts diff --git a/docs/work-orders/addon-indicator.md b/docs/work-orders/addon-indicator.md new file mode 100644 index 00000000..9a193b5a --- /dev/null +++ b/docs/work-orders/addon-indicator.md @@ -0,0 +1,35 @@ +# Add-On indicator (SH-184) + +Product rule superseding the SH-126 “frozen at create” FE assumption. + +## Authority + +| Concern | Owner | +| ------------------------------- | -------- | +| Cutoff math & week-boundary recalc | Backend | +| Clear `isAddOn` when schedule cleared | Backend | +| Audit of every recalculation | Backend | +| Display / board overlay | Frontend | + +The frontend **must not** treat `isAddOn` as frozen after create. Create may +send a **hint** (`resolveIsAddOnForCreate` / cutoff preview or unscheduled +manual override). After any schedule patch or clear, FE consumes the +authoritative `isAddOn` returned by the board PATCH (and subsequent board +fetch). + +## FE contract + +``` +Board row / PATCH response: isAddOn: boolean +Create (hint): isAddOn = cutoff preview | manual override +Schedule day/week patch: FE applies returned row.isAddOn (ON↔OFF) +Schedule cleared: FE expects isAddOn === false from BE; + optimistic clear sets isAddOn: false locally +Legacy: workOrderType=AddOn(7) without isAddOn ⇒ isAddOn=true +``` + +## Dependency + +Full backend recalculation + audit may land in a separate BE PR. Until then, +FE still maps and overlays whatever `isAddOn` the API returns so the contract +is correct once BE ships. diff --git a/docs/work-orders/board-search-api.md b/docs/work-orders/board-search-api.md index fde9438b..0051e53a 100644 --- a/docs/work-orders/board-search-api.md +++ b/docs/work-orders/board-search-api.md @@ -143,6 +143,11 @@ Pending (`No`)=2, Uploaded (`Yes`)=1, N/N (`NN`)=3. Body típico: `{ "status": "Error", "message": "..." }` — o FE exibe `message` via `ApiError`. +## Add-On indicator + +See [addon-indicator.md](./addon-indicator.md) (SH-184): backend recalculates +`isAddOn` on schedule move/clear with audit; FE only reflects the API value. + ## Validação UI O side sheet de filtros avançados valida Custom (From/To obrigatórios e `dateTo >= dateFrom`) antes de aplicar, para evitar 400 desnecessário. diff --git a/src/app/(protected)/workorders/_components/list/table/add-on-override-toggle.tsx b/src/app/(protected)/workorders/_components/list/table/add-on-override-toggle.tsx new file mode 100644 index 00000000..d7dc4ada --- /dev/null +++ b/src/app/(protected)/workorders/_components/list/table/add-on-override-toggle.tsx @@ -0,0 +1,25 @@ +type AddOnOverrideToggleProps = { + checked: boolean; + visible: boolean; + onChange: (checked: boolean) => void; +}; + +export function AddOnOverrideToggle({ checked, visible, onChange }: AddOnOverrideToggleProps) { + if (!visible) { + return null; + } + + return ( + + ); +} diff --git a/src/app/(protected)/workorders/_components/list/table/build-inline-draft-create-input.ts b/src/app/(protected)/workorders/_components/list/table/build-inline-draft-create-input.ts index 53c2f381..cdf14e52 100644 --- a/src/app/(protected)/workorders/_components/list/table/build-inline-draft-create-input.ts +++ b/src/app/(protected)/workorders/_components/list/table/build-inline-draft-create-input.ts @@ -37,5 +37,6 @@ export function buildInlineDraftCreateInput( pocNotes: draft.pocNotes, scheduleWeekOnly: draft.scheduleWeekOnly, targetWeek: draft.targetWeek || "", + isAddOnOverride: draft.isAddOnOverride, }; } diff --git a/src/app/(protected)/workorders/_components/list/table/cells/editable-wo-number-cell.tsx b/src/app/(protected)/workorders/_components/list/table/cells/editable-wo-number-cell.tsx index ce4b3462..fcd181fd 100644 --- a/src/app/(protected)/workorders/_components/list/table/cells/editable-wo-number-cell.tsx +++ b/src/app/(protected)/workorders/_components/list/table/cells/editable-wo-number-cell.tsx @@ -4,6 +4,7 @@ import { toast } from "react-toastify"; import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row"; +import { EditableWONumberInput } from "./editable-wo-number-input"; import { HighlightText } from "./highlight-text"; type EditableWONumberCellProps = { @@ -13,6 +14,8 @@ type EditableWONumberCellProps = { carriedOver?: number; + isAddOn?: boolean; + q?: string; currentId: string | number; @@ -46,6 +49,25 @@ function CounterPill({ label, count }: { label: string; count: number }) { ); } +function AddOnPill({ when }: { when: boolean }) { + if (!when) { + return null; + } + + return ( + + Add-On + + ); +} + export function EditableWONumberCell({ value, @@ -53,6 +75,8 @@ export function EditableWONumberCell({ carriedOver = 0, + isAddOn = false, + q, currentId, @@ -137,48 +161,13 @@ export function EditableWONumberCell({ return (
{editing ? ( - setDraft(e.target.value.replace(/\D/g, "").slice(0, 11))} - onFocus={(e) => e.target.select()} - onClick={(e) => e.stopPropagation()} - onKeyDown={(e) => { - if (e.key === "Enter") { - e.preventDefault(); - - commit(); - } else if (e.key === "Escape") { - e.preventDefault(); - - cancel(); - } - }} + ) : ( )} + + diff --git a/src/app/(protected)/workorders/_components/list/table/cells/editable-wo-number-input.tsx b/src/app/(protected)/workorders/_components/list/table/cells/editable-wo-number-input.tsx new file mode 100644 index 00000000..972ce9c4 --- /dev/null +++ b/src/app/(protected)/workorders/_components/list/table/cells/editable-wo-number-input.tsx @@ -0,0 +1,53 @@ +type EditableWONumberInputProps = { + draft: string; + invalid: boolean; + onChange: (value: string) => void; + onCommit: () => void; + onBlur?: () => void; + onCancel: () => void; +}; + +export function EditableWONumberInput({ + draft, + invalid, + onChange, + onCommit, + onBlur, + onCancel, +}: EditableWONumberInputProps) { + return ( + onChange(event.target.value.replace(/\D/g, "").slice(0, 11))} + onFocus={(event) => event.target.select()} + onClick={(event) => event.stopPropagation()} + onKeyDown={(event) => { + if (event.key === "Enter") { + event.preventDefault(); + onCommit(); + } else if (event.key === "Escape") { + event.preventDefault(); + onCancel(); + } + }} + onBlur={onBlur ?? onCommit} + placeholder="12345678901" + aria-label="Work order number" + aria-invalid={invalid} + className="font-mono focus:outline-none" + style={{ + fontSize: 13, + fontWeight: 700, + color: "var(--foreground)", + background: "var(--color-bg-surface)", + border: `1px solid ${invalid ? "var(--color-destructive)" : "var(--color-ring)"}`, + boxShadow: `0 0 0 1px ${invalid ? "var(--color-destructive)" : "var(--color-accent)"}`, + borderRadius: 4, + padding: "2px 4px", + width: 120, + }} + /> + ); +} diff --git a/src/app/(protected)/workorders/_components/list/table/inline-wo-number-cell.tsx b/src/app/(protected)/workorders/_components/list/table/inline-wo-number-cell.tsx new file mode 100644 index 00000000..8b3b196f --- /dev/null +++ b/src/app/(protected)/workorders/_components/list/table/inline-wo-number-cell.tsx @@ -0,0 +1,33 @@ +import type { WorkOrderTableDraft } from "@/domain/work-orders/types/work-order-table-row"; +import { AddOnOverrideToggle } from "./add-on-override-toggle"; +import { EditableWONumberCell } from "./cells/editable-wo-number-cell"; + +type InlineWoNumberCellProps = { + draft: WorkOrderTableDraft; + allRows: { id: string | number; woNumber: string }[]; + onPatch: (patch: Partial) => void; +}; + +export function InlineWoNumberCell({ draft, allRows, onPatch }: InlineWoNumberCellProps) { + const isUnscheduled = Boolean(!draft.scheduledOn && !draft.targetWeek); + + return ( + +
+ onPatch({ woNumber })} + /> + onPatch({ isAddOnOverride })} + /> +
+ + ); +} diff --git a/src/app/(protected)/workorders/_components/list/table/wo-table-inline-row-cells.tsx b/src/app/(protected)/workorders/_components/list/table/wo-table-inline-row-cells.tsx index 2357be2e..0d76d00d 100644 --- a/src/app/(protected)/workorders/_components/list/table/wo-table-inline-row-cells.tsx +++ b/src/app/(protected)/workorders/_components/list/table/wo-table-inline-row-cells.tsx @@ -8,12 +8,12 @@ import { AssignedToCell } from "./cells/assigned-to-cell"; import { ApptTimeCell } from "./cells/appt-time-cell"; import { DateCell } from "./cells/date-cell"; import { DueDateCell } from "./cells/due-date-cell"; -import { EditableWONumberCell } from "./cells/editable-wo-number-cell"; import { PMTypeCell } from "./cells/pm-type-cell"; import { SiteCell } from "./cells/site-cell"; import { StatusCell } from "./cells/status-cell"; import { TypeCell } from "./cells/type-cell"; import { VendorCell } from "./cells/vendor-cell"; +import { InlineWoNumberCell } from "./inline-wo-number-cell"; import { toInlineDraftRow } from "./to-inline-draft-row"; type WoTableInlineRowCellsProps = { @@ -67,15 +67,7 @@ export function WoTableInlineRowCells({ /> - - onPatch({ woNumber: v })} - /> - + onPatch({ type: t })} /> @@ -99,9 +91,21 @@ export function WoTableInlineRowCells({ targetWeek={draft.targetWeek} defaultMode={weekDefault ? "week" : "date"} highlight={!draft.scheduledOn && !draft.targetWeek} - onSave={(date) => onPatch({ scheduledOn: date, scheduleWeekOnly: false, targetWeek: "" })} + onSave={(date) => + onPatch({ + scheduledOn: date, + scheduleWeekOnly: false, + targetWeek: "", + isAddOnOverride: false, + }) + } onSaveWeek={(week) => - onPatch({ scheduledOn: "", targetWeek: week, scheduleWeekOnly: true }) + onPatch({ + scheduledOn: "", + targetWeek: week, + scheduleWeekOnly: true, + isAddOnOverride: false, + }) } /> diff --git a/src/app/(protected)/workorders/_components/list/table/wo-table-row-cells.tsx b/src/app/(protected)/workorders/_components/list/table/wo-table-row-cells.tsx index 40f97933..28ea7a4a 100644 --- a/src/app/(protected)/workorders/_components/list/table/wo-table-row-cells.tsx +++ b/src/app/(protected)/workorders/_components/list/table/wo-table-row-cells.tsx @@ -71,6 +71,7 @@ export function WoTableRowCells({ value={row.woNumber} rescheduleCount={row.rescheduleCount} carriedOver={row.carriedOver} + isAddOn={row.isAddOn} q={search} currentId={row.id} allOrders={allRows} diff --git a/src/app/(protected)/workorders/_components/list/table/wo-table-type-style.ts b/src/app/(protected)/workorders/_components/list/table/wo-table-type-style.ts index 23819e2a..d64eab65 100644 --- a/src/app/(protected)/workorders/_components/list/table/wo-table-type-style.ts +++ b/src/app/(protected)/workorders/_components/list/table/wo-table-type-style.ts @@ -4,7 +4,6 @@ export const WO_TABLE_TYPE_TEXT: Record = { Emergency: "var(--destructive)", Reactive: "#e0701a", PM: "var(--primary)", - "Add-On": "#7c3aed", Overdue: "#9F1239", }; diff --git a/src/app/(protected)/workorders/_components/wizard/wizard-step-type-schedule.tsx b/src/app/(protected)/workorders/_components/wizard/wizard-step-type-schedule.tsx index 51c6f0d3..7227e8d5 100644 --- a/src/app/(protected)/workorders/_components/wizard/wizard-step-type-schedule.tsx +++ b/src/app/(protected)/workorders/_components/wizard/wizard-step-type-schedule.tsx @@ -14,14 +14,13 @@ import { WizLabel, WizLabelRow, } from "@/app/(protected)/workorders/_components/wizard/wizard-labels"; -import { CheckCircle2, Clock, Flame, Plus, Users, Wrench, Zap } from "lucide-react"; +import { CheckCircle2, Clock, Flame, Users, Wrench, Zap } from "lucide-react"; import type { ElementType } from "react"; const WO_TYPE_ICONS: Record = { PM: Wrench, Reactive: Zap, Emergency: Flame, - "Add-On": Plus, Overdue: Clock, }; @@ -186,8 +185,20 @@ export function WizardStepTypeSchedule({ targetWeek={draft.scheduleWeekOnly ? draft.scheduledOn : ""} placeholder="Pick a date" allowWeekMode - onChange={(iso) => onPatch({ scheduledOn: iso, scheduleWeekOnly: false })} - onChangeWeek={(w) => onPatch({ scheduledOn: w, scheduleWeekOnly: Boolean(w) })} + onChange={(iso) => + onPatch({ + scheduledOn: iso, + scheduleWeekOnly: false, + isAddOnOverride: false, + }) + } + onChangeWeek={(w) => + onPatch({ + scheduledOn: w, + scheduleWeekOnly: Boolean(w), + isAddOnOverride: false, + }) + } />
@@ -206,6 +217,21 @@ export function WizardStepTypeSchedule({ /> + + {/* Manual Add-On only when there is no schedule date (cutoff needs a week). */} + {Boolean(!draft.scheduledOn) && ( + + )} ); } diff --git a/src/domain/work-orders/mappers/wizard-draft-mapper.ts b/src/domain/work-orders/mappers/wizard-draft-mapper.ts index d15215c4..6d6775a6 100644 --- a/src/domain/work-orders/mappers/wizard-draft-mapper.ts +++ b/src/domain/work-orders/mappers/wizard-draft-mapper.ts @@ -30,7 +30,6 @@ const TYPE_TO_PRIORITY: Record = { Emergency: "Sev 1", Reactive: "Sev 2", Overdue: "Sev 2", - "Add-On": "Sev 3", PM: "Sev 3", }; diff --git a/src/domain/work-orders/mappers/work-order-board-mapper.ts b/src/domain/work-orders/mappers/work-order-board-mapper.ts index 1fbdb364..2036151c 100644 --- a/src/domain/work-orders/mappers/work-order-board-mapper.ts +++ b/src/domain/work-orders/mappers/work-order-board-mapper.ts @@ -12,6 +12,7 @@ import { fmtDateRange } from "@/domain/work-orders/utils/wizard-date-utils"; import { assignWizardScheduleFields } from "@/domain/work-orders/mappers/assign-wizard-schedule-fields"; import { assignWizardVendorApptFields } from "@/domain/work-orders/mappers/assign-wizard-vendor-appt-fields"; import { assignWizardServicePocFields } from "@/domain/work-orders/mappers/assign-wizard-service-poc-fields"; +import { resolveIsAddOnForCreate } from "@/domain/work-orders/utils/wo-addon-cutoff"; const WO_TYPE_TO_API: Record = { PM: WORK_ORDER_TYPE_API.PM, @@ -20,8 +21,6 @@ const WO_TYPE_TO_API: Record = { Reactive: WORK_ORDER_TYPE_API.Reactive, - "Add-On": WORK_ORDER_TYPE_API.AddOn, - Overdue: WORK_ORDER_TYPE_API.Other, }; @@ -96,6 +95,15 @@ export function mapWizardDraftToBoardCreatePayload( workOrderType, siteCode, + + // Range start persists as scheduledDate (prototype hasScheduleDate). Use it for + // the create-time Add-On cutoff hint; week mode uses targetWeek / Monday. + isAddOn: resolveIsAddOnForCreate({ + scheduledOn: draft.scheduleWeekOnly + ? draft.targetWeek?.trim() || draft.scheduledOn + : draft.scheduledOn, + manualOverride: draft.isAddOnOverride, + }), }; if (draft.woNumber.trim()) { diff --git a/src/domain/work-orders/mappers/work-order-board-patch-mapper.ts b/src/domain/work-orders/mappers/work-order-board-patch-mapper.ts index 535ebb16..c3e55a58 100644 --- a/src/domain/work-orders/mappers/work-order-board-patch-mapper.ts +++ b/src/domain/work-orders/mappers/work-order-board-patch-mapper.ts @@ -40,6 +40,8 @@ const LOCAL_ONLY_KEYS = new Set([ "isPastDue", "originalWeek", "originalDate", + /** Optimistic overlay only — backend owns isAddOn after schedule ops (SH-184). */ + "isAddOn", ]); export function isLocalOnlyTablePatch(patch: WorkOrderTablePatch): boolean { diff --git a/src/domain/work-orders/mappers/work-order-table-row-mapper.ts b/src/domain/work-orders/mappers/work-order-table-row-mapper.ts index a12162c2..63d50eb7 100644 --- a/src/domain/work-orders/mappers/work-order-table-row-mapper.ts +++ b/src/domain/work-orders/mappers/work-order-table-row-mapper.ts @@ -2,6 +2,7 @@ import { mapApiStatusToWizard } from "@/domain/work-orders/mappers/wizard-draft- import { DOC_STATUS_API, LIFECYCLE_STATUS_API_TO_FE, + WORK_ORDER_TYPE_API, WORK_ORDER_TYPE_API_TO_FE, } from "@/domain/work-orders/types/work-order-board"; import type { @@ -100,11 +101,50 @@ function readRowVersion(record: Record, ...keys: string[]): str return ""; } -const WO_TYPES_SET = new Set(["PM", "Reactive", "Emergency", "Add-On", "Overdue"]); +const WO_TYPES_SET = new Set(["PM", "Reactive", "Emergency", "Overdue"]); + +function isLegacyAddOnType(record: Record): boolean { + const numeric = + readNumber(record, "workOrderType", "WorkOrderType") ?? readNumber(record, "type", "Type"); + if (numeric === WORK_ORDER_TYPE_API.AddOn) { + return true; + } + const raw = readString( + record, + "workOrderType", + "WorkOrderType", + "type", + "Type", + "woType", + "WoType", + "source", + "Source", + ); + return raw === "AddOn" || raw === "Add-On"; +} + +function readIsAddOn(record: Record): boolean { + for (const key of ["isAddOn", "IsAddOn"] as const) { + if (!(key in record)) continue; + const value = record[key]; + // Explicit boolean is authoritative; null/undefined fall through to legacy. + if (typeof value === "boolean") { + return value; + } + if (value == null) { + break; + } + return Boolean(value); + } + return isLegacyAddOnType(record); +} function readWoType(record: Record): WOType | "" { const numeric = readNumber(record, "workOrderType", "WorkOrderType") ?? readNumber(record, "type", "Type"); + if (numeric === WORK_ORDER_TYPE_API.AddOn) { + return ""; + } if (numeric != null && WORK_ORDER_TYPE_API_TO_FE[numeric]) { return WORK_ORDER_TYPE_API_TO_FE[numeric] as WOType; } @@ -120,12 +160,12 @@ function readWoType(record: Record): WOType | "" { "source", "Source", ); + if (raw === "AddOn" || raw === "Add-On") { + return ""; + } if (WO_TYPES_SET.has(raw)) { return raw as WOType; } - if (raw === "AddOn") { - return "Add-On"; - } return ""; } @@ -261,6 +301,7 @@ export function mapWorkOrderTableRow(raw: unknown): WorkOrderTableRow { dueDate: toDateInputValue(readString(item, "dueDate", "DueDate")), rescheduleCount: readNumber(item, "rescheduleCount", "RescheduleCount") ?? 0, carriedOver: readNumber(item, "carriedOver", "CarriedOver") ?? 0, + isAddOn: readIsAddOn(item), originalWeek: readString(item, "originalWeek", "OriginalWeek"), originalDate: toDateInputValue(readString(item, "originalDate", "OriginalDate")), isPastDue: readBool(item, "isPastDue", "IsPastDue"), diff --git a/src/domain/work-orders/types/work-order-board.ts b/src/domain/work-orders/types/work-order-board.ts index 021fe1c0..bac85127 100644 --- a/src/domain/work-orders/types/work-order-board.ts +++ b/src/domain/work-orders/types/work-order-board.ts @@ -41,11 +41,11 @@ export const LIFECYCLE_STATUS_API = { export type LifecycleStatusApi = (typeof LIFECYCLE_STATUS_API)[keyof typeof LIFECYCLE_STATUS_API]; +/** Maps API type enums to selectable FE types. Legacy AddOn(7) is not a type. */ export const WORK_ORDER_TYPE_API_TO_FE: Record = { [WORK_ORDER_TYPE_API.PM]: "PM", [WORK_ORDER_TYPE_API.Reactive]: "Reactive", [WORK_ORDER_TYPE_API.Emergency]: "Emergency", - [WORK_ORDER_TYPE_API.AddOn]: "Add-On", }; export const LIFECYCLE_STATUS_API_TO_FE: Record = { @@ -70,6 +70,11 @@ export interface WorkOrderBoardCreatePayload { scheduledDate?: string; targetWeek?: string; scheduleWeekOnly?: boolean; + /** + * Create-time Add-On hint only. Backend recalculates on schedule change/clear + * and owns audit (SH-184); FE must consume returned `isAddOn` afterward. + */ + isAddOn?: boolean; vendorId?: number; apptDate?: string; apptTime?: string; @@ -127,6 +132,8 @@ export interface WorkOrderBoardRowApi { woNumber?: string | null; rescheduleCount?: number; carriedOver?: number; + /** Backend-authoritative Add-On indicator (SH-184; recalculated on schedule). */ + isAddOn?: boolean | null; workOrderType?: number | null; isPastDue?: boolean; siteCode?: string | null; diff --git a/src/domain/work-orders/types/work-order-table-row.ts b/src/domain/work-orders/types/work-order-table-row.ts index 4bcb4b60..510b5d6a 100644 --- a/src/domain/work-orders/types/work-order-table-row.ts +++ b/src/domain/work-orders/types/work-order-table-row.ts @@ -37,6 +37,11 @@ export interface WorkOrderTableRow { dueDate: string; rescheduleCount: number; carriedOver: number; + /** + * Add-On indicator (not a WO type). Backend-authoritative (SH-184): + * recalculated on schedule week moves / clear; FE only reflects API value. + */ + isAddOn: boolean; originalWeek: string; originalDate: string; isPastDue: boolean; @@ -76,6 +81,8 @@ export interface WorkOrderTableDraft { pocNotes: string; scheduleWeekOnly: boolean; targetWeek: string; + /** Manual Add-On override when schedule is empty at inline create. */ + isAddOnOverride: boolean; } export const EMPTY_TABLE_DRAFT: WorkOrderTableDraft = { @@ -103,6 +110,7 @@ export const EMPTY_TABLE_DRAFT: WorkOrderTableDraft = { pocNotes: "", scheduleWeekOnly: false, targetWeek: "", + isAddOnOverride: false, }; export type WorkOrderDayGroup = { diff --git a/src/domain/work-orders/types/work-order-wizard.ts b/src/domain/work-orders/types/work-order-wizard.ts index 38cec358..2add447a 100644 --- a/src/domain/work-orders/types/work-order-wizard.ts +++ b/src/domain/work-orders/types/work-order-wizard.ts @@ -1,4 +1,5 @@ -export const WO_TYPES = ["PM", "Reactive", "Emergency", "Add-On", "Overdue"] as const; +/** Selectable business types — Add-On is an indicator (`isAddOn`), not a type. */ +export const WO_TYPES = ["PM", "Reactive", "Emergency", "Overdue"] as const; export type WOType = (typeof WO_TYPES)[number]; @@ -50,6 +51,12 @@ export interface WorkOrderWizardDraft { scheduleEnd?: string; /** Last Schedule On mode used. */ schedMode?: WizardSchedMode; + /** + * Manual Add-On override when `scheduledOn` is empty at create. + * Ignored when a cutoff preview can be computed from schedule. + * Create hint only — backend remains authoritative after schedule patches (SH-184). + */ + isAddOnOverride?: boolean; } export const EMPTY_WIZARD_DRAFT: WorkOrderWizardDraft = { @@ -79,6 +86,7 @@ export const EMPTY_WIZARD_DRAFT: WorkOrderWizardDraft = { targetWeek: "", scheduleEnd: "", schedMode: undefined, + isAddOnOverride: false, }; /** WO number is optional; when provided must be digits only, 1–20 characters. */ @@ -124,11 +132,6 @@ export const WO_TYPE_META: Record cutoff.getTime(); +} + +/** @deprecated Prefer `previewIsAddOnFromCutoff` — name implied a freeze (SH-126). */ +export const computeIsAddOnAtCreation = previewIsAddOnFromCutoff; + +/** + * Resolve create-payload `isAddOn` hint: cutoff preview when scheduled, else + * manual override. Backend may recalculate immediately and on later schedule + * patches/clears; FE must not treat this value as frozen (SH-184). + */ +export function resolveIsAddOnForCreate(input: { + createdAt?: Date | string | number; + scheduledOn: string; + manualOverride?: boolean; +}): boolean { + const computed = previewIsAddOnFromCutoff({ + createdAt: input.createdAt ?? new Date(), + scheduledOn: input.scheduledOn, + }); + if (computed !== null) { + return computed; + } + return Boolean(input.manualOverride); +} diff --git a/src/test/app/(protected)/workorders/work-orders-board-core-affordances.test.tsx b/src/test/app/(protected)/workorders/work-orders-board-core-affordances.test.tsx index 004e96d3..3ab80e4b 100644 --- a/src/test/app/(protected)/workorders/work-orders-board-core-affordances.test.tsx +++ b/src/test/app/(protected)/workorders/work-orders-board-core-affordances.test.tsx @@ -98,6 +98,7 @@ const canceledRow: WorkOrderTableRow = { dueDate: "", rescheduleCount: 0, carriedOver: 0, + isAddOn: false, originalWeek: "", originalDate: "", isPastDue: false, diff --git a/src/test/domain/work-orders/api/work-orders-api.test.ts b/src/test/domain/work-orders/api/work-orders-api.test.ts index af480a06..f641daa6 100644 --- a/src/test/domain/work-orders/api/work-orders-api.test.ts +++ b/src/test/domain/work-orders/api/work-orders-api.test.ts @@ -120,6 +120,7 @@ describe("workOrdersApi.createFromBoard", () => { workOrderType: 2, siteCode: "BK5", woNumber: "12345", + isAddOn: false, }); }); }); diff --git a/src/test/domain/work-orders/mappers/work-order-board-mapper.test.ts b/src/test/domain/work-orders/mappers/work-order-board-mapper.test.ts index 3d8034a7..d5b04393 100644 --- a/src/test/domain/work-orders/mappers/work-order-board-mapper.test.ts +++ b/src/test/domain/work-orders/mappers/work-order-board-mapper.test.ts @@ -33,8 +33,6 @@ describe("mapWizardTypeToApi", () => { expect(mapWizardTypeToApi("Reactive")).toBe(WORK_ORDER_TYPE_API.Reactive); - expect(mapWizardTypeToApi("Add-On")).toBe(WORK_ORDER_TYPE_API.AddOn); - expect(mapWizardTypeToApi("Overdue")).toBe(WORK_ORDER_TYPE_API.Other); expect(mapWizardTypeToApi("")).toBeNull(); @@ -55,9 +53,68 @@ describe("mapWizardDraftToBoardCreatePayload", () => { workOrderType: 2, siteCode: "BK5", + + isAddOn: false, }); }); + it("sets isAddOn from cutoff preview when scheduled (create hint; not frozen)", () => { + const payload = mapWizardDraftToBoardCreatePayload({ + ...EMPTY_WIZARD_DRAFT, + type: "PM", + siteCode: "BK5", + scheduledOn: "2026-07-08", + isAddOnOverride: false, + }); + + // createdAt defaults to now; after prior-Saturday 2026-07-04 for that week → true + expect(payload.isAddOn).toBe(true); + }); + + it("sets isAddOn from manual override when schedule is missing", () => { + const payload = mapWizardDraftToBoardCreatePayload({ + ...EMPTY_WIZARD_DRAFT, + + type: "PM", + + siteCode: "BK5", + + isAddOnOverride: true, + }); + + expect(payload.isAddOn).toBe(true); + }); + + it("derives isAddOn from range start via cutoff (range persists scheduledDate)", () => { + const payload = mapWizardDraftToBoardCreatePayload({ + ...EMPTY_WIZARD_DRAFT, + type: "PM", + siteCode: "BK5", + scheduledOn: "2026-07-08", + scheduleEnd: "2026-07-10", + schedMode: "range", + isAddOnOverride: false, + }); + + expect(payload.scheduledDate).toBe("2026-07-08"); + // createdAt defaults to now; after prior-Saturday 2026-07-04 for that week → true + expect(payload.isAddOn).toBe(true); + }); + + it("uses manual Add-On override when range has no start date", () => { + const payload = mapWizardDraftToBoardCreatePayload({ + ...EMPTY_WIZARD_DRAFT, + type: "PM", + siteCode: "BK5", + scheduledOn: "", + scheduleEnd: "", + schedMode: "range", + isAddOnOverride: true, + }); + + expect(payload.isAddOn).toBe(true); + }); + it("maps full wizard payload with structured service and notes fields", () => { const payload = mapWizardDraftToBoardCreatePayload({ ...EMPTY_WIZARD_DRAFT, @@ -114,6 +171,8 @@ describe("mapWizardDraftToBoardCreatePayload", () => { scheduledDate: "2026-06-25", + isAddOn: true, + vendorId: 5, apptDate: "2026-06-25", diff --git a/src/test/domain/work-orders/mappers/work-order-table-row-mapper.test.ts b/src/test/domain/work-orders/mappers/work-order-table-row-mapper.test.ts new file mode 100644 index 00000000..93123601 --- /dev/null +++ b/src/test/domain/work-orders/mappers/work-order-table-row-mapper.test.ts @@ -0,0 +1,52 @@ +import { describe, expect, it } from "vitest"; + +import { mapWorkOrderTableRow } from "@/domain/work-orders/mappers/work-order-table-row-mapper"; +import { WORK_ORDER_TYPE_API } from "@/domain/work-orders/types/work-order-board"; + +describe("mapWorkOrderTableRow isAddOn", () => { + it("reads isAddOn from board DTO when present", () => { + const row = mapWorkOrderTableRow({ + id: 1, + workOrderType: WORK_ORDER_TYPE_API.PM, + isAddOn: true, + }); + expect(row.type).toBe("PM"); + expect(row.isAddOn).toBe(true); + }); + + it("maps legacy AddOn type to isAddOn without a selectable type", () => { + const row = mapWorkOrderTableRow({ + id: 2, + workOrderType: WORK_ORDER_TYPE_API.AddOn, + }); + expect(row.type).toBe(""); + expect(row.isAddOn).toBe(true); + }); + + it("prefers explicit isAddOn false over legacy AddOn type", () => { + const row = mapWorkOrderTableRow({ + id: 3, + workOrderType: WORK_ORDER_TYPE_API.AddOn, + isAddOn: false, + }); + expect(row.isAddOn).toBe(false); + }); + + it("falls back to legacy AddOn type when isAddOn is null", () => { + const row = mapWorkOrderTableRow({ + id: 5, + workOrderType: WORK_ORDER_TYPE_API.AddOn, + isAddOn: null, + }); + expect(row.isAddOn).toBe(true); + }); + + it("defaults isAddOn to false for normal types", () => { + const row = mapWorkOrderTableRow({ + id: 4, + workOrderType: WORK_ORDER_TYPE_API.Reactive, + }); + expect(row.type).toBe("Reactive"); + expect(row.isAddOn).toBe(false); + }); +}); diff --git a/src/test/domain/work-orders/use-cases/use-work-order-table-mutations.test.tsx b/src/test/domain/work-orders/use-cases/use-work-order-table-mutations.test.tsx new file mode 100644 index 00000000..6ce07630 --- /dev/null +++ b/src/test/domain/work-orders/use-cases/use-work-order-table-mutations.test.tsx @@ -0,0 +1,344 @@ +import type { ReactNode } from "react"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { act, renderHook, waitFor } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { + CompletionDocStatus, + WorkOrderTableRow, +} from "@/domain/work-orders/types/work-order-table-row"; + +const getById = vi.fn(); +const update = vi.fn(); +const patchBoardField = vi.fn(); + +vi.mock("@/domain/work-orders/api/work-orders-api", () => ({ + workOrdersApi: { + getById: (...args: unknown[]) => getById(...args), + update: (...args: unknown[]) => update(...args), + patchBoardField: (...args: unknown[]) => patchBoardField(...args), + }, +})); + +vi.mock("react-toastify", () => ({ toast: { error: vi.fn() } })); + +import { + applySchedulePatch, + useWorkOrderTableMutations, +} from "@/domain/work-orders/use-cases/use-work-order-table-mutations"; + +function delay(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +function makeWrapper() { + const client = new QueryClient({ + defaultOptions: { queries: { retry: false }, mutations: { retry: false } }, + }); + return function Wrapper({ children }: { children: ReactNode }) { + return {children}; + }; +} + +type ServerState = { + status: string; + scheduledDate: string; + completedDate: string; + docStatus: CompletionDocStatus; +}; + +const BASE_SERVER: ServerState = { + status: "Open", + scheduledDate: "2026-06-01", + completedDate: "", + docStatus: "No", +}; + +const BASE_ROW = { + id: 42, + woNumber: "WO-42", + site: "SEA1", + type: "PM", + dispatcherId: "d1", + dispatcherName: "Dispatcher", + scheduledOn: "2026-06-01", + completedDate: "", + targetWeek: "", + dayGroup: "", + dayLabel: "", + location: "Seattle", + locationId: "loc-1", + pm: "HVAC", + serviceNotes: "", + extraServices: [], + vendorId: "", + company: "", + tech: "", + techPhone: "", + apptStart: "", + apptEnd: "", + apptTime: "", + status: "Incomplete", + docStatus: "No", + pocName: "", + pocPhone: "", + pocNotes: "", + dueDate: "", + rescheduleCount: 0, + carriedOver: 0, + originalWeek: "", + originalDate: "", + isPastDue: false, + scheduleWeekOnly: false, + mediaCount: 0, + title: "PM — SEA1", + priority: "Sev 3", + primaryDispatchId: null, + rowVersion: "AAAAAAA=", + dispatchRowVersion: "", + flagColor: null, + isAddOn: false, +} as WorkOrderTableRow; + +describe("useWorkOrderTableMutations completion-doc persistence", () => { + let serverState: ServerState; + let events: string[]; + let rowState: WorkOrderTableRow; + + beforeEach(() => { + getById.mockReset(); + update.mockReset(); + patchBoardField.mockReset(); + serverState = { ...BASE_SERVER }; + rowState = { ...BASE_ROW }; + events = []; + + patchBoardField.mockImplementation( + async ( + _id: unknown, + payload: { field: string; value?: string | null; workOrderVersion: string }, + ) => { + events.push(`boardPatch:${payload.field}:start`); + await delay(5); + if (payload.field === "docStatus" && typeof payload.value === "string") { + const docStatus = + payload.value === "NotNeeded" ? "NN" : (payload.value as CompletionDocStatus); + serverState = { ...serverState, docStatus }; + rowState = { + ...rowState, + docStatus, + rowVersion: `${payload.workOrderVersion}-next`, + }; + } + if (payload.field === "completedDate" && typeof payload.value === "string") { + serverState = { ...serverState, completedDate: payload.value }; + rowState = { + ...rowState, + completedDate: payload.value, + rowVersion: `${payload.workOrderVersion}-date`, + }; + } + events.push(`boardPatch:${payload.field}:end`); + return { ...rowState }; + }, + ); + }); + + it("persists docStatus via board PATCH with NotNeeded for UI NN", async () => { + const onPatch = vi.fn(); + const { result } = renderHook( + () => + useWorkOrderTableMutations({ + onPatch, + clearPatch: () => undefined, + getRow: () => rowState, + }), + { wrapper: makeWrapper() }, + ); + + act(() => { + result.current.patchField(42, { docStatus: "NN" }); + }); + + await waitFor(() => expect(events).toContain("boardPatch:docStatus:end")); + + expect(patchBoardField).toHaveBeenCalledWith(42, { + field: "docStatus", + value: "NotNeeded", + workOrderVersion: "AAAAAAA=", + }); + expect(update).not.toHaveBeenCalled(); + expect(getById).not.toHaveBeenCalled(); + expect(serverState.docStatus).toBe("NN"); + }); + + it("serializes board docStatus and completedDate via PATCH without racing or EditWorkorder", async () => { + const onPatch = vi.fn(); + const { result } = renderHook( + () => + useWorkOrderTableMutations({ + onPatch, + clearPatch: () => undefined, + getRow: () => rowState, + }), + { wrapper: makeWrapper() }, + ); + + act(() => { + result.current.patchField(42, { docStatus: "Yes" }); + result.current.patchField(42, { completedDate: "2026-07-20" }); + }); + + await waitFor(() => expect(events).toContain("boardPatch:completedDate:end")); + await waitFor(() => expect(events).toContain("boardPatch:docStatus:end")); + + expect(events).toEqual([ + "boardPatch:docStatus:start", + "boardPatch:docStatus:end", + "boardPatch:completedDate:start", + "boardPatch:completedDate:end", + ]); + + expect(getById).not.toHaveBeenCalled(); + expect(update).not.toHaveBeenCalled(); + expect(patchBoardField).toHaveBeenCalledWith( + 42, + expect.objectContaining({ field: "completedDate", value: "2026-07-20" }), + ); + + expect(serverState.docStatus).toBe("Yes"); + expect(serverState.completedDate).toBe("2026-07-20"); + expect(serverState.scheduledDate).toBe(BASE_SERVER.scheduledDate); + expect(serverState.status).toBe(BASE_SERVER.status); + }); +}); + +describe("applySchedulePatch isAddOn optimistic clear", () => { + it("clears isAddOn when schedule is cleared", () => { + expect(applySchedulePatch("", false)).toMatchObject({ + scheduledOn: "", + targetWeek: "", + scheduleWeekOnly: false, + isAddOn: false, + }); + }); + + it("does not set isAddOn on day or week moves (BE response owns it)", () => { + expect(applySchedulePatch("2026-07-15", false).isAddOn).toBeUndefined(); + expect(applySchedulePatch("2026-07-13", true).isAddOn).toBeUndefined(); + }); +}); + +describe("useWorkOrderTableMutations schedule isAddOn from BE", () => { + let rowState: WorkOrderTableRow; + + beforeEach(() => { + getById.mockReset(); + update.mockReset(); + patchBoardField.mockReset(); + rowState = { ...BASE_ROW, isAddOn: false, scheduledOn: "2026-06-01" }; + }); + + function mockSchedulePatchReturning(isAddOn: boolean) { + patchBoardField.mockImplementation( + async ( + _id: unknown, + payload: { field: string; value?: string | null; workOrderVersion: string }, + ) => { + if (payload.field === "scheduledDate") { + rowState = { + ...rowState, + scheduledOn: typeof payload.value === "string" ? payload.value : "", + isAddOn, + rowVersion: `${payload.workOrderVersion}-sched`, + }; + } else { + rowState = { + ...rowState, + rowVersion: `${payload.workOrderVersion}-noop`, + }; + } + return { ...rowState }; + }, + ); + } + + it("applies isAddOn ON when schedule patch response flips Add-On on", async () => { + mockSchedulePatchReturning(true); + + const onPatch = vi.fn(); + const { result } = renderHook( + () => + useWorkOrderTableMutations({ + onPatch, + clearPatch: () => undefined, + getRow: () => rowState, + }), + { wrapper: makeWrapper() }, + ); + + act(() => { + result.current.patchField(42, applySchedulePatch("2026-07-15", false)); + }); + + await waitFor(() => + expect(onPatch).toHaveBeenCalledWith( + 42, + expect.objectContaining({ scheduledOn: "2026-07-15", isAddOn: true }), + ), + ); + }); + + it("applies isAddOn OFF when schedule patch response flips Add-On off", async () => { + rowState = { ...rowState, isAddOn: true, scheduledOn: "2026-07-15" }; + mockSchedulePatchReturning(false); + + const onPatch = vi.fn(); + const { result } = renderHook( + () => + useWorkOrderTableMutations({ + onPatch, + clearPatch: () => undefined, + getRow: () => rowState, + }), + { wrapper: makeWrapper() }, + ); + + act(() => { + result.current.patchField(42, applySchedulePatch("2026-06-03", false)); + }); + + await waitFor(() => + expect(onPatch).toHaveBeenCalledWith( + 42, + expect.objectContaining({ scheduledOn: "2026-06-03", isAddOn: false }), + ), + ); + }); + + it("applies isAddOn false when schedule clear response clears Add-On", async () => { + rowState = { ...rowState, isAddOn: true, scheduledOn: "2026-07-15" }; + mockSchedulePatchReturning(false); + + const onPatch = vi.fn(); + const { result } = renderHook( + () => + useWorkOrderTableMutations({ + onPatch, + clearPatch: () => undefined, + getRow: () => rowState, + }), + { wrapper: makeWrapper() }, + ); + + act(() => { + result.current.patchField(42, applySchedulePatch("", false)); + }); + + await waitFor(() => + expect(onPatch).toHaveBeenCalledWith( + 42, + expect.objectContaining({ scheduledOn: "", isAddOn: false }), + ), + ); + }); +}); diff --git a/src/test/domain/work-orders/utils/slide-over-draft.test.ts b/src/test/domain/work-orders/utils/slide-over-draft.test.ts index 25788b12..04ca3026 100644 --- a/src/test/domain/work-orders/utils/slide-over-draft.test.ts +++ b/src/test/domain/work-orders/utils/slide-over-draft.test.ts @@ -38,6 +38,7 @@ function baseRow(overrides: Partial = {}): WorkOrderTableRow dueDate: "2026-07-20", rescheduleCount: 0, carriedOver: 0, + isAddOn: false, originalWeek: "", originalDate: "", isPastDue: false, diff --git a/src/test/domain/work-orders/utils/table-filter-utils.test.ts b/src/test/domain/work-orders/utils/table-filter-utils.test.ts index 4ff5dc93..706f8ec2 100644 --- a/src/test/domain/work-orders/utils/table-filter-utils.test.ts +++ b/src/test/domain/work-orders/utils/table-filter-utils.test.ts @@ -48,6 +48,7 @@ function makeRow(overrides: Partial = {}): WorkOrderTableRow dueDate: "2026-06-30", rescheduleCount: 0, carriedOver: 0, + isAddOn: false, originalWeek: "", originalDate: "", isPastDue: false, diff --git a/src/test/domain/work-orders/utils/wo-addon-cutoff.test.ts b/src/test/domain/work-orders/utils/wo-addon-cutoff.test.ts new file mode 100644 index 00000000..091ab621 --- /dev/null +++ b/src/test/domain/work-orders/utils/wo-addon-cutoff.test.ts @@ -0,0 +1,88 @@ +import { describe, expect, it } from "vitest"; + +import { + computeIsAddOnAtCreation, + getAddonCutoffIso, + previewIsAddOnFromCutoff, + resolveIsAddOnForCreate, +} from "@/domain/work-orders/utils/wo-addon-cutoff"; + +describe("getAddonCutoffIso", () => { + it("returns Saturday of the week prior to the scheduled week", () => { + // Week of Mon 2026-07-06 → prior Saturday 2026-07-04 + expect(getAddonCutoffIso("2026-07-08")).toBe("2026-07-04"); + expect(getAddonCutoffIso("2026-07-06")).toBe("2026-07-04"); + expect(getAddonCutoffIso("2026-07-10")).toBe("2026-07-04"); + }); + + it("returns null for missing or invalid scheduledOn", () => { + expect(getAddonCutoffIso("")).toBeNull(); + expect(getAddonCutoffIso("not-a-date")).toBeNull(); + }); +}); + +describe("previewIsAddOnFromCutoff", () => { + it("returns true when created after prior-Saturday 23:59", () => { + expect( + previewIsAddOnFromCutoff({ + createdAt: "2026-07-05T00:00:00", + scheduledOn: "2026-07-08", + }), + ).toBe(true); + }); + + it("returns false when created on or before prior-Saturday 23:59", () => { + expect( + previewIsAddOnFromCutoff({ + createdAt: "2026-07-04T23:59:59", + scheduledOn: "2026-07-08", + }), + ).toBe(false); + expect( + previewIsAddOnFromCutoff({ + createdAt: "2026-07-03T12:00:00", + scheduledOn: "2026-07-08", + }), + ).toBe(false); + }); + + it("returns null when scheduledOn is missing", () => { + expect( + previewIsAddOnFromCutoff({ + createdAt: new Date("2026-07-05T12:00:00"), + scheduledOn: "", + }), + ).toBeNull(); + }); + + it("keeps computeIsAddOnAtCreation as a deprecated alias", () => { + expect(computeIsAddOnAtCreation).toBe(previewIsAddOnFromCutoff); + }); +}); + +describe("resolveIsAddOnForCreate", () => { + it("uses cutoff preview when scheduledOn is set", () => { + expect( + resolveIsAddOnForCreate({ + createdAt: "2026-07-05T00:00:00", + scheduledOn: "2026-07-08", + manualOverride: false, + }), + ).toBe(true); + }); + + it("uses manual override when scheduledOn is missing", () => { + expect( + resolveIsAddOnForCreate({ + scheduledOn: "", + manualOverride: true, + }), + ).toBe(true); + expect( + resolveIsAddOnForCreate({ + scheduledOn: "", + manualOverride: false, + }), + ).toBe(false); + }); +});