Merge pull request #119 from Sea-Haven-Industries/fix/sh-183-closability-company-not-tech

fix(work-orders): require vendor company not technician to complete (SH-183)
This commit is contained in:
Arthur Bassi 2026-08-20 15:51:04 -03:00 • committed by GitHub
commit c101110148
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
21 changed files with 649 additions and 52 deletions

View file

@ -63,6 +63,10 @@ a status or mark unverified work Done.
- **MUST NOT** leave comments that only restate what Prettier or ESLint already
enforces (formatting, naming nits the linter catches). Style is settled by the
gates; review is for behavior, correctness, security, and architecture.
- **MUST NOT** put Jira issue keys or ticket titles in source comments, JSDoc,
or test names (for example `(SH-183)`). Ticket identity belongs in the PR,
commit message, and branch — not in the code. Flag and request removal if a
diff adds them.
- **MUST** make every comment actionable: tie it to a behavior, a risk, or an
evidence-based convention in these docs, and offer a concrete fix or a
targeted question. Use GitHub suggestion blocks when safe.

View file

@ -15,6 +15,7 @@ import type {
} from "@/domain/work-orders/types/work-order";
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
import type { WizardWOStatus } from "@/domain/work-orders/types/work-order-wizard";
import { toVendorTablePatch } from "@/domain/work-orders/utils/vendor-assignment-patch";
import { DocBadge } from "./slide-over-doc-badge";
type SlideOverInfoTabEditViewProps = {
@ -140,14 +141,7 @@ export function SlideOverInfoTabEditView({
tech={draft.tech}
techPhone={draft.techPhone}
vendors={vendors}
onSave={(patch) =>
onDraftChange({
vendorId: patch.vendorId,
company: patch.company,
tech: patch.tech,
techPhone: patch.techPhone,
})
}
onSave={(patch) => onDraftChange(toVendorTablePatch(patch))}
/>
</SlideOverInfoRow>
<SlideOverInfoRow label="Status">

View file

@ -10,6 +10,7 @@ import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-ta
import {
formatClosabilityMissingTitle,
getClosabilityGaps,
tableRowToClosabilityInput,
} from "@/domain/work-orders/utils/wo-closability";
import { HoverChevron } from "./hover-chevron";
import { WoTableStatusLabel } from "./wo-table-status-label";
@ -22,20 +23,7 @@ type StatusCellProps = {
};
function rowClosabilityGaps(wo: WorkOrderTableRow) {
return getClosabilityGaps({
dispatcherId: wo.dispatcherId,
dispatcherName: wo.dispatcherName,
company: wo.company,
tech: wo.tech,
completedDate: wo.completedDate ?? "",
woNumber: wo.woNumber,
pm: wo.pm,
docStatus: wo.docStatus,
mediaCount: wo.mediaCount,
type: wo.type,
upliftSummary: wo.upliftSummary,
hasPendingUplift: wo.hasPendingUplift,
});
return getClosabilityGaps(tableRowToClosabilityInput(wo));
}
function PastDueStatusCell({

View file

@ -16,6 +16,7 @@ import { PMTypeCell } from "./cells/pm-type-cell";
import { StatusCell } from "./cells/status-cell";
import { TypeCell } from "./cells/type-cell";
import { VendorCell } from "./cells/vendor-cell";
import { toVendorTablePatch } from "@/domain/work-orders/utils/vendor-assignment-patch";
import { toInlineDraftRow } from "./to-inline-draft-row";
import { WoTableInlineIdentityCells } from "./wo-table-inline-identity-cells";
@ -193,14 +194,7 @@ export function WoTableInlineRowCells({
tech={draft.tech}
techPhone={draft.techPhone}
vendors={vendors}
onSave={(p) =>
onPatch({
vendorId: p.vendorId,
company: p.company,
tech: p.tech,
techPhone: p.techPhone,
})
}
onSave={(p) => onPatch(toVendorTablePatch(p))}
/>
</td>
<td className="group/cell" style={{ padding: "0 14px" }}>

View file

@ -10,6 +10,7 @@ import { UpliftCell } from "./cells/uplift-cell";
import { VendorCell } from "./cells/vendor-cell";
import { EMPTY_UPLIFT_SUMMARY } from "@/domain/work-orders/types/work-order-uplift";
import { canOpenUpliftsDialog } from "@/domain/work-orders/utils/uplift-display-utils";
import { toVendorTablePatch } from "@/domain/work-orders/utils/vendor-assignment-patch";
import type { WoTableRowHandlers } from "./wo-table-row";
type WoTableRowServiceCellsProps = {
@ -67,14 +68,7 @@ export function WoTableRowServiceCells({
techPhone={row.techPhone}
vendors={vendors}
q={search}
onSave={(p) =>
onPatchRow({
vendorId: p.vendorId,
company: p.company,
tech: p.tech,
techPhone: p.techPhone,
})
}
onSave={(p) => onPatchRow(toVendorTablePatch(p))}
/>
</td>
<td

View file

@ -42,6 +42,7 @@ const LOCAL_ONLY_KEYS = new Set<keyof WorkOrderTablePatch>([
"originalDate",
/** Optimistic overlay only — backend owns isAddOn after schedule ops (SH-184). */
"isAddOn",
"primaryDispatchStatus",
]);
export function isLocalOnlyTablePatch(patch: WorkOrderTablePatch): boolean {

View file

@ -267,6 +267,10 @@ function readHasPendingUplift(
return undefined;
}
function readPrimaryDispatchStatus(item: Record<string, unknown>): string {
return readString(item, "primaryDispatchStatus", "PrimaryDispatchStatus");
}
export function mapWorkOrderTableRow(raw: unknown): WorkOrderTableRow {
const item = asRecord(raw);
const scheduledOn = toDateInputValue(
@ -348,6 +352,7 @@ export function mapWorkOrderTableRow(raw: unknown): WorkOrderTableRow {
extraServices: readStringArray(item, "extraServices", "ExtraServices"),
vendorId: vendorIdNum != null ? String(vendorIdNum) : "",
company: readString(item, "company", "Company", "vendorName", "VendorName"),
primaryDispatchStatus: readPrimaryDispatchStatus(item),
tech: readString(item, "techName", "TechName", "tech", "Tech", "technician", "Technician"),
techPhone: readString(item, "techPhone", "TechPhone"),
apptStart,

View file

@ -42,6 +42,7 @@ export interface WorkOrderBoardDetailInfoApi {
dayGroup?: string | null;
vendorId?: number | null;
vendorName?: string | null;
primaryDispatchStatus?: string | null;
techName?: string | null;
techPhone?: string | null;
apptDate?: string | null;

View file

@ -184,6 +184,7 @@ export interface WorkOrderBoardRowApi {
dayGroup?: string | null;
vendorId?: number | null;
vendorName?: string | null;
primaryDispatchStatus?: string | null;
techName?: string | null;
techPhone?: string | null;
apptDate?: string | null;

View file

@ -37,6 +37,7 @@ export interface WorkOrderTableRow {
extraServices: string[];
vendorId: string;
company: string;
primaryDispatchStatus?: string;
tech: string;
techPhone: string;
apptStart: string;

View file

@ -3,7 +3,7 @@ import type { WorkOrderDetail } from "@/domain/work-orders/types/work-order-deta
/**
* Overlay authoritative closability fields from board detail onto a legacy GetById detail.
* Board `info.type`, `completion.docStatus`, and `media.length` are the SH-114/SH-183 sources.
* Board `info.type`, `completion.docStatus`, and `media.length` are the SH-114 sources.
*/
export function enrichDetailClosabilityFromBoard(
detail: WorkOrderDetail,

View file

@ -0,0 +1,23 @@
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
export type VendorAssignmentFields = {
vendorId: string;
company: string;
tech: string;
techPhone: string;
};
export type VendorTablePatch = Pick<
WorkOrderTableRow,
"vendorId" | "company" | "tech" | "techPhone" | "primaryDispatchStatus"
>;
export function toVendorTablePatch(fields: VendorAssignmentFields): VendorTablePatch {
return {
vendorId: fields.vendorId,
company: fields.company,
tech: fields.tech,
techPhone: fields.techPhone,
primaryDispatchStatus: "",
};
}

View file

@ -1,16 +1,20 @@
import type { WorkOrderDispatchSummary } from "@/domain/work-orders/types/dispatch";
import type { WorkOrderDetail } from "@/domain/work-orders/types/work-order-detail";
import type {
CompletionDocStatus,
WorkOrderTableRow,
WorkOrderUpliftSummary,
} 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 (SH-183 ∪ pending uplift). */
/** 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 / vendor assignment — required for Completed. */
company: string;
/** Board vendor id; assignment is valid with vendorId even when company label is empty. */
vendorId: string;
/** Technician is optional for closability; modeled separately from company. */
tech: string;
/** Work completed date (API CompletedDate). Empty until set. */
@ -57,8 +61,8 @@ function hasDispatcher(row: Pick<WoClosabilityInput, "dispatcherId" | "dispatche
return Boolean(row.dispatcherId.trim() || row.dispatcherName.trim());
}
function hasCompany(company: string): boolean {
return Boolean(company.trim());
function hasAssignedCompany(row: Pick<WoClosabilityInput, "company" | "vendorId">): boolean {
return Boolean((row.company ?? "").trim() || (row.vendorId ?? "").trim());
}
function hasService(pm: string): boolean {
@ -80,7 +84,7 @@ export function getClosabilityGaps(row: WoClosabilityInput): string[] {
if (!hasDispatcher(row)) {
gaps.push("Dispatcher");
}
if (!hasCompany(row.company)) {
if (!hasAssignedCompany(row)) {
gaps.push("Company");
}
if (!row.completedDate.trim()) {
@ -143,6 +147,7 @@ export function wizardDraftToClosabilityInput(draft: WorkOrderWizardDraft): WoCl
dispatcherId: draft.assigneeId,
dispatcherName: draft.assigneeName,
company: draft.vendorName,
vendorId: draft.vendorId ?? "",
tech: "",
completedDate: "",
woNumber: draft.woNumber,
@ -153,19 +158,55 @@ export function wizardDraftToClosabilityInput(draft: WorkOrderWizardDraft): WoCl
};
}
const INACTIVE_DISPATCH_STATUSES = new Set(["cancelled", "canceled", "refused"]);
export function isInactiveDispatchStatus(status: string | undefined): boolean {
return INACTIVE_DISPATCH_STATUSES.has((status ?? "").trim().toLowerCase());
}
export function tableRowToClosabilityInput(wo: WorkOrderTableRow): WoClosabilityInput {
const inactive = isInactiveDispatchStatus(wo.primaryDispatchStatus);
return {
dispatcherId: wo.dispatcherId,
dispatcherName: wo.dispatcherName,
company: inactive ? "" : wo.company,
vendorId: inactive ? "" : (wo.vendorId ?? ""),
tech: wo.tech,
completedDate: wo.completedDate ?? "",
woNumber: wo.woNumber,
pm: wo.pm,
docStatus: wo.docStatus,
mediaCount: wo.mediaCount,
type: wo.type,
upliftSummary: wo.upliftSummary,
hasPendingUplift: wo.hasPendingUplift,
};
}
function currentAssignedDispatch(
dispatches: WorkOrderDispatchSummary[],
): WorkOrderDispatchSummary | undefined {
return dispatches.find((dispatch) => {
if (isInactiveDispatchStatus(dispatch.status)) {
return false;
}
const vendorId = String(dispatch.vendorId ?? "").trim();
return Boolean((dispatch.vendorName ?? "").trim() || vendorId);
});
}
/**
* Map standalone detail DTO onto closability. Uses authoritative `type` / `docStatus` /
* `mediaCount` (enriched from board detail on the view route). Never treats `source`
* as WorkOrderType. `problem` is the same service field the board maps into `pm`.
*/
export function detailToClosabilityInput(wo: WorkOrderDetail): WoClosabilityInput {
const vendorName = wo.dispatches.find((dispatch) =>
Boolean(dispatch.vendorName.trim()),
)?.vendorName;
const assigned = currentAssignedDispatch(wo.dispatches);
return {
dispatcherId: "",
dispatcherName: wo.assignedTo,
company: vendorName ?? "",
company: assigned?.vendorName ?? "",
vendorId: assigned ? String(assigned.vendorId ?? "").trim() : "",
tech: "",
completedDate: wo.completedDate,
woNumber: wo.workOrderNumber || wo.internalWONumber,

View file

@ -9,6 +9,7 @@ const incompleteRow = {
dispatcherId: "",
dispatcherName: "",
company: "",
vendorId: "",
tech: "",
completedDate: "",
woNumber: "",

View file

@ -9,6 +9,7 @@ const pastDueRow = {
dispatcherId: "",
dispatcherName: "",
company: "",
vendorId: "",
tech: "",
completedDate: "",
woNumber: "",

View file

@ -120,12 +120,12 @@ describe("StatusCell pending uplift closability", () => {
expect(onChangeStatus).toHaveBeenCalledWith("Completed");
});
it("disables Completed when company is missing (SH-183)", () => {
it("disables Completed when company is missing", () => {
const onChangeStatus = vi.fn();
renderWithProviders(
<StatusCell
wo={makeRow({ company: "" })}
wo={makeRow({ company: "", vendorId: "" })}
onChangeStatus={onChangeStatus}
onOpenSlideOver={vi.fn()}
/>,
@ -141,7 +141,119 @@ describe("StatusCell pending uplift closability", () => {
expect(onChangeStatus).not.toHaveBeenCalled();
});
it("allows Completed when technician is empty if company is set (SH-183)", () => {
it("allows Completed when vendorId is set even if company label is empty", () => {
const onChangeStatus = vi.fn();
renderWithProviders(
<StatusCell
wo={makeRow({ company: "", vendorId: "7", tech: "" })}
onChangeStatus={onChangeStatus}
onOpenSlideOver={vi.fn()}
/>,
{ withAuth: false },
);
fireEvent.click(screen.getByRole("button", { name: /in progress/i }));
fireEvent.click(screen.getByRole("button", { name: /completed/i }));
expect(onChangeStatus).toHaveBeenCalledWith("Completed");
});
it("allows Completed when vendorId has an empty company label and a live dispatch status", () => {
const onChangeStatus = vi.fn();
renderWithProviders(
<StatusCell
wo={makeRow({ company: "", vendorId: "7", tech: "", primaryDispatchStatus: "Sent" })}
onChangeStatus={onChangeStatus}
onOpenSlideOver={vi.fn()}
/>,
{ withAuth: false },
);
fireEvent.click(screen.getByRole("button", { name: /in progress/i }));
fireEvent.click(screen.getByRole("button", { name: /completed/i }));
expect(onChangeStatus).toHaveBeenCalledWith("Completed");
});
it.each(["Cancelled", "Canceled", "Refused"] as const)(
"disables Completed when vendorId is leftover from a %s dispatch",
(primaryDispatchStatus) => {
const onChangeStatus = vi.fn();
renderWithProviders(
<StatusCell
wo={makeRow({
company: "",
vendorId: "45",
tech: "",
primaryDispatchStatus,
})}
onChangeStatus={onChangeStatus}
onOpenSlideOver={vi.fn()}
/>,
{ withAuth: false },
);
fireEvent.click(screen.getByRole("button", { name: /in progress/i }));
const completed = screen.getByRole("button", { name: /completed/i });
expect(completed).toBeDisabled();
expect(completed).toHaveAttribute("title", "Missing: Company");
fireEvent.click(completed);
expect(onChangeStatus).not.toHaveBeenCalled();
},
);
it("disables Completed when the board omits vendorId after a Refused primary", () => {
const onChangeStatus = vi.fn();
renderWithProviders(
<StatusCell
wo={makeRow({
company: "",
vendorId: "",
tech: "",
primaryDispatchStatus: "Refused",
})}
onChangeStatus={onChangeStatus}
onOpenSlideOver={vi.fn()}
/>,
{ withAuth: false },
);
fireEvent.click(screen.getByRole("button", { name: /in progress/i }));
const completed = screen.getByRole("button", { name: /completed/i });
expect(completed).toBeDisabled();
expect(completed).toHaveAttribute("title", "Missing: Company");
});
it("allows Completed after a vendor patch clears leftover refused status", () => {
const onChangeStatus = vi.fn();
renderWithProviders(
<StatusCell
wo={makeRow({
company: "New Co",
vendorId: "9",
tech: "Pat",
primaryDispatchStatus: "",
})}
onChangeStatus={onChangeStatus}
onOpenSlideOver={vi.fn()}
/>,
{ withAuth: false },
);
fireEvent.click(screen.getByRole("button", { name: /in progress/i }));
fireEvent.click(screen.getByRole("button", { name: /completed/i }));
expect(onChangeStatus).toHaveBeenCalledWith("Completed");
});
it("allows Completed when technician is empty if company is set", () => {
const onChangeStatus = vi.fn();
renderWithProviders(

View file

@ -5,6 +5,7 @@ import {
isLocalOnlyTablePatch,
mapDocStatusToApi,
} from "@/domain/work-orders/mappers/work-order-board-patch-mapper";
import { toVendorTablePatch } from "@/domain/work-orders/utils/vendor-assignment-patch";
describe("mapDocStatusToApi", () => {
it("maps NN to NotNeeded", () => {
@ -25,6 +26,10 @@ describe("isLocalOnlyTablePatch", () => {
it("does not treat site as local-only", () => {
expect(isLocalOnlyTablePatch({ site: "BK5" })).toBe(false);
});
it("treats primaryDispatchStatus overlay as local-only", () => {
expect(isLocalOnlyTablePatch({ primaryDispatchStatus: "" })).toBe(true);
});
});
describe("expandTablePatchToBoardFieldOps", () => {
@ -136,6 +141,17 @@ describe("expandTablePatchToBoardFieldOps", () => {
}),
).toEqual([{ field: "vendorId", value: "7" }]);
expect(
expandTablePatchToBoardFieldOps(
toVendorTablePatch({
vendorId: "9",
company: "New Co",
tech: "Pat",
techPhone: "555",
}),
),
).toEqual([{ field: "vendorId", value: "9" }]);
expect(
expandTablePatchToBoardFieldOps({
site: "BK5",

View file

@ -50,3 +50,52 @@ describe("mapWorkOrderTableRow isAddOn", () => {
expect(row.isAddOn).toBe(false);
});
});
describe("mapWorkOrderTableRow primaryDispatchStatus", () => {
it.each([
["primaryDispatchStatus", "Refused"],
["PrimaryDispatchStatus", "Canceled"],
] as const)("maps exact DTO field %s onto primaryDispatchStatus", (key, value) => {
const row = mapWorkOrderTableRow({ id: 9, vendorId: 45, [key]: value });
expect(row.vendorId).toBe("45");
expect(row.primaryDispatchStatus).toBe(value);
});
it("maps backend #74 omitted vendor with Refused primaryDispatchStatus", () => {
const row = mapWorkOrderTableRow({
id: 9,
vendorId: null,
vendorName: null,
primaryDispatchStatus: "Refused",
});
expect(row.vendorId).toBe("");
expect(row.company).toBe("");
expect(row.primaryDispatchStatus).toBe("Refused");
});
it("maps live vendorId with empty company label and Sent status", () => {
const row = mapWorkOrderTableRow({
id: 9,
vendorId: 45,
vendorName: "",
primaryDispatchStatus: "Sent",
});
expect(row.vendorId).toBe("45");
expect(row.company).toBe("");
expect(row.primaryDispatchStatus).toBe("Sent");
});
it("maps vendor PATCH replacement dispatch as Pending with a new primaryDispatchId", () => {
const row = mapWorkOrderTableRow({
id: 1,
VendorId: 5,
VendorName: "New Co",
PrimaryDispatchStatus: "Pending",
PrimaryDispatchId: 11,
});
expect(row.vendorId).toBe("5");
expect(row.company).toBe("New Co");
expect(row.primaryDispatchStatus).toBe("Pending");
expect(row.primaryDispatchId).toBe(11);
});
});

View file

@ -21,10 +21,16 @@ vi.mock("@/domain/work-orders/api/work-orders-api", () => ({
vi.mock("react-toastify", () => ({ toast: { error: vi.fn() } }));
import { mapWorkOrderTableRow } from "@/domain/work-orders/mappers/work-order-table-row-mapper";
import {
applySchedulePatch,
useWorkOrderTableMutations,
} from "@/domain/work-orders/use-cases/use-work-order-table-mutations";
import { toVendorTablePatch } from "@/domain/work-orders/utils/vendor-assignment-patch";
import {
getClosabilityGaps,
tableRowToClosabilityInput,
} from "@/domain/work-orders/utils/wo-closability";
function delay(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
@ -345,3 +351,135 @@ describe("useWorkOrderTableMutations schedule isAddOn from BE", () => {
);
});
});
describe("useWorkOrderTableMutations vendor assignment round-trip", () => {
let rowState: WorkOrderTableRow;
beforeEach(() => {
getById.mockReset();
update.mockReset();
patchBoardField.mockReset();
rowState = {
...BASE_ROW,
vendorId: "1",
company: "",
tech: "",
completedDate: "2026-07-15",
docStatus: "Yes",
mediaCount: 1,
woNumber: "20260623001",
primaryDispatchStatus: "Refused",
primaryDispatchId: 10,
dispatchRowVersion: "disp-v1",
};
});
it("keeps Company off the gap list when vendor PATCH returns a Pending assignment", async () => {
const returnedRow = mapWorkOrderTableRow({
id: 42,
VendorId: 9,
VendorName: "New Co",
PrimaryDispatchStatus: "Pending",
PrimaryDispatchId: 11,
DispatcherId: "d1",
DispatcherName: "Dispatcher",
CompletedDate: "2026-07-15",
InternalWONumber: "20260623001",
PM: "HVAC",
DocStatus: "Yes",
MediaCount: 1,
WorkOrderType: "PM",
});
patchBoardField.mockResolvedValue(returnedRow);
const onPatch = vi.fn();
const { result } = renderHook(
() =>
useWorkOrderTableMutations({
onPatch,
clearPatch: () => undefined,
getRow: () => rowState,
}),
{ wrapper: makeWrapper() },
);
act(() => {
result.current.patchField(
42,
toVendorTablePatch({
vendorId: "9",
company: "New Co",
tech: "",
techPhone: "",
}),
);
});
await waitFor(() =>
expect(onPatch).toHaveBeenCalledWith(
42,
expect.objectContaining({
primaryDispatchStatus: "Pending",
primaryDispatchId: 11,
vendorId: "9",
}),
),
);
expect(patchBoardField).toHaveBeenCalledWith(
42,
expect.objectContaining({
field: "vendorId",
value: "9",
dispatchVersion: "disp-v1",
primaryDispatchId: 10,
}),
);
const payload = patchBoardField.mock.calls[0]?.[1] as Record<string, unknown>;
expect(payload).not.toHaveProperty("primaryDispatchStatus");
expect(returnedRow.primaryDispatchId).not.toBe(10);
expect(getClosabilityGaps(tableRowToClosabilityInput(returnedRow))).not.toContain("Company");
});
it("keeps a Company gap when vendor PATCH still returns Refused", async () => {
const returnedRow = mapWorkOrderTableRow({
id: 42,
VendorId: 9,
VendorName: "New Co",
PrimaryDispatchStatus: "Refused",
PrimaryDispatchId: 10,
});
patchBoardField.mockResolvedValue(returnedRow);
const onPatch = vi.fn();
const { result } = renderHook(
() =>
useWorkOrderTableMutations({
onPatch,
clearPatch: () => undefined,
getRow: () => rowState,
}),
{ wrapper: makeWrapper() },
);
act(() => {
result.current.patchField(
42,
toVendorTablePatch({
vendorId: "9",
company: "New Co",
tech: "",
techPhone: "",
}),
);
});
await waitFor(() =>
expect(onPatch).toHaveBeenCalledWith(
42,
expect.objectContaining({ primaryDispatchStatus: "Refused" }),
),
);
expect(getClosabilityGaps(tableRowToClosabilityInput(returnedRow))).toContain("Company");
});
});

View file

@ -0,0 +1,37 @@
import { describe, expect, it } from "vitest";
import { toVendorTablePatch } from "@/domain/work-orders/utils/vendor-assignment-patch";
import {
getClosabilityGaps,
tableRowToClosabilityInput,
} from "@/domain/work-orders/utils/wo-closability";
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
describe("toVendorTablePatch", () => {
it("clears leftover primaryDispatchStatus so a new vendor is a live assignment", () => {
const patch = toVendorTablePatch({
vendorId: "9",
company: "New Co",
tech: "Pat",
techPhone: "555",
});
expect(patch.primaryDispatchStatus).toBe("");
expect(patch.vendorId).toBe("9");
const input = tableRowToClosabilityInput({
dispatcherId: "u1",
dispatcherName: "Alex",
company: patch.company ?? "",
vendorId: patch.vendorId ?? "",
tech: patch.tech ?? "",
completedDate: "2026-07-15",
woNumber: "20260623001",
pm: "HVAC PM",
docStatus: "Yes",
mediaCount: 1,
type: "PM",
primaryDispatchStatus: patch.primaryDispatchStatus,
} as WorkOrderTableRow);
expect(getClosabilityGaps(input)).not.toContain("Company");
});
});

View file

@ -1,6 +1,7 @@
import { describe, expect, it } from "vitest";
import { readDetailWoType } from "@/domain/work-orders/utils/read-detail-closability-fields";
import type { WorkOrderDetail } from "@/domain/work-orders/types/work-order-detail";
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
import { EMPTY_WIZARD_DRAFT } from "@/domain/work-orders/types/work-order-wizard";
import type { WoClosabilityInput } from "@/domain/work-orders/utils/wo-closability";
import {
@ -13,6 +14,7 @@ import {
hasPendingUplift,
isCompletionStatusLabel,
isRealWoNumber,
tableRowToClosabilityInput,
wizardDraftToClosabilityInput,
} from "@/domain/work-orders/utils/wo-closability";
@ -21,6 +23,7 @@ function makeInput(overrides: Partial<WoClosabilityInput> = {}): WoClosabilityIn
dispatcherId: "u1",
dispatcherName: "Alex",
company: "Acme HVAC",
vendorId: "",
tech: "Sam",
completedDate: "2026-07-01",
woNumber: "1001",
@ -74,7 +77,7 @@ describe("getClosabilityGaps", () => {
expect(getClosabilityGaps(makeInput())).toEqual([]);
});
it("allows company without technician (SH-183)", () => {
it("allows company without technician", () => {
const gaps = getClosabilityGaps(makeInput({ tech: "" }));
expect(gaps).toEqual([]);
expect(gaps).not.toContain("Technician");
@ -85,6 +88,32 @@ describe("getClosabilityGaps", () => {
expect(getClosabilityGaps(makeInput({ company: " ", tech: "Sam" }))).toEqual(["Company"]);
});
it("allows Completed when vendorId is set and company/technician are empty", () => {
expect(getClosabilityGaps(makeInput({ company: "", vendorId: "7", tech: "" }))).toEqual([]);
});
it("blocks Completed when both company and vendorId are empty even with a technician", () => {
expect(getClosabilityGaps(makeInput({ company: "", vendorId: " ", tech: "Sam" }))).toEqual([
"Company",
]);
});
it.each(["PM", "Reactive", "Emergency", "Overdue", ""] as const)(
"blocks Completed without a company for type %s",
(type) => {
const gaps = getClosabilityGaps(
makeInput({
company: "",
vendorId: "",
type,
docStatus: "Yes",
mediaCount: 1,
}),
);
expect(gaps).toContain("Company");
},
);
it("lists field gaps before uplift and document gaps", () => {
expect(
getClosabilityGaps(
@ -188,6 +217,7 @@ describe("wizardDraftToClosabilityInput", () => {
dispatcherId: "u1",
dispatcherName: "Alice",
company: "ACME",
vendorId: "",
tech: "",
completedDate: "",
woNumber: "20260623001",
@ -221,6 +251,7 @@ describe("detailToClosabilityInput", () => {
const input = detailToClosabilityInput(detail);
expect(input.dispatcherName).toBe("Alice");
expect(input.company).toBe("ACME Tech");
expect(input.vendorId).toBe("");
expect(input.woNumber).toBe("20260623001");
expect(input.pm).toBe("HVAC PM");
expect(getClosabilityGaps(input)).toEqual([]);
@ -277,6 +308,171 @@ describe("detailToClosabilityInput", () => {
expect(getClosabilityGaps(detailToClosabilityInput(detail))).toEqual(["At least 1 photo"]);
});
it("treats vendorId without vendorName as assigned company", () => {
const detail = {
assignedTo: "Alice",
completedDate: "2026-07-15",
workOrderNumber: "20260623001",
internalWONumber: "SH-1",
problem: "HVAC PM",
source: "procurement-integration",
type: "",
docStatus: "",
mediaCount: 0,
dispatches: [{ vendorName: "", vendorId: 45 }],
} as WorkOrderDetail;
const input = detailToClosabilityInput(detail);
expect(input.company).toBe("");
expect(input.vendorId).toBe("45");
expect(getClosabilityGaps(input)).toEqual([]);
});
it.each(["Cancelled", "Canceled", "Refused", "CANCELLED", " canceled "])(
"ignores inactive dispatch status %s when resolving assigned company",
(status) => {
const inactiveOnly = {
assignedTo: "Alice",
completedDate: "2026-07-15",
workOrderNumber: "20260623001",
internalWONumber: "SH-1",
problem: "HVAC PM",
source: "procurement-integration",
type: "",
docStatus: "",
mediaCount: 0,
dispatches: [{ vendorName: "Old Co", vendorId: 45, status }],
} as WorkOrderDetail;
expect(detailToClosabilityInput(inactiveOnly).vendorId).toBe("");
expect(detailToClosabilityInput(inactiveOnly).company).toBe("");
expect(getClosabilityGaps(detailToClosabilityInput(inactiveOnly))).toContain("Company");
},
);
it.each(["Verified", "Completed", "Sent"])(
"treats dispatch status %s as a live company assignment",
(status) => {
const liveAssignment = {
assignedTo: "Alice",
completedDate: "2026-07-15",
workOrderNumber: "20260623001",
internalWONumber: "SH-1",
problem: "HVAC PM",
source: "procurement-integration",
type: "",
docStatus: "",
mediaCount: 0,
dispatches: [{ vendorName: "ACME Tech", vendorId: 45, status }],
} as WorkOrderDetail;
const input = detailToClosabilityInput(liveAssignment);
expect(input.company).toBe("ACME Tech");
expect(input.vendorId).toBe("45");
expect(getClosabilityGaps(input)).toEqual([]);
},
);
it("uses the active dispatch after an inactive cancelled spelling", () => {
const supersededThenCurrent = {
assignedTo: "Alice",
completedDate: "2026-07-15",
workOrderNumber: "20260623001",
internalWONumber: "SH-1",
problem: "HVAC PM",
source: "procurement-integration",
type: "",
docStatus: "",
mediaCount: 0,
dispatches: [
{ vendorName: "Old Co", vendorId: 1, status: "Canceled" },
{ vendorName: "ACME Tech", vendorId: 45, status: "Sent" },
],
} as WorkOrderDetail;
const input = detailToClosabilityInput(supersededThenCurrent);
expect(input.company).toBe("ACME Tech");
expect(input.vendorId).toBe("45");
expect(getClosabilityGaps(input)).toEqual([]);
});
});
describe("tableRowToClosabilityInput", () => {
const closableRow = {
dispatcherId: "u1",
dispatcherName: "Alex",
company: "",
vendorId: "45",
tech: "",
completedDate: "2026-07-15",
woNumber: "20260623001",
pm: "HVAC PM",
docStatus: "",
mediaCount: 0,
type: "",
} as WorkOrderTableRow;
it("keeps vendorId as a live assignment when primary dispatch status is absent", () => {
const input = tableRowToClosabilityInput(closableRow);
expect(input.vendorId).toBe("45");
expect(getClosabilityGaps(input)).toEqual([]);
});
it.each(["Cancelled", "Canceled", "Refused"] as const)(
"clears company assignment when primary dispatch status is %s",
(primaryDispatchStatus) => {
const input = tableRowToClosabilityInput({ ...closableRow, primaryDispatchStatus });
expect(input.company).toBe("");
expect(input.vendorId).toBe("");
expect(getClosabilityGaps(input)).toContain("Company");
},
);
it("raises Company when the board omits vendor assignment (inactive primary)", () => {
const input = tableRowToClosabilityInput({
...closableRow,
vendorId: "",
company: "",
primaryDispatchStatus: "Refused",
});
expect(input.vendorId).toBe("");
expect(getClosabilityGaps(input)).toContain("Company");
});
it("keeps a live vendorId after a vendor patch clears leftover refused status", () => {
const input = tableRowToClosabilityInput({
...closableRow,
company: "New Co",
vendorId: "9",
primaryDispatchStatus: "",
});
expect(getClosabilityGaps(input)).toEqual([]);
});
it.each(["Pending", "Sent"] as const)(
"keeps company assignment after vendor save when primary dispatch status is %s",
(primaryDispatchStatus) => {
const input = tableRowToClosabilityInput({
...closableRow,
company: "New Co",
vendorId: "9",
primaryDispatchStatus,
});
expect(input.vendorId).toBe("9");
expect(getClosabilityGaps(input)).not.toContain("Company");
},
);
it("raises Company when leftover Refused remains on the patched vendor id", () => {
const input = tableRowToClosabilityInput({
...closableRow,
company: "New Co",
vendorId: "9",
primaryDispatchStatus: "Refused",
});
expect(getClosabilityGaps(input)).toContain("Company");
});
});
describe("readDetailWoType", () => {