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.
This commit is contained in:
Arthur Bassi 2026-09-03 11:13:24 -03:00
parent 9cc9bc4dfb
commit c2d1add79c
9 changed files with 155 additions and 59 deletions

View file

@ -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,

View file

@ -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]"
}
>
<WoTableStatusLabel status={status} />

View file

@ -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
}
/>

View file

@ -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
<div>
<WizLabelRow
optional
onClear={hasWizardSchedule(draft) ? () => onPatch(clearSchedulePatch()) : undefined}
onClear={
hasWizardSchedule(draft)
? () => onPatch(withAutoScheduleStatus(draft, clearSchedulePatch()))
: undefined
}
>
Schedule On
</WizLabelRow>
@ -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,
}),
)
}
/>
</div>

View file

@ -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,

View file

@ -131,10 +131,10 @@ export function getStatusOptionState(
/** AAP `maybeAutoSchedule` — no dispatcher requirement. */
export function maybeAutoSchedulePatch(
row: Pick<WorkOrderTableRow, "status" | "scheduledOn" | "scheduleWeekOnly">,
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<T extends ScheduleDateInput & { status?: string }>(
current: T,
schedulePatch: Partial<T> & ScheduleDateInput,
): Partial<T> {
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) };
}

View file

@ -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");
});
});

View file

@ -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" }),
);
});
});

View file

@ -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.",