From 5e3b7b80573e8ffabdbbfadcc2940c664ac9725d Mon Sep 17 00:00:00 2001 From: "arthur.bassi" Date: Tue, 11 Aug 2026 14:06:54 -0300 Subject: [PATCH] fix(work-orders): use authoritative type/doc/media for detail closability Stop treating integration source as WorkOrderType. Enrich detail from board detail for SH-114/SH-183 gates. Co-authored-by: Cursor --- .../_hooks/use-work-order-view-controller.ts | 16 ++- .../mappers/read-detail-closability-fields.ts | 101 ++++++++++++++++++ .../work-orders/mappers/work-order-mapper.ts | 8 ++ .../work-orders/mocks/wo-wizard-mock-data.ts | 3 + .../work-orders/types/work-order-detail.ts | 8 ++ .../enrich-detail-closability-from-board.ts | 22 ++++ .../work-orders/utils/wo-closability.ts | 18 ++-- .../work-order-header-closability.test.tsx | 3 + .../work-order-board-patch-mapper.test.ts | 2 + ...rich-detail-closability-from-board.test.ts | 57 ++++++++++ .../work-orders/utils/wo-closability.test.ts | 100 ++++++++++++++++- 11 files changed, 321 insertions(+), 17 deletions(-) create mode 100644 src/domain/work-orders/mappers/read-detail-closability-fields.ts create mode 100644 src/domain/work-orders/utils/enrich-detail-closability-from-board.ts create mode 100644 src/test/domain/work-orders/utils/enrich-detail-closability-from-board.test.ts diff --git a/src/app/(protected)/workorders/_hooks/use-work-order-view-controller.ts b/src/app/(protected)/workorders/_hooks/use-work-order-view-controller.ts index 9053c006..3a5578f1 100644 --- a/src/app/(protected)/workorders/_hooks/use-work-order-view-controller.ts +++ b/src/app/(protected)/workorders/_hooks/use-work-order-view-controller.ts @@ -1,17 +1,22 @@ import { useMemo, useState } from "react"; -import { useWorkOrderDetail } from "@/domain/work-orders/use-cases/use-work-order-detail"; +import { useVendorEditor } from "@/app/(protected)/workorders/_hooks/use-vendor-editor"; +import { + useWorkOrderBoardDetail, + useWorkOrderDetail, +} from "@/domain/work-orders/use-cases/use-work-order-detail"; import { useAddWorkOrderComment, useChangeWorkOrderAssignment, useChangeWorkOrderStatus, } from "@/domain/work-orders/use-cases/use-work-order-mutations"; import { useWorkOrderUsers } from "@/domain/work-orders/use-cases/use-work-orders-list"; -import { useVendorEditor } from "@/app/(protected)/workorders/_hooks/use-vendor-editor"; +import { enrichDetailClosabilityFromBoard } from "@/domain/work-orders/utils/enrich-detail-closability-from-board"; export type WorkOrderActivityTab = "customer" | "internal" | "audit"; export function useWorkOrderViewController(workOrderId: string) { - const { data: wo, isLoading, error, refetch } = useWorkOrderDetail(workOrderId); + const { data: detail, isLoading, error, refetch } = useWorkOrderDetail(workOrderId); + const { data: boardDetail } = useWorkOrderBoardDetail(workOrderId); const { data: users = [] } = useWorkOrderUsers(); const statusMutation = useChangeWorkOrderStatus(workOrderId); const assignmentMutation = useChangeWorkOrderAssignment(workOrderId); @@ -25,6 +30,11 @@ export function useWorkOrderViewController(workOrderId: string) { const [statusAnchor, setStatusAnchor] = useState(null); const [assignAnchor, setAssignAnchor] = useState(null); + const wo = useMemo( + () => (detail ? enrichDetailClosabilityFromBoard(detail, boardDetail) : undefined), + [detail, boardDetail], + ); + const customerComments = useMemo( () => (wo?.comments ?? []).filter((comment) => comment.commentType === "customer"), [wo?.comments], diff --git a/src/domain/work-orders/mappers/read-detail-closability-fields.ts b/src/domain/work-orders/mappers/read-detail-closability-fields.ts new file mode 100644 index 00000000..d8d5dbe6 --- /dev/null +++ b/src/domain/work-orders/mappers/read-detail-closability-fields.ts @@ -0,0 +1,101 @@ +import { + DOC_STATUS_API, + WORK_ORDER_TYPE_API_TO_FE, +} from "@/domain/work-orders/types/work-order-board"; +import type { CompletionDocStatus } from "@/domain/work-orders/types/work-order-table-row"; +import type { WOType } from "@/domain/work-orders/types/work-order-wizard"; + +const DETAIL_WO_TYPES = new Set(["PM", "Reactive", "Emergency", "Add-On", "Overdue"]); + +function asRecord(raw: unknown): Record { + return typeof raw === "object" && raw !== null ? (raw as Record) : {}; +} + +function readString(record: Record, ...keys: string[]): string { + for (const key of keys) { + const value = record[key]; + if (typeof value === "string") { + return value; + } + if (typeof value === "number") { + return String(value); + } + } + return ""; +} + +function readNumber(record: Record, ...keys: string[]): number | null { + for (const key of keys) { + const value = record[key]; + if (typeof value === "number") { + return value; + } + if (typeof value === "string" && value !== "") { + const parsed = Number(value); + if (!Number.isNaN(parsed)) { + return parsed; + } + } + } + return null; +} + +/** Authoritative WO type only — never falls back to integration `source`. */ +export function readDetailWoType(record: Record): WOType | "" { + const numeric = + readNumber(record, "workOrderType", "WorkOrderType") ?? readNumber(record, "type", "Type"); + if (numeric != null && WORK_ORDER_TYPE_API_TO_FE[numeric]) { + return WORK_ORDER_TYPE_API_TO_FE[numeric] as WOType; + } + + const raw = readString( + record, + "workOrderType", + "WorkOrderType", + "type", + "Type", + "woType", + "WoType", + ); + if (DETAIL_WO_TYPES.has(raw)) { + return raw as WOType; + } + if (raw === "AddOn") { + return "Add-On"; + } + return ""; +} + +export function readDetailDocStatus(record: Record): CompletionDocStatus { + const numeric = readNumber(record, "docStatus", "DocStatus"); + if (numeric === DOC_STATUS_API.Yes) return "Yes"; + if (numeric === DOC_STATUS_API.No) return "No"; + if (numeric === DOC_STATUS_API.NotNeeded) return "NN"; + + const nestedCompletion = asRecord(record.completion ?? record.Completion); + const nestedNumeric = readNumber(nestedCompletion, "docStatus", "DocStatus"); + if (nestedNumeric === DOC_STATUS_API.Yes) return "Yes"; + if (nestedNumeric === DOC_STATUS_API.No) return "No"; + if (nestedNumeric === DOC_STATUS_API.NotNeeded) return "NN"; + + const raw = readString(record, "docStatus", "DocStatus", "completionDoc", "CompletionDoc"); + if (raw === "Yes" || raw === "No" || raw === "NN") { + return raw; + } + if (raw === "NotNeeded") { + return "NN"; + } + return ""; +} + +export function readDetailMediaCount(record: Record): number { + const explicit = readNumber(record, "mediaCount", "MediaCount"); + if (explicit != null) { + return explicit; + } + const media = record.media ?? record.Media; + if (Array.isArray(media)) { + return media.length; + } + return 0; +} diff --git a/src/domain/work-orders/mappers/work-order-mapper.ts b/src/domain/work-orders/mappers/work-order-mapper.ts index 2b61364c..012d8239 100644 --- a/src/domain/work-orders/mappers/work-order-mapper.ts +++ b/src/domain/work-orders/mappers/work-order-mapper.ts @@ -1,4 +1,9 @@ import { parseSiteCodeFromLocationName } from "@/domain/work-orders/mappers/work-order-board-mapper"; +import { + readDetailDocStatus, + readDetailMediaCount, + readDetailWoType, +} from "@/domain/work-orders/mappers/read-detail-closability-fields"; import type { EmployeeOption, LocationOption, @@ -304,6 +309,9 @@ export function mapWorkOrderDetail(raw: unknown): WorkOrderDetail { scheduledDate: toDateInputValue(readString(wo, "scheduledDate", "ScheduledDate")), completedDate: toDateInputValue(readString(wo, "completedDate", "CompletedDate")), source: readString(wo, "source", "Source"), + type: readDetailWoType(wo), + docStatus: readDetailDocStatus(wo), + mediaCount: readDetailMediaCount(wo), ...(contactIds !== undefined ? { contactIds } : {}), ...(categoryIds !== undefined ? { categoryIds } : {}), comments: Array.isArray(wo.comments) diff --git a/src/domain/work-orders/mocks/wo-wizard-mock-data.ts b/src/domain/work-orders/mocks/wo-wizard-mock-data.ts index e4c2399c..7eb7a6e1 100644 --- a/src/domain/work-orders/mocks/wo-wizard-mock-data.ts +++ b/src/domain/work-orders/mocks/wo-wizard-mock-data.ts @@ -154,6 +154,9 @@ export function buildMockWorkOrderDetail(values: WorkOrderFormValues): WorkOrder scheduledDate: values.scheduledDate, completedDate: values.completedDate, source: values.source, + type: "", + docStatus: "", + mediaCount: 0, comments: [], auditLog: [], dispatches: [], diff --git a/src/domain/work-orders/types/work-order-detail.ts b/src/domain/work-orders/types/work-order-detail.ts index 8c492a99..b41c0159 100644 --- a/src/domain/work-orders/types/work-order-detail.ts +++ b/src/domain/work-orders/types/work-order-detail.ts @@ -1,5 +1,7 @@ import type { WorkOrderCommentType, WorkOrderPriority, WorkOrderStatus } from "./work-order-common"; import type { WorkOrderDispatchSummary } from "./dispatch"; +import type { CompletionDocStatus } from "./work-order-table-row"; +import type { WOType } from "./work-order-wizard"; export interface WorkOrderComment { id: string | number; @@ -50,6 +52,12 @@ export interface WorkOrderDetail { scheduledDate: string; completedDate: string; source: string; + /** Authoritative WO type (not integration `source`). Empty when unknown. */ + type: WOType | ""; + /** Completion document status for PM/Overdue closability gates. */ + docStatus: CompletionDocStatus; + /** Photo/media count for Emergency/Reactive closability gates. */ + mediaCount: number; /** Present when GetById returns contacts; used to re-send on edit so backend does not wipe. */ contactIds?: number[]; /** Present when GetById returns categories; used to re-send on edit so backend does not wipe. */ 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 new file mode 100644 index 00000000..9d315378 --- /dev/null +++ b/src/domain/work-orders/utils/enrich-detail-closability-from-board.ts @@ -0,0 +1,22 @@ +import type { WorkOrderBoardDetail } from "@/domain/work-orders/types/work-order-board-detail"; +import type { WorkOrderDetail } from "@/domain/work-orders/types/work-order-detail"; + +/** + * 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. + */ +export function enrichDetailClosabilityFromBoard( + detail: WorkOrderDetail, + board: WorkOrderBoardDetail | undefined, +): WorkOrderDetail { + if (!board) { + return detail; + } + + return { + ...detail, + type: board.info.type, + docStatus: board.completion.docStatus, + mediaCount: board.media.length, + }; +} diff --git a/src/domain/work-orders/utils/wo-closability.ts b/src/domain/work-orders/utils/wo-closability.ts index 4c563b8a..79f106a8 100644 --- a/src/domain/work-orders/utils/wo-closability.ts +++ b/src/domain/work-orders/utils/wo-closability.ts @@ -1,7 +1,6 @@ import type { WorkOrderDetail } from "@/domain/work-orders/types/work-order-detail"; import type { CompletionDocStatus } from "@/domain/work-orders/types/work-order-table-row"; import { - WO_TYPES, type WOType, type WorkOrderWizardDraft, } from "@/domain/work-orders/types/work-order-wizard"; @@ -110,15 +109,10 @@ export function wizardDraftToClosabilityInput(draft: WorkOrderWizardDraft): WoCl }; } -function detailSourceToWoType(source: string): WOType | "" { - return (WO_TYPES as readonly string[]).includes(source) ? (source as WOType) : ""; -} - /** - * Map standalone detail DTO onto closability. `problem` is the same service - * field the board maps into `pm`; `source` carries wizard WO type when set. - * docStatus/media stay empty (detail route does not expose them) so those - * type-specific gates still fail closed. + * 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) => @@ -132,8 +126,8 @@ export function detailToClosabilityInput(wo: WorkOrderDetail): WoClosabilityInpu completedDate: wo.completedDate, woNumber: wo.workOrderNumber || wo.internalWONumber, pm: wo.problem, - docStatus: "", - mediaCount: 0, - type: detailSourceToWoType(wo.source), + docStatus: wo.docStatus, + mediaCount: wo.mediaCount, + type: wo.type, }; } diff --git a/src/test/app/(protected)/workorders/work-order-header-closability.test.tsx b/src/test/app/(protected)/workorders/work-order-header-closability.test.tsx index 8f11964f..a9b3677a 100644 --- a/src/test/app/(protected)/workorders/work-order-header-closability.test.tsx +++ b/src/test/app/(protected)/workorders/work-order-header-closability.test.tsx @@ -28,6 +28,9 @@ const incompleteDetail = { scheduledDate: "", completedDate: "", source: "", + type: "", + docStatus: "", + mediaCount: 0, comments: [], auditLog: [], dispatches: [], 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 034785e9..47e59615 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 @@ -87,6 +87,7 @@ describe("expandTablePatchToBoardFieldOps", () => { { field: "scheduleWeekOnly", value: "true" }, { field: "targetWeek", value: "2026-07-13" }, { field: "scheduledDate", value: "" }, + { field: "completedDate", value: "" }, ]); }); @@ -102,6 +103,7 @@ describe("expandTablePatchToBoardFieldOps", () => { { field: "scheduleWeekOnly", value: "false" }, { field: "scheduledDate", value: "2026-07-15" }, { field: "targetWeek", value: "" }, + { field: "completedDate", value: "" }, ]); }); diff --git a/src/test/domain/work-orders/utils/enrich-detail-closability-from-board.test.ts b/src/test/domain/work-orders/utils/enrich-detail-closability-from-board.test.ts new file mode 100644 index 00000000..2f981501 --- /dev/null +++ b/src/test/domain/work-orders/utils/enrich-detail-closability-from-board.test.ts @@ -0,0 +1,57 @@ +import { describe, expect, it } from "vitest"; +import type { WorkOrderBoardDetail } from "@/domain/work-orders/types/work-order-board-detail"; +import type { WorkOrderDetail } from "@/domain/work-orders/types/work-order-detail"; +import { enrichDetailClosabilityFromBoard } from "@/domain/work-orders/utils/enrich-detail-closability-from-board"; + +const baseDetail = { + id: 1, + internalWONumber: "SH-1", + workOrderNumber: "20260623001", + title: "Test", + location: "", + locationId: "", + locationZip: "", + assignedTo: "Alice", + dueDate: "", + createdDate: "", + createdDateFull: "", + description: "", + priority: "", + status: "Open", + po: "", + tt: "", + problem: "HVAC", + trade: "", + subTrade: "", + vendorNTE: "", + scheduledDate: "", + completedDate: "2026-07-15", + source: "PM", + type: "" as const, + docStatus: "" as const, + mediaCount: 0, + comments: [], + auditLog: [], + dispatches: [], +} satisfies WorkOrderDetail; + +describe("enrichDetailClosabilityFromBoard", () => { + it("returns detail unchanged when board is undefined", () => { + expect(enrichDetailClosabilityFromBoard(baseDetail, undefined)).toBe(baseDetail); + }); + + it("overlays authoritative type, docStatus, and mediaCount from board", () => { + const board = { + info: { type: "PM" }, + completion: { docStatus: "Yes" }, + media: [{ id: "1" }, { id: "2" }], + } as WorkOrderBoardDetail; + + const enriched = enrichDetailClosabilityFromBoard(baseDetail, board); + expect(enriched.type).toBe("PM"); + expect(enriched.docStatus).toBe("Yes"); + expect(enriched.mediaCount).toBe(2); + expect(enriched.source).toBe("PM"); + expect(enriched.problem).toBe("HVAC"); + }); +}); 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 0e9e6353..1db6d102 100644 --- a/src/test/domain/work-orders/utils/wo-closability.test.ts +++ b/src/test/domain/work-orders/utils/wo-closability.test.ts @@ -161,14 +161,17 @@ describe("wizardDraftToClosabilityInput", () => { }); describe("detailToClosabilityInput", () => { - it("maps problem/source and vendorName so Done is not permanently blocked", () => { + it("maps authoritative type/doc/media and problem so Done is not permanently blocked", () => { const detail = { assignedTo: "Alice", completedDate: "2026-07-15", workOrderNumber: "20260623001", internalWONumber: "SH-1", problem: "HVAC PM", - source: "Add-On", + source: "procurement-integration", + type: "Add-On", + docStatus: "", + mediaCount: 0, dispatches: [{ vendorName: "ACME Tech" }], } as WorkOrderDetail; @@ -180,12 +183,34 @@ describe("detailToClosabilityInput", () => { expect(input.woNumber).toBe("20260623001"); expect(input.pm).toBe("HVAC PM"); expect(input.type).toBe("Add-On"); + expect(input.docStatus).toBe(""); + expect(input.mediaCount).toBe(0); expect(getClosabilityGaps(input)).not.toContain("Service"); expect(getClosabilityGaps(input)).not.toContain("Company"); expect(getClosabilityGaps(input)).not.toContain("Technician"); expect(getClosabilityGaps(input)).toEqual([]); }); + it("does not treat integration source as WorkOrderType", () => { + const detail = { + assignedTo: "Alice", + completedDate: "2026-07-15", + workOrderNumber: "20260623001", + internalWONumber: "SH-1", + problem: "HVAC", + source: "PM", + type: "", + docStatus: "No", + mediaCount: 0, + dispatches: [{ vendorName: "ACME Tech" }], + } as WorkOrderDetail; + + const input = detailToClosabilityInput(detail); + expect(input.type).toBe(""); + expect(getClosabilityGaps(input)).toEqual([]); + expect(getClosabilityGaps(input)).not.toContain("Completion document"); + }); + it("fails closed on missing service when problem is empty", () => { const detail = { assignedTo: "Alice", @@ -194,6 +219,9 @@ describe("detailToClosabilityInput", () => { internalWONumber: "SH-1", problem: "", source: "Reactive", + type: "Reactive", + docStatus: "", + mediaCount: 1, dispatches: [{ vendorName: "ACME Tech" }], } as WorkOrderDetail; @@ -202,4 +230,72 @@ describe("detailToClosabilityInput", () => { expect(input.type).toBe("Reactive"); expect(getClosabilityGaps(input)).toContain("Service"); }); + + it("allows PM detail transition when completion document is present", () => { + const detail = { + assignedTo: "Alice", + completedDate: "2026-07-15", + workOrderNumber: "20260623001", + internalWONumber: "SH-1", + problem: "HVAC PM", + source: "procurement", + type: "PM", + docStatus: "Yes", + mediaCount: 0, + dispatches: [{ vendorName: "ACME Tech" }], + } as WorkOrderDetail; + + expect(getClosabilityGaps(detailToClosabilityInput(detail))).toEqual([]); + }); + + it("blocks PM detail transition without completion document", () => { + const detail = { + assignedTo: "Alice", + completedDate: "2026-07-15", + workOrderNumber: "20260623001", + internalWONumber: "SH-1", + problem: "HVAC PM", + source: "procurement", + type: "PM", + docStatus: "No", + mediaCount: 0, + dispatches: [{ vendorName: "ACME Tech" }], + } as WorkOrderDetail; + + expect(getClosabilityGaps(detailToClosabilityInput(detail))).toEqual(["Completion document"]); + }); + + it("allows Emergency detail transition when at least one photo exists", () => { + const detail = { + assignedTo: "Alice", + completedDate: "2026-07-15", + workOrderNumber: "20260623001", + internalWONumber: "SH-1", + problem: "Leak", + source: "portal", + type: "Emergency", + docStatus: "No", + mediaCount: 1, + dispatches: [{ vendorName: "ACME Tech" }], + } as WorkOrderDetail; + + expect(getClosabilityGaps(detailToClosabilityInput(detail))).toEqual([]); + }); + + it("blocks Emergency detail transition without photos", () => { + const detail = { + assignedTo: "Alice", + completedDate: "2026-07-15", + workOrderNumber: "20260623001", + internalWONumber: "SH-1", + problem: "Leak", + source: "portal", + type: "Emergency", + docStatus: "No", + mediaCount: 0, + dispatches: [{ vendorName: "ACME Tech" }], + } as WorkOrderDetail; + + expect(getClosabilityGaps(detailToClosabilityInput(detail))).toEqual(["At least 1 photo"]); + }); });