shoc-frontend-new/src/domain/work-orders/utils/wo-closability.ts
Arthur Bassi 4c9f8b7d81 fix(work-orders): treat vendor save as a live company assignment
Clear leftover primary dispatch status on vendor patch so Completed is not
blocked after a new company is chosen. Keep Jira keys out of source comments.
2026-08-20 14:17:20 -03:00

218 lines
7.1 KiB
TypeScript

import type { WorkOrderDispatchSummary } from "@/domain/work-orders/types/dispatch";
import type { WorkOrderDetail } from "@/domain/work-orders/types/work-order-detail";
import type {
CompletionDocStatus,
WorkOrderTableRow,
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. */
export type WoClosabilityInput = {
dispatcherId: string;
dispatcherName: string;
/** Company / vendor assignment — required for Completed. */
company: string;
/** Board vendor id; assignment is valid with vendorId even when company label is empty. */
vendorId: 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<WoClosabilityInput, "upliftSummary" | "hasPendingUplift">,
): boolean {
if (row.hasPendingUplift === true) {
return true;
}
return (row.upliftSummary?.pendingCount ?? 0) > 0;
}
function hasDispatcher(row: Pick<WoClosabilityInput, "dispatcherId" | "dispatcherName">): boolean {
return Boolean(row.dispatcherId.trim() || row.dispatcherName.trim());
}
function hasAssignedCompany(row: Pick<WoClosabilityInput, "company" | "vendorId">): boolean {
return Boolean((row.company ?? "").trim() || (row.vendorId ?? "").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 (!hasAssignedCompany(row)) {
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,
vendorId: draft.vendorId ?? "",
tech: "",
completedDate: "",
woNumber: draft.woNumber,
pm: draft.pm,
docStatus: "",
mediaCount: 0,
type: draft.type,
};
}
const INACTIVE_DISPATCH_STATUSES = new Set(["cancelled", "canceled", "refused"]);
export function isInactiveDispatchStatus(status: string | undefined): boolean {
return INACTIVE_DISPATCH_STATUSES.has((status ?? "").trim().toLowerCase());
}
export function tableRowToClosabilityInput(wo: WorkOrderTableRow): WoClosabilityInput {
const inactive = isInactiveDispatchStatus(wo.primaryDispatchStatus);
return {
dispatcherId: wo.dispatcherId,
dispatcherName: wo.dispatcherName,
company: inactive ? "" : wo.company,
vendorId: inactive ? "" : (wo.vendorId ?? ""),
tech: wo.tech,
completedDate: wo.completedDate ?? "",
woNumber: wo.woNumber,
pm: wo.pm,
docStatus: wo.docStatus,
mediaCount: wo.mediaCount,
type: wo.type,
upliftSummary: wo.upliftSummary,
hasPendingUplift: wo.hasPendingUplift,
};
}
function currentAssignedDispatch(
dispatches: WorkOrderDispatchSummary[],
): WorkOrderDispatchSummary | undefined {
return dispatches.find((dispatch) => {
if (isInactiveDispatchStatus(dispatch.status)) {
return false;
}
const vendorId = String(dispatch.vendorId ?? "").trim();
return Boolean((dispatch.vendorName ?? "").trim() || vendorId);
});
}
/**
* 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 assigned = currentAssignedDispatch(wo.dispatches);
return {
dispatcherId: "",
dispatcherName: wo.assignedTo,
company: assigned?.vendorName ?? "",
vendorId: assigned ? String(assigned.vendorId ?? "").trim() : "",
tech: "",
completedDate: wo.completedDate,
woNumber: wo.workOrderNumber || wo.internalWONumber,
pm: wo.problem,
docStatus: wo.docStatus,
mediaCount: wo.mediaCount,
type: wo.type,
};
}