From 1554cf30d0a1a074e921689f164a291ec5400e87 Mon Sep 17 00:00:00 2001 From: Arthur Bassi Date: Wed, 19 Aug 2026 11:49:19 -0300 Subject: [PATCH] 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", () => {