mirror of
https://github.com/Sea-Haven-Industries/shoc-frontend-new.git
synced 2026-09-30 09:13:11 +00:00
Merge pull request #102 from Sea-Haven-Industries/feature/sh-126-wo-addon-indicator
feat(work-orders): SH-126/SH-184 — Add-On indicator (BE-authoritative)
This commit is contained in:
commit
d43341f77a
40 changed files with 870 additions and 143 deletions
35
docs/work-orders/addon-indicator.md
Normal file
35
docs/work-orders/addon-indicator.md
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
# Add-On indicator (SH-184)
|
||||
|
||||
Product rule superseding the SH-126 “frozen at create” FE assumption.
|
||||
|
||||
## Authority
|
||||
|
||||
| Concern | Owner |
|
||||
| ------------------------------------- | -------- |
|
||||
| Cutoff math & week-boundary recalc | Backend |
|
||||
| Clear `isAddOn` when schedule cleared | Backend |
|
||||
| Audit of every recalculation | Backend |
|
||||
| Display / board overlay | Frontend |
|
||||
|
||||
The frontend **must not** treat `isAddOn` as frozen after create. Create may
|
||||
send a **hint** (`resolveIsAddOnForCreate` / cutoff preview or unscheduled
|
||||
manual override). After any schedule patch or clear, FE consumes the
|
||||
authoritative `isAddOn` returned by the board PATCH (and subsequent board
|
||||
fetch).
|
||||
|
||||
## FE contract
|
||||
|
||||
```
|
||||
Board row / PATCH response: isAddOn: boolean
|
||||
Create (hint): isAddOn = cutoff preview | manual override
|
||||
Schedule day/week patch: FE applies returned row.isAddOn (ON↔OFF)
|
||||
Schedule cleared: FE expects isAddOn === false from BE;
|
||||
optimistic clear sets isAddOn: false locally
|
||||
Legacy: workOrderType=AddOn(7) without isAddOn ⇒ isAddOn=true
|
||||
```
|
||||
|
||||
## Dependency
|
||||
|
||||
Full backend recalculation + audit may land in a separate BE PR. Until then,
|
||||
FE still maps and overlays whatever `isAddOn` the API returns so the contract
|
||||
is correct once BE ships.
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -0,0 +1,25 @@
|
|||
type AddOnOverrideToggleProps = {
|
||||
checked: boolean;
|
||||
visible: boolean;
|
||||
onChange: (checked: boolean) => void;
|
||||
};
|
||||
|
||||
export function AddOnOverrideToggle({ checked, visible, onChange }: AddOnOverrideToggleProps) {
|
||||
if (!visible) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<label className="flex cursor-pointer items-center gap-1">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={checked}
|
||||
onChange={(event) => onChange(event.target.checked)}
|
||||
aria-label="Mark as Add-On"
|
||||
/>
|
||||
<span className="text-[10px]" style={{ color: "var(--color-text-secondary)" }}>
|
||||
Add-On
|
||||
</span>
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
|
@ -41,5 +41,6 @@ export function buildInlineDraftCreateInput(
|
|||
pocNotes: draft.pocNotes,
|
||||
scheduleWeekOnly,
|
||||
targetWeek: draft.targetWeek || "",
|
||||
isAddOnOverride: draft.isAddOnOverride,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,12 +3,14 @@ import { toast } from "react-toastify";
|
|||
import { workOrdersApi } from "@/domain/work-orders/api/work-orders-api";
|
||||
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
|
||||
import { findDuplicateWoNumberRow } from "@/domain/work-orders/utils/find-duplicate-wo-number-row";
|
||||
import { EditableWONumberInput } from "./editable-wo-number-input";
|
||||
import { HighlightText } from "./highlight-text";
|
||||
|
||||
type EditableWONumberCellProps = {
|
||||
value: string;
|
||||
rescheduleCount: number;
|
||||
carriedOver?: number;
|
||||
isAddOn?: boolean;
|
||||
q?: string;
|
||||
currentId: string | number;
|
||||
allOrders: WorkOrderTableRow[];
|
||||
|
|
@ -33,60 +35,22 @@ function CounterPill({ label, count }: { label: string; count: number }) {
|
|||
);
|
||||
}
|
||||
|
||||
type WoNumberEditorProps = {
|
||||
draft: string;
|
||||
invalid: boolean;
|
||||
isChecking: boolean;
|
||||
onDraftChange: (value: string) => void;
|
||||
onCommit: () => void;
|
||||
onCancel: () => void;
|
||||
onBlur: () => void;
|
||||
};
|
||||
function AddOnPill({ when }: { when: boolean }) {
|
||||
if (!when) {
|
||||
return null;
|
||||
}
|
||||
|
||||
function WoNumberEditor({
|
||||
draft,
|
||||
invalid,
|
||||
isChecking,
|
||||
onDraftChange,
|
||||
onCommit,
|
||||
onCancel,
|
||||
onBlur,
|
||||
}: WoNumberEditorProps) {
|
||||
return (
|
||||
<input
|
||||
autoFocus
|
||||
disabled={isChecking}
|
||||
inputMode="numeric"
|
||||
value={draft}
|
||||
onChange={(e) => onDraftChange(e.target.value.replace(/\D/g, "").slice(0, 11))}
|
||||
onFocus={(e) => e.target.select()}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
onCommit();
|
||||
} else if (e.key === "Escape") {
|
||||
e.preventDefault();
|
||||
onCancel();
|
||||
}
|
||||
}}
|
||||
onBlur={onBlur}
|
||||
placeholder="12345678901"
|
||||
aria-label="Work order number"
|
||||
aria-invalid={invalid}
|
||||
className="font-mono focus:outline-none"
|
||||
<span
|
||||
title="Add-On"
|
||||
className="inline-flex h-[18px] items-center rounded px-1.5 text-[10px] font-semibold"
|
||||
style={{
|
||||
fontSize: 13,
|
||||
fontWeight: 700,
|
||||
color: "var(--foreground)",
|
||||
background: "var(--color-bg-surface)",
|
||||
border: `1px solid ${invalid ? "var(--color-destructive)" : "var(--color-ring)"}`,
|
||||
boxShadow: `0 0 0 1px ${invalid ? "var(--color-destructive)" : "var(--color-accent)"}`,
|
||||
borderRadius: 4,
|
||||
padding: "2px 4px",
|
||||
width: 120,
|
||||
background: "var(--type-addon-bg)",
|
||||
color: "var(--type-addon-text)",
|
||||
}}
|
||||
/>
|
||||
>
|
||||
Add-On
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -104,6 +68,7 @@ export function EditableWONumberCell({
|
|||
value,
|
||||
rescheduleCount,
|
||||
carriedOver = 0,
|
||||
isAddOn = false,
|
||||
q,
|
||||
currentId,
|
||||
allOrders,
|
||||
|
|
@ -169,14 +134,14 @@ export function EditableWONumberCell({
|
|||
return (
|
||||
<div className="flex items-center gap-1.5">
|
||||
{editing ? (
|
||||
<WoNumberEditor
|
||||
<EditableWONumberInput
|
||||
draft={draft}
|
||||
invalid={invalid}
|
||||
isChecking={isChecking}
|
||||
onDraftChange={setDraft}
|
||||
onChange={setDraft}
|
||||
onCommit={commit}
|
||||
onCancel={cancel}
|
||||
onBlur={handleBlur}
|
||||
onCancel={cancel}
|
||||
/>
|
||||
) : (
|
||||
<span
|
||||
|
|
@ -196,6 +161,7 @@ export function EditableWONumberCell({
|
|||
{value ? <HighlightText text={value} q={q} /> : "-"}
|
||||
</span>
|
||||
)}
|
||||
<AddOnPill when={isAddOn} />
|
||||
<CounterPill label="Reschedules" count={rescheduleCount} />
|
||||
<CounterPill label="Carried over" count={carriedOver} />
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,56 @@
|
|||
type EditableWONumberInputProps = {
|
||||
draft: string;
|
||||
invalid: boolean;
|
||||
isChecking?: boolean;
|
||||
onChange: (value: string) => void;
|
||||
onCommit: () => void;
|
||||
onBlur?: () => void;
|
||||
onCancel: () => void;
|
||||
};
|
||||
|
||||
export function EditableWONumberInput({
|
||||
draft,
|
||||
invalid,
|
||||
isChecking = false,
|
||||
onChange,
|
||||
onCommit,
|
||||
onBlur,
|
||||
onCancel,
|
||||
}: EditableWONumberInputProps) {
|
||||
return (
|
||||
<input
|
||||
autoFocus
|
||||
disabled={isChecking}
|
||||
inputMode="numeric"
|
||||
value={draft}
|
||||
onChange={(event) => onChange(event.target.value.replace(/\D/g, "").slice(0, 11))}
|
||||
onFocus={(event) => event.target.select()}
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Enter") {
|
||||
event.preventDefault();
|
||||
onCommit();
|
||||
} else if (event.key === "Escape") {
|
||||
event.preventDefault();
|
||||
onCancel();
|
||||
}
|
||||
}}
|
||||
onBlur={onBlur ?? onCommit}
|
||||
placeholder="12345678901"
|
||||
aria-label="Work order number"
|
||||
aria-invalid={invalid}
|
||||
className="font-mono focus:outline-none"
|
||||
style={{
|
||||
fontSize: 13,
|
||||
fontWeight: 700,
|
||||
color: "var(--foreground)",
|
||||
background: "var(--color-bg-surface)",
|
||||
border: `1px solid ${invalid ? "var(--color-destructive)" : "var(--color-ring)"}`,
|
||||
boxShadow: `0 0 0 1px ${invalid ? "var(--color-destructive)" : "var(--color-accent)"}`,
|
||||
borderRadius: 4,
|
||||
padding: "2px 4px",
|
||||
width: 120,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
@ -49,7 +49,7 @@ export function StatusCell({
|
|||
|
||||
setAnchorEl(e.currentTarget);
|
||||
}}
|
||||
title="This WO missed its deadline. Update the Due Date to unlock status updates."
|
||||
title="This WO missed its deadline. Update Schedule On to unlock status updates."
|
||||
className="group/cell flex w-full cursor-pointer items-center gap-1.5 whitespace-nowrap leading-none transition-opacity hover:opacity-80"
|
||||
style={{ fontSize: 12.5, fontWeight: 600, color: "var(--status-pastdue-text)" }}
|
||||
>
|
||||
|
|
@ -83,7 +83,7 @@ export function StatusCell({
|
|||
color: "var(--status-pending-text)",
|
||||
}}
|
||||
>
|
||||
This work order is past due. Update the Due Date before the status can be updated.
|
||||
This work order is past due. Update Schedule On before the status can be updated.
|
||||
</Text>
|
||||
|
||||
<button
|
||||
|
|
@ -97,7 +97,7 @@ export function StatusCell({
|
|||
}}
|
||||
className="mt-2 block text-[11px] font-medium text-[var(--color-primary)]"
|
||||
>
|
||||
Update Due Date →
|
||||
Update Schedule On →
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,33 @@
|
|||
import type { WorkOrderTableDraft } from "@/domain/work-orders/types/work-order-table-row";
|
||||
import { AddOnOverrideToggle } from "./add-on-override-toggle";
|
||||
import { EditableWONumberCell } from "./cells/editable-wo-number-cell";
|
||||
|
||||
type InlineWoNumberCellProps = {
|
||||
draft: WorkOrderTableDraft;
|
||||
allRows: { id: string | number; woNumber: string }[];
|
||||
onPatch: (patch: Partial<WorkOrderTableDraft>) => void;
|
||||
};
|
||||
|
||||
export function InlineWoNumberCell({ draft, allRows, onPatch }: InlineWoNumberCellProps) {
|
||||
const isUnscheduled = Boolean(!draft.scheduledOn && !draft.targetWeek);
|
||||
|
||||
return (
|
||||
<td style={{ padding: "0 14px" }}>
|
||||
<div className="flex flex-col gap-1">
|
||||
<EditableWONumberCell
|
||||
value={draft.woNumber}
|
||||
rescheduleCount={0}
|
||||
isAddOn={Boolean(draft.isAddOnOverride && isUnscheduled)}
|
||||
currentId="draft"
|
||||
allOrders={allRows as never[]}
|
||||
onSave={(woNumber) => onPatch({ woNumber })}
|
||||
/>
|
||||
<AddOnOverrideToggle
|
||||
checked={draft.isAddOnOverride}
|
||||
visible={isUnscheduled}
|
||||
onChange={(isAddOnOverride) => onPatch({ isAddOnOverride })}
|
||||
/>
|
||||
</div>
|
||||
</td>
|
||||
);
|
||||
}
|
||||
|
|
@ -4,6 +4,7 @@ import type {
|
|||
WorkOrderTableDraft,
|
||||
WorkOrderTableRow,
|
||||
} from "@/domain/work-orders/types/work-order-table-row";
|
||||
import { AddOnOverrideToggle } from "./add-on-override-toggle";
|
||||
import { EditableWONumberCell } from "./cells/editable-wo-number-cell";
|
||||
import { SiteCell } from "./cells/site-cell";
|
||||
import { InlineCreateField } from "./inline-create-field";
|
||||
|
|
@ -35,6 +36,8 @@ export function WoTableInlineIdentityCells({
|
|||
onPatch,
|
||||
onDuplicateFound,
|
||||
}: WoTableInlineIdentityCellsProps) {
|
||||
const isUnscheduled = Boolean(!draft.scheduledOn && !draft.targetWeek);
|
||||
|
||||
return (
|
||||
<>
|
||||
<td
|
||||
|
|
@ -73,14 +76,22 @@ export function WoTableInlineIdentityCells({
|
|||
style={{ padding: "8px 6px 0", verticalAlign: "top", whiteSpace: "nowrap" }}
|
||||
>
|
||||
<InlineCreateField showError={woShowError} error="Work order number is required">
|
||||
<EditableWONumberCell
|
||||
value={draft.woNumber}
|
||||
rescheduleCount={0}
|
||||
currentId="draft"
|
||||
allOrders={allRows as never[]}
|
||||
onSave={(v) => onPatch({ woNumber: v })}
|
||||
onDuplicateFound={onDuplicateFound}
|
||||
/>
|
||||
<div className="flex flex-col gap-1">
|
||||
<EditableWONumberCell
|
||||
value={draft.woNumber}
|
||||
rescheduleCount={0}
|
||||
isAddOn={Boolean(draft.isAddOnOverride && isUnscheduled)}
|
||||
currentId="draft"
|
||||
allOrders={allRows as never[]}
|
||||
onSave={(v) => onPatch({ woNumber: v })}
|
||||
onDuplicateFound={onDuplicateFound}
|
||||
/>
|
||||
<AddOnOverrideToggle
|
||||
checked={draft.isAddOnOverride}
|
||||
visible={isUnscheduled}
|
||||
onChange={(isAddOnOverride) => onPatch({ isAddOnOverride })}
|
||||
/>
|
||||
</div>
|
||||
</InlineCreateField>
|
||||
</td>
|
||||
</>
|
||||
|
|
|
|||
|
|
@ -63,6 +63,56 @@ function InlineMutedDashCell() {
|
|||
);
|
||||
}
|
||||
|
||||
function InlineScheduleOnCell({
|
||||
draft,
|
||||
weekDefault,
|
||||
onPatch,
|
||||
}: {
|
||||
draft: WorkOrderTableDraft;
|
||||
weekDefault: boolean;
|
||||
onPatch: (patch: Partial<WorkOrderTableDraft>) => void;
|
||||
}) {
|
||||
const hasSchedule = Boolean(draft.scheduledOn) || Boolean(draft.targetWeek);
|
||||
return (
|
||||
<td className="group/cell" style={{ padding: "0 14px" }}>
|
||||
<DateCell
|
||||
value={draft.scheduledOn}
|
||||
allowWeekMode
|
||||
targetWeek={draft.targetWeek}
|
||||
defaultMode={weekDefault ? "week" : "date"}
|
||||
highlight={!draft.scheduledOn && !draft.targetWeek}
|
||||
onSave={(date) =>
|
||||
onPatch({
|
||||
scheduledOn: date,
|
||||
scheduleWeekOnly: false,
|
||||
targetWeek: "",
|
||||
isAddOnOverride: false,
|
||||
})
|
||||
}
|
||||
onSaveWeek={(week) =>
|
||||
onPatch({
|
||||
scheduledOn: "",
|
||||
targetWeek: week,
|
||||
scheduleWeekOnly: true,
|
||||
isAddOnOverride: false,
|
||||
})
|
||||
}
|
||||
onClear={
|
||||
hasSchedule
|
||||
? () =>
|
||||
onPatch({
|
||||
scheduledOn: "",
|
||||
targetWeek: "",
|
||||
scheduleWeekOnly: false,
|
||||
isAddOnOverride: false,
|
||||
})
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
</td>
|
||||
);
|
||||
}
|
||||
|
||||
export function WoTableInlineRowCells({
|
||||
draft,
|
||||
weekDefault,
|
||||
|
|
@ -81,7 +131,6 @@ export function WoTableInlineRowCells({
|
|||
onDuplicateFound,
|
||||
}: WoTableInlineRowCellsProps) {
|
||||
const draftRow = toInlineDraftRow(draft);
|
||||
const hasSchedule = Boolean(draft.scheduledOn) || Boolean(draft.targetWeek);
|
||||
|
||||
return (
|
||||
<>
|
||||
|
|
@ -112,23 +161,7 @@ export function WoTableInlineRowCells({
|
|||
/>
|
||||
</td>
|
||||
|
||||
<td className="group/cell" style={{ padding: "0 14px" }}>
|
||||
<DateCell
|
||||
value={draft.scheduledOn}
|
||||
allowWeekMode
|
||||
targetWeek={draft.targetWeek}
|
||||
defaultMode={weekDefault ? "week" : "date"}
|
||||
onSave={(date) => onPatch({ scheduledOn: date, scheduleWeekOnly: false, targetWeek: "" })}
|
||||
onSaveWeek={(week) =>
|
||||
onPatch({ scheduledOn: "", targetWeek: week, scheduleWeekOnly: true })
|
||||
}
|
||||
onClear={
|
||||
hasSchedule
|
||||
? () => onPatch({ scheduledOn: "", targetWeek: "", scheduleWeekOnly: false })
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
</td>
|
||||
<InlineScheduleOnCell draft={draft} weekDefault={weekDefault} onPatch={onPatch} />
|
||||
|
||||
<td className="group/cell" style={{ padding: "0 14px" }}>
|
||||
<DueDateCell
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -4,7 +4,6 @@ export const WO_TABLE_TYPE_TEXT: Record<WOType, string> = {
|
|||
Emergency: "var(--destructive)",
|
||||
Reactive: "#e0701a",
|
||||
PM: "var(--primary)",
|
||||
"Add-On": "#7c3aed",
|
||||
Overdue: "#9F1239",
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -25,14 +25,13 @@ import {
|
|||
WizLabelRow,
|
||||
} from "@/app/(protected)/workorders/_components/wizard/wizard-labels";
|
||||
import { WizardSeverityPicker } from "@/app/(protected)/workorders/_components/wizard/wizard-severity-picker";
|
||||
import { CheckCircle2, Clock, Flame, Plus, Users, Wrench, Zap } from "lucide-react";
|
||||
import { CheckCircle2, Clock, Flame, Users, Wrench, Zap } from "lucide-react";
|
||||
import type { ElementType } from "react";
|
||||
|
||||
const WO_TYPE_ICONS: Record<WOType, ElementType> = {
|
||||
PM: Wrench,
|
||||
Reactive: Zap,
|
||||
Emergency: Flame,
|
||||
"Add-On": Plus,
|
||||
Overdue: Clock,
|
||||
};
|
||||
|
||||
|
|
@ -146,18 +145,7 @@ function WizardTypeScheduleDates({ draft, onUpdate, onPatch }: WizardTypeSchedul
|
|||
<div>
|
||||
<WizLabelRow
|
||||
optional
|
||||
onClear={
|
||||
draft.scheduledOn !== "" || draft.scheduleWeekOnly || draft.scheduleEnd
|
||||
? () =>
|
||||
onPatch({
|
||||
scheduledOn: "",
|
||||
scheduleWeekOnly: false,
|
||||
scheduleEnd: "",
|
||||
schedMode: undefined,
|
||||
targetWeek: "",
|
||||
})
|
||||
: undefined
|
||||
}
|
||||
onClear={hasWizardSchedule(draft) ? () => onPatch(clearSchedulePatch()) : undefined}
|
||||
>
|
||||
Schedule On
|
||||
</WizLabelRow>
|
||||
|
|
@ -175,6 +163,7 @@ function WizardTypeScheduleDates({ draft, onUpdate, onPatch }: WizardTypeSchedul
|
|||
scheduleEnd: "",
|
||||
schedMode: "date",
|
||||
targetWeek: "",
|
||||
isAddOnOverride: false,
|
||||
})
|
||||
}
|
||||
onChangeWeek={(w) =>
|
||||
|
|
@ -184,6 +173,7 @@ function WizardTypeScheduleDates({ draft, onUpdate, onPatch }: WizardTypeSchedul
|
|||
scheduleEnd: "",
|
||||
schedMode: "week",
|
||||
targetWeek: w,
|
||||
isAddOnOverride: false,
|
||||
})
|
||||
}
|
||||
onChangeRange={(s, e) =>
|
||||
|
|
@ -193,6 +183,7 @@ function WizardTypeScheduleDates({ draft, onUpdate, onPatch }: WizardTypeSchedul
|
|||
schedMode: "range",
|
||||
scheduleWeekOnly: false,
|
||||
targetWeek: "",
|
||||
isAddOnOverride: false,
|
||||
})
|
||||
}
|
||||
/>
|
||||
|
|
@ -225,6 +216,43 @@ type WizardStepTypeScheduleProps = {
|
|||
onDuplicateWoNumber?: (woNumber: string) => void;
|
||||
};
|
||||
|
||||
function hasWizardSchedule(draft: WorkOrderWizardDraft): boolean {
|
||||
return draft.scheduledOn !== "" || Boolean(draft.scheduleWeekOnly) || Boolean(draft.scheduleEnd);
|
||||
}
|
||||
|
||||
function clearSchedulePatch(): Partial<WorkOrderWizardDraft> {
|
||||
return {
|
||||
scheduledOn: "",
|
||||
scheduleWeekOnly: false,
|
||||
scheduleEnd: "",
|
||||
schedMode: undefined,
|
||||
targetWeek: "",
|
||||
isAddOnOverride: false,
|
||||
};
|
||||
}
|
||||
|
||||
function WizardAddOnOverrideField({
|
||||
checked,
|
||||
onChange,
|
||||
}: {
|
||||
checked: boolean;
|
||||
onChange: (value: boolean) => void;
|
||||
}) {
|
||||
return (
|
||||
<label className="flex cursor-pointer items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={checked}
|
||||
onChange={(e) => onChange(e.target.checked)}
|
||||
aria-label="Mark as Add-On"
|
||||
/>
|
||||
<Text as="p" variant="description" sx={{ fontSize: 13, margin: 0 }}>
|
||||
Mark as Add-On
|
||||
</Text>
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
export function WizardStepTypeSchedule({
|
||||
draft,
|
||||
users,
|
||||
|
|
@ -310,6 +338,13 @@ export function WizardStepTypeSchedule({
|
|||
</div>
|
||||
|
||||
<WizardTypeScheduleDates draft={draft} onUpdate={onUpdate} onPatch={onPatch} />
|
||||
|
||||
{Boolean(!hasWizardSchedule(draft)) && (
|
||||
<WizardAddOnOverrideField
|
||||
checked={Boolean(draft.isAddOnOverride)}
|
||||
onChange={(value) => onUpdate("isAddOnOverride", value)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -31,7 +31,6 @@ const TYPE_TO_PRIORITY: Record<WOType, string> = {
|
|||
Emergency: "Sev 1",
|
||||
Reactive: "Sev 2",
|
||||
Overdue: "Sev 2",
|
||||
"Add-On": "Sev 3",
|
||||
PM: "Sev 3",
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ import { fmtDateRange } from "@/domain/work-orders/utils/wizard-date-utils";
|
|||
import { assignWizardScheduleFields } from "@/domain/work-orders/mappers/assign-wizard-schedule-fields";
|
||||
import { assignWizardVendorApptFields } from "@/domain/work-orders/mappers/assign-wizard-vendor-appt-fields";
|
||||
import { assignWizardServicePocFields } from "@/domain/work-orders/mappers/assign-wizard-service-poc-fields";
|
||||
import { resolveIsAddOnForCreate } from "@/domain/work-orders/utils/wo-addon-cutoff";
|
||||
|
||||
const WO_TYPE_TO_API: Record<WOType, WorkOrderTypeApi> = {
|
||||
PM: WORK_ORDER_TYPE_API.PM,
|
||||
|
|
@ -27,8 +28,6 @@ const WO_TYPE_TO_API: Record<WOType, WorkOrderTypeApi> = {
|
|||
|
||||
Reactive: WORK_ORDER_TYPE_API.Reactive,
|
||||
|
||||
"Add-On": WORK_ORDER_TYPE_API.AddOn,
|
||||
|
||||
Overdue: WORK_ORDER_TYPE_API.Other,
|
||||
};
|
||||
|
||||
|
|
@ -103,6 +102,15 @@ export function mapWizardDraftToBoardCreatePayload(
|
|||
workOrderType,
|
||||
|
||||
siteCode,
|
||||
|
||||
// Range start persists as scheduledDate (prototype hasScheduleDate). Use it for
|
||||
// the create-time Add-On cutoff hint; week mode uses targetWeek / Monday.
|
||||
isAddOn: resolveIsAddOnForCreate({
|
||||
scheduledOn: draft.scheduleWeekOnly
|
||||
? draft.targetWeek?.trim() || draft.scheduledOn
|
||||
: draft.scheduledOn,
|
||||
manualOverride: draft.isAddOnOverride,
|
||||
}),
|
||||
};
|
||||
|
||||
if (draft.status) {
|
||||
|
|
|
|||
|
|
@ -40,6 +40,8 @@ const LOCAL_ONLY_KEYS = new Set<keyof WorkOrderTablePatch>([
|
|||
"isPastDue",
|
||||
"originalWeek",
|
||||
"originalDate",
|
||||
/** Optimistic overlay only — backend owns isAddOn after schedule ops (SH-184). */
|
||||
"isAddOn",
|
||||
]);
|
||||
|
||||
export function isLocalOnlyTablePatch(patch: WorkOrderTablePatch): boolean {
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import { mapApiStatusToWizard } from "@/domain/work-orders/mappers/wizard-draft-
|
|||
import {
|
||||
DOC_STATUS_API,
|
||||
LIFECYCLE_STATUS_API_TO_FE,
|
||||
WORK_ORDER_TYPE_API,
|
||||
WORK_ORDER_TYPE_API_TO_FE,
|
||||
} from "@/domain/work-orders/types/work-order-board";
|
||||
import type {
|
||||
|
|
@ -101,11 +102,50 @@ function readRowVersion(record: Record<string, unknown>, ...keys: string[]): str
|
|||
return "";
|
||||
}
|
||||
|
||||
const WO_TYPES_SET = new Set(["PM", "Reactive", "Emergency", "Add-On", "Overdue"]);
|
||||
const WO_TYPES_SET = new Set(["PM", "Reactive", "Emergency", "Overdue"]);
|
||||
|
||||
function isLegacyAddOnType(record: Record<string, unknown>): boolean {
|
||||
const numeric =
|
||||
readNumber(record, "workOrderType", "WorkOrderType") ?? readNumber(record, "type", "Type");
|
||||
if (numeric === WORK_ORDER_TYPE_API.AddOn) {
|
||||
return true;
|
||||
}
|
||||
const raw = readString(
|
||||
record,
|
||||
"workOrderType",
|
||||
"WorkOrderType",
|
||||
"type",
|
||||
"Type",
|
||||
"woType",
|
||||
"WoType",
|
||||
"source",
|
||||
"Source",
|
||||
);
|
||||
return raw === "AddOn" || raw === "Add-On";
|
||||
}
|
||||
|
||||
function readIsAddOn(record: Record<string, unknown>): boolean {
|
||||
for (const key of ["isAddOn", "IsAddOn"] as const) {
|
||||
if (!(key in record)) continue;
|
||||
const value = record[key];
|
||||
// Explicit boolean is authoritative; null/undefined fall through to legacy.
|
||||
if (typeof value === "boolean") {
|
||||
return value;
|
||||
}
|
||||
if (value == null) {
|
||||
break;
|
||||
}
|
||||
return Boolean(value);
|
||||
}
|
||||
return isLegacyAddOnType(record);
|
||||
}
|
||||
|
||||
function readWoType(record: Record<string, unknown>): WOType | "" {
|
||||
const numeric =
|
||||
readNumber(record, "workOrderType", "WorkOrderType") ?? readNumber(record, "type", "Type");
|
||||
if (numeric === WORK_ORDER_TYPE_API.AddOn) {
|
||||
return "";
|
||||
}
|
||||
if (numeric != null && WORK_ORDER_TYPE_API_TO_FE[numeric]) {
|
||||
return WORK_ORDER_TYPE_API_TO_FE[numeric] as WOType;
|
||||
}
|
||||
|
|
@ -121,12 +161,12 @@ function readWoType(record: Record<string, unknown>): WOType | "" {
|
|||
"source",
|
||||
"Source",
|
||||
);
|
||||
if (raw === "AddOn" || raw === "Add-On") {
|
||||
return "";
|
||||
}
|
||||
if (WO_TYPES_SET.has(raw)) {
|
||||
return raw as WOType;
|
||||
}
|
||||
if (raw === "AddOn") {
|
||||
return "Add-On";
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
|
|
@ -264,6 +304,7 @@ export function mapWorkOrderTableRow(raw: unknown): WorkOrderTableRow {
|
|||
dueDate: toDateInputValue(readString(item, "dueDate", "DueDate")),
|
||||
rescheduleCount: readNumber(item, "rescheduleCount", "RescheduleCount") ?? 0,
|
||||
carriedOver: readNumber(item, "carriedOver", "CarriedOver") ?? 0,
|
||||
isAddOn: readIsAddOn(item),
|
||||
originalWeek: readString(item, "originalWeek", "OriginalWeek"),
|
||||
originalDate: toDateInputValue(readString(item, "originalDate", "OriginalDate")),
|
||||
isPastDue: readBool(item, "isPastDue", "IsPastDue"),
|
||||
|
|
|
|||
|
|
@ -41,11 +41,11 @@ export const LIFECYCLE_STATUS_API = {
|
|||
|
||||
export type LifecycleStatusApi = (typeof LIFECYCLE_STATUS_API)[keyof typeof LIFECYCLE_STATUS_API];
|
||||
|
||||
/** Maps API type enums to selectable FE types. Legacy AddOn(7) is not a type. */
|
||||
export const WORK_ORDER_TYPE_API_TO_FE: Record<number, WOType | ""> = {
|
||||
[WORK_ORDER_TYPE_API.PM]: "PM",
|
||||
[WORK_ORDER_TYPE_API.Reactive]: "Reactive",
|
||||
[WORK_ORDER_TYPE_API.Emergency]: "Emergency",
|
||||
[WORK_ORDER_TYPE_API.AddOn]: "Add-On",
|
||||
};
|
||||
|
||||
export const LIFECYCLE_STATUS_API_TO_FE: Record<number, WizardWOStatus | "Canceled"> = {
|
||||
|
|
@ -72,6 +72,11 @@ export interface WorkOrderBoardCreatePayload {
|
|||
scheduledDate?: string;
|
||||
targetWeek?: string;
|
||||
scheduleWeekOnly?: boolean;
|
||||
/**
|
||||
* Create-time Add-On hint only. Backend recalculates on schedule change/clear
|
||||
* and owns audit (SH-184); FE must consume returned `isAddOn` afterward.
|
||||
*/
|
||||
isAddOn?: boolean;
|
||||
vendorId?: number;
|
||||
apptDate?: string;
|
||||
apptTime?: string;
|
||||
|
|
@ -133,6 +138,8 @@ export interface WorkOrderBoardRowApi {
|
|||
woNumber?: string | null;
|
||||
rescheduleCount?: number;
|
||||
carriedOver?: number;
|
||||
/** Backend-authoritative Add-On indicator (SH-184; recalculated on schedule). */
|
||||
isAddOn?: boolean | null;
|
||||
workOrderType?: number | null;
|
||||
isPastDue?: boolean;
|
||||
siteCode?: string | null;
|
||||
|
|
|
|||
|
|
@ -42,6 +42,11 @@ export interface WorkOrderTableRow {
|
|||
dueDate: string;
|
||||
rescheduleCount: number;
|
||||
carriedOver: number;
|
||||
/**
|
||||
* Add-On indicator (not a WO type). Backend-authoritative (SH-184):
|
||||
* recalculated on schedule week moves / clear; FE only reflects API value.
|
||||
*/
|
||||
isAddOn: boolean;
|
||||
originalWeek: string;
|
||||
originalDate: string;
|
||||
isPastDue: boolean;
|
||||
|
|
@ -82,6 +87,8 @@ export interface WorkOrderTableDraft {
|
|||
pocNotes: string;
|
||||
scheduleWeekOnly: boolean;
|
||||
targetWeek: string;
|
||||
/** Manual Add-On override when schedule is empty at inline create. */
|
||||
isAddOnOverride: boolean;
|
||||
}
|
||||
|
||||
export const EMPTY_TABLE_DRAFT: WorkOrderTableDraft = {
|
||||
|
|
@ -110,6 +117,7 @@ export const EMPTY_TABLE_DRAFT: WorkOrderTableDraft = {
|
|||
pocNotes: "",
|
||||
scheduleWeekOnly: false,
|
||||
targetWeek: "",
|
||||
isAddOnOverride: false,
|
||||
};
|
||||
|
||||
export type WorkOrderDayGroup = {
|
||||
|
|
|
|||
|
|
@ -2,7 +2,8 @@ import { typeNeedsSeverity, type WorkOrderSeverity } from "./work-order-severity
|
|||
|
||||
export type { WorkOrderSeverity } from "./work-order-severity";
|
||||
|
||||
export const WO_TYPES = ["PM", "Reactive", "Emergency", "Add-On", "Overdue"] as const;
|
||||
/** Selectable business types — Add-On is an indicator (`isAddOn`), not a type. */
|
||||
export const WO_TYPES = ["PM", "Reactive", "Emergency", "Overdue"] as const;
|
||||
|
||||
export type WOType = (typeof WO_TYPES)[number];
|
||||
|
||||
|
|
@ -56,6 +57,12 @@ export interface WorkOrderWizardDraft {
|
|||
scheduleEnd?: string;
|
||||
/** Last Schedule On mode used. */
|
||||
schedMode?: WizardSchedMode;
|
||||
/**
|
||||
* Manual Add-On override when `scheduledOn` is empty at create.
|
||||
* Ignored when a cutoff preview can be computed from schedule.
|
||||
* Create hint only — backend remains authoritative after schedule patches (SH-184).
|
||||
*/
|
||||
isAddOnOverride?: boolean;
|
||||
}
|
||||
|
||||
export const EMPTY_WIZARD_DRAFT: WorkOrderWizardDraft = {
|
||||
|
|
@ -86,6 +93,7 @@ export const EMPTY_WIZARD_DRAFT: WorkOrderWizardDraft = {
|
|||
targetWeek: "",
|
||||
scheduleEnd: "",
|
||||
schedMode: undefined,
|
||||
isAddOnOverride: false,
|
||||
};
|
||||
|
||||
/** Blank numbers receive an SH provisional number from the backend; nonblank values are alphanumeric. */
|
||||
|
|
@ -140,11 +148,6 @@ export const WO_TYPE_META: Record<WOType, { desc: string; bg: string; fg: string
|
|||
bg: "var(--type-emergency-bg)",
|
||||
fg: "var(--type-emergency-text)",
|
||||
},
|
||||
"Add-On": {
|
||||
desc: "Extra scope on an existing visit",
|
||||
bg: "var(--type-addon-bg)",
|
||||
fg: "var(--type-addon-text)",
|
||||
},
|
||||
Overdue: {
|
||||
desc: "No client response — due date passed",
|
||||
bg: "var(--type-overdue-bg)",
|
||||
|
|
|
|||
|
|
@ -197,6 +197,10 @@ export function maybeAutoSchedulePatch(row: WorkOrderTableRow): WorkOrderTablePa
|
|||
return {};
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a schedule table patch. Clearing the schedule optimistically clears
|
||||
* `isAddOn`; day/week moves leave `isAddOn` for the board PATCH response (SH-184).
|
||||
*/
|
||||
export function applySchedulePatch(
|
||||
scheduledOn: string,
|
||||
scheduleWeekOnly: boolean,
|
||||
|
|
@ -209,10 +213,20 @@ export function applySchedulePatch(
|
|||
...deriveDayFields(""),
|
||||
};
|
||||
}
|
||||
const trimmed = scheduledOn.trim();
|
||||
if (!trimmed) {
|
||||
return {
|
||||
scheduledOn: "",
|
||||
scheduleWeekOnly: false,
|
||||
targetWeek: "",
|
||||
isAddOn: false,
|
||||
...deriveDayFields(""),
|
||||
};
|
||||
}
|
||||
return {
|
||||
scheduledOn,
|
||||
scheduledOn: trimmed,
|
||||
scheduleWeekOnly: false,
|
||||
targetWeek: "",
|
||||
...deriveDayFields(scheduledOn),
|
||||
...deriveDayFields(trimmed),
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -12,18 +12,18 @@ import { weekRange } from "@/domain/work-orders/utils/list-week-utils";
|
|||
const TERMINAL_STATUSES = new Set(["Completed", "Canceled", "Done", "Cancelled"]);
|
||||
|
||||
export function computeIsPastDue(row: WorkOrderTableRow): boolean {
|
||||
if (!row.dueDate || TERMINAL_STATUSES.has(row.status)) {
|
||||
if (!row.scheduledOn || TERMINAL_STATUSES.has(row.status)) {
|
||||
return false;
|
||||
}
|
||||
const today = new Date().toISOString().slice(0, 10);
|
||||
return row.dueDate < today;
|
||||
return row.scheduledOn.slice(0, 10) < today;
|
||||
}
|
||||
|
||||
export function enrichTableRow(row: WorkOrderTableRow): WorkOrderTableRow {
|
||||
return {
|
||||
...row,
|
||||
// Recompute from dueDate (same date-only rule as BE). Avoid sticky
|
||||
// `apiTrue || compute` so a future Due Date clears Past Due immediately.
|
||||
// Recompute from Schedule On (SH-185). Avoid sticky `apiTrue || compute`
|
||||
// so a future Schedule On clears Past Due immediately.
|
||||
isPastDue: computeIsPastDue(row),
|
||||
};
|
||||
}
|
||||
|
|
|
|||
66
src/domain/work-orders/utils/wo-addon-cutoff.ts
Normal file
66
src/domain/work-orders/utils/wo-addon-cutoff.ts
Normal file
|
|
@ -0,0 +1,66 @@
|
|||
import { addDaysIso, mondayOf } from "@/domain/work-orders/utils/wizard-date-utils";
|
||||
|
||||
const ISO_DATE = /^\d{4}-\d{2}-\d{2}$/;
|
||||
|
||||
/**
|
||||
* Saturday of the week prior to the scheduledOn week (ISO date only).
|
||||
* Cutoff instant is end of that day (23:59:59 local).
|
||||
*
|
||||
* SH-184: backend recalculates `isAddOn` whenever scheduling moves across weeks
|
||||
* or is cleared, and owns audit. This helper is only a create-time FE hint.
|
||||
*/
|
||||
export function getAddonCutoffIso(scheduledOn: string): string | null {
|
||||
const trimmed = scheduledOn.trim();
|
||||
if (!ISO_DATE.test(trimmed)) {
|
||||
return null;
|
||||
}
|
||||
return addDaysIso(mondayOf(trimmed), -2);
|
||||
}
|
||||
|
||||
/**
|
||||
* Preview whether Add-On would apply for a create-time schedule + createdAt.
|
||||
* Returns null when scheduledOn is missing — callers may use a manual override.
|
||||
*
|
||||
* Not a freeze: after create (and on every schedule patch/clear) the backend is
|
||||
* authoritative; FE must consume the returned `isAddOn` (SH-184).
|
||||
*/
|
||||
export function previewIsAddOnFromCutoff(input: {
|
||||
createdAt: Date | string | number;
|
||||
scheduledOn: string;
|
||||
}): boolean | null {
|
||||
const cutoffIso = getAddonCutoffIso(input.scheduledOn);
|
||||
if (!cutoffIso) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const createdAt = input.createdAt instanceof Date ? input.createdAt : new Date(input.createdAt);
|
||||
if (Number.isNaN(createdAt.getTime())) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const cutoff = new Date(`${cutoffIso}T23:59:59`);
|
||||
return createdAt.getTime() > cutoff.getTime();
|
||||
}
|
||||
|
||||
/** @deprecated Prefer `previewIsAddOnFromCutoff` — name implied a freeze (SH-126). */
|
||||
export const computeIsAddOnAtCreation = previewIsAddOnFromCutoff;
|
||||
|
||||
/**
|
||||
* Resolve create-payload `isAddOn` hint: cutoff preview when scheduled, else
|
||||
* manual override. Backend may recalculate immediately and on later schedule
|
||||
* patches/clears; FE must not treat this value as frozen (SH-184).
|
||||
*/
|
||||
export function resolveIsAddOnForCreate(input: {
|
||||
createdAt?: Date | string | number;
|
||||
scheduledOn: string;
|
||||
manualOverride?: boolean;
|
||||
}): boolean {
|
||||
const computed = previewIsAddOnFromCutoff({
|
||||
createdAt: input.createdAt ?? new Date(),
|
||||
scheduledOn: input.scheduledOn,
|
||||
});
|
||||
if (computed !== null) {
|
||||
return computed;
|
||||
}
|
||||
return Boolean(input.manualOverride);
|
||||
}
|
||||
|
|
@ -72,6 +72,7 @@ function baseRow(overrides: Partial<WorkOrderTableRow> = {}): WorkOrderTableRow
|
|||
rowVersion: "",
|
||||
dispatchRowVersion: "",
|
||||
flagColor: null,
|
||||
isAddOn: false,
|
||||
};
|
||||
return {
|
||||
...defaults,
|
||||
|
|
|
|||
|
|
@ -84,6 +84,7 @@ function baseRow(): WorkOrderTableRow {
|
|||
rowVersion: "ROW-VER==",
|
||||
dispatchRowVersion: "",
|
||||
flagColor: null,
|
||||
isAddOn: false,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,38 @@
|
|||
import { fireEvent, render, screen } from "@testing-library/react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { StatusCell } from "@/app/(protected)/workorders/_components/list/table/cells/status-cell";
|
||||
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
|
||||
|
||||
const pastDueRow = {
|
||||
id: "wo-1",
|
||||
status: "Scheduled",
|
||||
dispatcherId: "",
|
||||
dispatcherName: "",
|
||||
company: "",
|
||||
tech: "",
|
||||
completedDate: "",
|
||||
woNumber: "",
|
||||
pm: "",
|
||||
docStatus: "",
|
||||
mediaCount: 0,
|
||||
type: "PM",
|
||||
isPastDue: true,
|
||||
scheduledOn: "2026-06-01",
|
||||
dueDate: "2026-08-01",
|
||||
} as WorkOrderTableRow;
|
||||
|
||||
describe("StatusCell Past Due copy (SH-185)", () => {
|
||||
it("directs the dispatcher to update Schedule On, not Due Date", () => {
|
||||
render(<StatusCell wo={pastDueRow} onChangeStatus={vi.fn()} onOpenSlideOver={vi.fn()} />);
|
||||
|
||||
const trigger = screen.getByRole("button", { name: /past due/i });
|
||||
expect(trigger.getAttribute("title") ?? "").toContain("Schedule On");
|
||||
expect(trigger.getAttribute("title") ?? "").not.toMatch(/due date/i);
|
||||
|
||||
fireEvent.click(trigger);
|
||||
|
||||
expect(screen.getByText(/update schedule on before the status can be updated/i)).toBeTruthy();
|
||||
expect(screen.getByRole("button", { name: /update schedule on/i })).toBeTruthy();
|
||||
expect(screen.queryByText(/due date/i)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
|
@ -59,6 +59,7 @@ function baseRow(overrides: Partial<WorkOrderTableRow> = {}): WorkOrderTableRow
|
|||
rowVersion: "",
|
||||
dispatchRowVersion: "",
|
||||
flagColor: null,
|
||||
isAddOn: false,
|
||||
};
|
||||
return {
|
||||
...defaults,
|
||||
|
|
|
|||
|
|
@ -49,6 +49,7 @@ function baseInfo(overrides: Partial<WorkOrderTableRow> = {}): WorkOrderTableRow
|
|||
rowVersion: "1",
|
||||
dispatchRowVersion: "",
|
||||
flagColor: null,
|
||||
isAddOn: false,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -51,6 +51,7 @@ describe("WizardStepTypeSchedule — Schedule On range selection", () => {
|
|||
schedMode: "range",
|
||||
scheduleWeekOnly: false,
|
||||
targetWeek: "",
|
||||
isAddOnOverride: false,
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -84,6 +85,7 @@ describe("WizardStepTypeSchedule — Schedule On range selection", () => {
|
|||
schedMode: "range",
|
||||
scheduleWeekOnly: false,
|
||||
targetWeek: "",
|
||||
isAddOnOverride: false,
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -42,6 +42,7 @@ const unscheduledRow: WorkOrderTableRow = {
|
|||
dueDate: "2026-07-15",
|
||||
rescheduleCount: 0,
|
||||
carriedOver: 0,
|
||||
isAddOn: false,
|
||||
originalWeek: "",
|
||||
originalDate: "",
|
||||
isPastDue: false,
|
||||
|
|
|
|||
|
|
@ -92,6 +92,7 @@ function baseRow(overrides: Partial<WorkOrderTableRow> = {}): WorkOrderTableRow
|
|||
rowVersion: "v1",
|
||||
dispatchRowVersion: "",
|
||||
flagColor: null,
|
||||
isAddOn: false,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -100,6 +100,7 @@ const canceledRow: WorkOrderTableRow = {
|
|||
dueDate: "",
|
||||
rescheduleCount: 0,
|
||||
carriedOver: 0,
|
||||
isAddOn: false,
|
||||
originalWeek: "",
|
||||
originalDate: "",
|
||||
isPastDue: false,
|
||||
|
|
|
|||
|
|
@ -121,6 +121,7 @@ describe("workOrdersApi.createFromBoard", () => {
|
|||
siteCode: "BK5",
|
||||
lifecycleStatus: 1,
|
||||
woNumber: "12345",
|
||||
isAddOn: false,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -33,8 +33,6 @@ describe("mapWizardTypeToApi", () => {
|
|||
|
||||
expect(mapWizardTypeToApi("Reactive")).toBe(WORK_ORDER_TYPE_API.Reactive);
|
||||
|
||||
expect(mapWizardTypeToApi("Add-On")).toBe(WORK_ORDER_TYPE_API.AddOn);
|
||||
|
||||
expect(mapWizardTypeToApi("Overdue")).toBe(WORK_ORDER_TYPE_API.Other);
|
||||
|
||||
expect(mapWizardTypeToApi("")).toBeNull();
|
||||
|
|
@ -56,18 +54,71 @@ describe("mapWizardDraftToBoardCreatePayload", () => {
|
|||
|
||||
siteCode: "BK5",
|
||||
|
||||
isAddOn: false,
|
||||
|
||||
lifecycleStatus: 1,
|
||||
});
|
||||
});
|
||||
|
||||
it("sets isAddOn from cutoff preview when scheduled (create hint; not frozen)", () => {
|
||||
const payload = mapWizardDraftToBoardCreatePayload({
|
||||
...EMPTY_WIZARD_DRAFT,
|
||||
type: "PM",
|
||||
siteCode: "BK5",
|
||||
scheduledOn: "2026-07-08",
|
||||
isAddOnOverride: false,
|
||||
});
|
||||
|
||||
// createdAt defaults to now; after prior-Saturday 2026-07-04 for that week → true
|
||||
expect(payload.isAddOn).toBe(true);
|
||||
});
|
||||
|
||||
it("sets isAddOn from manual override when schedule is missing", () => {
|
||||
const payload = mapWizardDraftToBoardCreatePayload({
|
||||
...EMPTY_WIZARD_DRAFT,
|
||||
type: "PM",
|
||||
siteCode: "BK5",
|
||||
isAddOnOverride: true,
|
||||
});
|
||||
|
||||
expect(payload.isAddOn).toBe(true);
|
||||
});
|
||||
|
||||
it("derives isAddOn from range start via cutoff (range persists scheduledDate)", () => {
|
||||
const payload = mapWizardDraftToBoardCreatePayload({
|
||||
...EMPTY_WIZARD_DRAFT,
|
||||
type: "PM",
|
||||
siteCode: "BK5",
|
||||
scheduledOn: "2026-07-08",
|
||||
scheduleEnd: "2026-07-10",
|
||||
schedMode: "range",
|
||||
isAddOnOverride: false,
|
||||
});
|
||||
|
||||
expect(payload.scheduledDate).toBe("2026-07-08");
|
||||
// createdAt defaults to now; after prior-Saturday 2026-07-04 for that week → true
|
||||
expect(payload.isAddOn).toBe(true);
|
||||
});
|
||||
|
||||
it("uses manual Add-On override when range has no start date", () => {
|
||||
const payload = mapWizardDraftToBoardCreatePayload({
|
||||
...EMPTY_WIZARD_DRAFT,
|
||||
type: "PM",
|
||||
siteCode: "BK5",
|
||||
scheduledOn: "",
|
||||
scheduleEnd: "",
|
||||
schedMode: "range",
|
||||
isAddOnOverride: true,
|
||||
});
|
||||
|
||||
expect(payload.isAddOn).toBe(true);
|
||||
});
|
||||
|
||||
it("maps wizard status to lifecycleStatus on create", () => {
|
||||
const payload = mapWizardDraftToBoardCreatePayload({
|
||||
...EMPTY_WIZARD_DRAFT,
|
||||
|
||||
type: "PM",
|
||||
|
||||
siteCode: "BK5",
|
||||
|
||||
status: "Scheduled",
|
||||
});
|
||||
|
||||
|
|
@ -130,6 +181,8 @@ describe("mapWizardDraftToBoardCreatePayload", () => {
|
|||
|
||||
scheduledDate: "2026-06-25",
|
||||
|
||||
isAddOn: true,
|
||||
|
||||
vendorId: 5,
|
||||
|
||||
apptDate: "2026-06-25",
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
@ -21,7 +21,10 @@ vi.mock("@/domain/work-orders/api/work-orders-api", () => ({
|
|||
|
||||
vi.mock("react-toastify", () => ({ toast: { error: vi.fn() } }));
|
||||
|
||||
import { useWorkOrderTableMutations } from "@/domain/work-orders/use-cases/use-work-order-table-mutations";
|
||||
import {
|
||||
applySchedulePatch,
|
||||
useWorkOrderTableMutations,
|
||||
} from "@/domain/work-orders/use-cases/use-work-order-table-mutations";
|
||||
|
||||
function delay(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
|
|
@ -94,6 +97,7 @@ const BASE_ROW: WorkOrderTableRow = {
|
|||
rowVersion: "AAAAAAA=",
|
||||
dispatchRowVersion: "",
|
||||
flagColor: null,
|
||||
isAddOn: false,
|
||||
};
|
||||
|
||||
describe("useWorkOrderTableMutations completion-doc persistence", () => {
|
||||
|
|
@ -208,3 +212,134 @@ describe("useWorkOrderTableMutations completion-doc persistence", () => {
|
|||
expect(serverState.status).toBe(BASE_SERVER.status);
|
||||
});
|
||||
});
|
||||
|
||||
describe("applySchedulePatch isAddOn optimistic clear", () => {
|
||||
it("clears isAddOn when schedule is cleared", () => {
|
||||
expect(applySchedulePatch("", false)).toMatchObject({
|
||||
scheduledOn: "",
|
||||
targetWeek: "",
|
||||
scheduleWeekOnly: false,
|
||||
isAddOn: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("does not set isAddOn on day or week moves (BE response owns it)", () => {
|
||||
expect(applySchedulePatch("2026-07-15", false).isAddOn).toBeUndefined();
|
||||
expect(applySchedulePatch("2026-07-13", true).isAddOn).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("useWorkOrderTableMutations schedule isAddOn from BE", () => {
|
||||
let rowState: WorkOrderTableRow;
|
||||
|
||||
beforeEach(() => {
|
||||
getById.mockReset();
|
||||
update.mockReset();
|
||||
patchBoardField.mockReset();
|
||||
rowState = { ...BASE_ROW, isAddOn: false, scheduledOn: "2026-06-01" };
|
||||
});
|
||||
|
||||
function mockSchedulePatchReturning(isAddOn: boolean) {
|
||||
patchBoardField.mockImplementation(
|
||||
async (
|
||||
_id: unknown,
|
||||
payload: { field: string; value?: string | null; workOrderVersion: string },
|
||||
) => {
|
||||
if (payload.field === "scheduledDate") {
|
||||
rowState = {
|
||||
...rowState,
|
||||
scheduledOn: typeof payload.value === "string" ? payload.value : "",
|
||||
isAddOn,
|
||||
rowVersion: `${payload.workOrderVersion}-sched`,
|
||||
};
|
||||
} else {
|
||||
rowState = {
|
||||
...rowState,
|
||||
rowVersion: `${payload.workOrderVersion}-noop`,
|
||||
};
|
||||
}
|
||||
return { ...rowState };
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
it("applies isAddOn ON when schedule patch response flips Add-On on", async () => {
|
||||
mockSchedulePatchReturning(true);
|
||||
|
||||
const onPatch = vi.fn();
|
||||
const { result } = renderHook(
|
||||
() =>
|
||||
useWorkOrderTableMutations({
|
||||
onPatch,
|
||||
clearPatch: () => undefined,
|
||||
getRow: () => rowState,
|
||||
}),
|
||||
{ wrapper: makeWrapper() },
|
||||
);
|
||||
|
||||
act(() => {
|
||||
result.current.patchField(42, applySchedulePatch("2026-07-15", false));
|
||||
});
|
||||
|
||||
await waitFor(() =>
|
||||
expect(onPatch).toHaveBeenCalledWith(
|
||||
42,
|
||||
expect.objectContaining({ scheduledOn: "2026-07-15", isAddOn: true }),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
it("applies isAddOn OFF when schedule patch response flips Add-On off", async () => {
|
||||
rowState = { ...rowState, isAddOn: true, scheduledOn: "2026-07-15" };
|
||||
mockSchedulePatchReturning(false);
|
||||
|
||||
const onPatch = vi.fn();
|
||||
const { result } = renderHook(
|
||||
() =>
|
||||
useWorkOrderTableMutations({
|
||||
onPatch,
|
||||
clearPatch: () => undefined,
|
||||
getRow: () => rowState,
|
||||
}),
|
||||
{ wrapper: makeWrapper() },
|
||||
);
|
||||
|
||||
act(() => {
|
||||
result.current.patchField(42, applySchedulePatch("2026-06-03", false));
|
||||
});
|
||||
|
||||
await waitFor(() =>
|
||||
expect(onPatch).toHaveBeenCalledWith(
|
||||
42,
|
||||
expect.objectContaining({ scheduledOn: "2026-06-03", isAddOn: false }),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
it("applies isAddOn false when schedule clear response clears Add-On", async () => {
|
||||
rowState = { ...rowState, isAddOn: true, scheduledOn: "2026-07-15" };
|
||||
mockSchedulePatchReturning(false);
|
||||
|
||||
const onPatch = vi.fn();
|
||||
const { result } = renderHook(
|
||||
() =>
|
||||
useWorkOrderTableMutations({
|
||||
onPatch,
|
||||
clearPatch: () => undefined,
|
||||
getRow: () => rowState,
|
||||
}),
|
||||
{ wrapper: makeWrapper() },
|
||||
);
|
||||
|
||||
act(() => {
|
||||
result.current.patchField(42, applySchedulePatch("", false));
|
||||
});
|
||||
|
||||
await waitFor(() =>
|
||||
expect(onPatch).toHaveBeenCalledWith(
|
||||
42,
|
||||
expect.objectContaining({ scheduledOn: "", isAddOn: false }),
|
||||
),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -40,6 +40,7 @@ function baseRow(overrides: Partial<WorkOrderTableRow> = {}): WorkOrderTableRow
|
|||
dueDate: "2026-07-20",
|
||||
rescheduleCount: 0,
|
||||
carriedOver: 0,
|
||||
isAddOn: false,
|
||||
originalWeek: "",
|
||||
originalDate: "",
|
||||
isPastDue: false,
|
||||
|
|
|
|||
|
|
@ -73,6 +73,7 @@ function row(overrides: Partial<WorkOrderTableRow> = {}): WorkOrderTableRow {
|
|||
rowVersion: "rv1",
|
||||
dispatchRowVersion: "",
|
||||
flagColor: null,
|
||||
isAddOn: false,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -50,6 +50,7 @@ function makeRow(overrides: Partial<WorkOrderTableRow> = {}): WorkOrderTableRow
|
|||
dueDate: "2026-06-30",
|
||||
rescheduleCount: 0,
|
||||
carriedOver: 0,
|
||||
isAddOn: false,
|
||||
originalWeek: "",
|
||||
originalDate: "",
|
||||
isPastDue: false,
|
||||
|
|
@ -155,43 +156,43 @@ describe("matchesAdvancedFilters", () => {
|
|||
});
|
||||
|
||||
describe("computeIsPastDue", () => {
|
||||
it("is true when dueDate is before today even if scheduledOn is future", () => {
|
||||
it("is true when scheduledOn is before today even if dueDate is future", () => {
|
||||
expect(
|
||||
computeIsPastDue(
|
||||
makeRow({
|
||||
dueDate: shiftIso(-1),
|
||||
scheduledOn: shiftIso(5),
|
||||
dueDate: shiftIso(5),
|
||||
scheduledOn: shiftIso(-1),
|
||||
status: "Scheduled",
|
||||
}),
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("is false when dueDate is future even if scheduledOn is past", () => {
|
||||
it("is false when scheduledOn is future even if dueDate is past", () => {
|
||||
expect(
|
||||
computeIsPastDue(
|
||||
makeRow({
|
||||
dueDate: shiftIso(5),
|
||||
scheduledOn: shiftIso(-1),
|
||||
dueDate: shiftIso(-1),
|
||||
scheduledOn: shiftIso(5),
|
||||
status: "Incomplete",
|
||||
}),
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("is false when dueDate is empty", () => {
|
||||
it("is false when scheduledOn is empty", () => {
|
||||
expect(
|
||||
computeIsPastDue(
|
||||
makeRow({
|
||||
dueDate: "",
|
||||
scheduledOn: shiftIso(-3),
|
||||
dueDate: shiftIso(-3),
|
||||
scheduledOn: "",
|
||||
status: "Scheduled",
|
||||
}),
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("is false for terminal statuses even with past dueDate", () => {
|
||||
it("is false for terminal statuses even with past scheduledOn", () => {
|
||||
expect(
|
||||
computeIsPastDue(
|
||||
makeRow({
|
||||
|
|
@ -204,19 +205,19 @@ describe("computeIsPastDue", () => {
|
|||
expect(
|
||||
computeIsPastDue(
|
||||
makeRow({
|
||||
dueDate: shiftIso(-2),
|
||||
scheduledOn: shiftIso(-2),
|
||||
status: "Canceled",
|
||||
}),
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("is false when dueDate is today", () => {
|
||||
it("is false when scheduledOn is today", () => {
|
||||
expect(
|
||||
computeIsPastDue(
|
||||
makeRow({
|
||||
dueDate: todayIso(),
|
||||
scheduledOn: shiftIso(-1),
|
||||
dueDate: shiftIso(-1),
|
||||
scheduledOn: todayIso(),
|
||||
status: "Scheduled",
|
||||
}),
|
||||
),
|
||||
|
|
@ -225,11 +226,11 @@ describe("computeIsPastDue", () => {
|
|||
});
|
||||
|
||||
describe("enrichTableRow", () => {
|
||||
it("recomputes isPastDue from dueDate and ignores sticky API true", () => {
|
||||
it("recomputes isPastDue from scheduledOn and ignores sticky API true", () => {
|
||||
const enriched = enrichTableRow(
|
||||
makeRow({
|
||||
dueDate: shiftIso(5),
|
||||
scheduledOn: shiftIso(-1),
|
||||
dueDate: shiftIso(-1),
|
||||
scheduledOn: shiftIso(5),
|
||||
isPastDue: true,
|
||||
status: "Scheduled",
|
||||
}),
|
||||
|
|
@ -237,11 +238,11 @@ describe("enrichTableRow", () => {
|
|||
expect(enriched.isPastDue).toBe(false);
|
||||
});
|
||||
|
||||
it("sets isPastDue true from past dueDate even when API flag is false", () => {
|
||||
it("sets isPastDue true from past scheduledOn even when API flag is false", () => {
|
||||
const enriched = enrichTableRow(
|
||||
makeRow({
|
||||
dueDate: shiftIso(-1),
|
||||
scheduledOn: shiftIso(5),
|
||||
dueDate: shiftIso(5),
|
||||
scheduledOn: shiftIso(-1),
|
||||
isPastDue: false,
|
||||
status: "Incomplete",
|
||||
}),
|
||||
|
|
|
|||
88
src/test/domain/work-orders/utils/wo-addon-cutoff.test.ts
Normal file
88
src/test/domain/work-orders/utils/wo-addon-cutoff.test.ts
Normal file
|
|
@ -0,0 +1,88 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
computeIsAddOnAtCreation,
|
||||
getAddonCutoffIso,
|
||||
previewIsAddOnFromCutoff,
|
||||
resolveIsAddOnForCreate,
|
||||
} from "@/domain/work-orders/utils/wo-addon-cutoff";
|
||||
|
||||
describe("getAddonCutoffIso", () => {
|
||||
it("returns Saturday of the week prior to the scheduled week", () => {
|
||||
// Week of Mon 2026-07-06 → prior Saturday 2026-07-04
|
||||
expect(getAddonCutoffIso("2026-07-08")).toBe("2026-07-04");
|
||||
expect(getAddonCutoffIso("2026-07-06")).toBe("2026-07-04");
|
||||
expect(getAddonCutoffIso("2026-07-10")).toBe("2026-07-04");
|
||||
});
|
||||
|
||||
it("returns null for missing or invalid scheduledOn", () => {
|
||||
expect(getAddonCutoffIso("")).toBeNull();
|
||||
expect(getAddonCutoffIso("not-a-date")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("previewIsAddOnFromCutoff", () => {
|
||||
it("returns true when created after prior-Saturday 23:59", () => {
|
||||
expect(
|
||||
previewIsAddOnFromCutoff({
|
||||
createdAt: "2026-07-05T00:00:00",
|
||||
scheduledOn: "2026-07-08",
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("returns false when created on or before prior-Saturday 23:59", () => {
|
||||
expect(
|
||||
previewIsAddOnFromCutoff({
|
||||
createdAt: "2026-07-04T23:59:59",
|
||||
scheduledOn: "2026-07-08",
|
||||
}),
|
||||
).toBe(false);
|
||||
expect(
|
||||
previewIsAddOnFromCutoff({
|
||||
createdAt: "2026-07-03T12:00:00",
|
||||
scheduledOn: "2026-07-08",
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("returns null when scheduledOn is missing", () => {
|
||||
expect(
|
||||
previewIsAddOnFromCutoff({
|
||||
createdAt: new Date("2026-07-05T12:00:00"),
|
||||
scheduledOn: "",
|
||||
}),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("keeps computeIsAddOnAtCreation as a deprecated alias", () => {
|
||||
expect(computeIsAddOnAtCreation).toBe(previewIsAddOnFromCutoff);
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveIsAddOnForCreate", () => {
|
||||
it("uses cutoff preview when scheduledOn is set", () => {
|
||||
expect(
|
||||
resolveIsAddOnForCreate({
|
||||
createdAt: "2026-07-05T00:00:00",
|
||||
scheduledOn: "2026-07-08",
|
||||
manualOverride: false,
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("uses manual override when scheduledOn is missing", () => {
|
||||
expect(
|
||||
resolveIsAddOnForCreate({
|
||||
scheduledOn: "",
|
||||
manualOverride: true,
|
||||
}),
|
||||
).toBe(true);
|
||||
expect(
|
||||
resolveIsAddOnForCreate({
|
||||
scheduledOn: "",
|
||||
manualOverride: false,
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Reference in a new issue