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"; /** 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: string; /** Technician is optional for closability; modeled separately from company. */ tech: string; completedDate: string; woNumber: string; pm: string; docStatus: CompletionDocStatus; mediaCount: number; type: WOType | ""; }; /** Real (non-placeholder) WO number — non-empty and not an SH-prefixed draft id. */ export function isRealWoNumber(woNumber: string): boolean { const trimmed = woNumber.trim(); if (!trimmed) { return false; } return !/^SH/i.test(trimmed); } /** Legacy detail menus use "Done"; board/wizard lifecycle uses "Completed". */ export function isCompletionStatusLabel(status: string): boolean { return status === "Done" || status === "Completed"; } function hasDispatcher(row: Pick): boolean { return Boolean(row.dispatcherId.trim() || row.dispatcherName.trim()); } function hasCompany(company: string): boolean { return Boolean(company.trim()); } function hasService(pm: string): boolean { return Boolean(pm.trim()); } function hasCompletionDocument(docStatus: CompletionDocStatus): boolean { return docStatus === "Yes" || docStatus === "NN"; } /** * Returns human-readable labels for requirements still missing before Completed is allowed. * Empty array means the WO may be marked Completed. */ export function getClosabilityGaps(row: WoClosabilityInput): string[] { const gaps: string[] = []; if (!hasDispatcher(row)) { gaps.push("Dispatcher"); } if (!hasCompany(row.company)) { gaps.push("Company"); } if (!row.completedDate.trim()) { gaps.push("Completed Date"); } if (!isRealWoNumber(row.woNumber)) { gaps.push("WO number"); } if (!hasService(row.pm)) { gaps.push("Service"); } if (row.type === "PM" || row.type === "Overdue") { if (!hasCompletionDocument(row.docStatus)) { gaps.push("Completion document"); } } if (row.type === "Emergency" || row.type === "Reactive") { if (row.mediaCount < 1) { gaps.push("At least 1 photo"); } } return gaps; } export function formatClosabilityMissingTitle(gaps: string[]): string { return `Missing: ${gaps.join(", ")}`; } /** Map create-wizard draft onto the shared closability contract. */ export function wizardDraftToClosabilityInput(draft: WorkOrderWizardDraft): WoClosabilityInput { return { dispatcherId: draft.assigneeId, dispatcherName: draft.assigneeName, company: draft.vendorName, tech: "", completedDate: "", woNumber: draft.woNumber, pm: draft.pm, docStatus: "", mediaCount: 0, type: draft.type, }; } 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. */ export function detailToClosabilityInput(wo: WorkOrderDetail): WoClosabilityInput { const vendorName = wo.dispatches.find((dispatch) => Boolean(dispatch.vendorName.trim()), )?.vendorName; return { dispatcherId: "", dispatcherName: wo.assignedTo, company: vendorName ?? "", tech: "", completedDate: wo.completedDate, woNumber: wo.workOrderNumber || wo.internalWONumber, pm: wo.problem, docStatus: "", mediaCount: 0, type: detailSourceToWoType(wo.source), }; }