mirror of
https://github.com/Sea-Haven-Industries/shoc-frontend-new.git
synced 2026-10-02 08:33:14 +00:00
# Conflicts: # src/app/(protected)/workorders/_components/list/table/cells/status-cell.tsx # src/domain/work-orders/utils/wo-closability.ts # src/test/domain/work-orders/utils/wo-closability.test.ts
177 lines
5.5 KiB
TypeScript
177 lines
5.5 KiB
TypeScript
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<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 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,
|
||
};
|
||
}
|