diff --git a/src/app/(protected)/workorders/_components/wizard/wizard-field-select-types.ts b/src/app/(protected)/workorders/_components/wizard/wizard-field-select-types.ts
index 17958148..83c53ae0 100644
--- a/src/app/(protected)/workorders/_components/wizard/wizard-field-select-types.ts
+++ b/src/app/(protected)/workorders/_components/wizard/wizard-field-select-types.ts
@@ -6,4 +6,6 @@ export interface WizardMenuOption {
description?: string;
left?: ReactNode;
right?: ReactNode;
+ disabled?: boolean;
+ title?: string;
}
diff --git a/src/app/(protected)/workorders/_components/wizard/wizard-step-vendor-time.tsx b/src/app/(protected)/workorders/_components/wizard/wizard-step-vendor-time.tsx
index cb2a7cbf..82e7e955 100644
--- a/src/app/(protected)/workorders/_components/wizard/wizard-step-vendor-time.tsx
+++ b/src/app/(protected)/workorders/_components/wizard/wizard-step-vendor-time.tsx
@@ -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: ,
+ 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 (
@@ -242,12 +261,11 @@ export function WizardStepVendorTime({
placeholder="Select status…"
search={false}
icon={}
- options={ALL_WIZARD_STATUSES.map((s) => ({
- key: s,
- label: s,
- left: ,
- }))}
- onPick={(s) => onUpdate("status", s as WizardWOStatus)}
+ options={buildWizardStatusOptions(closabilityGaps)}
+ onPick={(s) => {
+ if (s === "Completed" && closabilityGaps.length > 0) return;
+ onUpdate("status", s as WizardWOStatus);
+ }}
/>
diff --git a/src/domain/work-orders/utils/wo-closability.ts b/src/domain/work-orders/utils/wo-closability.ts
new file mode 100644
index 00000000..d9db0baf
--- /dev/null
+++ b/src/domain/work-orders/utils/wo-closability.ts
@@ -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): 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: "",
+ };
+}
diff --git a/src/test/app/(protected)/workorders/status-cell-closability.test.tsx b/src/test/app/(protected)/workorders/status-cell-closability.test.tsx
new file mode 100644
index 00000000..1ca88e1b
--- /dev/null
+++ b/src/test/app/(protected)/workorders/status-cell-closability.test.tsx
@@ -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(
+ ,
+ );
+
+ 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");
+ });
+});
diff --git a/src/test/app/(protected)/workorders/wizard-step-vendor-time-closability.test.tsx b/src/test/app/(protected)/workorders/wizard-step-vendor-time-closability.test.tsx
new file mode 100644
index 00000000..12ad3346
--- /dev/null
+++ b/src/test/app/(protected)/workorders/wizard-step-vendor-time-closability.test.tsx
@@ -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(
+ ,
+ );
+
+ 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(
+ ,
+ );
+
+ 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");
+ });
+});
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
new file mode 100644
index 00000000..8f11964f
--- /dev/null
+++ b/src/test/app/(protected)/workorders/work-order-header-closability.test.tsx
@@ -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(
+
+
+ ,
+ );
+
+ fireEvent.click(screen.getByText("Open"));
+ const anchor = onStatusAnchorChange.mock.calls[0]?.[0] as HTMLElement;
+ expect(anchor).toBeTruthy();
+
+ rerender(
+
+
+ ,
+ );
+
+ const done = screen.getByRole("menuitem", { name: /^Done$/i });
+ expect(done.getAttribute("aria-disabled")).toBe("true");
+ fireEvent.click(done);
+ expect(onStatusChange).not.toHaveBeenCalledWith("Done");
+ });
+});
diff --git a/src/test/domain/work-orders/mappers/work-order-board-patch-mapper.test.ts b/src/test/domain/work-orders/mappers/work-order-board-patch-mapper.test.ts
index 1e9e7ffa..034785e9 100644
--- a/src/test/domain/work-orders/mappers/work-order-board-patch-mapper.test.ts
+++ b/src/test/domain/work-orders/mappers/work-order-board-patch-mapper.test.ts
@@ -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([
diff --git a/src/test/domain/work-orders/utils/table-filter-utils.test.ts b/src/test/domain/work-orders/utils/table-filter-utils.test.ts
index 088e3bdb..35804d3d 100644
--- a/src/test/domain/work-orders/utils/table-filter-utils.test.ts
+++ b/src/test/domain/work-orders/utils/table-filter-utils.test.ts
@@ -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",
});
diff --git a/src/test/domain/work-orders/utils/wo-closability.test.ts b/src/test/domain/work-orders/utils/wo-closability.test.ts
new file mode 100644
index 00000000..69f9ea2b
--- /dev/null
+++ b/src/test/domain/work-orders/utils/wo-closability.test.ts
@@ -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 {
+ 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");
+ });
+});