diff --git a/src/app/(protected)/workorders/[id].tsx b/src/app/(protected)/workorders/[id].tsx index e30574da..48f0fff0 100644 --- a/src/app/(protected)/workorders/[id].tsx +++ b/src/app/(protected)/workorders/[id].tsx @@ -25,7 +25,7 @@ export default function WorkOrderViewPage() { const navigate = useNavigate(); const workOrderId = id ?? ""; const controller = useWorkOrderViewController(workOrderId); - const { wo, isLoading, error, refetch, vendorEditor } = controller; + const { wo, isLoading, isClosabilityReady, error, refetch, vendorEditor } = controller; if (isLoading && !wo) { return ; @@ -48,7 +48,10 @@ export default function WorkOrderViewPage() { const handleStatusChange = (status: WorkOrderStatus) => { const isCompletionStatus = isCompletionStatusLabel(status); - if (isCompletionStatus && getClosabilityGaps(detailToClosabilityInput(wo)).length > 0) { + if ( + isCompletionStatus && + (!isClosabilityReady || getClosabilityGaps(detailToClosabilityInput(wo)).length > 0) + ) { return; } controller.statusMutation.mutate(status); @@ -58,6 +61,7 @@ export default function WorkOrderViewPage() { void; onStatusChange: (status: WorkOrderStatus) => void; @@ -35,6 +39,7 @@ interface WorkOrderHeaderSectionProps { export function WorkOrderHeaderSection({ wo, + isClosabilityReady, statusAnchor, onStatusAnchorChange, onStatusChange, @@ -83,15 +88,17 @@ export function WorkOrderHeaderSection({ > {WORK_ORDER_STATUS_OPTIONS.map((status) => { const isCompletionStatus = isCompletionStatusLabel(status); - const isCompletedBlocked = isCompletionStatus && closabilityGaps.length > 0; + const isCompletedBlocked = + isCompletionStatus && (!isClosabilityReady || closabilityGaps.length > 0); + const blockedTitle = !isClosabilityReady + ? CLOSABILITY_UNAVAILABLE_TITLE + : formatClosabilityMissingTitle(closabilityGaps); return ( { if (isCompletedBlocked) { return; diff --git a/src/app/(protected)/workorders/_hooks/use-work-order-view-controller.ts b/src/app/(protected)/workorders/_hooks/use-work-order-view-controller.ts index 3a5578f1..64dd5280 100644 --- a/src/app/(protected)/workorders/_hooks/use-work-order-view-controller.ts +++ b/src/app/(protected)/workorders/_hooks/use-work-order-view-controller.ts @@ -16,7 +16,10 @@ export type WorkOrderActivityTab = "customer" | "internal" | "audit"; export function useWorkOrderViewController(workOrderId: string) { const { data: detail, isLoading, error, refetch } = useWorkOrderDetail(workOrderId); - const { data: boardDetail } = useWorkOrderBoardDetail(workOrderId); + const { data: boardDetail, isSuccess: isBoardDetailSuccess } = + useWorkOrderBoardDetail(workOrderId); + /** Fail closed until board detail succeeds — legacy GetById lacks type/doc/media. */ + const isClosabilityReady = isBoardDetailSuccess && boardDetail != null; const { data: users = [] } = useWorkOrderUsers(); const statusMutation = useChangeWorkOrderStatus(workOrderId); const assignmentMutation = useChangeWorkOrderAssignment(workOrderId); @@ -63,6 +66,7 @@ export function useWorkOrderViewController(workOrderId: string) { return { wo, isLoading, + isClosabilityReady, error, refetch, users, diff --git a/src/test/app/(protected)/workorders/work-order-header-closability.test.tsx b/src/test/app/(protected)/workorders/work-order-header-closability.test.tsx index a9b3677a..c979adc0 100644 --- a/src/test/app/(protected)/workorders/work-order-header-closability.test.tsx +++ b/src/test/app/(protected)/workorders/work-order-header-closability.test.tsx @@ -2,6 +2,7 @@ import { fireEvent, render, screen } from "@testing-library/react"; import { MemoryRouter } from "react-router"; import { describe, expect, it, vi } from "vitest"; import { WorkOrderHeaderSection } from "@/app/(protected)/workorders/_components/view/work-order-header-section"; +import type { WorkOrderStatus } from "@/domain/work-orders/types/work-order"; import type { WorkOrderDetail } from "@/domain/work-orders/types/work-order-detail"; const incompleteDetail = { @@ -36,23 +37,55 @@ const incompleteDetail = { dispatches: [], } as WorkOrderDetail; +/** Base gates satisfied, type still empty (unenriched) — race window without board detail. */ +const unenrichedButOtherwiseClosable = { + ...incompleteDetail, + assignedTo: "Alice", + workOrderNumber: "20260623001", + problem: "HVAC", + completedDate: "2026-07-15", + type: "", + docStatus: "", + mediaCount: 0, + dispatches: [{ vendorName: "Acme HVAC" }], +} as WorkOrderDetail; + +function renderHeader(props: { + wo: WorkOrderDetail; + isClosabilityReady: boolean; + statusAnchor: HTMLElement | null; + onStatusAnchorChange: (anchor: HTMLElement | null) => void; + onStatusChange: (status: WorkOrderStatus) => void; +}) { + return ( + + + + ); +} + describe("WorkOrderHeaderSection closability gate", () => { it("does not submit Done when closability gaps remain", () => { const onStatusChange = vi.fn(); const onStatusAnchorChange = vi.fn(); const { rerender } = render( - - - , + renderHeader({ + wo: incompleteDetail, + isClosabilityReady: true, + statusAnchor: null, + onStatusAnchorChange, + onStatusChange, + }), ); fireEvent.click(screen.getByText("Open")); @@ -60,17 +93,13 @@ describe("WorkOrderHeaderSection closability gate", () => { expect(anchor).toBeTruthy(); rerender( - - - , + renderHeader({ + wo: incompleteDetail, + isClosabilityReady: true, + statusAnchor: anchor, + onStatusAnchorChange, + onStatusChange, + }), ); const done = screen.getByRole("menuitem", { name: /^Done$/i }); @@ -78,4 +107,39 @@ describe("WorkOrderHeaderSection closability gate", () => { fireEvent.click(done); expect(onStatusChange).not.toHaveBeenCalledWith("Done"); }); + + it("does not submit Done while board closability is not ready", () => { + const onStatusChange = vi.fn(); + const onStatusAnchorChange = vi.fn(); + + const { rerender } = render( + renderHeader({ + wo: unenrichedButOtherwiseClosable, + isClosabilityReady: false, + statusAnchor: null, + onStatusAnchorChange, + onStatusChange, + }), + ); + + fireEvent.click(screen.getByText("Open")); + const anchor = onStatusAnchorChange.mock.calls[0]?.[0] as HTMLElement; + expect(anchor).toBeTruthy(); + + rerender( + renderHeader({ + wo: unenrichedButOtherwiseClosable, + isClosabilityReady: false, + statusAnchor: anchor, + onStatusAnchorChange, + onStatusChange, + }), + ); + + const done = screen.getByRole("menuitem", { name: /^Done$/i }); + expect(done.getAttribute("aria-disabled")).toBe("true"); + expect(done).toHaveAttribute("title", "Unable to verify completion requirements"); + fireEvent.click(done); + expect(onStatusChange).not.toHaveBeenCalledWith("Done"); + }); });