shoc-frontend-new/src/domain/work-orders/utils/wo-closability.ts
Arthur Bassi 8a221c8819 fix(work-orders): consume board pendingUpliftCount for SH-188 gate
Map the authoritative board/search/detail pendingUpliftCount into the
Completed closability gate and align the pending-uplift tooltip with SH-188.
2026-08-12 09:55:34 -03:00

144 lines
4.4 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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-114 ∪ pending uplift). */
export type WoClosabilityInput = {
dispatcherId: string;
dispatcherName: string;
/** Vendor/company assignment on the board row. */
company: string;
/** Technician name is informational and does not satisfy the vendor requirement. */
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);
}
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 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 (!row.company.trim()) {
gaps.push("Company / vendor");
}
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.length === 1 && gaps[0] === 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: draft.vendorName,
completedDate: "",
woNumber: draft.woNumber,
pm: draft.pm,
docStatus: "",
mediaCount: 0,
type: draft.type,
};
}