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/cells/status-cell.tsx b/src/app/(protected)/workorders/_components/list/table/cells/status-cell.tsx index 494c8fef..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,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({ 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))} /> ([ "originalDate", /** Optimistic overlay only — backend owns isAddOn after schedule ops (SH-184). */ "isAddOn", + "primaryDispatchStatus", ]); export function isLocalOnlyTablePatch(patch: WorkOrderTablePatch): boolean { 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..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 @@ -267,6 +267,10 @@ function readHasPendingUplift( return undefined; } +function readPrimaryDispatchStatus(item: Record): 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, 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..8e691382 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,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; diff --git a/src/domain/work-orders/types/work-order-board.ts b/src/domain/work-orders/types/work-order-board.ts index 5011b67b..dfad8c12 100644 --- a/src/domain/work-orders/types/work-order-board.ts +++ b/src/domain/work-orders/types/work-order-board.ts @@ -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; 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 4594bb49..d07549d7 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,7 @@ export interface WorkOrderTableRow { extraServices: string[]; vendorId: string; company: string; + primaryDispatchStatus?: string; tech: string; techPhone: string; apptStart: string; diff --git a/src/domain/work-orders/utils/enrich-detail-closability-from-board.ts b/src/domain/work-orders/utils/enrich-detail-closability-from-board.ts index 9d315378..f86caa0c 100644 --- a/src/domain/work-orders/utils/enrich-detail-closability-from-board.ts +++ b/src/domain/work-orders/utils/enrich-detail-closability-from-board.ts @@ -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, diff --git a/src/domain/work-orders/utils/vendor-assignment-patch.ts b/src/domain/work-orders/utils/vendor-assignment-patch.ts new file mode 100644 index 00000000..0f39e146 --- /dev/null +++ b/src/domain/work-orders/utils/vendor-assignment-patch.ts @@ -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: "", + }; +} diff --git a/src/domain/work-orders/utils/wo-closability.ts b/src/domain/work-orders/utils/wo-closability.ts index 1290ecd3..841f68d2 100644 --- a/src/domain/work-orders/utils/wo-closability.ts +++ b/src/domain/work-orders/utils/wo-closability.ts @@ -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): 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, 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..e6e50cfe 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 @@ -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( , @@ -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( + , + { 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( + , + { 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( + , + { 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( + , + { 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-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/mappers/work-order-table-row-mapper.test.ts b/src/test/domain/work-orders/mappers/work-order-table-row-mapper.test.ts index 93123601..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 @@ -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); + }); +}); 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..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,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 { 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; + 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"); + }); +}); 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 c900ccec..9d97358d 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"; @@ -21,6 +23,7 @@ function makeInput(overrides: Partial = {}): 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", () => {