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 1/5] 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); + }); +}); From 56f007bcf22aa4018846f6068e4ff7ec7fb6929a Mon Sep 17 00:00:00 2001 From: "arthur.bassi" Date: Tue, 11 Aug 2026 15:13:51 -0300 Subject: [PATCH 2/5] style(work-orders): format addon-indicator doc after unstack rebase --- docs/work-orders/addon-indicator.md | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/docs/work-orders/addon-indicator.md b/docs/work-orders/addon-indicator.md index 9a193b5a..78dcd8ed 100644 --- a/docs/work-orders/addon-indicator.md +++ b/docs/work-orders/addon-indicator.md @@ -4,12 +4,12 @@ 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 | +| 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 From 99724f831c4526d383636a5c6d0697922986c8a6 Mon Sep 17 00:00:00 2001 From: "arthur.bassi" Date: Tue, 11 Aug 2026 15:42:29 -0300 Subject: [PATCH 3/5] 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. --- .../list/table/wo-table-inline-row-cells.tsx | 64 +++++++++++-------- .../wo-table-unscheduled-highlight.test.tsx | 1 + .../mappers/work-order-board-mapper.test.ts | 5 +- .../use-work-order-table-mutations.test.tsx | 51 --------------- 4 files changed, 43 insertions(+), 78 deletions(-) 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 0d76d00d..ffc1afeb 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 @@ -28,6 +28,44 @@ type WoTableInlineRowCellsProps = { onPatch: (patch: Partial) => void; }; +function InlineScheduleOnCell({ + draft, + weekDefault, + onPatch, +}: { + draft: WorkOrderTableDraft; + weekDefault: boolean; + onPatch: (patch: Partial) => void; +}) { + return ( + + + onPatch({ + scheduledOn: date, + scheduleWeekOnly: false, + targetWeek: "", + isAddOnOverride: false, + }) + } + onSaveWeek={(week) => + onPatch({ + scheduledOn: "", + targetWeek: week, + scheduleWeekOnly: true, + isAddOnOverride: false, + }) + } + /> + + ); +} + export function WoTableInlineRowCells({ draft, weekDefault, @@ -84,31 +122,7 @@ export function WoTableInlineRowCells({ /> - - - onPatch({ - scheduledOn: date, - scheduleWeekOnly: false, - targetWeek: "", - isAddOnOverride: false, - }) - } - onSaveWeek={(week) => - onPatch({ - scheduledOn: "", - targetWeek: week, - scheduleWeekOnly: true, - isAddOnOverride: false, - }) - } - /> - + { expect(payload.isAddOn).toBe(true); }); - it("derives isAddOn from range start via cutoff (range persists scheduledDate)", () => { + it("derives isAddOn from range start via cutoff (range omits scheduledDate on create)", () => { const payload = mapWizardDraftToBoardCreatePayload({ ...EMPTY_WIZARD_DRAFT, type: "PM", @@ -96,7 +96,8 @@ describe("mapWizardDraftToBoardCreatePayload", () => { isAddOnOverride: false, }); - expect(payload.scheduledDate).toBe("2026-07-08"); + // On create, range mode does not persist scheduledDate (notes carry the range). + expect(payload.scheduledDate).toBeUndefined(); // createdAt defaults to now; after prior-Saturday 2026-07-04 for that week → true expect(payload.isAddOn).toBe(true); }); diff --git a/src/test/domain/work-orders/use-cases/use-work-order-table-mutations.test.tsx b/src/test/domain/work-orders/use-cases/use-work-order-table-mutations.test.tsx index 6ce07630..275f7b79 100644 --- a/src/test/domain/work-orders/use-cases/use-work-order-table-mutations.test.tsx +++ b/src/test/domain/work-orders/use-cases/use-work-order-table-mutations.test.tsx @@ -42,14 +42,12 @@ function makeWrapper() { type ServerState = { status: string; scheduledDate: string; - completedDate: string; docStatus: CompletionDocStatus; }; const BASE_SERVER: ServerState = { status: "Open", scheduledDate: "2026-06-01", - completedDate: "", docStatus: "No", }; @@ -61,7 +59,6 @@ const BASE_ROW = { dispatcherId: "d1", dispatcherName: "Dispatcher", scheduledOn: "2026-06-01", - completedDate: "", targetWeek: "", dayGroup: "", dayLabel: "", @@ -129,14 +126,6 @@ describe("useWorkOrderTableMutations completion-doc persistence", () => { 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 }; }, @@ -170,46 +159,6 @@ describe("useWorkOrderTableMutations completion-doc persistence", () => { 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", () => { From 83ae05be272aa734456af2469ea1fc134c2fdd91 Mon Sep 17 00:00:00 2001 From: "arthur.bassi" Date: Wed, 12 Aug 2026 16:07:26 -0300 Subject: [PATCH 4/5] chore(work-orders): resolve Add-On indicator conflicts with current dev --- scripts/governance-baseline.json | 13 +- .../_components/comp-doc/comp-doc-co-line.tsx | 19 ++ .../comp-doc/comp-doc-detail-row.tsx | 39 +++ .../comp-doc/comp-doc-details-section.tsx | 75 +++++ .../comp-doc-dialog-footer-draft-actions.tsx | 70 +++++ .../comp-doc/comp-doc-dialog-footer-draft.tsx | 50 ++++ ...mp-doc-dialog-footer-generated-actions.tsx | 62 ++++ .../comp-doc-dialog-footer-generated.tsx | 35 +++ ...doc-dialog-footer-not-required-actions.tsx | 53 ++++ .../comp-doc-dialog-footer-not-required.tsx | 47 ++++ .../comp-doc/comp-doc-dialog-footer.tsx | 94 +++++++ .../comp-doc/comp-doc-dialog-header.tsx | 96 +++++++ .../comp-doc-dialog-resource-links.tsx | 48 ++++ .../_components/comp-doc/comp-doc-dialog.tsx | 132 +++++++++ .../comp-doc/comp-doc-draft-toast.tsx | 29 ++ .../comp-doc/comp-doc-edit-row.tsx | 51 ++++ .../comp-doc/comp-doc-editable-row.tsx | 54 ++++ .../comp-doc/comp-doc-footer-btn-sx.ts | 8 + .../comp-doc/comp-doc-footer-button-sx.ts | 8 + .../comp-doc/comp-doc-not-required-stamp.tsx | 25 ++ .../comp-doc/comp-doc-notes-field.tsx | 50 ++++ .../comp-doc/comp-doc-preview-badge.tsx | 32 +++ .../comp-doc-preview-company-header.tsx | 36 +++ .../comp-doc/comp-doc-preview-document.tsx | 58 ++++ .../comp-doc/comp-doc-preview-header.tsx | 36 +++ .../comp-doc-preview-not-required-stamp.tsx | 25 ++ .../comp-doc-preview-procedure-notes.tsx | 68 +++++ .../comp-doc/comp-doc-preview-procedure.tsx | 87 ++++++ .../comp-doc-preview-service-info.tsx | 89 ++++++ .../comp-doc-preview-status-badge.tsx | 32 +++ .../comp-doc/comp-doc-preview-summary.tsx | 96 +++++++ .../comp-doc-preview-vendor-safety.tsx | 35 +++ .../_components/comp-doc/comp-doc-preview.tsx | 65 +++++ .../comp-doc/comp-doc-procedure-list.tsx | 32 +++ .../_components/comp-doc/comp-doc-sidebar.tsx | 174 ++++++++++++ .../comp-doc/comp-doc-status-card.tsx | 61 ++++ .../comp-doc/use-comp-doc-dialog-seed.ts | 59 ++++ .../comp-doc/use-comp-doc-dialog.tsx | 179 ++++++++++++ .../use-comp-doc-not-required-actions.ts | 58 ++++ .../comp-doc/use-comp-doc-pdf-actions.ts | 98 +++++++ .../comp-doc/use-comp-doc-persist-flow.ts | 84 ++++++ .../_components/detail/audit-timeline.tsx | 67 +++++ .../detail/slide-over-audit-list.tsx | 164 +++++++++++ .../_components/detail/slide-over-footer.tsx | 143 ++++++++++ .../_components/detail/slide-over-header.tsx | 106 +++++++ .../detail/slide-over-info-row.tsx | 22 ++ .../detail/tabs/slide-over-audit-tab.tsx | 11 + .../detail/tabs/slide-over-comments-tab.tsx | 265 ++++++++++++++++++ .../tabs/slide-over-completion-actions.tsx | 92 ++++++ .../tabs/slide-over-completion-media.tsx | 52 ++++ .../slide-over-completion-readonly-status.tsx | 45 +++ .../tabs/slide-over-completion-signoffs.tsx | 35 +++ .../detail/tabs/slide-over-completion-tab.tsx | 143 ++++++++++ .../detail/tabs/slide-over-doc-badge.tsx | 44 +++ .../detail/tabs/slide-over-extras-tab.tsx | 163 +++++++++++ .../slide-over-info-tab-assigned-field.tsx | 28 ++ .../tabs/slide-over-info-tab-display-view.tsx | 92 ++++++ .../tabs/slide-over-info-tab-edit-view.tsx | 160 +++++++++++ .../tabs/slide-over-info-tab-status-field.tsx | 26 ++ .../tabs/slide-over-info-tab-vendor-field.tsx | 45 +++ .../detail/tabs/slide-over-info-tab.tsx | 64 +++++ .../detail/use-slide-over-edit-state.ts | 162 +++++++++++ .../detail/use-slide-over-media-state.ts | 101 +++++++ .../detail/use-work-order-slide-over.ts | 160 +++++++++++ .../detail/work-order-activity-section.tsx | 101 +++++++ .../detail/work-order-assignee-menu.tsx | 56 ++++ .../work-order-description-accordion.tsx | 20 ++ .../detail/work-order-detail-sections.tsx | 49 ++++ .../detail/work-order-details-card.tsx | 45 +++ .../work-order-dispatches-accordion.tsx | 85 ++++++ .../detail/work-order-simple-accordions.tsx | 61 ++++ .../detail/work-order-slide-over-tab-nav.tsx | 52 ++++ .../work-order-slide-over-tab-panel.tsx | 33 +++ .../detail/work-order-slide-over-tabs.tsx | 156 +++++++++++ .../detail/work-order-slide-over.tsx | 108 +++++++ .../detail/work-order-status-menu.tsx | 50 ++++ .../table/build-inline-draft-create-input.ts | 7 +- .../list/table/cells/comp-doc-cell.tsx | 18 ++ .../list/table/cells/date-cell.tsx | 18 ++ .../list/table/cells/site-cell.tsx | 14 +- .../table/cells/site-dialog-poc-fields.tsx | 76 +++-- .../list/table/cells/site-dialog.tsx | 143 ++++------ .../list/table/cells/use-site-dialog-state.ts | 142 ++++++++++ .../list/table/inline-create-field.tsx | 32 +++ .../list/table/to-inline-draft-row.ts | 1 + .../_components/list/table/wo-table-cols.ts | 3 +- .../table/wo-table-inline-identity-cells.tsx | 93 ++++++ .../table/wo-table-inline-row-actions.tsx | 11 +- .../list/table/wo-table-inline-row-cells.tsx | 76 +++-- .../list/table/wo-table-inline-row.tsx | 87 ++++-- .../list/table/wo-table-row-service-cells.tsx | 16 +- .../list/work-order-cancel-dialog.tsx | 50 ++++ .../list/work-orders-list-overlays.tsx | 90 ++++++ .../_components/media/media-upload-modal.tsx | 93 +++++- .../media/media-uploader-dropzone.tsx | 60 ++++ .../media/media-uploader-grid-item-chrome.tsx | 113 ++++++++ .../media/media-uploader-grid-item-meta.tsx | 67 +++++ .../media-uploader-grid-item-preview.tsx | 52 ++++ .../media/media-uploader-grid-item.tsx | 46 +++ .../_components/media/media-uploader-grid.tsx | 42 +++ .../_components/media/media-uploader.tsx | 208 ++++++-------- .../_hooks/create-comp-doc-dialog-actions.ts | 170 +++++++++++ .../workorders/_hooks/use-comp-doc-dialog.ts | 157 +++++++++++ .../_hooks/use-media-upload-session.ts | 199 +++++++++++++ .../_hooks/use-work-orders-list-page.ts | 131 +++++++++ .../_hooks/use-work-orders-table-ui-state.ts | 8 +- src/app/(protected)/workorders/index.tsx | 147 +++++----- .../api/work-order-board-documents-api.ts | 50 +++- .../mappers/assign-wizard-schedule-fields.ts | 4 +- .../work-orders/mappers/doc-status-mapper.ts | 28 ++ .../mappers/expand-scalar-board-ops.ts | 9 +- .../mappers/wizard-draft-mapper.ts | 1 + .../mappers/work-order-board-detail-mapper.ts | 8 +- .../work-orders/mappers/work-order-mapper.ts | 10 +- .../mappers/work-order-table-row-mapper.ts | 1 + .../work-orders/mocks/wo-wizard-mock-data.ts | 1 + .../types/work-order-board-patch.ts | 8 + .../work-orders/types/work-order-board.ts | 3 + .../work-orders/types/work-order-detail.ts | 6 + .../work-orders/types/work-order-media.ts | 6 + .../work-orders/types/work-order-table-row.ts | 2 + .../use-cases/use-work-order-mutations.ts | 45 ++- .../use-work-order-table-mutations.ts | 126 ++++++--- .../utils/completion-doc-service-gate.ts | 4 + .../utils/completion-doc-service-title.ts | 19 +- .../utils/get-inline-create-missing.ts | 62 ++++ .../utils/run-sequential-uploads.ts | 24 ++ .../work-orders/utils/slide-over-draft.ts | 4 +- .../utils/work-order-media-limits.ts | 17 ++ .../utils/work-order-patch-queue.ts | 20 ++ .../build-inline-draft-create-input.test.ts | 55 ++++ .../comp-doc-cell-service-gate.test.tsx | 29 ++ ...mp-doc-dialog-completion-contract.test.tsx | 230 +++++++++++++++ .../comp-doc-dialog-footer-exclusive.test.tsx | 53 ++++ .../media-upload-modal-api.test.tsx | 115 ++++++++ .../use-comp-doc-dialog-address-seed.test.tsx | 149 ++++++++++ .../use-media-upload-session.test.tsx | 206 ++++++++++++++ .../workorders/use-site-dialog-state.test.tsx | 112 ++++++++ .../use-slide-over-media-state.test.tsx | 143 ++++++++++ .../wo-table-unscheduled-highlight.test.tsx | 1 + ...work-order-slide-over-failed-save.test.tsx | 172 ++++++++++++ ...ork-orders-advanced-search-wiring.test.tsx | 7 + ...ork-orders-board-core-affordances.test.tsx | 23 +- .../app/v/dispatch-snapshot-details.test.tsx | 52 ++++ .../work-orders/api/work-orders-api.test.ts | 69 ++++- .../completed-date-board-column.test.ts | 26 ++ .../work-order-board-detail-mapper.test.ts | 29 ++ .../mappers/work-order-board-mapper.test.ts | 15 + .../mappers/work-order-form-payload.test.ts | 14 + .../mappers/work-order-mapper.test.ts | 37 +++ ...der-table-mutations-completed-date.test.ts | 50 ++++ .../use-work-order-table-mutations.test.tsx | 51 ++++ .../completion-doc-service-title.test.ts | 27 ++ .../utils/get-inline-create-missing.test.ts | 82 ++++++ .../utils/run-sequential-uploads.test.ts | 65 +++++ .../utils/slide-over-draft.test.ts | 15 + .../slide-over-failed-save-baseline.test.ts | 100 +++++++ .../utils/slide-over-save-contract.test.ts | 76 +++++ .../utils/table-filter-utils.test.ts | 1 + .../utils/wizard-date-utils.test.ts | 28 +- .../utils/work-order-media-limits.test.ts | 14 + .../utils/work-order-patch-queue.test.ts | 62 ++++ 162 files changed, 9912 insertions(+), 492 deletions(-) create mode 100644 src/app/(protected)/workorders/_components/comp-doc/comp-doc-co-line.tsx create mode 100644 src/app/(protected)/workorders/_components/comp-doc/comp-doc-detail-row.tsx create mode 100644 src/app/(protected)/workorders/_components/comp-doc/comp-doc-details-section.tsx create mode 100644 src/app/(protected)/workorders/_components/comp-doc/comp-doc-dialog-footer-draft-actions.tsx create mode 100644 src/app/(protected)/workorders/_components/comp-doc/comp-doc-dialog-footer-draft.tsx create mode 100644 src/app/(protected)/workorders/_components/comp-doc/comp-doc-dialog-footer-generated-actions.tsx create mode 100644 src/app/(protected)/workorders/_components/comp-doc/comp-doc-dialog-footer-generated.tsx create mode 100644 src/app/(protected)/workorders/_components/comp-doc/comp-doc-dialog-footer-not-required-actions.tsx create mode 100644 src/app/(protected)/workorders/_components/comp-doc/comp-doc-dialog-footer-not-required.tsx create mode 100644 src/app/(protected)/workorders/_components/comp-doc/comp-doc-dialog-footer.tsx create mode 100644 src/app/(protected)/workorders/_components/comp-doc/comp-doc-dialog-header.tsx create mode 100644 src/app/(protected)/workorders/_components/comp-doc/comp-doc-dialog-resource-links.tsx create mode 100644 src/app/(protected)/workorders/_components/comp-doc/comp-doc-dialog.tsx create mode 100644 src/app/(protected)/workorders/_components/comp-doc/comp-doc-draft-toast.tsx create mode 100644 src/app/(protected)/workorders/_components/comp-doc/comp-doc-edit-row.tsx create mode 100644 src/app/(protected)/workorders/_components/comp-doc/comp-doc-editable-row.tsx create mode 100644 src/app/(protected)/workorders/_components/comp-doc/comp-doc-footer-btn-sx.ts create mode 100644 src/app/(protected)/workorders/_components/comp-doc/comp-doc-footer-button-sx.ts create mode 100644 src/app/(protected)/workorders/_components/comp-doc/comp-doc-not-required-stamp.tsx create mode 100644 src/app/(protected)/workorders/_components/comp-doc/comp-doc-notes-field.tsx create mode 100644 src/app/(protected)/workorders/_components/comp-doc/comp-doc-preview-badge.tsx create mode 100644 src/app/(protected)/workorders/_components/comp-doc/comp-doc-preview-company-header.tsx create mode 100644 src/app/(protected)/workorders/_components/comp-doc/comp-doc-preview-document.tsx create mode 100644 src/app/(protected)/workorders/_components/comp-doc/comp-doc-preview-header.tsx create mode 100644 src/app/(protected)/workorders/_components/comp-doc/comp-doc-preview-not-required-stamp.tsx create mode 100644 src/app/(protected)/workorders/_components/comp-doc/comp-doc-preview-procedure-notes.tsx create mode 100644 src/app/(protected)/workorders/_components/comp-doc/comp-doc-preview-procedure.tsx create mode 100644 src/app/(protected)/workorders/_components/comp-doc/comp-doc-preview-service-info.tsx create mode 100644 src/app/(protected)/workorders/_components/comp-doc/comp-doc-preview-status-badge.tsx create mode 100644 src/app/(protected)/workorders/_components/comp-doc/comp-doc-preview-summary.tsx create mode 100644 src/app/(protected)/workorders/_components/comp-doc/comp-doc-preview-vendor-safety.tsx create mode 100644 src/app/(protected)/workorders/_components/comp-doc/comp-doc-preview.tsx create mode 100644 src/app/(protected)/workorders/_components/comp-doc/comp-doc-procedure-list.tsx create mode 100644 src/app/(protected)/workorders/_components/comp-doc/comp-doc-sidebar.tsx create mode 100644 src/app/(protected)/workorders/_components/comp-doc/comp-doc-status-card.tsx create mode 100644 src/app/(protected)/workorders/_components/comp-doc/use-comp-doc-dialog-seed.ts create mode 100644 src/app/(protected)/workorders/_components/comp-doc/use-comp-doc-dialog.tsx create mode 100644 src/app/(protected)/workorders/_components/comp-doc/use-comp-doc-not-required-actions.ts create mode 100644 src/app/(protected)/workorders/_components/comp-doc/use-comp-doc-pdf-actions.ts create mode 100644 src/app/(protected)/workorders/_components/comp-doc/use-comp-doc-persist-flow.ts create mode 100644 src/app/(protected)/workorders/_components/detail/audit-timeline.tsx create mode 100644 src/app/(protected)/workorders/_components/detail/slide-over-audit-list.tsx create mode 100644 src/app/(protected)/workorders/_components/detail/slide-over-footer.tsx create mode 100644 src/app/(protected)/workorders/_components/detail/slide-over-header.tsx create mode 100644 src/app/(protected)/workorders/_components/detail/slide-over-info-row.tsx create mode 100644 src/app/(protected)/workorders/_components/detail/tabs/slide-over-audit-tab.tsx create mode 100644 src/app/(protected)/workorders/_components/detail/tabs/slide-over-comments-tab.tsx create mode 100644 src/app/(protected)/workorders/_components/detail/tabs/slide-over-completion-actions.tsx create mode 100644 src/app/(protected)/workorders/_components/detail/tabs/slide-over-completion-media.tsx create mode 100644 src/app/(protected)/workorders/_components/detail/tabs/slide-over-completion-readonly-status.tsx create mode 100644 src/app/(protected)/workorders/_components/detail/tabs/slide-over-completion-signoffs.tsx create mode 100644 src/app/(protected)/workorders/_components/detail/tabs/slide-over-completion-tab.tsx create mode 100644 src/app/(protected)/workorders/_components/detail/tabs/slide-over-doc-badge.tsx create mode 100644 src/app/(protected)/workorders/_components/detail/tabs/slide-over-extras-tab.tsx create mode 100644 src/app/(protected)/workorders/_components/detail/tabs/slide-over-info-tab-assigned-field.tsx create mode 100644 src/app/(protected)/workorders/_components/detail/tabs/slide-over-info-tab-display-view.tsx create mode 100644 src/app/(protected)/workorders/_components/detail/tabs/slide-over-info-tab-edit-view.tsx create mode 100644 src/app/(protected)/workorders/_components/detail/tabs/slide-over-info-tab-status-field.tsx create mode 100644 src/app/(protected)/workorders/_components/detail/tabs/slide-over-info-tab-vendor-field.tsx create mode 100644 src/app/(protected)/workorders/_components/detail/tabs/slide-over-info-tab.tsx create mode 100644 src/app/(protected)/workorders/_components/detail/use-slide-over-edit-state.ts create mode 100644 src/app/(protected)/workorders/_components/detail/use-slide-over-media-state.ts create mode 100644 src/app/(protected)/workorders/_components/detail/use-work-order-slide-over.ts create mode 100644 src/app/(protected)/workorders/_components/detail/work-order-activity-section.tsx create mode 100644 src/app/(protected)/workorders/_components/detail/work-order-assignee-menu.tsx create mode 100644 src/app/(protected)/workorders/_components/detail/work-order-description-accordion.tsx create mode 100644 src/app/(protected)/workorders/_components/detail/work-order-detail-sections.tsx create mode 100644 src/app/(protected)/workorders/_components/detail/work-order-details-card.tsx create mode 100644 src/app/(protected)/workorders/_components/detail/work-order-dispatches-accordion.tsx create mode 100644 src/app/(protected)/workorders/_components/detail/work-order-simple-accordions.tsx create mode 100644 src/app/(protected)/workorders/_components/detail/work-order-slide-over-tab-nav.tsx create mode 100644 src/app/(protected)/workorders/_components/detail/work-order-slide-over-tab-panel.tsx create mode 100644 src/app/(protected)/workorders/_components/detail/work-order-slide-over-tabs.tsx create mode 100644 src/app/(protected)/workorders/_components/detail/work-order-slide-over.tsx create mode 100644 src/app/(protected)/workorders/_components/detail/work-order-status-menu.tsx create mode 100644 src/app/(protected)/workorders/_components/list/table/cells/use-site-dialog-state.ts create mode 100644 src/app/(protected)/workorders/_components/list/table/inline-create-field.tsx create mode 100644 src/app/(protected)/workorders/_components/list/table/wo-table-inline-identity-cells.tsx create mode 100644 src/app/(protected)/workorders/_components/list/work-order-cancel-dialog.tsx create mode 100644 src/app/(protected)/workorders/_components/list/work-orders-list-overlays.tsx create mode 100644 src/app/(protected)/workorders/_components/media/media-uploader-dropzone.tsx create mode 100644 src/app/(protected)/workorders/_components/media/media-uploader-grid-item-chrome.tsx create mode 100644 src/app/(protected)/workorders/_components/media/media-uploader-grid-item-meta.tsx create mode 100644 src/app/(protected)/workorders/_components/media/media-uploader-grid-item-preview.tsx create mode 100644 src/app/(protected)/workorders/_components/media/media-uploader-grid-item.tsx create mode 100644 src/app/(protected)/workorders/_components/media/media-uploader-grid.tsx create mode 100644 src/app/(protected)/workorders/_hooks/create-comp-doc-dialog-actions.ts create mode 100644 src/app/(protected)/workorders/_hooks/use-comp-doc-dialog.ts create mode 100644 src/app/(protected)/workorders/_hooks/use-media-upload-session.ts create mode 100644 src/app/(protected)/workorders/_hooks/use-work-orders-list-page.ts create mode 100644 src/domain/work-orders/mappers/doc-status-mapper.ts create mode 100644 src/domain/work-orders/types/work-order-board-patch.ts create mode 100644 src/domain/work-orders/utils/completion-doc-service-gate.ts create mode 100644 src/domain/work-orders/utils/get-inline-create-missing.ts create mode 100644 src/domain/work-orders/utils/run-sequential-uploads.ts create mode 100644 src/domain/work-orders/utils/work-order-patch-queue.ts create mode 100644 src/test/app/(protected)/workorders/build-inline-draft-create-input.test.ts create mode 100644 src/test/app/(protected)/workorders/comp-doc-cell-service-gate.test.tsx create mode 100644 src/test/app/(protected)/workorders/comp-doc-dialog-completion-contract.test.tsx create mode 100644 src/test/app/(protected)/workorders/comp-doc-dialog-footer-exclusive.test.tsx create mode 100644 src/test/app/(protected)/workorders/media-upload-modal-api.test.tsx create mode 100644 src/test/app/(protected)/workorders/use-comp-doc-dialog-address-seed.test.tsx create mode 100644 src/test/app/(protected)/workorders/use-media-upload-session.test.tsx create mode 100644 src/test/app/(protected)/workorders/use-site-dialog-state.test.tsx create mode 100644 src/test/app/(protected)/workorders/use-slide-over-media-state.test.tsx create mode 100644 src/test/app/(protected)/workorders/work-order-slide-over-failed-save.test.tsx create mode 100644 src/test/app/v/dispatch-snapshot-details.test.tsx create mode 100644 src/test/domain/work-orders/mappers/completed-date-board-column.test.ts create mode 100644 src/test/domain/work-orders/use-cases/use-work-order-table-mutations-completed-date.test.ts create mode 100644 src/test/domain/work-orders/utils/completion-doc-service-title.test.ts create mode 100644 src/test/domain/work-orders/utils/get-inline-create-missing.test.ts create mode 100644 src/test/domain/work-orders/utils/run-sequential-uploads.test.ts create mode 100644 src/test/domain/work-orders/utils/slide-over-failed-save-baseline.test.ts create mode 100644 src/test/domain/work-orders/utils/slide-over-save-contract.test.ts create mode 100644 src/test/domain/work-orders/utils/work-order-patch-queue.test.ts diff --git a/scripts/governance-baseline.json b/scripts/governance-baseline.json index 401e1e70..3862e898 100644 --- a/scripts/governance-baseline.json +++ b/scripts/governance-baseline.json @@ -2,16 +2,5 @@ "version": 1, "purpose": "Frozen grandfathered debt inventory for the frontend governance checks. New entries and cap increases fail the governance comparison. Existing caps may only decrease and entries must be removed when compliant.", "maxFileLines": 500, - "godfileDebt": [ - { - "path": "src/app/(protected)/workorders/[id].tsx", - "maxLines": 797, - "reason": "Work-order detail route page; legacy godfile targeted for decomposition into focused state components." - }, - { - "path": "src/app/(protected)/vendors/index.tsx", - "maxLines": 612, - "reason": "Vendors list route; legacy page with mixed query/filter/table state pending extraction." - } - ] + "godfileDebt": [] } diff --git a/src/app/(protected)/workorders/_components/comp-doc/comp-doc-co-line.tsx b/src/app/(protected)/workorders/_components/comp-doc/comp-doc-co-line.tsx new file mode 100644 index 00000000..5d3804bb --- /dev/null +++ b/src/app/(protected)/workorders/_components/comp-doc/comp-doc-co-line.tsx @@ -0,0 +1,19 @@ +import type { ElementType } from "react"; + +type CompDocCoLineProps = { + icon: ElementType; + text: string; + bold?: boolean; +}; + +export function CompDocCoLine({ icon: Icon, text, bold }: CompDocCoLineProps) { + return ( +
+ {text} + +
+ ); +} diff --git a/src/app/(protected)/workorders/_components/comp-doc/comp-doc-detail-row.tsx b/src/app/(protected)/workorders/_components/comp-doc/comp-doc-detail-row.tsx new file mode 100644 index 00000000..c5f08558 --- /dev/null +++ b/src/app/(protected)/workorders/_components/comp-doc/comp-doc-detail-row.tsx @@ -0,0 +1,39 @@ +import type { ElementType } from "react"; + +type CompDocDetailRowProps = { + icon: ElementType; + label: string; + value: string; + last?: boolean; +}; + +export function CompDocDetailRow({ icon: Icon, label, value, last }: CompDocDetailRowProps) { + return ( +
+ +
+
{label}
+
+ {value} +
+
+
+ ); +} diff --git a/src/app/(protected)/workorders/_components/comp-doc/comp-doc-details-section.tsx b/src/app/(protected)/workorders/_components/comp-doc/comp-doc-details-section.tsx new file mode 100644 index 00000000..3a38f2e3 --- /dev/null +++ b/src/app/(protected)/workorders/_components/comp-doc/comp-doc-details-section.tsx @@ -0,0 +1,75 @@ +import { Building2, MapPin, Users, Wrench } from "lucide-react"; +import type { CompDocSiteInfo } from "@/domain/work-orders/types/completion-doc"; +import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row"; +import { CompDocDetailRow } from "./comp-doc-detail-row"; +import { CompDocEditableRow } from "./comp-doc-editable-row"; + +type CompDocDetailsSectionProps = { + row: WorkOrderTableRow; + site: CompDocSiteInfo; + editAddress: string; + onAddressChange: (value: string) => void; + editDateCompleted: string; + onDateCompletedChange: (value: string) => void; + amazonRep: string; + seaRep: string; + isGenerated: boolean; +}; + +export function CompDocDetailsSection({ + row, + site, + editAddress, + onAddressChange, + editDateCompleted, + onDateCompletedChange, + amazonRep, + seaRep, + isGenerated, +}: CompDocDetailsSectionProps) { + return ( +
+
+ Work Order Details +
+
+ + + + + + +
+
+ ); +} diff --git a/src/app/(protected)/workorders/_components/comp-doc/comp-doc-dialog-footer-draft-actions.tsx b/src/app/(protected)/workorders/_components/comp-doc/comp-doc-dialog-footer-draft-actions.tsx new file mode 100644 index 00000000..86749598 --- /dev/null +++ b/src/app/(protected)/workorders/_components/comp-doc/comp-doc-dialog-footer-draft-actions.tsx @@ -0,0 +1,70 @@ +import type { RefObject } from "react"; +import { Ban, Save, Sparkles, Upload } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { COMP_DOC_FOOTER_BTN_SX } from "./comp-doc-footer-button-sx"; + +type CompDocDialogFooterDraftActionsProps = { + rowVersion?: string | number | null; + isUploading: boolean; + pdfInputRef: RefObject; + onMarkNotRequired: () => void; + onSaveDraft: () => void; + onGenerate: () => void; +}; + +export function CompDocDialogFooterDraftActions({ + rowVersion, + isUploading, + pdfInputRef, + onMarkNotRequired, + onSaveDraft, + onGenerate, +}: CompDocDialogFooterDraftActionsProps) { + return ( + <> + +
+ + + +
+ + ); +} diff --git a/src/app/(protected)/workorders/_components/comp-doc/comp-doc-dialog-footer-draft.tsx b/src/app/(protected)/workorders/_components/comp-doc/comp-doc-dialog-footer-draft.tsx new file mode 100644 index 00000000..fad1529f --- /dev/null +++ b/src/app/(protected)/workorders/_components/comp-doc/comp-doc-dialog-footer-draft.tsx @@ -0,0 +1,50 @@ +import { Ban, Save, Sparkles } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { COMP_DOC_FOOTER_BTN_SX } from "./comp-doc-footer-btn-sx"; + +type CompDocDialogFooterDraftProps = { + onMarkNotRequired: () => void; + onSaveDraft: () => void; + onGenerate: () => void; +}; + +export function CompDocDialogFooterDraft({ + onMarkNotRequired, + onSaveDraft, + onGenerate, +}: CompDocDialogFooterDraftProps) { + return ( + <> + +
+ + +
+ + ); +} diff --git a/src/app/(protected)/workorders/_components/comp-doc/comp-doc-dialog-footer-generated-actions.tsx b/src/app/(protected)/workorders/_components/comp-doc/comp-doc-dialog-footer-generated-actions.tsx new file mode 100644 index 00000000..5c006a97 --- /dev/null +++ b/src/app/(protected)/workorders/_components/comp-doc/comp-doc-dialog-footer-generated-actions.tsx @@ -0,0 +1,62 @@ +import type { RefObject } from "react"; +import { CheckCircle2, Download, Upload } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { COMP_DOC_FOOTER_BTN_SX } from "./comp-doc-footer-button-sx"; + +type CompDocDialogFooterGeneratedActionsProps = { + generatedAt: string; + signOffAttachment?: string | null; + isDocumentsReadOnly: boolean; + rowVersion?: string | number | null; + isUploading: boolean; + pdfInputRef: RefObject; + onDownloadPdf: () => void; +}; + +export function CompDocDialogFooterGeneratedActions({ + generatedAt, + signOffAttachment, + isDocumentsReadOnly, + rowVersion, + isUploading, + pdfInputRef, + onDownloadPdf, +}: CompDocDialogFooterGeneratedActionsProps) { + return ( + <> + + {" "} + {signOffAttachment ? "Document on file" : `Generated ${generatedAt}`} + +
+ {!isDocumentsReadOnly && ( + + )} + +
+ + ); +} diff --git a/src/app/(protected)/workorders/_components/comp-doc/comp-doc-dialog-footer-generated.tsx b/src/app/(protected)/workorders/_components/comp-doc/comp-doc-dialog-footer-generated.tsx new file mode 100644 index 00000000..91b9792c --- /dev/null +++ b/src/app/(protected)/workorders/_components/comp-doc/comp-doc-dialog-footer-generated.tsx @@ -0,0 +1,35 @@ +import { CheckCircle2, Download } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { COMP_DOC_FOOTER_BTN_SX } from "./comp-doc-footer-btn-sx"; + +type CompDocDialogFooterGeneratedProps = { + generatedAt: string; + onDownloadPdf: () => void; +}; + +export function CompDocDialogFooterGenerated({ + generatedAt, + onDownloadPdf, +}: CompDocDialogFooterGeneratedProps) { + return ( + <> + + Generated {generatedAt} + +
+ +
+ + ); +} diff --git a/src/app/(protected)/workorders/_components/comp-doc/comp-doc-dialog-footer-not-required-actions.tsx b/src/app/(protected)/workorders/_components/comp-doc/comp-doc-dialog-footer-not-required-actions.tsx new file mode 100644 index 00000000..79d16bca --- /dev/null +++ b/src/app/(protected)/workorders/_components/comp-doc/comp-doc-dialog-footer-not-required-actions.tsx @@ -0,0 +1,53 @@ +import { Ban, Sparkles, Undo2 } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { COMP_DOC_FOOTER_BTN_SX } from "./comp-doc-footer-button-sx"; + +type CompDocDialogFooterNotRequiredActionsProps = { + isDocumentsReadOnly: boolean; + onUndo: () => void; +}; + +export function CompDocDialogFooterNotRequiredActions({ + isDocumentsReadOnly, + onUndo, +}: CompDocDialogFooterNotRequiredActionsProps) { + return ( + <> + + Marked{" "} + not required for this work order + +
+ {!isDocumentsReadOnly && ( + + )} + +
+ + ); +} diff --git a/src/app/(protected)/workorders/_components/comp-doc/comp-doc-dialog-footer-not-required.tsx b/src/app/(protected)/workorders/_components/comp-doc/comp-doc-dialog-footer-not-required.tsx new file mode 100644 index 00000000..da1976bb --- /dev/null +++ b/src/app/(protected)/workorders/_components/comp-doc/comp-doc-dialog-footer-not-required.tsx @@ -0,0 +1,47 @@ +import { Ban, Sparkles, Undo2 } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { COMP_DOC_FOOTER_BTN_SX } from "./comp-doc-footer-btn-sx"; + +type CompDocDialogFooterNotRequiredProps = { + onUndo: () => void; +}; + +export function CompDocDialogFooterNotRequired({ onUndo }: CompDocDialogFooterNotRequiredProps) { + return ( + <> + + Marked{" "} + not required for this work order + +
+ + +
+ + ); +} diff --git a/src/app/(protected)/workorders/_components/comp-doc/comp-doc-dialog-footer.tsx b/src/app/(protected)/workorders/_components/comp-doc/comp-doc-dialog-footer.tsx new file mode 100644 index 00000000..b21cbd05 --- /dev/null +++ b/src/app/(protected)/workorders/_components/comp-doc/comp-doc-dialog-footer.tsx @@ -0,0 +1,94 @@ +import type { RefObject } from "react"; +import type { DocState } from "@/domain/work-orders/types/completion-doc"; +import { CompDocDialogFooterDraftActions } from "./comp-doc-dialog-footer-draft-actions"; +import { CompDocDialogFooterGeneratedActions } from "./comp-doc-dialog-footer-generated-actions"; +import { CompDocDialogFooterNotRequiredActions } from "./comp-doc-dialog-footer-not-required-actions"; + +type CompDocDialogFooterProps = { + docState: DocState; + isDocumentsReadOnly: boolean; + rowVersion?: string | number | null; + isUploading: boolean; + signOffAttachment?: string | null; + generatedAt: string; + pdfInputRef: RefObject; + onMarkNotRequired: () => void; + onSaveDraft: () => void; + onGenerate: () => void; + onUndo: () => void; + onDownloadPdf: () => void; + onUploadPdf: (files: FileList | null) => void; +}; + +export function CompDocDialogFooter({ + docState, + isDocumentsReadOnly, + rowVersion, + isUploading, + signOffAttachment, + generatedAt, + pdfInputRef, + onMarkNotRequired, + onSaveDraft, + onGenerate, + onUndo, + onDownloadPdf, + onUploadPdf, +}: CompDocDialogFooterProps) { + // Exclusive footer branches (prototype: one docState at a time). A signed PDF + // on file implies Generated even if local docState has not caught up yet — never + // render Draft actions alongside Generated. + const showNotRequired = docState === "not-required"; + const showGenerated = + !showNotRequired && (docState === "generated" || Boolean(signOffAttachment)); + const showDraft = !showNotRequired && !showGenerated && docState === "draft"; + + return ( +
+ { + onUploadPdf(event.target.files); + event.target.value = ""; + }} + /> + {Boolean(showDraft && !isDocumentsReadOnly) && ( + + )} + {showGenerated && ( + + )} + {showNotRequired && ( + + )} +
+ ); +} diff --git a/src/app/(protected)/workorders/_components/comp-doc/comp-doc-dialog-header.tsx b/src/app/(protected)/workorders/_components/comp-doc/comp-doc-dialog-header.tsx new file mode 100644 index 00000000..08a9cb20 --- /dev/null +++ b/src/app/(protected)/workorders/_components/comp-doc/comp-doc-dialog-header.tsx @@ -0,0 +1,96 @@ +import { FileText, X } from "lucide-react"; +import type { CompletionDocStateConfig } from "@/domain/work-orders/types/completion-doc"; +import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row"; +import { serviceTitle } from "@/domain/work-orders/utils/completion-doc-service-title"; + +type CompDocDialogHeaderProps = { + info: WorkOrderTableRow; + cfg: CompletionDocStateConfig; + isUploading: boolean; + onClose: () => void; +}; + +export function CompDocDialogHeader({ info, cfg, isUploading, onClose }: CompDocDialogHeaderProps) { + return ( +
+
+ +
+
+ Completion Document +
+
+ WO{" "} + + #{info.woNumber} + + {" · "} + {serviceTitle(info)} +
+
+
+
+ + + {cfg.pillLabel} + + +
+
+ ); +} diff --git a/src/app/(protected)/workorders/_components/comp-doc/comp-doc-dialog-resource-links.tsx b/src/app/(protected)/workorders/_components/comp-doc/comp-doc-dialog-resource-links.tsx new file mode 100644 index 00000000..bb772f0b --- /dev/null +++ b/src/app/(protected)/workorders/_components/comp-doc/comp-doc-dialog-resource-links.tsx @@ -0,0 +1,48 @@ +import { Download, FileText } from "lucide-react"; + +type CompDocDialogResourceLinksProps = { + templateUrl?: string | null; + signOffAttachment?: string | null; +}; + +export function CompDocDialogResourceLinks({ + templateUrl, + signOffAttachment, +}: CompDocDialogResourceLinksProps) { + if (!templateUrl && !signOffAttachment) return null; + + return ( +
+ {Boolean(templateUrl) && ( + + + Download blank template + + )} + {Boolean(signOffAttachment) && ( + + + Open uploaded PDF + + )} +
+ ); +} diff --git a/src/app/(protected)/workorders/_components/comp-doc/comp-doc-dialog.tsx b/src/app/(protected)/workorders/_components/comp-doc/comp-doc-dialog.tsx new file mode 100644 index 00000000..f87fbb78 --- /dev/null +++ b/src/app/(protected)/workorders/_components/comp-doc/comp-doc-dialog.tsx @@ -0,0 +1,132 @@ +import type { MouseEvent } from "react"; +import { Dialog, DialogContent, DialogTitle } from "@mui/material"; +import { dialogBlurBackdropSlotProps } from "@/components/ui/dialog-blur-backdrop"; +import { Text } from "@/components/ui/text"; +import type { CompDocData, CompDocSaveStatus } from "@/domain/work-orders/types/completion-doc"; +import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row"; +import { CompDocDialogFooter } from "./comp-doc-dialog-footer"; +import { CompDocDialogHeader } from "./comp-doc-dialog-header"; +import { CompDocDialogResourceLinks } from "./comp-doc-dialog-resource-links"; +import { CompDocPreview } from "./comp-doc-preview"; +import { CompDocSidebar } from "./comp-doc-sidebar"; +import { useCompDocDialog } from "./use-comp-doc-dialog"; + +type CompDocDialogProps = { + row: WorkOrderTableRow; + open: boolean; + onOpenChange: (open: boolean) => void; + data?: CompDocData; + onSave: (data: CompDocData, status: CompDocSaveStatus) => void | Promise; + onDateCompletedChange?: (date: string) => void | Promise; +}; + +export function CompDocDialog({ + row, + open, + onOpenChange, + data, + onSave, + onDateCompletedChange, +}: CompDocDialogProps) { + const dialog = useCompDocDialog({ + row, + open, + data, + onSave, + onDateCompletedChange, + }); + const handleClose = dialog.isUploading ? undefined : () => onOpenChange(false); + + return ( + ) => e.stopPropagation(), + }, + }} + > + + + Completion Document — WO #{dialog.info.woNumber} + + + Generate, save or mark not-required the completion document for this work order. + + +
+ handleClose?.()} + /> + + + +
+ + +
+ + +
+
+
+ ); +} diff --git a/src/app/(protected)/workorders/_components/comp-doc/comp-doc-draft-toast.tsx b/src/app/(protected)/workorders/_components/comp-doc/comp-doc-draft-toast.tsx new file mode 100644 index 00000000..910b7227 --- /dev/null +++ b/src/app/(protected)/workorders/_components/comp-doc/comp-doc-draft-toast.tsx @@ -0,0 +1,29 @@ +import { CheckCircle2 } from "lucide-react"; +import { toast } from "react-toastify"; + +export function showCompDocDraftSavedToast() { + toast( +
+ Draft saved +
, + { + position: "bottom-center", + autoClose: 3000, + hideProgressBar: true, + closeButton: false, + icon: false, + className: "!min-h-0 !bg-transparent !p-0 !shadow-none", + }, + ); +} diff --git a/src/app/(protected)/workorders/_components/comp-doc/comp-doc-edit-row.tsx b/src/app/(protected)/workorders/_components/comp-doc/comp-doc-edit-row.tsx new file mode 100644 index 00000000..3823f39b --- /dev/null +++ b/src/app/(protected)/workorders/_components/comp-doc/comp-doc-edit-row.tsx @@ -0,0 +1,51 @@ +import type { CSSProperties } from "react"; + +const COMP_DOC_INPUT_CLS = "w-full rounded-md px-2.5 py-1.5 focus:outline-none focus:ring-1"; + +const COMP_DOC_INPUT_STYLE: CSSProperties = { + fontSize: 13, + border: "1px solid var(--color-border)", + color: "var(--color-text-primary)", + background: "var(--color-bg-surface)", +}; + +type CompDocEditRowProps = { + label: string; + value: string; + onChange: (value: string) => void; + type?: string; + readOnly?: boolean; +}; + +export function CompDocEditRow({ + label, + value, + onChange, + type = "text", + readOnly = false, +}: CompDocEditRowProps) { + return ( +
+
+ {label} +
+ onChange(e.target.value)} + readOnly={readOnly} + className={COMP_DOC_INPUT_CLS} + style={{ + ...COMP_DOC_INPUT_STYLE, + ...(readOnly + ? { + opacity: 0.6, + cursor: "default", + pointerEvents: "none" as const, + } + : {}), + }} + /> +
+ ); +} diff --git a/src/app/(protected)/workorders/_components/comp-doc/comp-doc-editable-row.tsx b/src/app/(protected)/workorders/_components/comp-doc/comp-doc-editable-row.tsx new file mode 100644 index 00000000..8e9261e2 --- /dev/null +++ b/src/app/(protected)/workorders/_components/comp-doc/comp-doc-editable-row.tsx @@ -0,0 +1,54 @@ +import type { CSSProperties } from "react"; + +const COMP_DOC_INPUT_CLS = "w-full rounded-md px-2.5 py-1.5 focus:outline-none focus:ring-1"; + +const COMP_DOC_INPUT_STYLE: CSSProperties = { + fontSize: 13, + border: "1px solid var(--color-border)", + color: "var(--color-text-primary)", + background: "var(--color-bg-surface)", +}; + +const COMP_DOC_INPUT_DISABLED_STYLE: CSSProperties = { + opacity: 0.6, + cursor: "default", + pointerEvents: "none", +}; + +type CompDocEditableRowProps = { + label: string; + value: string; + onChange: (value: string) => void; + type?: string; + placeholder?: string; + disabled?: boolean; +}; + +export function CompDocEditableRow({ + label, + value, + onChange, + type = "text", + placeholder = "", + disabled = false, +}: CompDocEditableRowProps) { + return ( +
+
+ {label} +
+ onChange(e.target.value)} + readOnly={disabled} + placeholder={placeholder} + className={COMP_DOC_INPUT_CLS} + style={{ + ...COMP_DOC_INPUT_STYLE, + ...(disabled ? COMP_DOC_INPUT_DISABLED_STYLE : {}), + }} + /> +
+ ); +} diff --git a/src/app/(protected)/workorders/_components/comp-doc/comp-doc-footer-btn-sx.ts b/src/app/(protected)/workorders/_components/comp-doc/comp-doc-footer-btn-sx.ts new file mode 100644 index 00000000..f5c0f2ba --- /dev/null +++ b/src/app/(protected)/workorders/_components/comp-doc/comp-doc-footer-btn-sx.ts @@ -0,0 +1,8 @@ +export const COMP_DOC_FOOTER_BTN_SX = { + minHeight: 28, + height: 28, + fontSize: 12, + px: 1.25, + py: 0.5, + gap: 0.75, +} as const; diff --git a/src/app/(protected)/workorders/_components/comp-doc/comp-doc-footer-button-sx.ts b/src/app/(protected)/workorders/_components/comp-doc/comp-doc-footer-button-sx.ts new file mode 100644 index 00000000..f5c0f2ba --- /dev/null +++ b/src/app/(protected)/workorders/_components/comp-doc/comp-doc-footer-button-sx.ts @@ -0,0 +1,8 @@ +export const COMP_DOC_FOOTER_BTN_SX = { + minHeight: 28, + height: 28, + fontSize: 12, + px: 1.25, + py: 0.5, + gap: 0.75, +} as const; diff --git a/src/app/(protected)/workorders/_components/comp-doc/comp-doc-not-required-stamp.tsx b/src/app/(protected)/workorders/_components/comp-doc/comp-doc-not-required-stamp.tsx new file mode 100644 index 00000000..ab3cbf04 --- /dev/null +++ b/src/app/(protected)/workorders/_components/comp-doc/comp-doc-not-required-stamp.tsx @@ -0,0 +1,25 @@ +import { Ban } from "lucide-react"; + +export function CompDocNotRequiredStamp() { + return ( +
+
+ NOT REQUIRED +
+
+ ); +} diff --git a/src/app/(protected)/workorders/_components/comp-doc/comp-doc-notes-field.tsx b/src/app/(protected)/workorders/_components/comp-doc/comp-doc-notes-field.tsx new file mode 100644 index 00000000..f6deb5e5 --- /dev/null +++ b/src/app/(protected)/workorders/_components/comp-doc/comp-doc-notes-field.tsx @@ -0,0 +1,50 @@ +type CompDocNotesFieldProps = { + notes: string; + onNotesChange: (value: string) => void; + isGenerated: boolean; +}; + +export function CompDocNotesField({ notes, onNotesChange, isGenerated }: CompDocNotesFieldProps) { + return ( +
+ +