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..c4bc18c9 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 { 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, 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 d55176bd..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 @@ -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} - Update Schedule On → + {PAST_DUE_RESCHEDULE_CTA} @@ -124,6 +128,7 @@ export function StatusCell({ onPastDueAttempt, onRequestComplete, readOnly = false, + statusOptions = BOARD_INLINE_STATUSES, }: StatusCellProps) { const [anchorEl, setAnchorEl] = useState(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 ( { - 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]" } > diff --git a/src/app/(protected)/workorders/_components/list/table/to-inline-draft-row.ts b/src/app/(protected)/workorders/_components/list/table/to-inline-draft-row.ts index 283b4ebd..7e7a7a78 100644 --- a/src/app/(protected)/workorders/_components/list/table/to-inline-draft-row.ts +++ b/src/app/(protected)/workorders/_components/list/table/to-inline-draft-row.ts @@ -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, 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 8f6482ea..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,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({ onPatch({ status })} onOpenSlideOver={() => {}} onRequestComplete={onRequestComplete} 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/app/(protected)/workorders/_components/wizard/wizard-step-vendor-time.tsx b/src/app/(protected)/workorders/_components/wizard/wizard-step-vendor-time.tsx index f009b9d7..1fbd6142 100644 --- a/src/app/(protected)/workorders/_components/wizard/wizard-step-vendor-time.tsx +++ b/src/app/(protected)/workorders/_components/wizard/wizard-step-vendor-time.tsx @@ -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: , - 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={} - 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 })); }} /> - setConfirmComplete(false)} - onKeepOpen={() => setConfirmComplete(false)} - onConfirm={() => { - onUpdate("status", "Completed"); - setConfirmComplete(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 713ccd84..f77d44a9 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 @@ -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 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)}`; } diff --git a/src/domain/work-orders/utils/wo-lifecycle-transitions.ts b/src/domain/work-orders/utils/wo-lifecycle-transitions.ts new file mode 100644 index 00000000..264344eb --- /dev/null +++ b/src/domain/work-orders/utils/wo-lifecycle-transitions.ts @@ -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(["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( + current: T, + schedulePatch: Partial & ScheduleDateInput, +): Partial { + 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; +} 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..674a9b63 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,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"); + }); }); diff --git a/src/test/app/(protected)/workorders/status-cell-closability.test.tsx b/src/test/app/(protected)/workorders/status-cell-closability.test.tsx index 828d260f..31c90363 100644 --- a/src/test/app/(protected)/workorders/status-cell-closability.test.tsx +++ b/src/test/app/(protected)/workorders/status-cell-closability.test.tsx @@ -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(); + + 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( + , + ); + + 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( + , + ); + + 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(); + }); }); diff --git a/src/test/app/(protected)/workorders/status-cell-past-due-copy.test.tsx b/src/test/app/(protected)/workorders/status-cell-past-due-copy.test.tsx index df022f15..f55a67ea 100644 --- a/src/test/app/(protected)/workorders/status-cell-past-due-copy.test.tsx +++ b/src/test/app/(protected)/workorders/status-cell-past-due-copy.test.tsx @@ -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(); 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(); }); }); diff --git a/src/test/app/(protected)/workorders/status-cell-pending-uplift.test.tsx b/src/test/app/(protected)/workorders/status-cell-pending-uplift.test.tsx index dd2f9d58..69b76209 100644 --- a/src/test/app/(protected)/workorders/status-cell-pending-uplift.test.tsx +++ b/src/test/app/(protected)/workorders/status-cell-pending-uplift.test.tsx @@ -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", () => { diff --git a/src/test/app/(protected)/workorders/wizard-schedule-date-range.test.tsx b/src/test/app/(protected)/workorders/wizard-schedule-date-range.test.tsx index a79ac2e6..11c2d05d 100644 --- a/src/test/app/(protected)/workorders/wizard-schedule-date-range.test.tsx +++ b/src/test/app/(protected)/workorders/wizard-schedule-date-range.test.tsx @@ -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", }); }); diff --git a/src/test/app/(protected)/workorders/wizard-step-vendor-time-closability.test.tsx b/src/test/app/(protected)/workorders/wizard-step-vendor-time-closability.test.tsx index e2d04458..9280a735 100644 --- a/src/test/app/(protected)/workorders/wizard-step-vendor-time-closability.test.tsx +++ b/src/test/app/(protected)/workorders/wizard-step-vendor-time-closability.test.tsx @@ -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( , ); - 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( { />, ); + 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( + , + ); + 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( + , + ); + + fireEvent.click(screen.getByRole("button", { name: /scheduled/i })); + fireEvent.click(screen.getByRole("button", { name: /^pending$/i })); + expect(onPatch).toHaveBeenCalledWith({ status: "Scheduled" }); }); }); 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 4d1c374c..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 @@ -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 }) => ( + {children} + ), + }, + ); + + act(() => { + result.current.patchField(42, { status: "En Route" }); + }); + + await waitFor(() => + expect(invalidateSpy).toHaveBeenCalledWith( + expect.objectContaining({ queryKey: queryKeys.workOrders.all }), + ), + ); + }); +}); diff --git a/src/test/domain/work-orders/utils/format-board-audit-action.test.ts b/src/test/domain/work-orders/utils/format-board-audit-action.test.ts index 7e8da248..3ba0ad1b 100644 --- a/src/test/domain/work-orders/utils/format-board-audit-action.test.ts +++ b/src/test/domain/work-orders/utils/format-board-audit-action.test.ts @@ -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", () => { 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 new file mode 100644 index 00000000..5e99b7a1 --- /dev/null +++ b/src/test/domain/work-orders/utils/wo-lifecycle-transitions.test.ts @@ -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.", + ); + }); +});