diff --git a/src/app/(protected)/workorders/_components/list/table/cells/status-cell.tsx b/src/app/(protected)/workorders/_components/list/table/cells/status-cell.tsx index 0723150d..de7ed3c2 100644 --- a/src/app/(protected)/workorders/_components/list/table/cells/status-cell.tsx +++ b/src/app/(protected)/workorders/_components/list/table/cells/status-cell.tsx @@ -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,21 +23,7 @@ type StatusCellProps = { }; function rowClosabilityGaps(wo: WorkOrderTableRow) { - return getClosabilityGaps({ - dispatcherId: wo.dispatcherId, - dispatcherName: wo.dispatcherName, - company: wo.company, - vendorId: 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, - }); + return getClosabilityGaps(tableRowToClosabilityInput(wo)); } function PastDueStatusCell({ diff --git a/src/domain/work-orders/mappers/work-order-table-row-mapper.ts b/src/domain/work-orders/mappers/work-order-table-row-mapper.ts index 8dfe70e0..b17b683a 100644 --- a/src/domain/work-orders/mappers/work-order-table-row-mapper.ts +++ b/src/domain/work-orders/mappers/work-order-table-row-mapper.ts @@ -267,6 +267,18 @@ function readHasPendingUplift( return undefined; } +function readPrimaryDispatchStatus(item: Record): string { + return readString( + item, + "primaryDispatchStatus", + "PrimaryDispatchStatus", + "dispatchStatus", + "DispatchStatus", + "vendorDispatchStatus", + "VendorDispatchStatus", + ); +} + export function mapWorkOrderTableRow(raw: unknown): WorkOrderTableRow { const item = asRecord(raw); const scheduledOn = toDateInputValue( @@ -348,6 +360,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, diff --git a/src/domain/work-orders/types/work-order-board-detail.ts b/src/domain/work-orders/types/work-order-board-detail.ts index dda17df8..14adb595 100644 --- a/src/domain/work-orders/types/work-order-board-detail.ts +++ b/src/domain/work-orders/types/work-order-board-detail.ts @@ -42,6 +42,10 @@ export interface WorkOrderBoardDetailInfoApi { dayGroup?: string | null; vendorId?: number | null; vendorName?: string | null; + /** Primary dispatch status when the board contract supplies it (SH-183). */ + primaryDispatchStatus?: string | null; + dispatchStatus?: string | null; + vendorDispatchStatus?: string | null; techName?: string | null; techPhone?: string | null; apptDate?: string | null; diff --git a/src/domain/work-orders/types/work-order-board.ts b/src/domain/work-orders/types/work-order-board.ts index 5011b67b..d8c178c4 100644 --- a/src/domain/work-orders/types/work-order-board.ts +++ b/src/domain/work-orders/types/work-order-board.ts @@ -184,6 +184,10 @@ export interface WorkOrderBoardRowApi { dayGroup?: string | null; vendorId?: number | null; vendorName?: string | null; + /** Primary dispatch status when the board contract supplies it (SH-183). */ + primaryDispatchStatus?: string | null; + dispatchStatus?: string | null; + vendorDispatchStatus?: string | null; techName?: string | null; techPhone?: string | null; apptDate?: string | null; diff --git a/src/domain/work-orders/types/work-order-table-row.ts b/src/domain/work-orders/types/work-order-table-row.ts index 4b066382..e6432ab7 100644 --- a/src/domain/work-orders/types/work-order-table-row.ts +++ b/src/domain/work-orders/types/work-order-table-row.ts @@ -37,6 +37,8 @@ export interface WorkOrderTableRow { extraServices: string[]; vendorId: string; company: string; + /** Primary dispatch status when the board contract supplies it (SH-183). */ + primaryDispatchStatus?: string; tech: string; techPhone: string; apptStart: string; diff --git a/src/domain/work-orders/utils/wo-closability.ts b/src/domain/work-orders/utils/wo-closability.ts index 822f23d5..9feb2db4 100644 --- a/src/domain/work-orders/utils/wo-closability.ts +++ b/src/domain/work-orders/utils/wo-closability.ts @@ -2,6 +2,7 @@ import type { WorkOrderDispatchSummary } from "@/domain/work-orders/types/dispat 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"; @@ -160,10 +161,30 @@ export function wizardDraftToClosabilityInput(draft: WorkOrderWizardDraft): WoCl /** Backend terminal-cancel spellings; dispatch status is a raw API string. */ const INACTIVE_DISPATCH_STATUSES = new Set(["cancelled", "canceled", "refused"]); -function isInactiveDispatchStatus(status: string | undefined): boolean { +export function isInactiveDispatchStatus(status: string | undefined): boolean { return INACTIVE_DISPATCH_STATUSES.has((status ?? "").trim().toLowerCase()); } +/** Map board row onto closability; inactive primary dispatch is not a live company (SH-183). */ +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, + }; +} + /** Current vendor assignment — cancelled/refused dispatches do not count as company (SH-183). */ function currentAssignedDispatch( dispatches: WorkOrderDispatchSummary[], diff --git a/src/test/app/(protected)/workorders/status-cell-pending-uplift.test.tsx b/src/test/app/(protected)/workorders/status-cell-pending-uplift.test.tsx index 577c1469..4dead871 100644 --- a/src/test/app/(protected)/workorders/status-cell-pending-uplift.test.tsx +++ b/src/test/app/(protected)/workorders/status-cell-pending-uplift.test.tsx @@ -159,6 +159,53 @@ describe("StatusCell pending uplift closability", () => { expect(onChangeStatus).toHaveBeenCalledWith("Completed"); }); + it("allows Completed when vendorId has an empty company label and a live dispatch status (SH-183)", () => { + const onChangeStatus = vi.fn(); + + renderWithProviders( + , + { 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 (SH-183)", + (primaryDispatchStatus) => { + const onChangeStatus = vi.fn(); + + renderWithProviders( + , + { 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("allows Completed when technician is empty if company is set (SH-183)", () => { const onChangeStatus = vi.fn(); diff --git a/src/test/domain/work-orders/mappers/work-order-table-row-mapper.test.ts b/src/test/domain/work-orders/mappers/work-order-table-row-mapper.test.ts index 93123601..a2fbd50c 100644 --- a/src/test/domain/work-orders/mappers/work-order-table-row-mapper.test.ts +++ b/src/test/domain/work-orders/mappers/work-order-table-row-mapper.test.ts @@ -50,3 +50,15 @@ describe("mapWorkOrderTableRow isAddOn", () => { expect(row.isAddOn).toBe(false); }); }); + +describe("mapWorkOrderTableRow primaryDispatchStatus", () => { + it.each([ + ["primaryDispatchStatus", "Refused"], + ["dispatchStatus", "Canceled"], + ["vendorDispatchStatus", "Cancelled"], + ] as const)("maps %s onto primaryDispatchStatus", (key, value) => { + const row = mapWorkOrderTableRow({ id: 9, vendorId: 45, [key]: value }); + expect(row.vendorId).toBe("45"); + expect(row.primaryDispatchStatus).toBe(value); + }); +}); diff --git a/src/test/domain/work-orders/utils/wo-closability.test.ts b/src/test/domain/work-orders/utils/wo-closability.test.ts index 45182f08..d7eaf16b 100644 --- a/src/test/domain/work-orders/utils/wo-closability.test.ts +++ b/src/test/domain/work-orders/utils/wo-closability.test.ts @@ -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"; @@ -396,6 +398,38 @@ describe("detailToClosabilityInput", () => { }); }); +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 (SH-183)", () => { + 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 (SH-183)", + (primaryDispatchStatus) => { + const input = tableRowToClosabilityInput({ ...closableRow, primaryDispatchStatus }); + expect(input.company).toBe(""); + expect(input.vendorId).toBe(""); + expect(getClosabilityGaps(input)).toContain("Company"); + }, + ); +}); + describe("readDetailWoType", () => { it.each(["PM", "Reactive", "Emergency", "Overdue"])( "passes through the business type %s",