From c2d1add79c84ef9d92b456ff8b7f5cc674e66270 Mon Sep 17 00:00:00 2001 From: Arthur Bassi Date: Thu, 3 Sep 2026 11:13:24 -0300 Subject: [PATCH] fix(work-orders): persist AAP auto-schedule on a single board patch Merge derived status once so dated rows cannot stay Pending or Incomplete. Create and wizard demote Scheduled when there is no concrete date. --- .../table/build-inline-draft-create-input.ts | 11 +++- .../list/table/cells/status-cell.tsx | 6 +- .../list/table/wo-table-inline-row-cells.tsx | 47 ++++++++------ .../wizard/wizard-step-type-schedule.tsx | 61 +++++++++++-------- .../use-work-order-table-mutations.ts | 14 ++--- .../utils/wo-lifecycle-transitions.ts | 28 +++++++-- .../build-inline-draft-create-input.test.ts | 18 ++++++ .../use-work-order-table-mutations.test.tsx | 4 ++ .../utils/wo-lifecycle-transitions.test.ts | 25 ++++++++ 9 files changed, 155 insertions(+), 59 deletions(-) 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 5f911bd8..20def0b6 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 @@ -2,6 +2,7 @@ import type { LocationOption, WorkOrderUser } from "@/domain/work-orders/types/w import type { WorkOrderTableDraft } from "@/domain/work-orders/types/work-order-table-row"; import type { WorkOrderWizardDraft } from "@/domain/work-orders/types/work-order-wizard"; import { resolveSiteCode } from "@/domain/work-orders/mappers/work-order-board-mapper"; +import { maybeAutoSchedulePatch } from "@/domain/work-orders/utils/wo-lifecycle-transitions"; export function buildInlineDraftCreateInput( draft: WorkOrderTableDraft, @@ -14,6 +15,14 @@ export function buildInlineDraftCreateInput( const hasWeekTarget = Boolean(draft.targetWeek?.trim()); const scheduleWeekOnly = draft.scheduleWeekOnly && (hasWeekTarget || Boolean(draft.scheduledOn)); + const status = draft.status || "Incomplete"; + const autoStatus = maybeAutoSchedulePatch({ + status, + scheduledOn: draft.scheduledOn, + scheduleWeekOnly, + }).status; + const resolvedStatus = + autoStatus === "Scheduled" || autoStatus === "Pending" ? autoStatus : status; return { locationId: String(draft.locationId || ""), @@ -36,7 +45,7 @@ export function buildInlineDraftCreateInput( vendorNotes: "", apptStart: draft.apptStart, apptEnd: draft.apptEnd, - status: draft.status || "Incomplete", + status: resolvedStatus, pocName: draft.pocName, pocPhone: draft.pocPhone, pocNotes: draft.pocNotes, diff --git a/src/app/(protected)/workorders/_components/list/table/cells/status-cell.tsx b/src/app/(protected)/workorders/_components/list/table/cells/status-cell.tsx index a9ee2647..b9331330 100644 --- a/src/app/(protected)/workorders/_components/list/table/cells/status-cell.tsx +++ b/src/app/(protected)/workorders/_components/list/table/cells/status-cell.tsx @@ -167,7 +167,7 @@ export function StatusCell({ onClose={() => setAnchorEl(null)} anchorOrigin={{ vertical: "bottom", horizontal: "left" }} onClick={(e) => e.stopPropagation()} - slotProps={{ paper: { className: "min-w-[168px] rounded-lg border border-border p-1" } }} + slotProps={{ paper: { className: "min-w-[190px] rounded-lg border border-border p-1" } }} > {statusOptions.map((status) => { const option = getStatusOptionState(wo, status, closabilityGaps); @@ -192,8 +192,8 @@ export function StatusCell({ }} className={ option.disabled - ? "flex w-full cursor-not-allowed items-center gap-2 rounded px-2 py-1.5 opacity-50" - : "flex w-full items-center gap-2 rounded px-2 py-1.5 hover:bg-[#F9FAFB]" + ? "flex w-full cursor-not-allowed items-center justify-between gap-2 rounded px-2 py-1.5 opacity-50" + : "flex w-full items-center justify-between gap-2 rounded px-2 py-1.5 hover:bg-[#F9FAFB]" } > 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 4f4c9f51..cde640c8 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 @@ -13,7 +13,10 @@ import { ApptTimeCell } from "./cells/appt-time-cell"; import { DateCell } from "./cells/date-cell"; import { DueDateCell } from "./cells/due-date-cell"; import { PMTypeCell } from "./cells/pm-type-cell"; -import { CREATION_INLINE_STATUSES } from "@/domain/work-orders/utils/wo-lifecycle-transitions"; +import { + CREATION_INLINE_STATUSES, + withAutoScheduleStatus, +} from "@/domain/work-orders/utils/wo-lifecycle-transitions"; import { StatusCell } from "./cells/status-cell"; import { TypeCell } from "./cells/type-cell"; import { VendorCell } from "./cells/vendor-cell"; @@ -85,30 +88,36 @@ function InlineScheduleOnCell({ defaultMode={weekDefault ? "week" : "date"} highlight={!draft.scheduledOn && !draft.targetWeek} onSave={(date) => - onPatch({ - scheduledOn: date, - scheduleWeekOnly: false, - targetWeek: "", - isAddOnOverride: false, - }) + onPatch( + withAutoScheduleStatus(draft, { + scheduledOn: date, + scheduleWeekOnly: false, + targetWeek: "", + isAddOnOverride: false, + }), + ) } onSaveWeek={(week) => - onPatch({ - scheduledOn: "", - targetWeek: week, - scheduleWeekOnly: true, - isAddOnOverride: false, - }) + onPatch( + withAutoScheduleStatus(draft, { + scheduledOn: "", + targetWeek: week, + scheduleWeekOnly: true, + isAddOnOverride: false, + }), + ) } onClear={ hasSchedule ? () => - onPatch({ - scheduledOn: "", - targetWeek: "", - scheduleWeekOnly: false, - isAddOnOverride: false, - }) + onPatch( + withAutoScheduleStatus(draft, { + scheduledOn: "", + targetWeek: "", + scheduleWeekOnly: false, + isAddOnOverride: false, + }), + ) : undefined } /> 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 565c7de1..f4df71a3 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 @@ -13,6 +13,7 @@ import { type WorkOrderSeverity, } from "@/domain/work-orders/types/work-order-severity"; import { getDispatcherAvatarColor } from "@/domain/work-orders/utils/dispatcher-avatar-color"; +import { withAutoScheduleStatus } from "@/domain/work-orders/utils/wo-lifecycle-transitions"; import { WizardDateField } from "@/app/(protected)/workorders/_components/wizard/wizard-date-field"; import { WizardDueDateField } from "@/app/(protected)/workorders/_components/wizard/wizard-due-date-field"; import { WizardFieldSelect } from "@/app/(protected)/workorders/_components/wizard/wizard-field-select"; @@ -146,7 +147,11 @@ function WizardTypeScheduleDates({ draft, onUpdate, onPatch }: WizardTypeSchedul
onPatch(clearSchedulePatch()) : undefined} + onClear={ + hasWizardSchedule(draft) + ? () => onPatch(withAutoScheduleStatus(draft, clearSchedulePatch())) + : undefined + } > Schedule On @@ -158,34 +163,40 @@ function WizardTypeScheduleDates({ draft, onUpdate, onPatch }: WizardTypeSchedul scheduleEnd={draft.scheduleEnd} schedMode={draft.schedMode} onChange={(iso) => - onPatch({ - scheduledOn: iso, - scheduleWeekOnly: false, - scheduleEnd: "", - schedMode: "date", - targetWeek: "", - isAddOnOverride: false, - }) + onPatch( + withAutoScheduleStatus(draft, { + scheduledOn: iso, + scheduleWeekOnly: false, + scheduleEnd: "", + schedMode: "date", + targetWeek: "", + isAddOnOverride: false, + }), + ) } onChangeWeek={(w) => - onPatch({ - scheduledOn: w, - scheduleWeekOnly: Boolean(w), - scheduleEnd: "", - schedMode: "week", - targetWeek: w, - isAddOnOverride: false, - }) + onPatch( + withAutoScheduleStatus(draft, { + scheduledOn: w, + scheduleWeekOnly: Boolean(w), + scheduleEnd: "", + schedMode: "week", + targetWeek: w, + isAddOnOverride: false, + }), + ) } onChangeRange={(s, e) => - onPatch({ - scheduledOn: s, - scheduleEnd: e, - schedMode: "range", - scheduleWeekOnly: false, - targetWeek: "", - isAddOnOverride: false, - }) + onPatch( + withAutoScheduleStatus(draft, { + scheduledOn: s, + scheduleEnd: e, + schedMode: "range", + scheduleWeekOnly: false, + targetWeek: "", + isAddOnOverride: false, + }), + ) } />
diff --git a/src/domain/work-orders/use-cases/use-work-order-table-mutations.ts b/src/domain/work-orders/use-cases/use-work-order-table-mutations.ts index e0a68614..adb2017a 100644 --- a/src/domain/work-orders/use-cases/use-work-order-table-mutations.ts +++ b/src/domain/work-orders/use-cases/use-work-order-table-mutations.ts @@ -131,12 +131,9 @@ export function useWorkOrderTableMutations( const patchMutation = useMutation({ meta: { suppressErrorToast: true }, mutationFn: ({ id, patch }: { id: string | number; patch: WorkOrderTablePatch }) => - enqueuePatch(id, () => { - const next = mergeAutoSchedulePatch(getRow?.(id), patch); - return runWorkOrderTablePatch(id, next, getRow); - }), + enqueuePatch(id, () => runWorkOrderTablePatch(id, patch, getRow)), onMutate: async ({ id, patch }) => { - onPatch(id, mergeAutoSchedulePatch(getRow?.(id), patch)); + onPatch(id, patch); }, onSuccess: (result, { id }) => { if (result.kind === "board") { @@ -167,15 +164,18 @@ export function useWorkOrderTableMutations( }, }); + const resolvePatch = (id: string | number, patch: WorkOrderTablePatch) => + mergeAutoSchedulePatch(getRow?.(id), patch); + const patchField: WorkOrderTablePatchFn = (id, patch, options) => { - void patchMutation.mutateAsync({ id, patch }).then( + void patchMutation.mutateAsync({ id, patch: resolvePatch(id, patch) }).then( () => options?.onSuccess?.(), (error: Error) => options?.onError?.(error), ); }; const patchFieldAsync = (id: string | number, patch: WorkOrderTablePatch) => - patchMutation.mutateAsync({ id, patch }); + patchMutation.mutateAsync({ id, patch: resolvePatch(id, patch) }); return { patchField, diff --git a/src/domain/work-orders/utils/wo-lifecycle-transitions.ts b/src/domain/work-orders/utils/wo-lifecycle-transitions.ts index 0ff1b9f7..daea62f1 100644 --- a/src/domain/work-orders/utils/wo-lifecycle-transitions.ts +++ b/src/domain/work-orders/utils/wo-lifecycle-transitions.ts @@ -131,10 +131,10 @@ export function getStatusOptionState( /** AAP `maybeAutoSchedule` — no dispatcher requirement. */ export function maybeAutoSchedulePatch( - row: Pick, + row: ScheduleDateInput & { status?: string }, ): WorkOrderTablePatch { const hasDate = hasConcreteScheduleDate(row); - if (hasDate && isPreScheduleStatus(row.status)) { + if (hasDate && isPreScheduleStatus(row.status ?? "")) { return { status: "Scheduled" }; } if (!hasDate && row.status === "Scheduled") { @@ -143,15 +143,18 @@ export function maybeAutoSchedulePatch( return {}; } -function patchTouchesScheduleOrStatus(patch: WorkOrderTablePatch): boolean { +function patchTouchesSchedule(patch: WorkOrderTablePatch): boolean { return ( - patch.status !== undefined || patch.scheduledOn !== undefined || patch.scheduleWeekOnly !== undefined || patch.targetWeek !== undefined ); } +function patchTouchesScheduleOrStatus(patch: WorkOrderTablePatch): boolean { + return patch.status !== undefined || patchTouchesSchedule(patch); +} + export function mergeAutoSchedulePatch( row: WorkOrderTableRow | undefined, patch: WorkOrderTablePatch, @@ -167,3 +170,20 @@ export function mergeAutoSchedulePatch( }; return { ...patch, ...maybeAutoSchedulePatch(merged) }; } + +/** Apply schedule fields and the AAP auto-status for that next schedule. */ +export function withAutoScheduleStatus( + current: T, + schedulePatch: Partial & ScheduleDateInput, +): Partial { + const next = { + status: current.status ?? "", + scheduledOn: + schedulePatch.scheduledOn !== undefined ? schedulePatch.scheduledOn : current.scheduledOn, + scheduleWeekOnly: + schedulePatch.scheduleWeekOnly !== undefined + ? schedulePatch.scheduleWeekOnly + : current.scheduleWeekOnly, + }; + return { ...schedulePatch, ...maybeAutoSchedulePatch(next) }; +} diff --git a/src/test/app/(protected)/workorders/build-inline-draft-create-input.test.ts b/src/test/app/(protected)/workorders/build-inline-draft-create-input.test.ts index 441f39e2..d4cc9a1f 100644 --- a/src/test/app/(protected)/workorders/build-inline-draft-create-input.test.ts +++ b/src/test/app/(protected)/workorders/build-inline-draft-create-input.test.ts @@ -92,4 +92,22 @@ describe("buildInlineDraftCreateInput", () => { expect(input.assigneeId).toBe(""); expect(payload).not.toHaveProperty("assignTo"); }); + + it("demotes Scheduled to Pending when create has no concrete date", () => { + const draft = { + ...EMPTY_TABLE_DRAFT, + site: "BK5", + locationId: "9", + woNumber: "25005", + pocName: "Jane", + pocPhone: "555-0100", + status: "Scheduled" as const, + scheduledOn: "", + scheduleWeekOnly: true, + targetWeek: "", + }; + + const input = buildInlineDraftCreateInput(draft, [], [{ id: 9, name: "BK5 · Dallas, TX" }]); + expect(input.status).toBe("Pending"); + }); }); 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 6015695b..ed614089 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 @@ -254,6 +254,10 @@ describe("maybeAutoSchedule on board patch", () => { expect.objectContaining({ scheduledOn: "2026-07-15", status: "Scheduled" }), ); await waitFor(() => expect(patchBoardField).toHaveBeenCalled()); + expect(patchBoardField).toHaveBeenCalledWith( + 42, + expect.objectContaining({ field: "lifecycleStatus", value: "Scheduled" }), + ); }); }); diff --git a/src/test/domain/work-orders/utils/wo-lifecycle-transitions.test.ts b/src/test/domain/work-orders/utils/wo-lifecycle-transitions.test.ts index f8150706..838595b9 100644 --- a/src/test/domain/work-orders/utils/wo-lifecycle-transitions.test.ts +++ b/src/test/domain/work-orders/utils/wo-lifecycle-transitions.test.ts @@ -103,6 +103,31 @@ describe("wo-lifecycle-transitions", () => { expect(mergeAutoSchedulePatch(row, { vendorId: "9" })).toEqual({ vendorId: "9" }); }); + it("reapplies auto-schedule after a Pending or Incomplete pick on a dated row", () => { + const row = { + status: "Scheduled", + scheduledOn: "2026-06-01", + scheduleWeekOnly: false, + } as WorkOrderTableRow; + expect(mergeAutoSchedulePatch(row, { status: "Pending" })).toEqual({ status: "Scheduled" }); + expect(mergeAutoSchedulePatch(row, { status: "Incomplete" })).toEqual({ + status: "Scheduled", + }); + }); + + it("still demotes Scheduled when the schedule is cleared", () => { + const row = { + status: "Scheduled", + scheduledOn: "2026-06-01", + scheduleWeekOnly: false, + } as WorkOrderTableRow; + expect(mergeAutoSchedulePatch(row, { scheduledOn: "", scheduleWeekOnly: false })).toEqual({ + scheduledOn: "", + scheduleWeekOnly: false, + status: "Pending", + }); + }); + it("uses AAP completed tooltip hierarchy", () => { expect(formatAapCompletedBlockTitle(["Dispatcher", "Company"])).toBe( "Fill in Assigned To, Vendor before completing this work order.",