fix(work-orders): compare completion labels via string helper

[recover] remove malicious eslint payload (was 19dfa0f9)
This commit is contained in:
Arthur Bassi 2026-08-10 17:12:14 -03:00
parent 6fe1cd0f27
commit c850be2aeb
15 changed files with 631 additions and 53 deletions

View file

@ -14,7 +14,12 @@ import { WorkOrderDispatchesAccordion } from "@/app/(protected)/workorders/_comp
import { WorkOrderActivityPanel } from "@/app/(protected)/workorders/_components/view/work-order-activity-panel";
import { WorkOrderVendorEditorDialog } from "@/app/(protected)/workorders/_components/view/work-order-vendor-editor-dialog";
import { useWorkOrderViewController } from "@/app/(protected)/workorders/_hooks/use-work-order-view-controller";
import {
detailToClosabilityInput,
getClosabilityGaps,
isCompletionStatusLabel,
} from "@/domain/work-orders/utils/wo-closability";
import type { WorkOrderStatus } from "@/domain/work-orders/types/work-order";
export default function WorkOrderViewPage() {
const { id } = useParams();
const navigate = useNavigate();
@ -41,13 +46,21 @@ export default function WorkOrderViewPage() {
return <DetailPageSkeleton variant="view" />;
}
const handleStatusChange = (status: WorkOrderStatus) => {
const isCompletionStatus = isCompletionStatusLabel(status);
if (isCompletionStatus && getClosabilityGaps(detailToClosabilityInput(wo)).length > 0) {
return;
}
controller.statusMutation.mutate(status);
};
return (
<Box className="flex flex-col gap-4 p-4">
<WorkOrderHeaderSection
wo={wo}
statusAnchor={controller.statusAnchor}
onStatusAnchorChange={controller.setStatusAnchor}
onStatusChange={(status) => controller.statusMutation.mutate(status)}
onStatusChange={handleStatusChange}
onDispatch={() => controller.setDispatchOpen(true)}
onEdit={() => navigate(`/workorders/edit/${id}`)}
onBack={() => navigate("/workorders")}

View file

@ -2,20 +2,29 @@ import { Menu, MenuItem } from "@mui/material";
import { WorkOrderStatusBadge } from "@/components/domain/status-badge";
import { MenuAnchorButton } from "@/components/ui/menu-anchor-button";
import { WORK_ORDER_STATUS_OPTIONS } from "@/domain/work-orders/types/work-order";
import {
formatClosabilityMissingTitle,
getClosabilityGaps,
isCompletionStatusLabel,
type WoClosabilityInput,
} from "@/domain/work-orders/utils/wo-closability";
import { useMenuAnchor } from "@/hooks/use-menu-anchor";
type WorkOrderStatusMenuProps = {
status: string;
disabled?: boolean;
closability: WoClosabilityInput;
onStatusChange: (status: string) => void;
};
export function WorkOrderStatusMenu({
status,
disabled = false,
closability,
onStatusChange,
}: WorkOrderStatusMenuProps) {
const { anchorEl, open, setAnchor, close } = useMenuAnchor();
const closabilityGaps = getClosabilityGaps(closability);
return (
<>
@ -29,21 +38,31 @@ export function WorkOrderStatusMenu({
<WorkOrderStatusBadge status={status} />
</MenuAnchorButton>
<Menu anchorEl={anchorEl} open={open} onClose={close}>
{WORK_ORDER_STATUS_OPTIONS.map((option) => (
<MenuItem
key={option}
selected={option === status}
disabled={disabled}
onClick={() => {
close();
if (option !== status) {
onStatusChange(option);
{WORK_ORDER_STATUS_OPTIONS.map((option) => {
const isCompletionStatus = isCompletionStatusLabel(option);
const isCompletedBlocked = isCompletionStatus && closabilityGaps.length > 0;
return (
<MenuItem
key={option}
selected={option === status}
disabled={disabled || isCompletedBlocked}
title={
isCompletedBlocked ? formatClosabilityMissingTitle(closabilityGaps) : undefined
}
}}
>
{option}
</MenuItem>
))}
onClick={() => {
if (isCompletedBlocked) {
return;
}
close();
if (option !== status) {
onStatusChange(option);
}
}}
>
{option}
</MenuItem>
);
})}
</Menu>
</>
);

View file

@ -11,6 +11,10 @@ import {
} from "@/domain/work-orders/types/work-order-wizard";
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
import {
formatClosabilityMissingTitle,
getClosabilityGaps,
} from "@/domain/work-orders/utils/wo-closability";
import { HoverChevron } from "./hover-chevron";
@ -36,6 +40,7 @@ export function StatusCell({
onPastDueAttempt,
}: StatusCellProps) {
const [anchorEl, setAnchorEl] = useState<HTMLElement | null>(null);
const closabilityGaps = getClosabilityGaps(wo);
if (wo.isPastDue) {
return (
@ -135,24 +140,39 @@ export function StatusCell({
onClick={(e) => e.stopPropagation()}
slotProps={{ paper: { className: "min-w-[168px] rounded-lg border border-border p-1" } }}
>
{ALL_WIZARD_STATUSES.map((status) => (
<button
key={status}
type="button"
onClick={() => {
onChangeStatus(status);
{ALL_WIZARD_STATUSES.map((status) => {
const isCompletedBlocked = status === "Completed" && closabilityGaps.length > 0;
const missingTitle = isCompletedBlocked
? formatClosabilityMissingTitle(closabilityGaps)
: undefined;
setAnchorEl(null);
}}
className="flex w-full items-center gap-2 rounded px-2 py-1.5 hover:bg-[#F9FAFB]"
>
<WoTableStatusLabel status={status} />
return (
<button
key={status}
type="button"
disabled={isCompletedBlocked}
title={missingTitle}
onClick={() => {
if (isCompletedBlocked) {
return;
}
onChangeStatus(status);
setAnchorEl(null);
}}
className={
isCompletedBlocked
? "flex w-full cursor-not-allowed items-center gap-2 rounded px-2 py-1.5 opacity-50"
: "flex w-full items-center gap-2 rounded px-2 py-1.5 hover:bg-[#F9FAFB]"
}
>
<WoTableStatusLabel status={status} />
{wo.status === status && (
<Check size={10} className="ml-auto text-[var(--color-primary)]" />
)}
</button>
))}
{wo.status === status && (
<Check size={10} className="ml-auto text-[var(--color-primary)]" />
)}
</button>
);
})}
</Popover>
</>
);

View file

@ -15,7 +15,9 @@ export function toInlineDraftRow(draft: WorkOrderTableDraft) {
site: draft.site,
location: draft.location,
dispatcherId: draft.dispatcherId,
dispatcherName: "",
scheduledOn: draft.scheduledOn,
completedDate: "",
dueDate: draft.dueDate,
pm: draft.pm,
serviceNotes: draft.serviceNotes,

View file

@ -5,6 +5,12 @@ import {
WORK_ORDER_STATUS_OPTIONS,
type WorkOrderStatus,
} from "@/domain/work-orders/types/work-order";
import {
detailToClosabilityInput,
formatClosabilityMissingTitle,
getClosabilityGaps,
isCompletionStatusLabel,
} from "@/domain/work-orders/utils/wo-closability";
const STATUS_COLORS: Record<
string,
@ -36,6 +42,8 @@ export function WorkOrderHeaderSection({
onEdit,
onBack,
}: WorkOrderHeaderSectionProps) {
const closabilityGaps = getClosabilityGaps(detailToClosabilityInput(wo));
return (
<>
<Breadcrumbs>
@ -73,20 +81,31 @@ export function WorkOrderHeaderSection({
open={Boolean(statusAnchor)}
onClose={() => onStatusAnchorChange(null)}
>
{WORK_ORDER_STATUS_OPTIONS.map((status) => (
<MenuItem
key={status}
selected={status === wo.status}
onClick={() => {
onStatusAnchorChange(null);
if (status !== wo.status) {
onStatusChange(status);
{WORK_ORDER_STATUS_OPTIONS.map((status) => {
const isCompletionStatus = isCompletionStatusLabel(status);
const isCompletedBlocked = isCompletionStatus && closabilityGaps.length > 0;
return (
<MenuItem
key={status}
selected={status === wo.status}
disabled={isCompletedBlocked}
title={
isCompletedBlocked ? formatClosabilityMissingTitle(closabilityGaps) : undefined
}
}}
>
{status}
</MenuItem>
))}
onClick={() => {
if (isCompletedBlocked) {
return;
}
onStatusAnchorChange(null);
if (status !== wo.status) {
onStatusChange(status);
}
}}
>
{status}
</MenuItem>
);
})}
</Menu>
</Stack>

View file

@ -53,8 +53,17 @@ export function WizardFieldSelectOptions({
<button
key={o.key}
type="button"
onClick={() => onPick(o.key)}
className="flex w-full items-center gap-2 rounded px-2 py-1.5 transition-colors hover:bg-[#F9FAFB]"
disabled={o.disabled}
title={o.title}
onClick={() => {
if (o.disabled) return;
onPick(o.key);
}}
className={
o.disabled
? "flex w-full cursor-not-allowed items-center gap-2 rounded px-2 py-1.5 opacity-50"
: "flex w-full items-center gap-2 rounded px-2 py-1.5 transition-colors hover:bg-[#F9FAFB]"
}
>
{o.left != null && <span className="flex shrink-0 items-center">{o.left}</span>}
<span className="min-w-0 flex-1 text-left">

View file

@ -6,4 +6,6 @@ export interface WizardMenuOption {
description?: string;
left?: ReactNode;
right?: ReactNode;
disabled?: boolean;
title?: string;
}

View file

@ -13,6 +13,11 @@ import {
formatVendorDistance,
formatVendorTechnicianLabel,
} from "@/domain/work-orders/utils/vendor-dropdown-label";
import {
formatClosabilityMissingTitle,
getClosabilityGaps,
wizardDraftToClosabilityInput,
} from "@/domain/work-orders/utils/wo-closability";
import { WizardFieldSelect } from "@/app/(protected)/workorders/_components/wizard/wizard-field-select";
import {
DLG_INPUT_CLS,
@ -110,6 +115,19 @@ function WizardVendorDetail({
);
}
function buildWizardStatusOptions(closabilityGaps: string[]) {
return ALL_WIZARD_STATUSES.map((s) => {
const isCompletedBlocked = s === "Completed" && closabilityGaps.length > 0;
return {
key: s,
label: s,
left: <ColorDot color={WIZARD_STATUS_DOTS[s]} />,
disabled: isCompletedBlocked,
title: isCompletedBlocked ? formatClosabilityMissingTitle(closabilityGaps) : undefined,
};
});
}
export function WizardStepVendorTime({
draft,
vendors,
@ -120,6 +138,7 @@ export function WizardStepVendorTime({
const statusValue = (draft.status || "Incomplete") as WizardWOStatus;
const statusDotColor = WIZARD_STATUS_DOTS[statusValue] ?? WIZARD_STATUS_DOTS.Incomplete;
const selectedDistance = formatVendorDistance(selectedVendor?.distanceMiles);
const closabilityGaps = getClosabilityGaps(wizardDraftToClosabilityInput(draft));
return (
<div className="space-y-5">
@ -242,12 +261,11 @@ export function WizardStepVendorTime({
placeholder="Select status…"
search={false}
icon={<ColorDot color={statusDotColor} />}
options={ALL_WIZARD_STATUSES.map((s) => ({
key: s,
label: s,
left: <ColorDot color={WIZARD_STATUS_DOTS[s]} />,
}))}
onPick={(s) => onUpdate("status", s as WizardWOStatus)}
options={buildWizardStatusOptions(closabilityGaps)}
onPick={(s) => {
if (s === "Completed" && closabilityGaps.length > 0) return;
onUpdate("status", s as WizardWOStatus);
}}
/>
</div>
</div>

View file

@ -0,0 +1,129 @@
import type { WorkOrderDetail } from "@/domain/work-orders/types/work-order-detail";
import type { CompletionDocStatus } from "@/domain/work-orders/types/work-order-table-row";
import type { WOType, WorkOrderWizardDraft } from "@/domain/work-orders/types/work-order-wizard";
/** Fields required to evaluate whether a WO may be marked Completed. */
export type WoClosabilityInput = {
dispatcherId: string;
dispatcherName: string;
/** Company / vendor assignment — required for Completed (SH-183). */
company: string;
/** Technician is optional for closability; modeled separately from company. */
tech: string;
completedDate: string;
woNumber: string;
pm: string;
docStatus: CompletionDocStatus;
mediaCount: number;
type: WOType | "";
};
/** Real (non-placeholder) WO number — non-empty and not an SH-prefixed draft id. */
export function isRealWoNumber(woNumber: string): boolean {
const trimmed = woNumber.trim();
if (!trimmed) {
return false;
}
return !/^SH/i.test(trimmed);
}
/** Legacy detail menus use "Done"; board/wizard lifecycle uses "Completed". */
export function isCompletionStatusLabel(status: string): boolean {
return status === "Done" || status === "Completed";
}
function hasDispatcher(row: Pick<WoClosabilityInput, "dispatcherId" | "dispatcherName">): boolean {
return Boolean(row.dispatcherId.trim() || row.dispatcherName.trim());
}
function hasCompany(company: string): boolean {
return Boolean(company.trim());
}
function hasService(pm: string): boolean {
return Boolean(pm.trim());
}
function hasCompletionDocument(docStatus: CompletionDocStatus): boolean {
return docStatus === "Yes" || docStatus === "NN";
}
/**
* Returns human-readable labels for requirements still missing before Completed is allowed.
* Empty array means the WO may be marked Completed.
*/
export function getClosabilityGaps(row: WoClosabilityInput): string[] {
const gaps: string[] = [];
if (!hasDispatcher(row)) {
gaps.push("Dispatcher");
}
if (!hasCompany(row.company)) {
gaps.push("Company");
}
if (!row.completedDate.trim()) {
gaps.push("Completed Date");
}
if (!isRealWoNumber(row.woNumber)) {
gaps.push("WO number");
}
if (!hasService(row.pm)) {
gaps.push("Service");
}
if (row.type === "PM" || row.type === "Overdue") {
if (!hasCompletionDocument(row.docStatus)) {
gaps.push("Completion document");
}
}
if (row.type === "Emergency" || row.type === "Reactive") {
if (row.mediaCount < 1) {
gaps.push("At least 1 photo");
}
}
return gaps;
}
export function formatClosabilityMissingTitle(gaps: string[]): string {
return `Missing: ${gaps.join(", ")}`;
}
/** Map create-wizard draft onto the shared closability contract. */
export function wizardDraftToClosabilityInput(draft: WorkOrderWizardDraft): WoClosabilityInput {
return {
dispatcherId: draft.assigneeId,
dispatcherName: draft.assigneeName,
company: draft.vendorName,
tech: "",
completedDate: "",
woNumber: draft.woNumber,
pm: draft.pm,
docStatus: "",
mediaCount: 0,
type: draft.type,
};
}
/**
* Map standalone detail DTO onto closability. Fields the detail route does not
* expose (service, docStatus, media, type) stay empty so Completed fails closed.
*/
export function detailToClosabilityInput(wo: WorkOrderDetail): WoClosabilityInput {
const vendorName = wo.dispatches.find((dispatch) =>
Boolean(dispatch.vendorName.trim()),
)?.vendorName;
return {
dispatcherId: "",
dispatcherName: wo.assignedTo,
company: vendorName ?? "",
tech: "",
completedDate: wo.completedDate,
woNumber: wo.workOrderNumber || wo.internalWONumber,
pm: "",
docStatus: "",
mediaCount: 0,
type: "",
};
}

View file

@ -0,0 +1,37 @@
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 { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
const incompleteRow = {
id: "wo-1",
status: "Scheduled",
dispatcherId: "",
dispatcherName: "",
company: "",
tech: "",
completedDate: "",
woNumber: "",
pm: "",
docStatus: "",
mediaCount: 0,
type: "PM",
} as WorkOrderTableRow;
describe("StatusCell closability gate", () => {
it("does not submit Completed when required work-order data is missing", () => {
const onChangeStatus = vi.fn();
render(
<StatusCell wo={incompleteRow} onChangeStatus={onChangeStatus} onOpenSlideOver={vi.fn()} />,
);
fireEvent.click(screen.getByRole("button", { name: /scheduled/i }));
const completed = screen.getByRole("button", { name: /^completed$/i });
expect(completed.hasAttribute("disabled")).toBe(true);
expect(completed.getAttribute("title") ?? "").toContain("Missing:");
fireEvent.click(completed);
expect(onChangeStatus).not.toHaveBeenCalledWith("Completed");
});
});

View file

@ -0,0 +1,44 @@
import { fireEvent, render, screen, within } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import { WizardStepVendorTime } from "@/app/(protected)/workorders/_components/wizard/wizard-step-vendor-time";
import { EMPTY_WIZARD_DRAFT } from "@/domain/work-orders/types/work-order-wizard";
describe("WizardStepVendorTime closability gate", () => {
it("disables Completed when closability requirements are missing", () => {
const onUpdate = vi.fn();
render(
<WizardStepVendorTime
draft={{ ...EMPTY_WIZARD_DRAFT, type: "PM", status: "Scheduled" }}
vendors={[]}
onUpdate={onUpdate}
onPatch={vi.fn()}
/>,
);
fireEvent.click(screen.getByRole("button", { name: /scheduled/i }));
const completed = screen.getByRole("button", { name: /^completed$/i });
expect(completed).toBeDisabled();
expect(completed).toHaveAttribute("title", expect.stringContaining("Missing:"));
fireEvent.click(completed);
expect(onUpdate).not.toHaveBeenCalledWith("status", "Completed");
});
it("allows selecting a non-Completed status", () => {
const onUpdate = vi.fn();
render(
<WizardStepVendorTime
draft={{ ...EMPTY_WIZARD_DRAFT, type: "PM", status: "Incomplete" }}
vendors={[]}
onUpdate={onUpdate}
onPatch={vi.fn()}
/>,
);
fireEvent.click(screen.getByRole("button", { name: /incomplete/i }));
const menu = screen.getByRole("button", { name: /^pending$/i }).closest("div");
expect(menu).not.toBeNull();
fireEvent.click(within(menu!.parentElement!).getByRole("button", { name: /^pending$/i }));
expect(onUpdate).toHaveBeenCalledWith("status", "Pending");
});
});

View file

@ -0,0 +1,78 @@
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 { WorkOrderDetail } from "@/domain/work-orders/types/work-order-detail";
const incompleteDetail = {
id: 1,
internalWONumber: "SH-1",
workOrderNumber: "",
title: "Test WO",
location: "",
locationId: "",
locationZip: "",
assignedTo: "",
dueDate: "",
createdDate: "",
createdDateFull: "",
description: "",
priority: "",
status: "Open",
po: "",
tt: "",
problem: "",
trade: "",
subTrade: "",
vendorNTE: "",
scheduledDate: "",
completedDate: "",
source: "",
comments: [],
auditLog: [],
dispatches: [],
} as WorkOrderDetail;
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>,
);
fireEvent.click(screen.getByText("Open"));
const anchor = onStatusAnchorChange.mock.calls[0]?.[0] as HTMLElement;
expect(anchor).toBeTruthy();
rerender(
<MemoryRouter>
<WorkOrderHeaderSection
wo={incompleteDetail}
statusAnchor={anchor}
onStatusAnchorChange={onStatusAnchorChange}
onStatusChange={onStatusChange}
onDispatch={vi.fn()}
onEdit={vi.fn()}
onBack={vi.fn()}
/>
</MemoryRouter>,
);
const done = screen.getByRole("menuitem", { name: /^Done$/i });
expect(done.getAttribute("aria-disabled")).toBe("true");
fireEvent.click(done);
expect(onStatusChange).not.toHaveBeenCalledWith("Done");
});
});

View file

@ -81,6 +81,7 @@ describe("expandTablePatchToBoardFieldOps", () => {
scheduleWeekOnly: true,
targetWeek: "2026-07-13",
scheduledOn: "",
completedDate: "",
}),
).toEqual([
{ field: "scheduleWeekOnly", value: "true" },
@ -94,6 +95,7 @@ describe("expandTablePatchToBoardFieldOps", () => {
expandTablePatchToBoardFieldOps({
scheduleWeekOnly: false,
scheduledOn: "2026-07-15",
completedDate: "",
targetWeek: "",
}),
).toEqual([

View file

@ -83,12 +83,14 @@ describe("buildDayGroups", () => {
const later = makeRow({
id: "2",
scheduledOn: "2026-06-24",
completedDate: "",
dayGroup: "06/24",
dayLabel: "WED · 06/24",
});
const earlier = makeRow({
id: "1",
scheduledOn: "2026-06-23",
completedDate: "",
dayGroup: "06/23",
dayLabel: "TUE · 06/23",
});

View file

@ -0,0 +1,184 @@
import { describe, expect, it } from "vitest";
import type { WorkOrderDetail } from "@/domain/work-orders/types/work-order-detail";
import type { WoClosabilityInput } from "@/domain/work-orders/utils/wo-closability";
import {
detailToClosabilityInput,
formatClosabilityMissingTitle,
getClosabilityGaps,
isRealWoNumber,
wizardDraftToClosabilityInput,
} from "@/domain/work-orders/utils/wo-closability";
import { EMPTY_WIZARD_DRAFT } from "@/domain/work-orders/types/work-order-wizard";
function makeInput(overrides: Partial<WoClosabilityInput> = {}): WoClosabilityInput {
return {
dispatcherId: "u1",
dispatcherName: "Alice",
company: "ACME",
tech: "Bob",
completedDate: "2026-07-15",
woNumber: "20260623001",
pm: "HVAC",
docStatus: "Yes",
mediaCount: 2,
type: "PM",
...overrides,
};
}
describe("isRealWoNumber", () => {
it("rejects empty and SH-prefixed placeholders", () => {
expect(isRealWoNumber("")).toBe(false);
expect(isRealWoNumber(" ")).toBe(false);
expect(isRealWoNumber("SH-123")).toBe(false);
expect(isRealWoNumber("sh999")).toBe(false);
});
it("accepts non-SH numbers", () => {
expect(isRealWoNumber("20260623001")).toBe(true);
expect(isRealWoNumber(" 42 ")).toBe(true);
});
});
describe("getClosabilityGaps", () => {
it("returns empty when all requirements are met", () => {
expect(getClosabilityGaps(makeInput())).toEqual([]);
});
it("lists universal gaps", () => {
expect(
getClosabilityGaps(
makeInput({
dispatcherId: "",
dispatcherName: "",
company: "",
tech: "",
completedDate: "",
woNumber: "SH-1",
pm: "",
}),
),
).toEqual(["Dispatcher", "Company", "Completed Date", "WO number", "Service"]);
});
it("treats dispatcherName alone as sufficient", () => {
expect(getClosabilityGaps(makeInput({ dispatcherId: "", dispatcherName: "Alice" }))).toEqual(
[],
);
});
it("allows company without technician (SH-183)", () => {
const gaps = getClosabilityGaps(makeInput({ company: "ACME", tech: "" }));
expect(gaps).toEqual([]);
expect(gaps).not.toContain("Technician");
expect(gaps).not.toContain("Company");
});
it("blocks missing company even when technician is set", () => {
expect(getClosabilityGaps(makeInput({ company: "", tech: "Bob" }))).toEqual(["Company"]);
expect(getClosabilityGaps(makeInput({ company: " ", tech: "Bob" }))).toEqual(["Company"]);
});
it("requires completion document for PM and Overdue", () => {
expect(getClosabilityGaps(makeInput({ type: "PM", docStatus: "No" }))).toEqual([
"Completion document",
]);
expect(getClosabilityGaps(makeInput({ type: "Overdue", docStatus: "" }))).toEqual([
"Completion document",
]);
expect(getClosabilityGaps(makeInput({ type: "PM", docStatus: "NN" }))).toEqual([]);
expect(getClosabilityGaps(makeInput({ type: "Overdue", docStatus: "Yes" }))).toEqual([]);
});
it("requires at least one photo for Emergency and Reactive", () => {
expect(
getClosabilityGaps(makeInput({ type: "Emergency", mediaCount: 0, docStatus: "No" })),
).toEqual(["At least 1 photo"]);
expect(getClosabilityGaps(makeInput({ type: "Reactive", mediaCount: 0 }))).toEqual([
"At least 1 photo",
]);
expect(
getClosabilityGaps(makeInput({ type: "Emergency", mediaCount: 1, docStatus: "No" })),
).toEqual([]);
});
it("blocks missing service on every type", () => {
expect(getClosabilityGaps(makeInput({ type: "Reactive", pm: "", mediaCount: 1 }))).toEqual([
"Service",
]);
expect(getClosabilityGaps(makeInput({ type: "PM", pm: " ", docStatus: "Yes" }))).toEqual([
"Service",
]);
});
});
describe("formatClosabilityMissingTitle", () => {
it("joins gaps for tooltip copy", () => {
expect(formatClosabilityMissingTitle(["Dispatcher", "Service"])).toBe(
"Missing: Dispatcher, Service",
);
});
});
describe("wizardDraftToClosabilityInput", () => {
it("maps vendorName to company and leaves tech empty", () => {
const input = wizardDraftToClosabilityInput({
...EMPTY_WIZARD_DRAFT,
assigneeId: "u1",
assigneeName: "Alice",
vendorName: "ACME",
woNumber: "20260623001",
pm: "HVAC",
type: "PM",
});
expect(input).toEqual({
dispatcherId: "u1",
dispatcherName: "Alice",
company: "ACME",
tech: "",
completedDate: "",
woNumber: "20260623001",
pm: "HVAC",
docStatus: "",
mediaCount: 0,
type: "PM",
});
expect(getClosabilityGaps(input)).toContain("Completed Date");
expect(getClosabilityGaps(input)).toContain("Completion document");
expect(getClosabilityGaps(input)).not.toContain("Company");
expect(getClosabilityGaps(input)).not.toContain("Technician");
});
it("blocks Completed for a sparse create draft", () => {
const gaps = getClosabilityGaps(wizardDraftToClosabilityInput(EMPTY_WIZARD_DRAFT));
expect(gaps.length).toBeGreaterThan(0);
expect(gaps).toEqual(
expect.arrayContaining(["Dispatcher", "Company", "Completed Date", "WO number", "Service"]),
);
expect(gaps).not.toContain("Technician");
});
});
describe("detailToClosabilityInput", () => {
it("maps vendorName to company and fails closed on missing service/media fields", () => {
const detail = {
assignedTo: "Alice",
completedDate: "2026-07-15",
workOrderNumber: "20260623001",
internalWONumber: "SH-1",
dispatches: [{ vendorName: "ACME Tech" }],
} as WorkOrderDetail;
const input = detailToClosabilityInput(detail);
expect(input.dispatcherName).toBe("Alice");
expect(input.company).toBe("ACME Tech");
expect(input.tech).toBe("");
expect(input.completedDate).toBe("2026-07-15");
expect(input.woNumber).toBe("20260623001");
expect(input.pm).toBe("");
expect(getClosabilityGaps(input)).toContain("Service");
expect(getClosabilityGaps(input)).not.toContain("Company");
expect(getClosabilityGaps(input)).not.toContain("Technician");
});
});