fix(work-orders): fail closed until board detail closability is ready

This commit is contained in:
arthur.bassi 2026-08-12 15:16:50 -03:00
parent 5e3b7b8057
commit d701befc42
4 changed files with 108 additions and 29 deletions

View file

@ -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 <DetailPageSkeleton variant="view" />;
@ -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() {
<Box className="flex flex-col gap-4 p-4">
<WorkOrderHeaderSection
wo={wo}
isClosabilityReady={isClosabilityReady}
statusAnchor={controller.statusAnchor}
onStatusAnchorChange={controller.setStatusAnchor}
onStatusChange={handleStatusChange}

View file

@ -23,8 +23,12 @@ const STATUS_COLORS: Record<
Cancelled: "default",
};
const CLOSABILITY_UNAVAILABLE_TITLE = "Unable to verify completion requirements";
interface WorkOrderHeaderSectionProps {
wo: WorkOrderDetail;
/** False while board detail is pending/errored — fail closed for Done. */
isClosabilityReady: boolean;
statusAnchor: HTMLElement | null;
onStatusAnchorChange: (anchor: HTMLElement | null) => 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 (
<MenuItem
key={status}
selected={status === wo.status}
disabled={isCompletedBlocked}
title={
isCompletedBlocked ? formatClosabilityMissingTitle(closabilityGaps) : undefined
}
title={isCompletedBlocked ? blockedTitle : undefined}
onClick={() => {
if (isCompletedBlocked) {
return;

View file

@ -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,

View file

@ -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 (
<MemoryRouter>
<WorkOrderHeaderSection
wo={props.wo}
isClosabilityReady={props.isClosabilityReady}
statusAnchor={props.statusAnchor}
onStatusAnchorChange={props.onStatusAnchorChange}
onStatusChange={props.onStatusChange}
onDispatch={vi.fn()}
onEdit={vi.fn()}
onBack={vi.fn()}
/>
</MemoryRouter>
);
}
describe("WorkOrderHeaderSection closability gate", () => {
it("does not submit Done when closability gaps remain", () => {
const onStatusChange = vi.fn();
const onStatusAnchorChange = vi.fn();
const { rerender } = render(
<MemoryRouter>
<WorkOrderHeaderSection
wo={incompleteDetail}
statusAnchor={null}
onStatusAnchorChange={onStatusAnchorChange}
onStatusChange={onStatusChange}
onDispatch={vi.fn()}
onEdit={vi.fn()}
onBack={vi.fn()}
/>
</MemoryRouter>,
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(
<MemoryRouter>
<WorkOrderHeaderSection
wo={incompleteDetail}
statusAnchor={anchor}
onStatusAnchorChange={onStatusAnchorChange}
onStatusChange={onStatusChange}
onDispatch={vi.fn()}
onEdit={vi.fn()}
onBack={vi.fn()}
/>
</MemoryRouter>,
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");
});
});