mirror of
https://github.com/Sea-Haven-Industries/shoc-frontend-new.git
synced 2026-10-06 01:52:04 +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 navigate = useNavigate();
|
||||||
const workOrderId = id ?? "";
|
const workOrderId = id ?? "";
|
||||||
const controller = useWorkOrderViewController(workOrderId);
|
const controller = useWorkOrderViewController(workOrderId);
|
||||||
const { wo, isLoading, error, refetch, vendorEditor } = controller;
|
const { wo, isLoading, isClosabilityReady, error, refetch, vendorEditor } = controller;
|
||||||
|
|
||||||
if (isLoading && !wo) {
|
if (isLoading && !wo) {
|
||||||
return <DetailPageSkeleton variant="view" />;
|
return <DetailPageSkeleton variant="view" />;
|
||||||
|
|
@ -48,7 +48,10 @@ export default function WorkOrderViewPage() {
|
||||||
|
|
||||||
const handleStatusChange = (status: WorkOrderStatus) => {
|
const handleStatusChange = (status: WorkOrderStatus) => {
|
||||||
const isCompletionStatus = isCompletionStatusLabel(status);
|
const isCompletionStatus = isCompletionStatusLabel(status);
|
||||||
if (isCompletionStatus && getClosabilityGaps(detailToClosabilityInput(wo)).length > 0) {
|
if (
|
||||||
|
isCompletionStatus &&
|
||||||
|
(!isClosabilityReady || getClosabilityGaps(detailToClosabilityInput(wo)).length > 0)
|
||||||
|
) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
controller.statusMutation.mutate(status);
|
controller.statusMutation.mutate(status);
|
||||||
|
|
@ -58,6 +61,7 @@ export default function WorkOrderViewPage() {
|
||||||
<Box className="flex flex-col gap-4 p-4">
|
<Box className="flex flex-col gap-4 p-4">
|
||||||
<WorkOrderHeaderSection
|
<WorkOrderHeaderSection
|
||||||
wo={wo}
|
wo={wo}
|
||||||
|
isClosabilityReady={isClosabilityReady}
|
||||||
statusAnchor={controller.statusAnchor}
|
statusAnchor={controller.statusAnchor}
|
||||||
onStatusAnchorChange={controller.setStatusAnchor}
|
onStatusAnchorChange={controller.setStatusAnchor}
|
||||||
onStatusChange={handleStatusChange}
|
onStatusChange={handleStatusChange}
|
||||||
|
|
|
||||||
|
|
@ -23,8 +23,12 @@ const STATUS_COLORS: Record<
|
||||||
Cancelled: "default",
|
Cancelled: "default",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const CLOSABILITY_UNAVAILABLE_TITLE = "Unable to verify completion requirements";
|
||||||
|
|
||||||
interface WorkOrderHeaderSectionProps {
|
interface WorkOrderHeaderSectionProps {
|
||||||
wo: WorkOrderDetail;
|
wo: WorkOrderDetail;
|
||||||
|
/** False while board detail is pending/errored — fail closed for Done. */
|
||||||
|
isClosabilityReady: boolean;
|
||||||
statusAnchor: HTMLElement | null;
|
statusAnchor: HTMLElement | null;
|
||||||
onStatusAnchorChange: (anchor: HTMLElement | null) => void;
|
onStatusAnchorChange: (anchor: HTMLElement | null) => void;
|
||||||
onStatusChange: (status: WorkOrderStatus) => void;
|
onStatusChange: (status: WorkOrderStatus) => void;
|
||||||
|
|
@ -35,6 +39,7 @@ interface WorkOrderHeaderSectionProps {
|
||||||
|
|
||||||
export function WorkOrderHeaderSection({
|
export function WorkOrderHeaderSection({
|
||||||
wo,
|
wo,
|
||||||
|
isClosabilityReady,
|
||||||
statusAnchor,
|
statusAnchor,
|
||||||
onStatusAnchorChange,
|
onStatusAnchorChange,
|
||||||
onStatusChange,
|
onStatusChange,
|
||||||
|
|
@ -83,15 +88,17 @@ export function WorkOrderHeaderSection({
|
||||||
>
|
>
|
||||||
{WORK_ORDER_STATUS_OPTIONS.map((status) => {
|
{WORK_ORDER_STATUS_OPTIONS.map((status) => {
|
||||||
const isCompletionStatus = isCompletionStatusLabel(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 (
|
return (
|
||||||
<MenuItem
|
<MenuItem
|
||||||
key={status}
|
key={status}
|
||||||
selected={status === wo.status}
|
selected={status === wo.status}
|
||||||
disabled={isCompletedBlocked}
|
disabled={isCompletedBlocked}
|
||||||
title={
|
title={isCompletedBlocked ? blockedTitle : undefined}
|
||||||
isCompletedBlocked ? formatClosabilityMissingTitle(closabilityGaps) : undefined
|
|
||||||
}
|
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
if (isCompletedBlocked) {
|
if (isCompletedBlocked) {
|
||||||
return;
|
return;
|
||||||
|
|
|
||||||
|
|
@ -16,7 +16,10 @@ export type WorkOrderActivityTab = "customer" | "internal" | "audit";
|
||||||
|
|
||||||
export function useWorkOrderViewController(workOrderId: string) {
|
export function useWorkOrderViewController(workOrderId: string) {
|
||||||
const { data: detail, isLoading, error, refetch } = useWorkOrderDetail(workOrderId);
|
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 { data: users = [] } = useWorkOrderUsers();
|
||||||
const statusMutation = useChangeWorkOrderStatus(workOrderId);
|
const statusMutation = useChangeWorkOrderStatus(workOrderId);
|
||||||
const assignmentMutation = useChangeWorkOrderAssignment(workOrderId);
|
const assignmentMutation = useChangeWorkOrderAssignment(workOrderId);
|
||||||
|
|
@ -63,6 +66,7 @@ export function useWorkOrderViewController(workOrderId: string) {
|
||||||
return {
|
return {
|
||||||
wo,
|
wo,
|
||||||
isLoading,
|
isLoading,
|
||||||
|
isClosabilityReady,
|
||||||
error,
|
error,
|
||||||
refetch,
|
refetch,
|
||||||
users,
|
users,
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ import { fireEvent, render, screen } from "@testing-library/react";
|
||||||
import { MemoryRouter } from "react-router";
|
import { MemoryRouter } from "react-router";
|
||||||
import { describe, expect, it, vi } from "vitest";
|
import { describe, expect, it, vi } from "vitest";
|
||||||
import { WorkOrderHeaderSection } from "@/app/(protected)/workorders/_components/view/work-order-header-section";
|
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";
|
import type { WorkOrderDetail } from "@/domain/work-orders/types/work-order-detail";
|
||||||
|
|
||||||
const incompleteDetail = {
|
const incompleteDetail = {
|
||||||
|
|
@ -36,23 +37,55 @@ const incompleteDetail = {
|
||||||
dispatches: [],
|
dispatches: [],
|
||||||
} as WorkOrderDetail;
|
} 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", () => {
|
describe("WorkOrderHeaderSection closability gate", () => {
|
||||||
it("does not submit Done when closability gaps remain", () => {
|
it("does not submit Done when closability gaps remain", () => {
|
||||||
const onStatusChange = vi.fn();
|
const onStatusChange = vi.fn();
|
||||||
const onStatusAnchorChange = vi.fn();
|
const onStatusAnchorChange = vi.fn();
|
||||||
|
|
||||||
const { rerender } = render(
|
const { rerender } = render(
|
||||||
<MemoryRouter>
|
renderHeader({
|
||||||
<WorkOrderHeaderSection
|
wo: incompleteDetail,
|
||||||
wo={incompleteDetail}
|
isClosabilityReady: true,
|
||||||
statusAnchor={null}
|
statusAnchor: null,
|
||||||
onStatusAnchorChange={onStatusAnchorChange}
|
onStatusAnchorChange,
|
||||||
onStatusChange={onStatusChange}
|
onStatusChange,
|
||||||
onDispatch={vi.fn()}
|
}),
|
||||||
onEdit={vi.fn()}
|
|
||||||
onBack={vi.fn()}
|
|
||||||
/>
|
|
||||||
</MemoryRouter>,
|
|
||||||
);
|
);
|
||||||
|
|
||||||
fireEvent.click(screen.getByText("Open"));
|
fireEvent.click(screen.getByText("Open"));
|
||||||
|
|
@ -60,17 +93,13 @@ describe("WorkOrderHeaderSection closability gate", () => {
|
||||||
expect(anchor).toBeTruthy();
|
expect(anchor).toBeTruthy();
|
||||||
|
|
||||||
rerender(
|
rerender(
|
||||||
<MemoryRouter>
|
renderHeader({
|
||||||
<WorkOrderHeaderSection
|
wo: incompleteDetail,
|
||||||
wo={incompleteDetail}
|
isClosabilityReady: true,
|
||||||
statusAnchor={anchor}
|
statusAnchor: anchor,
|
||||||
onStatusAnchorChange={onStatusAnchorChange}
|
onStatusAnchorChange,
|
||||||
onStatusChange={onStatusChange}
|
onStatusChange,
|
||||||
onDispatch={vi.fn()}
|
}),
|
||||||
onEdit={vi.fn()}
|
|
||||||
onBack={vi.fn()}
|
|
||||||
/>
|
|
||||||
</MemoryRouter>,
|
|
||||||
);
|
);
|
||||||
|
|
||||||
const done = screen.getByRole("menuitem", { name: /^Done$/i });
|
const done = screen.getByRole("menuitem", { name: /^Done$/i });
|
||||||
|
|
@ -78,4 +107,39 @@ describe("WorkOrderHeaderSection closability gate", () => {
|
||||||
fireEvent.click(done);
|
fireEvent.click(done);
|
||||||
expect(onStatusChange).not.toHaveBeenCalledWith("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