mirror of
https://github.com/Sea-Haven-Industries/shoc-frontend-new.git
synced 2026-10-01 08:33:11 +00:00
fix(work-orders): fail closed until board detail closability is ready
This commit is contained in:
parent
5e3b7b8057
commit
d701befc42
4 changed files with 108 additions and 29 deletions
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue