From 1554cf30d0a1a074e921689f164a291ec5400e87 Mon Sep 17 00:00:00 2001 From: Arthur Bassi Date: Wed, 19 Aug 2026 11:49:19 -0300 Subject: [PATCH 01/11] fix(work-orders): require vendor company not technician to complete (SH-183) --- .../list/table/cells/status-cell.tsx | 1 + .../work-orders/utils/wo-closability.ts | 19 +++++++---- .../status-cell-closability.test.tsx | 1 + .../status-cell-past-due-copy.test.tsx | 1 + .../status-cell-pending-uplift.test.tsx | 20 ++++++++++- .../work-orders/utils/wo-closability.test.ts | 33 +++++++++++++++++++ 6 files changed, 67 insertions(+), 8 deletions(-) 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 494c8fef..0723150d 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 @@ -26,6 +26,7 @@ function rowClosabilityGaps(wo: WorkOrderTableRow) { dispatcherId: wo.dispatcherId, dispatcherName: wo.dispatcherName, company: wo.company, + vendorId: wo.vendorId ?? "", tech: wo.tech, completedDate: wo.completedDate ?? "", woNumber: wo.woNumber, diff --git a/src/domain/work-orders/utils/wo-closability.ts b/src/domain/work-orders/utils/wo-closability.ts index 1290ecd3..823a241c 100644 --- a/src/domain/work-orders/utils/wo-closability.ts +++ b/src/domain/work-orders/utils/wo-closability.ts @@ -11,6 +11,8 @@ export type WoClosabilityInput = { dispatcherName: string; /** Company / vendor assignment — required for Completed (SH-183). */ 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 +59,8 @@ function hasDispatcher(row: Pick): boolean { + return Boolean((row.company ?? "").trim() || (row.vendorId ?? "").trim()); } function hasService(pm: string): boolean { @@ -80,7 +82,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 +145,7 @@ export function wizardDraftToClosabilityInput(draft: WorkOrderWizardDraft): WoCl dispatcherId: draft.assigneeId, dispatcherName: draft.assigneeName, company: draft.vendorName, + vendorId: draft.vendorId ?? "", tech: "", completedDate: "", woNumber: draft.woNumber, @@ -159,13 +162,15 @@ export function wizardDraftToClosabilityInput(draft: WorkOrderWizardDraft): WoCl * 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 = wo.dispatches.find((dispatch) => { + const vendorId = String(dispatch.vendorId ?? "").trim(); + return Boolean(dispatch.vendorName.trim() || vendorId); + }); 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, diff --git a/src/test/app/(protected)/workorders/status-cell-closability.test.tsx b/src/test/app/(protected)/workorders/status-cell-closability.test.tsx index 1ca88e1b..828d260f 100644 --- a/src/test/app/(protected)/workorders/status-cell-closability.test.tsx +++ b/src/test/app/(protected)/workorders/status-cell-closability.test.tsx @@ -9,6 +9,7 @@ const incompleteRow = { dispatcherId: "", dispatcherName: "", company: "", + vendorId: "", tech: "", completedDate: "", woNumber: "", diff --git a/src/test/app/(protected)/workorders/status-cell-past-due-copy.test.tsx b/src/test/app/(protected)/workorders/status-cell-past-due-copy.test.tsx index 961e830c..df022f15 100644 --- a/src/test/app/(protected)/workorders/status-cell-past-due-copy.test.tsx +++ b/src/test/app/(protected)/workorders/status-cell-past-due-copy.test.tsx @@ -9,6 +9,7 @@ const pastDueRow = { dispatcherId: "", dispatcherName: "", company: "", + vendorId: "", tech: "", completedDate: "", woNumber: "", 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 a3755204..577c1469 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 @@ -125,7 +125,7 @@ describe("StatusCell pending uplift closability", () => { renderWithProviders( , @@ -141,6 +141,24 @@ describe("StatusCell pending uplift closability", () => { expect(onChangeStatus).not.toHaveBeenCalled(); }); + it("allows Completed when vendorId is set even if company label is empty (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("allows Completed when technician is empty if company is set (SH-183)", () => { const onChangeStatus = vi.fn(); 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 c900ccec..7e60565f 100644 --- a/src/test/domain/work-orders/utils/wo-closability.test.ts +++ b/src/test/domain/work-orders/utils/wo-closability.test.ts @@ -21,6 +21,7 @@ function makeInput(overrides: Partial = {}): WoClosabilityIn dispatcherId: "u1", dispatcherName: "Alex", company: "Acme HVAC", + vendorId: "", tech: "Sam", completedDate: "2026-07-01", woNumber: "1001", @@ -85,6 +86,16 @@ describe("getClosabilityGaps", () => { expect(getClosabilityGaps(makeInput({ company: " ", tech: "Sam" }))).toEqual(["Company"]); }); + it("allows Completed when vendorId is set and company/technician are empty (SH-183)", () => { + 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("lists field gaps before uplift and document gaps", () => { expect( getClosabilityGaps( @@ -188,6 +199,7 @@ describe("wizardDraftToClosabilityInput", () => { dispatcherId: "u1", dispatcherName: "Alice", company: "ACME", + vendorId: "", tech: "", completedDate: "", woNumber: "20260623001", @@ -221,6 +233,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 +290,26 @@ describe("detailToClosabilityInput", () => { expect(getClosabilityGaps(detailToClosabilityInput(detail))).toEqual(["At least 1 photo"]); }); + + it("treats vendorId without vendorName as assigned company (SH-183)", () => { + 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([]); + }); }); describe("readDetailWoType", () => { From a43de20ed4ee7dabba34d227926c155c8a17f274 Mon Sep 17 00:00:00 2001 From: Arthur Bassi Date: Wed, 19 Aug 2026 12:02:17 -0300 Subject: [PATCH 02/11] fix(work-orders): ignore cancelled dispatches in detail closability (SH-183) --- .../work-orders/utils/wo-closability.ts | 19 ++++++-- .../work-orders/utils/wo-closability.test.ts | 47 +++++++++++++++++++ 2 files changed, 62 insertions(+), 4 deletions(-) diff --git a/src/domain/work-orders/utils/wo-closability.ts b/src/domain/work-orders/utils/wo-closability.ts index 823a241c..ab9f9494 100644 --- a/src/domain/work-orders/utils/wo-closability.ts +++ b/src/domain/work-orders/utils/wo-closability.ts @@ -1,3 +1,4 @@ +import type { WorkOrderDispatchSummary } from "@/domain/work-orders/types/dispatch"; import type { WorkOrderDetail } from "@/domain/work-orders/types/work-order-detail"; import type { CompletionDocStatus, @@ -156,16 +157,26 @@ export function wizardDraftToClosabilityInput(draft: WorkOrderWizardDraft): WoCl }; } +/** Current vendor assignment — cancelled dispatches do not count as company (SH-183). */ +function currentAssignedDispatch( + dispatches: WorkOrderDispatchSummary[], +): WorkOrderDispatchSummary | undefined { + return dispatches.find((dispatch) => { + if (dispatch.status === "Cancelled") { + 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 assigned = wo.dispatches.find((dispatch) => { - const vendorId = String(dispatch.vendorId ?? "").trim(); - return Boolean(dispatch.vendorName.trim() || vendorId); - }); + const assigned = currentAssignedDispatch(wo.dispatches); return { dispatcherId: "", dispatcherName: wo.assignedTo, 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 7e60565f..9aa2c15e 100644 --- a/src/test/domain/work-orders/utils/wo-closability.test.ts +++ b/src/test/domain/work-orders/utils/wo-closability.test.ts @@ -96,6 +96,22 @@ describe("getClosabilityGaps", () => { ]); }); + it.each(["PM", "Reactive", "Emergency", "Overdue", ""] as const)( + "blocks Completed without a company for type %s (SH-183)", + (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( @@ -310,6 +326,37 @@ describe("detailToClosabilityInput", () => { expect(input.vendorId).toBe("45"); expect(getClosabilityGaps(input)).toEqual([]); }); + + it("ignores cancelled dispatches when resolving assigned company (SH-183)", () => { + const cancelledOnly = { + 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, status: "Cancelled" }], + } as WorkOrderDetail; + + expect(detailToClosabilityInput(cancelledOnly).vendorId).toBe(""); + expect(getClosabilityGaps(detailToClosabilityInput(cancelledOnly))).toContain("Company"); + + const supersededThenCurrent = { + ...cancelledOnly, + dispatches: [ + { vendorName: "Old Co", vendorId: 1, status: "Cancelled" }, + { 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("readDetailWoType", () => { From e3b8360a68a24d5daca93dd7f03c2ec4eaf8eae0 Mon Sep 17 00:00:00 2001 From: Arthur Bassi Date: Wed, 19 Aug 2026 15:04:50 -0300 Subject: [PATCH 03/11] fix(work-orders): treat Canceled and Refused dispatches as inactive (SH-183) Backend returns Cancelled, Canceled, and Refused; only Cancelled was skipped, so Complete stayed enabled with no live vendor. --- .../work-orders/utils/wo-closability.ts | 11 ++++-- .../work-orders/utils/wo-closability.test.ts | 36 +++++++++++++------ 2 files changed, 34 insertions(+), 13 deletions(-) diff --git a/src/domain/work-orders/utils/wo-closability.ts b/src/domain/work-orders/utils/wo-closability.ts index ab9f9494..822f23d5 100644 --- a/src/domain/work-orders/utils/wo-closability.ts +++ b/src/domain/work-orders/utils/wo-closability.ts @@ -157,12 +157,19 @@ export function wizardDraftToClosabilityInput(draft: WorkOrderWizardDraft): WoCl }; } -/** Current vendor assignment — cancelled dispatches do not count as company (SH-183). */ +/** 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 { + return INACTIVE_DISPATCH_STATUSES.has((status ?? "").trim().toLowerCase()); +} + +/** Current vendor assignment — cancelled/refused dispatches do not count as company (SH-183). */ function currentAssignedDispatch( dispatches: WorkOrderDispatchSummary[], ): WorkOrderDispatchSummary | undefined { return dispatches.find((dispatch) => { - if (dispatch.status === "Cancelled") { + if (isInactiveDispatchStatus(dispatch.status)) { return false; } const vendorId = String(dispatch.vendorId ?? "").trim(); 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 9aa2c15e..c50b27fb 100644 --- a/src/test/domain/work-orders/utils/wo-closability.test.ts +++ b/src/test/domain/work-orders/utils/wo-closability.test.ts @@ -327,8 +327,30 @@ describe("detailToClosabilityInput", () => { expect(getClosabilityGaps(input)).toEqual([]); }); - it("ignores cancelled dispatches when resolving assigned company (SH-183)", () => { - const cancelledOnly = { + it.each(["Cancelled", "Canceled", "Refused", "CANCELLED", " canceled "])( + "ignores inactive dispatch status %s when resolving assigned company (SH-183)", + (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("uses the active dispatch after an inactive cancelled spelling (SH-183)", () => { + const supersededThenCurrent = { assignedTo: "Alice", completedDate: "2026-07-15", workOrderNumber: "20260623001", @@ -338,16 +360,8 @@ describe("detailToClosabilityInput", () => { type: "", docStatus: "", mediaCount: 0, - dispatches: [{ vendorName: "", vendorId: 45, status: "Cancelled" }], - } as WorkOrderDetail; - - expect(detailToClosabilityInput(cancelledOnly).vendorId).toBe(""); - expect(getClosabilityGaps(detailToClosabilityInput(cancelledOnly))).toContain("Company"); - - const supersededThenCurrent = { - ...cancelledOnly, dispatches: [ - { vendorName: "Old Co", vendorId: 1, status: "Cancelled" }, + { vendorName: "Old Co", vendorId: 1, status: "Canceled" }, { vendorName: "ACME Tech", vendorId: 45, status: "Sent" }, ], } as WorkOrderDetail; From 854267e213084746a91de0765af3f18d440409e1 Mon Sep 17 00:00:00 2001 From: Arthur Bassi Date: Wed, 19 Aug 2026 15:16:23 -0300 Subject: [PATCH 04/11] test(work-orders): keep Verified and Completed as live company assignment (SH-183) Cancelled/Canceled/Refused stay inactive; verified or completed dispatches still gate Complete on the vendor that did the work. --- .../work-orders/utils/wo-closability.test.ts | 23 +++++++++++++++++++ 1 file changed, 23 insertions(+) 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 c50b27fb..45182f08 100644 --- a/src/test/domain/work-orders/utils/wo-closability.test.ts +++ b/src/test/domain/work-orders/utils/wo-closability.test.ts @@ -349,6 +349,29 @@ describe("detailToClosabilityInput", () => { }, ); + it.each(["Verified", "Completed", "Sent"])( + "treats dispatch status %s as a live company assignment (SH-183)", + (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 (SH-183)", () => { const supersededThenCurrent = { assignedTo: "Alice", From e9b00bab57cdfa928e43429208684dc27913a129 Mon Sep 17 00:00:00 2001 From: Arthur Bassi Date: Thu, 20 Aug 2026 10:52:43 -0300 Subject: [PATCH 05/11] fix(work-orders): ignore inactive primary dispatch on board closability (SH-183) Board Completed must not treat Cancelled/Canceled/Refused leftover vendorId as a live company assignment. --- .../list/table/cells/status-cell.tsx | 17 +------ .../mappers/work-order-table-row-mapper.ts | 13 +++++ .../types/work-order-board-detail.ts | 4 ++ .../work-orders/types/work-order-board.ts | 4 ++ .../work-orders/types/work-order-table-row.ts | 2 + .../work-orders/utils/wo-closability.ts | 23 ++++++++- .../status-cell-pending-uplift.test.tsx | 47 +++++++++++++++++++ .../work-order-table-row-mapper.test.ts | 12 +++++ .../work-orders/utils/wo-closability.test.ts | 34 ++++++++++++++ 9 files changed, 140 insertions(+), 16 deletions(-) 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", From f77428ab19167e231340c160884fe7a7266e3740 Mon Sep 17 00:00:00 2001 From: Arthur Bassi Date: Thu, 20 Aug 2026 13:22:12 -0300 Subject: [PATCH 06/11] fix(work-orders): map only official primaryDispatchStatus (SH-183) Drop undocumented board aliases so the Completed gate matches the backend DTO field. --- .../mappers/work-order-table-row-mapper.ts | 10 +--------- .../work-orders/types/work-order-board-detail.ts | 4 +--- src/domain/work-orders/types/work-order-board.ts | 4 +--- .../mappers/work-order-table-row-mapper.test.ts | 15 ++++++++++++--- .../work-orders/utils/wo-closability.test.ts | 6 ++++++ 5 files changed, 21 insertions(+), 18 deletions(-) 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 b17b683a..cea22e59 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 @@ -268,15 +268,7 @@ function readHasPendingUplift( } function readPrimaryDispatchStatus(item: Record): string { - return readString( - item, - "primaryDispatchStatus", - "PrimaryDispatchStatus", - "dispatchStatus", - "DispatchStatus", - "vendorDispatchStatus", - "VendorDispatchStatus", - ); + return readString(item, "primaryDispatchStatus", "PrimaryDispatchStatus"); } export function mapWorkOrderTableRow(raw: unknown): WorkOrderTableRow { 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 14adb595..163775e9 100644 --- a/src/domain/work-orders/types/work-order-board-detail.ts +++ b/src/domain/work-orders/types/work-order-board-detail.ts @@ -42,10 +42,8 @@ export interface WorkOrderBoardDetailInfoApi { dayGroup?: string | null; vendorId?: number | null; vendorName?: string | null; - /** Primary dispatch status when the board contract supplies it (SH-183). */ + /** Exact board DTO field; inactive primary dispatch is not a live company (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 d8c178c4..bc836d30 100644 --- a/src/domain/work-orders/types/work-order-board.ts +++ b/src/domain/work-orders/types/work-order-board.ts @@ -184,10 +184,8 @@ export interface WorkOrderBoardRowApi { dayGroup?: string | null; vendorId?: number | null; vendorName?: string | null; - /** Primary dispatch status when the board contract supplies it (SH-183). */ + /** Exact board DTO field; inactive primary dispatch is not a live company (SH-183). */ primaryDispatchStatus?: string | null; - dispatchStatus?: string | null; - vendorDispatchStatus?: string | null; techName?: string | null; techPhone?: string | null; apptDate?: string | null; 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 a2fbd50c..99283be9 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 @@ -54,11 +54,20 @@ describe("mapWorkOrderTableRow isAddOn", () => { describe("mapWorkOrderTableRow primaryDispatchStatus", () => { it.each([ ["primaryDispatchStatus", "Refused"], - ["dispatchStatus", "Canceled"], - ["vendorDispatchStatus", "Cancelled"], - ] as const)("maps %s onto primaryDispatchStatus", (key, value) => { + ["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("ignores undocumented dispatchStatus aliases", () => { + const row = mapWorkOrderTableRow({ + id: 9, + vendorId: 45, + dispatchStatus: "Refused", + vendorDispatchStatus: "Cancelled", + }); + expect(row.primaryDispatchStatus).toBe(""); + }); }); 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 d7eaf16b..c463dd5f 100644 --- a/src/test/domain/work-orders/utils/wo-closability.test.ts +++ b/src/test/domain/work-orders/utils/wo-closability.test.ts @@ -428,6 +428,12 @@ describe("tableRowToClosabilityInput", () => { expect(getClosabilityGaps(input)).toContain("Company"); }, ); + + it("raises Company when the board omits vendor assignment (inactive primary, SH-183)", () => { + const input = tableRowToClosabilityInput({ ...closableRow, vendorId: "", company: "" }); + expect(input.vendorId).toBe(""); + expect(getClosabilityGaps(input)).toContain("Company"); + }); }); describe("readDetailWoType", () => { From 4c9f8b7d81ca26fb9fa8e4d9f9a06aa2fa492f32 Mon Sep 17 00:00:00 2001 From: Arthur Bassi Date: Thu, 20 Aug 2026 14:17:20 -0300 Subject: [PATCH 07/11] fix(work-orders): treat vendor save as a live company assignment Clear leftover primary dispatch status on vendor patch so Completed is not blocked after a new company is chosen. Keep Jira keys out of source comments. --- REVIEW_AND_PR_FRAMEWORK.md | 4 ++ .../tabs/slide-over-info-tab-edit-view.tsx | 10 +--- .../list/table/wo-table-inline-row-cells.tsx | 10 +--- .../list/table/wo-table-row-service-cells.tsx | 10 +--- .../types/work-order-board-detail.ts | 1 - .../work-orders/types/work-order-board.ts | 1 - .../work-orders/types/work-order-table-row.ts | 1 - .../enrich-detail-closability-from-board.ts | 2 +- .../utils/vendor-assignment-patch.ts | 18 ++++++ .../work-orders/utils/wo-closability.ts | 7 +-- .../status-cell-pending-uplift.test.tsx | 57 +++++++++++++++++-- .../work-order-table-row-mapper.test.ts | 22 +++++-- .../utils/vendor-assignment-patch.test.ts | 37 ++++++++++++ .../work-orders/utils/wo-closability.test.ts | 37 ++++++++---- 14 files changed, 164 insertions(+), 53 deletions(-) create mode 100644 src/domain/work-orders/utils/vendor-assignment-patch.ts create mode 100644 src/test/domain/work-orders/utils/vendor-assignment-patch.test.ts diff --git a/REVIEW_AND_PR_FRAMEWORK.md b/REVIEW_AND_PR_FRAMEWORK.md index 977582f4..3496a066 100644 --- a/REVIEW_AND_PR_FRAMEWORK.md +++ b/REVIEW_AND_PR_FRAMEWORK.md @@ -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. diff --git a/src/app/(protected)/workorders/_components/detail/tabs/slide-over-info-tab-edit-view.tsx b/src/app/(protected)/workorders/_components/detail/tabs/slide-over-info-tab-edit-view.tsx index bdfe8af9..8b92c86f 100644 --- a/src/app/(protected)/workorders/_components/detail/tabs/slide-over-info-tab-edit-view.tsx +++ b/src/app/(protected)/workorders/_components/detail/tabs/slide-over-info-tab-edit-view.tsx @@ -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))} /> diff --git a/src/app/(protected)/workorders/_components/list/table/wo-table-inline-row-cells.tsx b/src/app/(protected)/workorders/_components/list/table/wo-table-inline-row-cells.tsx index f50d464d..02ee2880 100644 --- a/src/app/(protected)/workorders/_components/list/table/wo-table-inline-row-cells.tsx +++ b/src/app/(protected)/workorders/_components/list/table/wo-table-inline-row-cells.tsx @@ -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))} /> diff --git a/src/app/(protected)/workorders/_components/list/table/wo-table-row-service-cells.tsx b/src/app/(protected)/workorders/_components/list/table/wo-table-row-service-cells.tsx index 7c7c2d50..26773141 100644 --- a/src/app/(protected)/workorders/_components/list/table/wo-table-row-service-cells.tsx +++ b/src/app/(protected)/workorders/_components/list/table/wo-table-row-service-cells.tsx @@ -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))} /> { 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( @@ -141,7 +141,7 @@ describe("StatusCell pending uplift closability", () => { expect(onChangeStatus).not.toHaveBeenCalled(); }); - it("allows Completed when vendorId is set even if company label is empty (SH-183)", () => { + it("allows Completed when vendorId is set even if company label is empty", () => { const onChangeStatus = vi.fn(); renderWithProviders( @@ -159,7 +159,7 @@ 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)", () => { + it("allows Completed when vendorId has an empty company label and a live dispatch status", () => { const onChangeStatus = vi.fn(); renderWithProviders( @@ -178,7 +178,7 @@ describe("StatusCell pending uplift closability", () => { }); it.each(["Cancelled", "Canceled", "Refused"] as const)( - "disables Completed when vendorId is leftover from a %s dispatch (SH-183)", + "disables Completed when vendorId is leftover from a %s dispatch", (primaryDispatchStatus) => { const onChangeStatus = vi.fn(); @@ -206,7 +206,54 @@ describe("StatusCell pending uplift closability", () => { }, ); - it("allows Completed when technician is empty if company is set (SH-183)", () => { + it("disables Completed when the board omits vendorId after a Refused primary", () => { + 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"); + }); + + it("allows Completed after a vendor patch clears leftover refused status", () => { + 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("allows Completed when technician is empty if company is set", () => { const onChangeStatus = vi.fn(); renderWithProviders( 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 99283be9..970eeff7 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 @@ -61,13 +61,27 @@ describe("mapWorkOrderTableRow primaryDispatchStatus", () => { expect(row.primaryDispatchStatus).toBe(value); }); - it("ignores undocumented dispatchStatus aliases", () => { + 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, - dispatchStatus: "Refused", - vendorDispatchStatus: "Cancelled", + vendorName: "", + primaryDispatchStatus: "Sent", }); - expect(row.primaryDispatchStatus).toBe(""); + expect(row.vendorId).toBe("45"); + expect(row.company).toBe(""); + expect(row.primaryDispatchStatus).toBe("Sent"); }); }); diff --git a/src/test/domain/work-orders/utils/vendor-assignment-patch.test.ts b/src/test/domain/work-orders/utils/vendor-assignment-patch.test.ts new file mode 100644 index 00000000..72aaa7d2 --- /dev/null +++ b/src/test/domain/work-orders/utils/vendor-assignment-patch.test.ts @@ -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"); + }); +}); 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 c463dd5f..240ed796 100644 --- a/src/test/domain/work-orders/utils/wo-closability.test.ts +++ b/src/test/domain/work-orders/utils/wo-closability.test.ts @@ -77,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"); @@ -88,7 +88,7 @@ describe("getClosabilityGaps", () => { expect(getClosabilityGaps(makeInput({ company: " ", tech: "Sam" }))).toEqual(["Company"]); }); - it("allows Completed when vendorId is set and company/technician are empty (SH-183)", () => { + it("allows Completed when vendorId is set and company/technician are empty", () => { expect(getClosabilityGaps(makeInput({ company: "", vendorId: "7", tech: "" }))).toEqual([]); }); @@ -99,7 +99,7 @@ describe("getClosabilityGaps", () => { }); it.each(["PM", "Reactive", "Emergency", "Overdue", ""] as const)( - "blocks Completed without a company for type %s (SH-183)", + "blocks Completed without a company for type %s", (type) => { const gaps = getClosabilityGaps( makeInput({ @@ -309,7 +309,7 @@ describe("detailToClosabilityInput", () => { expect(getClosabilityGaps(detailToClosabilityInput(detail))).toEqual(["At least 1 photo"]); }); - it("treats vendorId without vendorName as assigned company (SH-183)", () => { + it("treats vendorId without vendorName as assigned company", () => { const detail = { assignedTo: "Alice", completedDate: "2026-07-15", @@ -330,7 +330,7 @@ describe("detailToClosabilityInput", () => { }); it.each(["Cancelled", "Canceled", "Refused", "CANCELLED", " canceled "])( - "ignores inactive dispatch status %s when resolving assigned company (SH-183)", + "ignores inactive dispatch status %s when resolving assigned company", (status) => { const inactiveOnly = { assignedTo: "Alice", @@ -352,7 +352,7 @@ describe("detailToClosabilityInput", () => { ); it.each(["Verified", "Completed", "Sent"])( - "treats dispatch status %s as a live company assignment (SH-183)", + "treats dispatch status %s as a live company assignment", (status) => { const liveAssignment = { assignedTo: "Alice", @@ -374,7 +374,7 @@ describe("detailToClosabilityInput", () => { }, ); - it("uses the active dispatch after an inactive cancelled spelling (SH-183)", () => { + it("uses the active dispatch after an inactive cancelled spelling", () => { const supersededThenCurrent = { assignedTo: "Alice", completedDate: "2026-07-15", @@ -413,14 +413,14 @@ describe("tableRowToClosabilityInput", () => { type: "", } as WorkOrderTableRow; - it("keeps vendorId as a live assignment when primary dispatch status is absent (SH-183)", () => { + 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 (SH-183)", + "clears company assignment when primary dispatch status is %s", (primaryDispatchStatus) => { const input = tableRowToClosabilityInput({ ...closableRow, primaryDispatchStatus }); expect(input.company).toBe(""); @@ -429,11 +429,26 @@ describe("tableRowToClosabilityInput", () => { }, ); - it("raises Company when the board omits vendor assignment (inactive primary, SH-183)", () => { - const input = tableRowToClosabilityInput({ ...closableRow, vendorId: "", 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([]); + }); }); describe("readDetailWoType", () => { From 493104c56d42857972ab283cdad8a92afefe04eb Mon Sep 17 00:00:00 2001 From: Arthur Bassi Date: Thu, 20 Aug 2026 14:29:57 -0300 Subject: [PATCH 08/11] fix(work-orders): narrow vendor patch type for inline drafts Vendor assignment patch must not carry table-row status, which blocked tsc because Canceled is not a draft status. --- src/domain/work-orders/utils/vendor-assignment-patch.ts | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/src/domain/work-orders/utils/vendor-assignment-patch.ts b/src/domain/work-orders/utils/vendor-assignment-patch.ts index ef6cf05c..0f39e146 100644 --- a/src/domain/work-orders/utils/vendor-assignment-patch.ts +++ b/src/domain/work-orders/utils/vendor-assignment-patch.ts @@ -1,4 +1,4 @@ -import type { WorkOrderTablePatch } from "@/domain/work-orders/types/work-order-table-row"; +import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row"; export type VendorAssignmentFields = { vendorId: string; @@ -7,7 +7,12 @@ export type VendorAssignmentFields = { techPhone: string; }; -export function toVendorTablePatch(fields: VendorAssignmentFields): WorkOrderTablePatch { +export type VendorTablePatch = Pick< + WorkOrderTableRow, + "vendorId" | "company" | "tech" | "techPhone" | "primaryDispatchStatus" +>; + +export function toVendorTablePatch(fields: VendorAssignmentFields): VendorTablePatch { return { vendorId: fields.vendorId, company: fields.company, From 972e5913f3d9fc114c721164366d6f5c13fdae86 Mon Sep 17 00:00:00 2001 From: Arthur Bassi Date: Thu, 20 Aug 2026 14:47:04 -0300 Subject: [PATCH 09/11] fix(work-orders): treat vendor status overlay as local-only on board patch Board PATCH persists vendorId only. primaryDispatchStatus is optimistic until the producer returns a live dispatch after vendor save. --- .../mappers/work-order-board-patch-mapper.ts | 1 + .../work-order-board-patch-mapper.test.ts | 16 +++++++++++++ .../work-orders/utils/wo-closability.test.ts | 24 +++++++++++++++++++ 3 files changed, 41 insertions(+) diff --git a/src/domain/work-orders/mappers/work-order-board-patch-mapper.ts b/src/domain/work-orders/mappers/work-order-board-patch-mapper.ts index c3e55a58..87d68f25 100644 --- a/src/domain/work-orders/mappers/work-order-board-patch-mapper.ts +++ b/src/domain/work-orders/mappers/work-order-board-patch-mapper.ts @@ -42,6 +42,7 @@ const LOCAL_ONLY_KEYS = new Set([ "originalDate", /** Optimistic overlay only — backend owns isAddOn after schedule ops (SH-184). */ "isAddOn", + "primaryDispatchStatus", ]); export function isLocalOnlyTablePatch(patch: WorkOrderTablePatch): boolean { 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 f26d13fe..8e728756 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 @@ -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", 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 240ed796..9d97358d 100644 --- a/src/test/domain/work-orders/utils/wo-closability.test.ts +++ b/src/test/domain/work-orders/utils/wo-closability.test.ts @@ -449,6 +449,30 @@ describe("tableRowToClosabilityInput", () => { }); 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", () => { From d8da3a50483f38ee50b1d7d0e5ccdaa7d3c824b1 Mon Sep 17 00:00:00 2001 From: Arthur Bassi Date: Thu, 20 Aug 2026 15:09:35 -0300 Subject: [PATCH 10/11] test(work-orders): pin vendor PATCH response as live company assignment Round-trip: board PATCH emits vendorId only; a Pending response keeps Completed unblocked, while a leftover Refused response still raises Missing: Company. --- .../use-work-order-table-mutations.test.tsx | 118 ++++++++++++++++++ 1 file changed, 118 insertions(+) diff --git a/src/test/domain/work-orders/use-cases/use-work-order-table-mutations.test.tsx b/src/test/domain/work-orders/use-cases/use-work-order-table-mutations.test.tsx index 73729279..6b2b3a64 100644 --- a/src/test/domain/work-orders/use-cases/use-work-order-table-mutations.test.tsx +++ b/src/test/domain/work-orders/use-cases/use-work-order-table-mutations.test.tsx @@ -25,6 +25,11 @@ 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 { return new Promise((resolve) => setTimeout(resolve, ms)); @@ -345,3 +350,116 @@ 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: WorkOrderTableRow = { + ...rowState, + vendorId: "9", + company: "New Co", + primaryDispatchStatus: "Pending", + primaryDispatchId: 11, + }; + 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" }), + ), + ); + + expect(patchBoardField).toHaveBeenCalledWith( + 42, + expect.objectContaining({ field: "vendorId", value: "9" }), + ); + const payload = patchBoardField.mock.calls[0]?.[1] as Record; + expect(payload).not.toHaveProperty("primaryDispatchStatus"); + expect(getClosabilityGaps(tableRowToClosabilityInput(returnedRow))).not.toContain("Company"); + }); + + it("keeps a Company gap when vendor PATCH still returns Refused", async () => { + const returnedRow: WorkOrderTableRow = { + ...rowState, + vendorId: "9", + company: "New Co", + primaryDispatchStatus: "Refused", + }; + 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"); + }); +}); From 9a347a67fbcf7b5434d28c3b37458f7db75abdf9 Mon Sep 17 00:00:00 2001 From: Arthur Bassi Date: Thu, 20 Aug 2026 15:39:39 -0300 Subject: [PATCH 11/11] test(work-orders): pin vendor PATCH producer row onto closability --- .../work-order-table-row-mapper.test.ts | 14 ++++++ .../use-work-order-table-mutations.test.tsx | 50 +++++++++++++------ 2 files changed, 49 insertions(+), 15 deletions(-) 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 970eeff7..74bdc2bb 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 @@ -84,4 +84,18 @@ describe("mapWorkOrderTableRow primaryDispatchStatus", () => { 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); + }); }); diff --git a/src/test/domain/work-orders/use-cases/use-work-order-table-mutations.test.tsx b/src/test/domain/work-orders/use-cases/use-work-order-table-mutations.test.tsx index 6b2b3a64..1f629b09 100644 --- a/src/test/domain/work-orders/use-cases/use-work-order-table-mutations.test.tsx +++ b/src/test/domain/work-orders/use-cases/use-work-order-table-mutations.test.tsx @@ -21,6 +21,7 @@ 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, @@ -374,13 +375,21 @@ describe("useWorkOrderTableMutations vendor assignment round-trip", () => { }); it("keeps Company off the gap list when vendor PATCH returns a Pending assignment", async () => { - const returnedRow: WorkOrderTableRow = { - ...rowState, - vendorId: "9", - company: "New Co", - primaryDispatchStatus: "Pending", - primaryDispatchId: 11, - }; + 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(); @@ -409,26 +418,37 @@ describe("useWorkOrderTableMutations vendor assignment round-trip", () => { await waitFor(() => expect(onPatch).toHaveBeenCalledWith( 42, - expect.objectContaining({ primaryDispatchStatus: "Pending" }), + expect.objectContaining({ + primaryDispatchStatus: "Pending", + primaryDispatchId: 11, + vendorId: "9", + }), ), ); expect(patchBoardField).toHaveBeenCalledWith( 42, - expect.objectContaining({ field: "vendorId", value: "9" }), + expect.objectContaining({ + field: "vendorId", + value: "9", + dispatchVersion: "disp-v1", + primaryDispatchId: 10, + }), ); const payload = patchBoardField.mock.calls[0]?.[1] as Record; 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: WorkOrderTableRow = { - ...rowState, - vendorId: "9", - company: "New Co", - primaryDispatchStatus: "Refused", - }; + const returnedRow = mapWorkOrderTableRow({ + id: 42, + VendorId: 9, + VendorName: "New Co", + PrimaryDispatchStatus: "Refused", + PrimaryDispatchId: 10, + }); patchBoardField.mockResolvedValue(returnedRow); const onPatch = vi.fn();