2026-08-10 17:12:14 -03:00
|
|
|
import type { WorkOrderDetail } from "@/domain/work-orders/types/work-order-detail";
|
|
|
|
|
import type { CompletionDocStatus } from "@/domain/work-orders/types/work-order-table-row";
|
2026-08-10 19:11:35 -03:00
|
|
|
import {
|
|
|
|
|
WO_TYPES,
|
|
|
|
|
type WOType,
|
|
|
|
|
type WorkOrderWizardDraft,
|
|
|
|
|
} from "@/domain/work-orders/types/work-order-wizard";
|
2026-08-10 17:12:14 -03:00
|
|
|
|
|
|
|
|
/** 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<WoClosabilityInput, "dispatcherId" | "dispatcherName">): 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,
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-10 19:11:35 -03:00
|
|
|
function detailSourceToWoType(source: string): WOType | "" {
|
|
|
|
|
return (WO_TYPES as readonly string[]).includes(source) ? (source as WOType) : "";
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-10 17:12:14 -03:00
|
|
|
/**
|
2026-08-10 19:11:35 -03:00
|
|
|
* 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.
|
2026-08-10 17:12:14 -03:00
|
|
|
*/
|
|
|
|
|
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,
|
2026-08-10 19:11:35 -03:00
|
|
|
pm: wo.problem,
|
2026-08-10 17:12:14 -03:00
|
|
|
docStatus: "",
|
|
|
|
|
mediaCount: 0,
|
2026-08-10 19:11:35 -03:00
|
|
|
type: detailSourceToWoType(wo.source),
|
2026-08-10 17:12:14 -03:00
|
|
|
};
|
|
|
|
|
}
|