mirror of
https://github.com/Sea-Haven-Industries/shoc-frontend-new.git
synced 2026-10-01 19:03:12 +00:00
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:
parent
8cd6d92ba7
commit
5e3b7b8057
11 changed files with 321 additions and 17 deletions
|
|
@ -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],
|
||||
|
|
|
|||
101
src/domain/work-orders/mappers/read-detail-closability-fields.ts
Normal file
101
src/domain/work-orders/mappers/read-detail-closability-fields.ts
Normal 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;
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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: [],
|
||||
|
|
|
|||
|
|
@ -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. */
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -28,6 +28,9 @@ const incompleteDetail = {
|
|||
scheduledDate: "",
|
||||
completedDate: "",
|
||||
source: "",
|
||||
type: "",
|
||||
docStatus: "",
|
||||
mediaCount: 0,
|
||||
comments: [],
|
||||
auditLog: [],
|
||||
dispatches: [],
|
||||
|
|
|
|||
|
|
@ -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: "" },
|
||||
]);
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
});
|
||||
});
|
||||
|
|
@ -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"]);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue