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..a9ee2647 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); @@ -164,20 +169,17 @@ export function StatusCell({ onClick={(e) => e.stopPropagation()} slotProps={{ paper: { className: "min-w-[168px] 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,7 +191,7 @@ export function StatusCell({ setAnchorEl(null); }} className={ - isCompletedBlocked + 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]" } 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..4f4c9f51 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,7 @@ 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 { StatusCell } from "./cells/status-cell"; import { TypeCell } from "./cells/type-cell"; import { VendorCell } from "./cells/vendor-cell"; @@ -215,6 +216,7 @@ export function WoTableInlineRowCells({ onPatch({ status })} onOpenSlideOver={() => {}} onRequestComplete={onRequestComplete} 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..8e39022d 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,14 @@ 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, +} 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 +173,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 +192,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 +271,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); }} /> - 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..e0a68614 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 = { @@ -125,9 +131,12 @@ export function useWorkOrderTableMutations( const patchMutation = useMutation({ meta: { suppressErrorToast: true }, mutationFn: ({ id, patch }: { id: string | number; patch: WorkOrderTablePatch }) => - enqueuePatch(id, () => runWorkOrderTablePatch(id, patch, getRow)), + enqueuePatch(id, () => { + const next = mergeAutoSchedulePatch(getRow?.(id), patch); + return runWorkOrderTablePatch(id, next, getRow); + }), onMutate: async ({ id, patch }) => { - onPatch(id, patch); + onPatch(id, mergeAutoSchedulePatch(getRow?.(id), patch)); }, onSuccess: (result, { id }) => { if (result.kind === "board") { @@ -189,19 +198,9 @@ export function tableRowFromDetail(detail: Awaited(["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: Pick, +): 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) }; +} 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-step-vendor-time-closability.test.tsx b/src/test/app/(protected)/workorders/wizard-step-vendor-time-closability.test.tsx index e2d04458..2d0654e7 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,25 +19,44 @@ describe("WizardStepVendorTime Appt Time", () => { }); }); -describe("WizardStepVendorTime closability gate", () => { - it("disables Completed when closability requirements are missing", () => { +describe("WizardStepVendorTime creation statuses (AAP)", () => { + it("offers only Incomplete, Pending, and Scheduled", () => { + render( + , + ); + + 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("disables Scheduled without a concrete date", () => { const onUpdate = vi.fn(); 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 })); + 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", () => { 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..6015695b 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,39 @@ 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()); + }); +}); + describe("applySchedulePatch isAddOn optimistic clear", () => { it("clears isAddOn when schedule is cleared", () => { expect(applySchedulePatch("", false)).toMatchObject({ @@ -642,3 +676,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..77cbb059 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 @@ -24,6 +24,18 @@ describe("formatBoardAuditAction", () => { ).toBe('Changed status to "Incomplete"'); }); + it("does not include the previous status in the audit sentence", () => { + const sentence = formatBoardAuditAction({ + action: "StatusChanged", + fieldName: "LifecycleStatus", + oldValue: "Pending", + newValue: "En Route", + }); + expect(sentence).toBe('Changed status to "En Route"'); + expect(sentence).not.toMatch(/from/i); + expect(sentence).not.toContain("Pending"); + }); + it("formats WeekRolled / CarriedOver with increment", () => { expect( formatBoardAuditAction({ 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..f8150706 --- /dev/null +++ b/src/test/domain/work-orders/utils/wo-lifecycle-transitions.test.ts @@ -0,0 +1,125 @@ +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, +} 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" }); + }); + + 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.", + ); + }); +});