import type { WorkOrderDetail } from "@/domain/work-orders/types/work-order-detail"; import type { CompletionDocStatus, 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). */ 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; /** Work completed date (API CompletedDate). Empty until set. */ completedDate: string; woNumber: string; pm: string; docStatus: CompletionDocStatus; mediaCount: number; type: WOType | ""; upliftSummary?: WorkOrderUpliftSummary | null; hasPendingUplift?: boolean; }; export const PENDING_UPLIFT_GAP = "Pending uplift approval"; /** SH-188 tooltip when Completed is blocked solely by a pending uplift. */ export const UPLIFT_PENDING_COMPLETED_TITLE = "This work order has an uplift pending approval and cannot be completed."; /** 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"; } export function hasPendingUplift( row: Pick, ): boolean { if (row.hasPendingUplift === true) { return true; } return (row.upliftSummary?.pendingCount ?? 0) > 0; } 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"; } /** * Gaps that block Completed, in design hierarchy: * fields → uplift pending → completion document / photos. * (Scheduled-date gate is a separate design P1 — not in this module yet.) */ 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"); } // Authoritative pending uplift (board pendingUpliftCount / hasPendingUplift) blocks completion. // Missing uplift fields mean no pending evidence until the board contract supplies the count. if (hasPendingUplift(row)) { gaps.push(PENDING_UPLIFT_GAP); } 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 { if (gaps.includes(PENDING_UPLIFT_GAP)) { return UPLIFT_PENDING_COMPLETED_TITLE; } return `Missing: ${gaps.join(", ")}`; } export type CompDocGateResult = { allowed: true } | { allowed: false; reason: string }; /** * Gate for completion-document generation. CompDocDialog (not yet on `dev`) should call this * before opening generate flow and surface `reason` when blocked. * * Intentionally omits the "Completion document" closability gap — generate is how that gap is filled. */ export function canGenerateCompDoc(row: WoClosabilityInput): CompDocGateResult { const gaps = getClosabilityGaps(row).filter((gap) => gap !== "Completion document"); if (gaps.length > 0) { return { allowed: false, reason: formatClosabilityMissingTitle(gaps) }; } return { allowed: true }; } /** 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, }; } /** * 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; return { dispatcherId: "", dispatcherName: wo.assignedTo, company: vendorName ?? "", tech: "", completedDate: wo.completedDate, woNumber: wo.workOrderNumber || wo.internalWONumber, pm: wo.problem, docStatus: wo.docStatus, mediaCount: wo.mediaCount, type: wo.type, }; }