mirror of
https://github.com/Sea-Haven-Industries/shoc-frontend-new.git
synced 2026-09-30 11:33:12 +00:00
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.
218 lines
7.1 KiB
TypeScript
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,
|
|
};
|
|
}
|