From f6a22a90965f70c88d1ca69628d2ff857acd113b Mon Sep 17 00:00:00 2001 From: Codex Review Integration Date: Wed, 16 Sep 2026 11:43:34 -0300 Subject: [PATCH 1/2] fix(work-orders): offer only valid manual status transitions inline (SH-357, SH-358) The inline status control no longer offers the automatic Incomplete and Scheduled states or the work order's current status. Inline create keeps its explicit Incomplete/Pending/Scheduled creation list. --- .../list/table/cells/status-cell.tsx | 8 ++- .../utils/wo-lifecycle-transitions.ts | 17 +++++- .../status-cell-closability.test.tsx | 56 ++++++++++++++++--- .../utils/wo-lifecycle-transitions.test.ts | 21 ++++++- 4 files changed, 87 insertions(+), 15 deletions(-) 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 b9331330..0f0ceb50 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 @@ -9,10 +9,10 @@ import { tableRowToClosabilityInput, } from "@/domain/work-orders/utils/wo-closability"; import { - BOARD_INLINE_STATUSES, PAST_DUE_BADGE_TOOLTIP, PAST_DUE_POPOVER_BODY, PAST_DUE_RESCHEDULE_CTA, + getInlineStatusTransitions, getStatusOptionState, } from "@/domain/work-orders/utils/wo-lifecycle-transitions"; import { HoverChevron } from "./hover-chevron"; @@ -27,6 +27,7 @@ type StatusCellProps = { onPastDueAttempt?: () => void; onRequestComplete?: () => void; readOnly?: boolean; + /** Explicit list for inline create; existing WOs derive valid transitions from `wo.status`. */ statusOptions?: readonly WizardWOStatus[]; }; @@ -128,10 +129,11 @@ export function StatusCell({ onPastDueAttempt, onRequestComplete, readOnly = false, - statusOptions = BOARD_INLINE_STATUSES, + statusOptions, }: StatusCellProps) { const [anchorEl, setAnchorEl] = useState(null); const closabilityGaps = rowClosabilityGaps(wo); + const options = statusOptions ?? getInlineStatusTransitions(wo.status); if (readOnly) { return ; @@ -169,7 +171,7 @@ export function StatusCell({ onClick={(e) => e.stopPropagation()} slotProps={{ paper: { className: "min-w-[190px] rounded-lg border border-border p-1" } }} > - {statusOptions.map((status) => { + {options.map((status) => { const option = getStatusOptionState(wo, status, closabilityGaps); return ( diff --git a/src/domain/work-orders/utils/wo-lifecycle-transitions.ts b/src/domain/work-orders/utils/wo-lifecycle-transitions.ts index 264344eb..f6ea317c 100644 --- a/src/domain/work-orders/utils/wo-lifecycle-transitions.ts +++ b/src/domain/work-orders/utils/wo-lifecycle-transitions.ts @@ -11,8 +11,21 @@ import { 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; +/** + * SH-169: lifecycle states the server derives (Incomplete from create, Scheduled from Schedule On). + * Past Due is an overlay, not a status. None of these may be picked manually on an existing WO. + */ +export const AUTOMATIC_LIFECYCLE_STATUSES: readonly WizardWOStatus[] = ["Incomplete", "Scheduled"]; + +/** Manually selectable statuses for an existing WO — Canceled is kebab-only (SH-113). */ +export const BOARD_INLINE_STATUSES: readonly WizardWOStatus[] = ALL_WIZARD_STATUSES.filter( + (status) => !AUTOMATIC_LIFECYCLE_STATUSES.includes(status), +); + +/** SH-358: inline options for an existing WO never repeat its current status. */ +export function getInlineStatusTransitions(currentStatus: string): WizardWOStatus[] { + return BOARD_INLINE_STATUSES.filter((status) => status !== currentStatus); +} /** AAP `CREATION_STATUSES` — wizard Step 3 and inline create. */ export const CREATION_INLINE_STATUSES: readonly WizardWOStatus[] = [ 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 31c90363..7f42d257 100644 --- a/src/test/app/(protected)/workorders/status-cell-closability.test.tsx +++ b/src/test/app/(protected)/workorders/status-cell-closability.test.tsx @@ -1,6 +1,7 @@ import { fireEvent, render, screen } from "@testing-library/react"; import { describe, expect, it, vi } from "vitest"; import { StatusCell } from "@/app/(protected)/workorders/_components/list/table/cells/status-cell"; +import type { WizardWOStatus } from "@/domain/work-orders/types/work-order-wizard"; import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row"; const incompleteRow = { @@ -43,21 +44,62 @@ describe("StatusCell closability gate", () => { expect(screen.queryByRole("button", { name: /^canceled$/i })).toBeNull(); }); - it("disables Scheduled when the row has no concrete date", () => { + it.each([ + "Pending", + "En Route", + "On Site", + "In Progress", + "Rescheduled", + "Pending Quote", + ])("offers only manual transitions away from %s (SH-357, SH-358)", (current) => { 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(); + fireEvent.click(screen.getByRole("button", { name: new RegExp(current, "i") })); + const offered = Array.from(document.querySelectorAll(".MuiPopover-paper button")).map( + (button) => button.textContent?.trim(), + ); + + expect(offered).not.toContain("Incomplete"); + expect(offered).not.toContain("Scheduled"); + expect(offered).not.toContain("Past Due"); + expect(offered).not.toContain("Canceled"); + expect(offered).not.toContain(current); + expect(offered).toEqual( + [ + "Pending", + "En Route", + "On Site", + "In Progress", + "Completed", + "Rescheduled", + "Pending Quote", + ].filter((status) => status !== current), + ); + }); + + it("offers every manual status, and no automatic one, from Incomplete or Scheduled", () => { + for (const current of ["Incomplete", "Scheduled"] as const) { + const { unmount } = render( + , + ); + + fireEvent.click(screen.getByRole("button", { name: new RegExp(current, "i") })); + expect(screen.getByRole("button", { name: /^pending$/i })).toBeTruthy(); + expect(screen.queryByRole("button", { name: /^incomplete$/i })).toBeNull(); + expect(screen.queryByRole("button", { name: /^scheduled$/i })).toBeNull(); + unmount(); + } }); it("limits creation options to Incomplete, Pending, and Scheduled", () => { 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 index 5e99b7a1..75ea23c6 100644 --- a/src/test/domain/work-orders/utils/wo-lifecycle-transitions.test.ts +++ b/src/test/domain/work-orders/utils/wo-lifecycle-transitions.test.ts @@ -1,12 +1,14 @@ import { describe, expect, it } from "vitest"; import { PENDING_UPLIFT_GAP } from "@/domain/work-orders/utils/wo-closability"; import { + AUTOMATIC_LIFECYCLE_STATUSES, BOARD_INLINE_STATUSES, COMPLETED_DOC_BLOCK_MSG, COMPLETED_MEDIA_BLOCK_MSG, CREATION_INLINE_STATUSES, SCHEDULED_NEEDS_DATE_MSG, formatAapCompletedBlockTitle, + getInlineStatusTransitions, getStatusOptionState, hasConcreteScheduleDate, maybeAutoSchedulePatch, @@ -17,11 +19,9 @@ import { import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row"; describe("wo-lifecycle-transitions", () => { - it("lists AAP board statuses without Canceled", () => { + it("lists only manual board statuses: no automatic states and no Canceled (SH-169)", () => { expect(BOARD_INLINE_STATUSES).toEqual([ - "Incomplete", "Pending", - "Scheduled", "En Route", "On Site", "In Progress", @@ -30,6 +30,21 @@ describe("wo-lifecycle-transitions", () => { "Pending Quote", ]); expect(BOARD_INLINE_STATUSES).not.toContain("Canceled"); + for (const automatic of AUTOMATIC_LIFECYCLE_STATUSES) { + expect(BOARD_INLINE_STATUSES).not.toContain(automatic); + } + }); + + it("never offers the current status as a transition (SH-358)", () => { + expect(getInlineStatusTransitions("On Site")).toEqual([ + "Pending", + "En Route", + "In Progress", + "Completed", + "Rescheduled", + "Pending Quote", + ]); + expect(getInlineStatusTransitions("Incomplete")).toEqual(BOARD_INLINE_STATUSES); }); it("lists AAP creation statuses only", () => { From e65049691c51e4fe5e0b452a2264813868861baf Mon Sep 17 00:00:00 2001 From: Codex Review Integration Date: Wed, 16 Sep 2026 17:01:58 -0300 Subject: [PATCH 2/2] chore(work-orders): remove ticket keys from lifecycle docs --- src/domain/work-orders/utils/wo-lifecycle-transitions.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/domain/work-orders/utils/wo-lifecycle-transitions.ts b/src/domain/work-orders/utils/wo-lifecycle-transitions.ts index f6ea317c..f0184ef9 100644 --- a/src/domain/work-orders/utils/wo-lifecycle-transitions.ts +++ b/src/domain/work-orders/utils/wo-lifecycle-transitions.ts @@ -12,17 +12,17 @@ import { } from "@/domain/work-orders/utils/wo-closability"; /** - * SH-169: lifecycle states the server derives (Incomplete from create, Scheduled from Schedule On). + * Lifecycle states the server derives (Incomplete from create, Scheduled from Schedule On). * Past Due is an overlay, not a status. None of these may be picked manually on an existing WO. */ export const AUTOMATIC_LIFECYCLE_STATUSES: readonly WizardWOStatus[] = ["Incomplete", "Scheduled"]; -/** Manually selectable statuses for an existing WO — Canceled is kebab-only (SH-113). */ +/** Manually selectable statuses for an existing WO — Canceled is kebab-only. */ export const BOARD_INLINE_STATUSES: readonly WizardWOStatus[] = ALL_WIZARD_STATUSES.filter( (status) => !AUTOMATIC_LIFECYCLE_STATUSES.includes(status), ); -/** SH-358: inline options for an existing WO never repeat its current status. */ +/** Inline options for an existing WO never repeat its current status. */ export function getInlineStatusTransitions(currentStatus: string): WizardWOStatus[] { return BOARD_INLINE_STATUSES.filter((status) => status !== currentStatus); }