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 <cursoragent@cursor.com>
This commit is contained in:
arthur.bassi 2026-08-11 14:06:54 -03:00
parent 8cd6d92ba7
commit 5e3b7b8057
11 changed files with 321 additions and 17 deletions

View file

@ -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<HTMLElement | null>(null);
const [assignAnchor, setAssignAnchor] = useState<HTMLElement | null>(null);
const wo = useMemo(
() => (detail ? enrichDetailClosabilityFromBoard(detail, boardDetail) : undefined),
[detail, boardDetail],
);
const customerComments = useMemo(
() => (wo?.comments ?? []).filter((comment) => comment.commentType === "customer"),
[wo?.comments],

View file

@ -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<string, unknown> {
return typeof raw === "object" && raw !== null ? (raw as Record<string, unknown>) : {};
}
function readString(record: Record<string, unknown>, ...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<string, unknown>, ...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<string, unknown>): 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<string, unknown>): 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<string, unknown>): 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;
}

View file

@ -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)

View file

@ -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: [],

View file

@ -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. */

View file

@ -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,
};
}

View file

@ -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,
};
}

View file

@ -28,6 +28,9 @@ const incompleteDetail = {
scheduledDate: "",
completedDate: "",
source: "",
type: "",
docStatus: "",
mediaCount: 0,
comments: [],
auditLog: [],
dispatches: [],

View file

@ -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: "" },
]);
});

View file

@ -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");
});
});

View file

@ -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"]);
});
});