Merge pull request #165 from Sea-Haven-Industries/feat/SH-169-wo-inline-lifecycle
Some checks are pending
Frontend checks / Build and test (push) Waiting to run
Frontend checks / governance (push) Waiting to run
Frontend checks / Visual regression (push) Waiting to run
Deploy / deploy (push) Waiting to run

feat(work-orders): align inline lifecycle transitions with AAP
This commit is contained in:
Arthur Bassi 2026-09-04 14:58:44 -03:00 • committed by GitHub
commit 7cc7cc19d0
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
18 changed files with 781 additions and 131 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 { resolveCreationStatus } from "@/domain/work-orders/utils/wo-lifecycle-transitions";
export function buildInlineDraftCreateInput(
draft: WorkOrderTableDraft,
@ -36,7 +37,11 @@ export function buildInlineDraftCreateInput(
vendorNotes: "",
apptStart: draft.apptStart,
apptEnd: draft.apptEnd,
status: draft.status || "Incomplete",
status: resolveCreationStatus({
status: draft.status || "Incomplete",
scheduledOn: draft.scheduledOn,
scheduleWeekOnly,
}),
pocName: draft.pocName,
pocPhone: draft.pocPhone,
pocNotes: draft.pocNotes,

View file

@ -2,16 +2,19 @@ import { useState } from "react";
import { AlertTriangle, Check } from "lucide-react";
import { Popover } from "@mui/material";
import { Text } from "@/components/ui/text";
import {
ALL_WIZARD_STATUSES,
type WizardWOStatus,
} from "@/domain/work-orders/types/work-order-wizard";
import type { WizardWOStatus } from "@/domain/work-orders/types/work-order-wizard";
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
import {
formatClosabilityMissingTitle,
getClosabilityGaps,
tableRowToClosabilityInput,
} from "@/domain/work-orders/utils/wo-closability";
import {
BOARD_INLINE_STATUSES,
PAST_DUE_BADGE_TOOLTIP,
PAST_DUE_POPOVER_BODY,
PAST_DUE_RESCHEDULE_CTA,
getStatusOptionState,
} from "@/domain/work-orders/utils/wo-lifecycle-transitions";
import { HoverChevron } from "./hover-chevron";
import { WoTableStatusLabel } from "./wo-table-status-label";
@ -24,6 +27,7 @@ type StatusCellProps = {
onPastDueAttempt?: () => void;
onRequestComplete?: () => void;
readOnly?: boolean;
statusOptions?: readonly WizardWOStatus[];
};
function ReadOnlyStatusLabel({ status }: { status: string }) {
@ -64,7 +68,7 @@ function PastDueStatusCell({
onPastDueAttempt?.();
setAnchorEl(e.currentTarget);
}}
title="This WO missed its deadline. Update Schedule On to unlock status updates."
title={PAST_DUE_BADGE_TOOLTIP}
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)" }}
>
@ -97,7 +101,7 @@ function PastDueStatusCell({
color: "var(--status-pending-text)",
}}
>
This work order is past due. Update Schedule On before the status can be updated.
{PAST_DUE_POPOVER_BODY}
</Text>
<button
type="button"
@ -108,7 +112,7 @@ function PastDueStatusCell({
}}
className="mt-2 block text-[11px] font-medium text-[var(--color-primary)]"
>
Update Schedule On →
{PAST_DUE_RESCHEDULE_CTA}
</button>
</div>
</div>
@ -124,6 +128,7 @@ export function StatusCell({
onPastDueAttempt,
onRequestComplete,
readOnly = false,
statusOptions = BOARD_INLINE_STATUSES,
}: StatusCellProps) {
const [anchorEl, setAnchorEl] = useState<HTMLElement | null>(null);
const closabilityGaps = rowClosabilityGaps(wo);
@ -162,22 +167,19 @@ 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" } }}
>
{ALL_WIZARD_STATUSES.map((status) => {
const isCompletedBlocked = status === "Completed" && closabilityGaps.length > 0;
const missingTitle = isCompletedBlocked
? formatClosabilityMissingTitle(closabilityGaps)
: undefined;
{statusOptions.map((status) => {
const option = getStatusOptionState(wo, status, closabilityGaps);
return (
<button
key={status}
type="button"
disabled={isCompletedBlocked}
title={missingTitle}
disabled={option.disabled}
title={option.title}
onClick={() => {
if (isCompletedBlocked) {
if (option.disabled) {
return;
}
if (status === "Completed" && wo.status !== "Completed") {
@ -189,9 +191,9 @@ export function StatusCell({
setAnchorEl(null);
}}
className={
isCompletedBlocked
? "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]"
option.disabled
? "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

@ -17,6 +17,8 @@ export function toInlineDraftRow(draft: WorkOrderTableDraft) {
dispatcherId: draft.dispatcherId,
dispatcherName: "",
scheduledOn: draft.scheduledOn,
scheduleWeekOnly: draft.scheduleWeekOnly,
vendorId: draft.vendorId,
completedDate: "",
dueDate: draft.dueDate,
pm: draft.pm,

View file

@ -13,6 +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,
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";
@ -84,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
}
/>
@ -215,6 +225,7 @@ export function WoTableInlineRowCells({
<td className="group/cell" style={{ padding: "0 14px" }}>
<StatusCell
wo={draftRow as never}
statusOptions={CREATION_INLINE_STATUSES}
onChangeStatus={(status) => onPatch({ status })}
onOpenSlideOver={() => {}}
onRequestComplete={onRequestComplete}

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

@ -1,9 +1,7 @@
import { useState } from "react";
import { Clock, ExternalLink, Mail, User, Wrench } from "lucide-react";
import { Text } from "@/components/ui/text";
import type { VendorDropdownItem } from "@/domain/work-orders/types/work-order";
import {
ALL_WIZARD_STATUSES,
TIME_SLOTS,
WIZARD_STATUS_DOTS,
type WizardWOStatus,
@ -15,12 +13,15 @@ import {
formatVendorTechnicianLabel,
} from "@/domain/work-orders/utils/vendor-dropdown-label";
import {
formatClosabilityMissingTitle,
getClosabilityGaps,
wizardDraftToClosabilityInput,
} from "@/domain/work-orders/utils/wo-closability";
import {
CREATION_INLINE_STATUSES,
getStatusOptionState,
withAutoScheduleStatus,
} from "@/domain/work-orders/utils/wo-lifecycle-transitions";
import { WizardFieldSelect } from "@/app/(protected)/workorders/_components/wizard/wizard-field-select";
import { WorkOrderCompleteDialog } from "@/app/(protected)/workorders/_components/list/work-order-complete-dialog";
import {
DLG_INPUT_CLS,
DLG_INPUT_STYLE,
@ -173,15 +174,15 @@ function WizardApptTimeFields({ draft, onUpdate, onPatch }: WizardApptTimeFields
);
}
function buildWizardStatusOptions(closabilityGaps: string[]) {
return ALL_WIZARD_STATUSES.map((status) => {
const isCompletedBlocked = status === "Completed" && closabilityGaps.length > 0;
function buildWizardStatusOptions(draft: WorkOrderWizardDraft, closabilityGaps: string[]) {
return CREATION_INLINE_STATUSES.map((status) => {
const option = getStatusOptionState(draft, status, closabilityGaps);
return {
key: status,
label: status,
left: <ColorDot color={WIZARD_STATUS_DOTS[status]} />,
disabled: isCompletedBlocked,
title: isCompletedBlocked ? formatClosabilityMissingTitle(closabilityGaps) : undefined,
disabled: option.disabled,
title: option.title,
};
});
}
@ -192,7 +193,6 @@ export function WizardStepVendorTime({
onUpdate,
onPatch,
}: WizardStepVendorTimeProps) {
const [confirmComplete, setConfirmComplete] = useState(false);
const selectedVendor = vendors.find((v) => String(v.id) === draft.vendorId);
const statusValue = (draft.status || "Incomplete") as WizardWOStatus;
const statusDotColor = WIZARD_STATUS_DOTS[statusValue] ?? WIZARD_STATUS_DOTS.Incomplete;
@ -272,27 +272,16 @@ export function WizardStepVendorTime({
placeholder="Select status…"
search={false}
icon={<ColorDot color={statusDotColor} />}
options={buildWizardStatusOptions(closabilityGaps)}
options={buildWizardStatusOptions(draft, closabilityGaps)}
onPick={(status) => {
if (status === "Completed" && closabilityGaps.length > 0) return;
if (status === "Completed" && draft.status !== "Completed") {
setConfirmComplete(true);
const option = getStatusOptionState(draft, status as WizardWOStatus, closabilityGaps);
if (option.disabled) {
return;
}
onUpdate("status", status as WizardWOStatus);
onPatch(withAutoScheduleStatus(draft, { status: status as WizardWOStatus }));
}}
/>
</div>
<WorkOrderCompleteDialog
open={confirmComplete}
isPending={false}
onDialogClose={() => setConfirmComplete(false)}
onKeepOpen={() => setConfirmComplete(false)}
onConfirm={() => {
onUpdate("status", "Completed");
setConfirmComplete(false);
}}
/>
</div>
);
}

View file

@ -19,9 +19,15 @@ import type {
} from "@/domain/work-orders/types/work-order-table-row";
import { deriveDayFields } from "@/domain/work-orders/utils/table-day-utils";
import { enrichTableRow } from "@/domain/work-orders/utils/table-filter-utils";
import {
maybeAutoSchedulePatch,
mergeAutoSchedulePatch,
} from "@/domain/work-orders/utils/wo-lifecycle-transitions";
import { createWorkOrderPatchQueue } from "@/domain/work-orders/utils/work-order-patch-queue";
import { queryKeys } from "@/infra/query-key/query-key";
export { maybeAutoSchedulePatch };
export type { WorkOrderTablePatch };
export type WorkOrderTablePatchOptions = {
@ -124,10 +130,16 @@ export function useWorkOrderTableMutations(
const patchMutation = useMutation({
meta: { suppressErrorToast: true },
mutationFn: ({ id, patch }: { id: string | number; patch: WorkOrderTablePatch }) =>
enqueuePatch(id, () => runWorkOrderTablePatch(id, patch, getRow)),
onMutate: async ({ id, patch }) => {
onPatch(id, patch);
mutationFn: ({
id,
persistPatch,
}: {
id: string | number;
patch: WorkOrderTablePatch;
persistPatch: WorkOrderTablePatch;
}) => enqueuePatch(id, () => runWorkOrderTablePatch(id, persistPatch, getRow)),
onMutate: async ({ id, persistPatch }) => {
onPatch(id, persistPatch);
},
onSuccess: (result, { id }) => {
if (result.kind === "board") {
@ -158,15 +170,21 @@ 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(
const persistPatch = resolvePatch(id, patch);
void patchMutation.mutateAsync({ id, patch, persistPatch }).then(
() => options?.onSuccess?.(),
(error: Error) => options?.onError?.(error),
);
};
const patchFieldAsync = (id: string | number, patch: WorkOrderTablePatch) =>
patchMutation.mutateAsync({ id, patch });
const patchFieldAsync = (id: string | number, patch: WorkOrderTablePatch) => {
const persistPatch = resolvePatch(id, patch);
return patchMutation.mutateAsync({ id, patch, persistPatch });
};
return {
patchField,
@ -189,19 +207,9 @@ export function tableRowFromDetail(detail: Awaited<ReturnType<typeof workOrdersA
);
}
/** Optimistic UI only — persistence auto-schedule is handled by the board PATCH API. */
export function maybeAutoSchedulePatch(row: WorkOrderTableRow): WorkOrderTablePatch {
const hasDate = !!row.scheduledOn;
const hasDispatcher = !!row.dispatcherId;
if (hasDate && hasDispatcher && row.status === "Incomplete") {
return { status: "Scheduled" };
}
return {};
}
/**
* Build a schedule table patch. Clearing the schedule optimistically clears
* `isAddOn`; day/week moves leave `isAddOn` for the board PATCH response (SH-184).
* `isAddOn`; day/week moves leave `isAddOn` for the board PATCH response.
*/
export function applySchedulePatch(
scheduledOn: string,

View file

@ -26,11 +26,19 @@ export type AuditActionRuleContext = {
export type AuditActionRule = (ctx: AuditActionRuleContext) => string | null;
function tryStatusRule({ fieldKey, actionKey, newValue }: AuditActionRuleContext): string | null {
function tryStatusRule({
fieldKey,
actionKey,
oldValue,
newValue,
}: AuditActionRuleContext): string | null {
const matches =
fieldKey.includes("status") || fieldKey === "lifecyclestatus" || actionKey.includes("status");
if (!matches || !newValue.text) return null;
if (oldValue.text) {
return `Changed status from ${wrapAuditValue(oldValue)} to ${wrapAuditValue(newValue)}`;
}
return `Changed status to ${wrapAuditValue(newValue)}`;
}

View file

@ -0,0 +1,195 @@
import {
ALL_WIZARD_STATUSES,
type WizardWOStatus,
} from "@/domain/work-orders/types/work-order-wizard";
import type {
WorkOrderTablePatch,
WorkOrderTableRow,
} from "@/domain/work-orders/types/work-order-table-row";
import {
PENDING_UPLIFT_GAP,
UPLIFT_PENDING_COMPLETED_TITLE,
} from "@/domain/work-orders/utils/wo-closability";
/** AAP `ALL_STATUSES` — Canceled is kebab-only. */
export const BOARD_INLINE_STATUSES: readonly WizardWOStatus[] = ALL_WIZARD_STATUSES;
/** AAP `CREATION_STATUSES` — wizard Step 3 and inline create. */
export const CREATION_INLINE_STATUSES: readonly WizardWOStatus[] = [
"Incomplete",
"Pending",
"Scheduled",
];
export const SCHEDULED_NEEDS_DATE_MSG =
"Set a Schedule On date before marking this work order as Scheduled.";
export const PAST_DUE_BADGE_TOOLTIP =
"This WO missed its service window. Reschedule to unlock status updates.";
export const PAST_DUE_POPOVER_BODY =
"This work order is past due. A new date must be scheduled before the status can be updated.";
export const PAST_DUE_RESCHEDULE_CTA = "Reschedule →";
export const COMPLETED_DOC_BLOCK_MSG =
"Generate or mark the completion document as not required before completing this work order";
export const COMPLETED_MEDIA_BLOCK_MSG =
"Upload at least one completion photo before completing this work order.";
const COMPLETE_ACTION = "completing this work order";
const EMPTY_FIELD_TOKENS = new Set(["", "-", "—", "N/A", "n/a"]);
const PRE_SCHEDULE_STATUSES = new Set<string>(["Incomplete", "Pending", ""]);
const FIELD_GAP_TO_AAP_LABEL: [string, string][] = [
["Dispatcher", "Assigned To"],
["Company", "Vendor"],
["Completed Date", "Completed Date"],
["Service", "Service"],
];
export type ScheduleDateInput = {
scheduledOn?: string;
scheduleWeekOnly?: boolean;
schedMode?: string;
};
export type StatusOptionState = {
disabled: boolean;
title?: string;
};
export function isLifecycleFieldEmpty(value?: string): boolean {
if (value == null) {
return true;
}
const trimmed = value.trim();
return EMPTY_FIELD_TOKENS.has(trimmed);
}
/** AAP `hasScheduleDate`: concrete day/range only — week-only does not count. */
export function hasConcreteScheduleDate(row: ScheduleDateInput): boolean {
if (row.scheduleWeekOnly === true || row.schedMode === "week") {
return false;
}
return !isLifecycleFieldEmpty(row.scheduledOn);
}
export function isPreScheduleStatus(status: string): boolean {
return PRE_SCHEDULE_STATUSES.has(status);
}
export function formatAapCompletedBlockTitle(gaps: string[]): string | undefined {
if (gaps.length === 0) {
return undefined;
}
const fields = FIELD_GAP_TO_AAP_LABEL.filter(([gap]) => gaps.includes(gap)).map(
([, label]) => label,
);
if (fields.length === 1 && fields[0] === "Service") {
return `Select a service before ${COMPLETE_ACTION}.`;
}
if (fields.length > 0) {
return `Fill in ${fields.join(", ")} before ${COMPLETE_ACTION}.`;
}
if (gaps.includes("WO number")) {
return `Assign a real APM number (not SH) before ${COMPLETE_ACTION}.`;
}
if (gaps.includes(PENDING_UPLIFT_GAP)) {
return UPLIFT_PENDING_COMPLETED_TITLE;
}
if (gaps.includes("Completion document")) {
return COMPLETED_DOC_BLOCK_MSG;
}
if (gaps.includes("At least 1 photo")) {
return COMPLETED_MEDIA_BLOCK_MSG;
}
return undefined;
}
export function getStatusOptionState(
row: ScheduleDateInput,
status: WizardWOStatus,
closabilityGaps: string[],
): StatusOptionState {
if (status === "Scheduled" && !hasConcreteScheduleDate(row)) {
return { disabled: true, title: SCHEDULED_NEEDS_DATE_MSG };
}
if (status === "Completed") {
const title = formatAapCompletedBlockTitle(closabilityGaps);
if (title) {
return { disabled: true, title };
}
}
return { disabled: false };
}
/** AAP `maybeAutoSchedule` — no dispatcher requirement. */
export function maybeAutoSchedulePatch(
row: ScheduleDateInput & { status?: string },
): WorkOrderTablePatch {
const hasDate = hasConcreteScheduleDate(row);
if (hasDate && isPreScheduleStatus(row.status ?? "")) {
return { status: "Scheduled" };
}
if (!hasDate && row.status === "Scheduled") {
return { status: "Pending" };
}
return {};
}
function patchTouchesScheduleOrStatus(patch: WorkOrderTablePatch): boolean {
return (
patch.status !== undefined ||
patch.scheduledOn !== undefined ||
patch.scheduleWeekOnly !== undefined ||
patch.targetWeek !== undefined
);
}
export function mergeAutoSchedulePatch(
row: WorkOrderTableRow | undefined,
patch: WorkOrderTablePatch,
): WorkOrderTablePatch {
if (!row || !patchTouchesScheduleOrStatus(patch)) {
return patch;
}
const merged = {
status: patch.status ?? row.status,
scheduledOn: patch.scheduledOn !== undefined ? patch.scheduledOn : row.scheduledOn,
scheduleWeekOnly:
patch.scheduleWeekOnly !== undefined ? patch.scheduleWeekOnly : row.scheduleWeekOnly,
};
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: schedulePatch.status ?? current.status ?? "",
scheduledOn:
schedulePatch.scheduledOn !== undefined ? schedulePatch.scheduledOn : current.scheduledOn,
scheduleWeekOnly:
schedulePatch.scheduleWeekOnly !== undefined
? schedulePatch.scheduleWeekOnly
: current.scheduleWeekOnly,
};
return { ...schedulePatch, ...maybeAutoSchedulePatch(next) };
}
/** AAP create: apply maybeAutoSchedule to the draft before POST. */
export function resolveCreationStatus(row: ScheduleDateInput & { status: string }): WizardWOStatus {
const autoStatus = maybeAutoSchedulePatch(row).status;
if (autoStatus === "Scheduled" || autoStatus === "Pending") {
return autoStatus;
}
return (row.status || "Incomplete") as WizardWOStatus;
}

View file

@ -92,4 +92,39 @@ describe("buildInlineDraftCreateInput", () => {
expect(input.assigneeId).toBe("");
expect(payload).not.toHaveProperty("assignTo");
});
it("promotes Pending to Scheduled when create has a concrete date", () => {
const draft = {
...EMPTY_TABLE_DRAFT,
site: "BK5",
locationId: "9",
woNumber: "25006",
pocName: "Jane",
pocPhone: "555-0100",
status: "Pending" as const,
scheduledOn: "2026-06-01",
scheduleWeekOnly: false,
};
const input = buildInlineDraftCreateInput(draft, [], [{ id: 9, name: "BK5 · Dallas, TX" }]);
expect(input.status).toBe("Scheduled");
});
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

@ -31,8 +31,47 @@ describe("StatusCell closability gate", () => {
const completed = screen.getByRole("button", { name: /^completed$/i });
expect(completed.hasAttribute("disabled")).toBe(true);
expect(completed.getAttribute("title") ?? "").toContain("Missing:");
expect(completed.getAttribute("title") ?? "").toContain("Fill in");
fireEvent.click(completed);
expect(onChangeStatus).not.toHaveBeenCalledWith("Completed");
});
it("does not offer Canceled", () => {
render(<StatusCell wo={incompleteRow} onChangeStatus={vi.fn()} onOpenSlideOver={vi.fn()} />);
fireEvent.click(screen.getByRole("button", { name: /scheduled/i }));
expect(screen.queryByRole("button", { name: /^canceled$/i })).toBeNull();
});
it("disables Scheduled when the row has no concrete date", () => {
const onChangeStatus = vi.fn();
render(
<StatusCell
wo={{ ...incompleteRow, status: "Pending", scheduledOn: "" }}
onChangeStatus={onChangeStatus}
onOpenSlideOver={vi.fn()}
/>,
);
fireEvent.click(screen.getByRole("button", { name: /pending/i }));
const scheduled = screen.getByRole("button", { name: /^scheduled$/i });
expect(scheduled).toBeDisabled();
fireEvent.click(scheduled);
expect(onChangeStatus).not.toHaveBeenCalled();
});
it("limits creation options to Incomplete, Pending, and Scheduled", () => {
render(
<StatusCell
wo={{ ...incompleteRow, status: "Incomplete" }}
statusOptions={["Incomplete", "Pending", "Scheduled"]}
onChangeStatus={vi.fn()}
onOpenSlideOver={vi.fn()}
/>,
);
fireEvent.click(screen.getByRole("button", { name: /incomplete/i }));
expect(screen.getByRole("button", { name: /^pending$/i })).toBeTruthy();
expect(screen.queryByRole("button", { name: /^en route$/i })).toBeNull();
});
});

View file

@ -22,18 +22,20 @@ const pastDueRow = {
dueDate: "2026-08-01",
} as WorkOrderTableRow;
describe("StatusCell Past Due copy (SH-185)", () => {
describe("StatusCell Past Due copy", () => {
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") ?? "").toContain("Reschedule to unlock status updates");
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.getByText(/a new date must be scheduled before the status can be updated/i),
).toBeTruthy();
expect(screen.getByRole("button", { name: /reschedule/i })).toBeTruthy();
expect(screen.queryByText(/due date/i)).toBeNull();
});
});

View file

@ -157,7 +157,7 @@ describe("StatusCell pending uplift closability", () => {
const completed = screen.getByRole("button", { name: /completed/i });
expect(completed).toBeDisabled();
expect(completed).toHaveAttribute("title", "Missing: Company");
expect(completed).toHaveAttribute("title", "Fill in Vendor before completing this work order.");
fireEvent.click(completed);
expect(onChangeStatus).not.toHaveBeenCalled();
});
@ -227,7 +227,10 @@ describe("StatusCell pending uplift closability", () => {
const completed = screen.getByRole("button", { name: /completed/i });
expect(completed).toBeDisabled();
expect(completed).toHaveAttribute("title", "Missing: Company");
expect(completed).toHaveAttribute(
"title",
"Fill in Vendor before completing this work order.",
);
fireEvent.click(completed);
expect(onChangeStatus).not.toHaveBeenCalled();
},
@ -254,7 +257,7 @@ describe("StatusCell pending uplift closability", () => {
const completed = screen.getByRole("button", { name: /completed/i });
expect(completed).toBeDisabled();
expect(completed).toHaveAttribute("title", "Missing: Company");
expect(completed).toHaveAttribute("title", "Fill in Vendor before completing this work order.");
});
it("allows Completed after a vendor patch clears leftover refused status", () => {

View file

@ -52,6 +52,7 @@ describe("WizardStepTypeSchedule — Schedule On range selection", () => {
scheduleWeekOnly: false,
targetWeek: "",
isAddOnOverride: false,
status: "Scheduled",
});
});
@ -86,6 +87,7 @@ describe("WizardStepTypeSchedule — Schedule On range selection", () => {
scheduleWeekOnly: false,
targetWeek: "",
isAddOnOverride: false,
status: "Scheduled",
});
});

View file

@ -19,28 +19,25 @@ describe("WizardStepVendorTime Appt Time", () => {
});
});
describe("WizardStepVendorTime closability gate", () => {
it("disables Completed when closability requirements are missing", () => {
const onUpdate = vi.fn();
describe("WizardStepVendorTime creation statuses (AAP)", () => {
it("offers only Incomplete, Pending, and Scheduled", () => {
render(
<WizardStepVendorTime
draft={{ ...EMPTY_WIZARD_DRAFT, type: "PM", status: "Scheduled" }}
draft={{ ...EMPTY_WIZARD_DRAFT, type: "PM", status: "Incomplete" }}
vendors={[]}
onUpdate={onUpdate}
onUpdate={vi.fn()}
onPatch={vi.fn()}
/>,
);
fireEvent.click(screen.getByRole("button", { name: /scheduled/i }));
const completed = screen.getByRole("button", { name: /^completed$/i });
expect(completed).toBeDisabled();
expect(completed).toHaveAttribute("title", expect.stringContaining("Missing:"));
fireEvent.click(completed);
expect(onUpdate).not.toHaveBeenCalledWith("status", "Completed");
fireEvent.click(screen.getByRole("button", { name: /incomplete/i }));
expect(screen.getByRole("button", { name: /^pending$/i })).toBeInTheDocument();
expect(screen.getByRole("button", { name: /^scheduled$/i })).toBeInTheDocument();
expect(screen.queryByRole("button", { name: /^completed$/i })).not.toBeInTheDocument();
expect(screen.queryByRole("button", { name: /^en route$/i })).not.toBeInTheDocument();
});
it("allows selecting a non-Completed status", () => {
it("disables Scheduled without a concrete date", () => {
const onUpdate = vi.fn();
render(
<WizardStepVendorTime
@ -51,10 +48,54 @@ describe("WizardStepVendorTime closability gate", () => {
/>,
);
fireEvent.click(screen.getByRole("button", { name: /incomplete/i }));
const scheduled = screen.getByRole("button", { name: /^scheduled$/i });
expect(scheduled).toBeDisabled();
expect(scheduled).toHaveAttribute(
"title",
"Set a Schedule On date before marking this work order as Scheduled.",
);
fireEvent.click(scheduled);
expect(onUpdate).not.toHaveBeenCalledWith("status", "Scheduled");
});
it("allows selecting a non-Completed status", () => {
const onPatch = vi.fn();
render(
<WizardStepVendorTime
draft={{ ...EMPTY_WIZARD_DRAFT, type: "PM", status: "Incomplete" }}
vendors={[]}
onUpdate={vi.fn()}
onPatch={onPatch}
/>,
);
fireEvent.click(screen.getByRole("button", { name: /incomplete/i }));
const menu = screen.getByRole("button", { name: /^pending$/i }).closest("div");
expect(menu).not.toBeNull();
fireEvent.click(within(menu!.parentElement!).getByRole("button", { name: /^pending$/i }));
expect(onUpdate).toHaveBeenCalledWith("status", "Pending");
expect(onPatch).toHaveBeenCalledWith({ status: "Pending" });
});
it("promotes Pending to Scheduled when picked on a dated draft", () => {
const onPatch = vi.fn();
render(
<WizardStepVendorTime
draft={{
...EMPTY_WIZARD_DRAFT,
type: "PM",
status: "Scheduled",
scheduledOn: "2026-06-01",
scheduleWeekOnly: false,
}}
vendors={[]}
onUpdate={vi.fn()}
onPatch={onPatch}
/>,
);
fireEvent.click(screen.getByRole("button", { name: /scheduled/i }));
fireEvent.click(screen.getByRole("button", { name: /^pending$/i }));
expect(onPatch).toHaveBeenCalledWith({ status: "Scheduled" });
});
});

View file

@ -28,6 +28,7 @@ import {
applySchedulePatch,
useWorkOrderTableMutations,
} from "@/domain/work-orders/use-cases/use-work-order-table-mutations";
import { queryKeys } from "@/infra/query-key/query-key";
import { toVendorTablePatch } from "@/domain/work-orders/utils/vendor-assignment-patch";
import {
getClosabilityGaps,
@ -223,6 +224,43 @@ describe("useWorkOrderTableMutations completion-doc persistence", () => {
});
});
describe("maybeAutoSchedule on board patch", () => {
it("optimistically promotes Incomplete to Scheduled when a date is set", async () => {
const rowState = { ...BASE_ROW, status: "Incomplete" as const, scheduledOn: "" };
patchBoardField.mockImplementation(async (_id: unknown, payload: { field: string }) => {
if (payload.field === "lifecycleStatus") {
return { ...rowState, status: "Scheduled", scheduledOn: "2026-07-15" };
}
return { ...rowState, scheduledOn: "2026-07-15" };
});
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));
});
expect(onPatch).toHaveBeenCalledWith(
42,
expect.objectContaining({ scheduledOn: "2026-07-15", status: "Scheduled" }),
);
await waitFor(() => expect(patchBoardField).toHaveBeenCalled());
expect(patchBoardField).toHaveBeenCalledWith(
42,
expect.objectContaining({ field: "lifecycleStatus", value: "Scheduled" }),
);
});
});
describe("applySchedulePatch isAddOn optimistic clear", () => {
it("clears isAddOn when schedule is cleared", () => {
expect(applySchedulePatch("", false)).toMatchObject({
@ -642,3 +680,39 @@ describe("useWorkOrderTableMutations apptTime clear", () => {
);
});
});
describe("useWorkOrderTableMutations audit cache refresh", () => {
it("invalidates work-order queries after a successful status transition", async () => {
const client = new QueryClient({
defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
});
const invalidateSpy = vi.spyOn(client, "invalidateQueries");
const nextRow = { ...BASE_ROW, status: "En Route" as const, rowVersion: "wo-v2" };
patchBoardField.mockReset();
patchBoardField.mockResolvedValue(nextRow);
const { result } = renderHook(
() =>
useWorkOrderTableMutations({
onPatch: vi.fn(),
clearPatch: () => undefined,
getRow: () => BASE_ROW,
}),
{
wrapper: ({ children }: { children: ReactNode }) => (
<QueryClientProvider client={client}>{children}</QueryClientProvider>
),
},
);
act(() => {
result.current.patchField(42, { status: "En Route" });
});
await waitFor(() =>
expect(invalidateSpy).toHaveBeenCalledWith(
expect.objectContaining({ queryKey: queryKeys.workOrders.all }),
),
);
});
});

View file

@ -21,7 +21,28 @@ describe("formatBoardAuditAction", () => {
oldValue: "Scheduled",
newValue: "Incomplete",
}),
).toBe('Changed status to "Incomplete"');
).toBe('Changed status from "Scheduled" to "Incomplete"');
});
it("includes the previous status in the audit sentence when present", () => {
const sentence = formatBoardAuditAction({
action: "StatusChanged",
fieldName: "LifecycleStatus",
oldValue: "Pending",
newValue: "En Route",
});
expect(sentence).toBe('Changed status from "Pending" to "En Route"');
});
it("omits from when the previous status is empty", () => {
expect(
formatBoardAuditAction({
action: "StatusChanged",
fieldName: "LifecycleStatus",
oldValue: "",
newValue: "Scheduled",
}),
).toBe('Changed status to "Scheduled"');
});
it("formats WeekRolled / CarriedOver with increment", () => {

View file

@ -0,0 +1,202 @@
import { describe, expect, it } from "vitest";
import { PENDING_UPLIFT_GAP } from "@/domain/work-orders/utils/wo-closability";
import {
BOARD_INLINE_STATUSES,
COMPLETED_DOC_BLOCK_MSG,
COMPLETED_MEDIA_BLOCK_MSG,
CREATION_INLINE_STATUSES,
SCHEDULED_NEEDS_DATE_MSG,
formatAapCompletedBlockTitle,
getStatusOptionState,
hasConcreteScheduleDate,
maybeAutoSchedulePatch,
mergeAutoSchedulePatch,
resolveCreationStatus,
withAutoScheduleStatus,
} from "@/domain/work-orders/utils/wo-lifecycle-transitions";
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
describe("wo-lifecycle-transitions", () => {
it("lists AAP board statuses without Canceled", () => {
expect(BOARD_INLINE_STATUSES).toEqual([
"Incomplete",
"Pending",
"Scheduled",
"En Route",
"On Site",
"In Progress",
"Completed",
"Rescheduled",
"Pending Quote",
]);
expect(BOARD_INLINE_STATUSES).not.toContain("Canceled");
});
it("lists AAP creation statuses only", () => {
expect(CREATION_INLINE_STATUSES).toEqual(["Incomplete", "Pending", "Scheduled"]);
});
it("treats week-only as no concrete schedule date", () => {
expect(hasConcreteScheduleDate({ scheduledOn: "2026-06-01", scheduleWeekOnly: true })).toBe(
false,
);
expect(hasConcreteScheduleDate({ scheduledOn: "2026-06-01", schedMode: "week" })).toBe(false);
expect(hasConcreteScheduleDate({ scheduledOn: "—" })).toBe(false);
expect(hasConcreteScheduleDate({ scheduledOn: "2026-06-01" })).toBe(true);
});
it("disables Scheduled without a concrete date", () => {
expect(getStatusOptionState({ scheduledOn: "" }, "Scheduled", [])).toEqual({
disabled: true,
title: SCHEDULED_NEEDS_DATE_MSG,
});
});
it("promotes Incomplete/Pending when a concrete date exists", () => {
expect(
maybeAutoSchedulePatch({
status: "Incomplete",
scheduledOn: "2026-06-01",
scheduleWeekOnly: false,
}),
).toEqual({ status: "Scheduled" });
expect(
maybeAutoSchedulePatch({
status: "Pending",
scheduledOn: "2026-06-01",
scheduleWeekOnly: false,
}),
).toEqual({ status: "Scheduled" });
});
it("reverts Scheduled without a concrete date to Pending", () => {
expect(
maybeAutoSchedulePatch({
status: "Scheduled",
scheduledOn: "",
scheduleWeekOnly: false,
}),
).toEqual({ status: "Pending" });
});
it("does not auto-change In Progress when a date exists", () => {
expect(
maybeAutoSchedulePatch({
status: "In Progress",
scheduledOn: "2026-06-01",
scheduleWeekOnly: false,
}),
).toEqual({});
});
it("merges auto-schedule onto an existing-row patch", () => {
const row = {
status: "Incomplete",
scheduledOn: "",
scheduleWeekOnly: false,
} as WorkOrderTableRow;
expect(
mergeAutoSchedulePatch(row, { scheduledOn: "2026-06-02", scheduleWeekOnly: false }),
).toEqual({
scheduledOn: "2026-06-02",
scheduleWeekOnly: false,
status: "Scheduled",
});
expect(mergeAutoSchedulePatch(row, { vendorId: "9" })).toEqual({ vendorId: "9" });
expect(
mergeAutoSchedulePatch({ ...row, scheduledOn: "2026-06-01" }, { vendorId: "9" }),
).toEqual({ vendorId: "9" });
});
it("promotes a pre-schedule wizard pick to Scheduled on a dated draft", () => {
const draft = {
status: "Scheduled",
scheduledOn: "2026-06-01",
scheduleWeekOnly: false,
};
expect(withAutoScheduleStatus(draft, { status: "Pending" })).toEqual({ status: "Scheduled" });
expect(withAutoScheduleStatus(draft, { status: "Incomplete" })).toEqual({
status: "Scheduled",
});
});
it("still derives Scheduled when a concrete date is set on a draft", () => {
const draft = {
status: "Pending",
scheduledOn: "",
scheduleWeekOnly: false,
};
expect(
withAutoScheduleStatus(draft, { scheduledOn: "2026-06-02", scheduleWeekOnly: false }),
).toEqual({
scheduledOn: "2026-06-02",
scheduleWeekOnly: false,
status: "Scheduled",
});
});
it("promotes pre-schedule picks on dated rows", () => {
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("promotes a Pending create pick to Scheduled when the draft is dated", () => {
expect(
resolveCreationStatus({
status: "Pending",
scheduledOn: "2026-06-01",
scheduleWeekOnly: false,
}),
).toBe("Scheduled");
});
it("demotes Scheduled without a concrete date at create", () => {
expect(
resolveCreationStatus({
status: "Scheduled",
scheduledOn: "",
scheduleWeekOnly: false,
}),
).toBe("Pending");
});
it("uses AAP completed tooltip hierarchy", () => {
expect(formatAapCompletedBlockTitle(["Dispatcher", "Company"])).toBe(
"Fill in Assigned To, Vendor before completing this work order.",
);
expect(formatAapCompletedBlockTitle(["Service"])).toBe(
"Select a service before completing this work order.",
);
expect(formatAapCompletedBlockTitle(["WO number"])).toBe(
"Assign a real APM number (not SH) before completing this work order.",
);
expect(formatAapCompletedBlockTitle([PENDING_UPLIFT_GAP])).toBe(
"This work order has an uplift pending approval and cannot be completed.",
);
expect(formatAapCompletedBlockTitle(["Completion document"])).toBe(COMPLETED_DOC_BLOCK_MSG);
expect(formatAapCompletedBlockTitle(["At least 1 photo"])).toBe(COMPLETED_MEDIA_BLOCK_MSG);
expect(formatAapCompletedBlockTitle(["Company", PENDING_UPLIFT_GAP])).toBe(
"Fill in Vendor before completing this work order.",
);
});
});