shoc-frontend-new/src/domain/work-orders/utils/wo-lifecycle-transitions.ts
Arthur Bassi 3ed8600beb fix(work-orders): promote dated pre-schedule picks to Scheduled
Align create, wizard, and board status patches with AAP maybeAutoSchedule.
Include the previous status in audit copy when the API sends it.
2026-09-04 14:31:08 -03:00

195 lines
6 KiB
TypeScript

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;
}