import { v4 as uuid } from "uuid"; import type { TrackedDeadline, DeadlineResolution, RegistryEntry, DeadlineAuditEntry, } from "../types"; import { getDeadlineType } from "./deadline-catalog"; import { computeDueDate, addWorkingDays } from "./working-days"; export interface DeadlineDisplayStatus { label: string; variant: "green" | "yellow" | "red" | "blue" | "gray"; daysRemaining: number | null; } /** * Create a new tracked deadline from a type definition + start date. */ export function createTrackedDeadline( typeId: string, startDate: string, chainParentId?: string, ): TrackedDeadline | null { const def = getDeadlineType(typeId); if (!def) return null; const start = new Date(startDate); start.setHours(0, 0, 0, 0); const due = computeDueDate( start, def.days, def.dayType, def.isBackwardDeadline, ); return { id: uuid(), typeId, startDate, dueDate: formatDate(due), resolution: "pending", chainParentId, auditLog: [ { action: "created", timestamp: new Date().toISOString(), detail: `Termen creat: ${def.label} (${def.days} ${def.dayType === "working" ? "zile lucrătoare" : "zile calendaristice"})`, }, ], createdAt: new Date().toISOString(), }; } /** * Resolve a deadline with a given resolution. */ export function resolveDeadline( deadline: TrackedDeadline, resolution: DeadlineResolution, note?: string, ): TrackedDeadline { const auditEntry: DeadlineAuditEntry = { action: "resolved", timestamp: new Date().toISOString(), detail: `Rezolvat: ${resolution}${note ? ` — ${note}` : ""}`, }; return { ...deadline, resolution, resolvedDate: new Date().toISOString(), resolutionNote: note, auditLog: [...(deadline.auditLog ?? []), auditEntry], }; } /** * Get the display status for a tracked deadline — color coding + label. */ export function getDeadlineDisplayStatus( deadline: TrackedDeadline, ): DeadlineDisplayStatus { const def = getDeadlineType(deadline.typeId); // Already resolved if (deadline.resolution !== "pending") { if (deadline.resolution === "aprobat-tacit") { return { label: "Aprobat tacit", variant: "blue", daysRemaining: null }; } if (deadline.resolution === "respins") { return { label: "Respins", variant: "gray", daysRemaining: null }; } if (deadline.resolution === "anulat") { return { label: "Anulat", variant: "gray", daysRemaining: null }; } if (deadline.resolution === "intrerupt") { return { label: "Intrerupt", variant: "yellow", daysRemaining: null }; } return { label: "Finalizat", variant: "gray", daysRemaining: null }; } // Pending — compute days remaining const now = new Date(); now.setHours(0, 0, 0, 0); const due = new Date(deadline.dueDate); due.setHours(0, 0, 0, 0); const diff = due.getTime() - now.getTime(); const daysRemaining = Math.ceil(diff / (1000 * 60 * 60 * 24)); // Overdue + tacit applicable → tacit approval if (daysRemaining < 0 && def?.tacitApprovalApplicable) { return { label: "Aprobat tacit", variant: "blue", daysRemaining }; } if (daysRemaining < 0) { return { label: "Depășit termen", variant: "red", daysRemaining }; } if (daysRemaining <= 5) { return { label: "Urgent", variant: "yellow", daysRemaining }; } return { label: "În termen", variant: "green", daysRemaining }; } /** * Aggregate deadline stats across all entries. */ export function aggregateDeadlines(entries: RegistryEntry[]): { active: number; urgent: number; overdue: number; tacit: number; missingRecipientReg: number; expiringSoon: number; all: Array<{ deadline: TrackedDeadline; entry: RegistryEntry; status: DeadlineDisplayStatus; }>; } { let active = 0; let urgent = 0; let overdue = 0; let tacit = 0; const all: Array<{ deadline: TrackedDeadline; entry: RegistryEntry; status: DeadlineDisplayStatus; }> = []; // Count entries missing recipient registration (outgoing with deadlines) let missingRecipientReg = 0; let expiringSoon = 0; const now = new Date(); now.setHours(0, 0, 0, 0); for (const entry of entries) { // Check missing recipient registration for outgoing entries if ( entry.direction === "iesit" && entry.status === "deschis" && (entry.trackedDeadlines ?? []).length > 0 && !entry.recipientRegDate ) { missingRecipientReg++; } // Check document expiry if (entry.expiryDate && entry.status === "deschis") { const expiry = new Date(entry.expiryDate); expiry.setHours(0, 0, 0, 0); const daysLeft = Math.ceil( (expiry.getTime() - now.getTime()) / (1000 * 60 * 60 * 24), ); const alertDays = entry.expiryAlertDays ?? 30; if (daysLeft <= alertDays) { expiringSoon++; } } for (const dl of entry.trackedDeadlines ?? []) { const status = getDeadlineDisplayStatus(dl); all.push({ deadline: dl, entry, status }); if (dl.resolution === "pending") { active++; if (status.variant === "yellow") urgent++; if (status.variant === "red") overdue++; if (status.variant === "blue") tacit++; } else if (dl.resolution === "aprobat-tacit") { tacit++; } } } // Sort: overdue first, then by due date ascending all.sort((a, b) => { const aP = a.deadline.resolution === "pending" ? 0 : 1; const bP = b.deadline.resolution === "pending" ? 0 : 1; if (aP !== bP) return aP - bP; return a.deadline.dueDate.localeCompare(b.deadline.dueDate); }); return { active, urgent, overdue, tacit, missingRecipientReg, expiringSoon, all, }; } function formatDate(d: Date): string { const y = d.getFullYear(); const m = String(d.getMonth() + 1).padStart(2, "0"); const day = String(d.getDate()).padStart(2, "0"); return `${y}-${m}-${day}`; } // ── Grouped deadlines for simplified dashboard ── export interface DeadlineEntryGroup { entry: RegistryEntry; /** Main deadlines (user-created, non-background) */ mainDeadlines: Array<{ deadline: TrackedDeadline; status: DeadlineDisplayStatus; chainChildren: Array<{ deadline: TrackedDeadline; status: DeadlineDisplayStatus; }>; }>; } /** * Group deadlines by entry for the simplified dashboard. * Only includes entries with pending main deadlines. * Auto-tracked / background deadlines are nested under their chain parent. */ export function groupDeadlinesByEntry( entries: RegistryEntry[], ): DeadlineEntryGroup[] { const groups: DeadlineEntryGroup[] = []; for (const entry of entries) { const deadlines = entry.trackedDeadlines ?? []; if (deadlines.length === 0) continue; // Separate: main deadlines vs chain children vs background const chainChildMap = new Map< string, Array<{ deadline: TrackedDeadline; status: DeadlineDisplayStatus }> >(); const mainItems: Array<{ deadline: TrackedDeadline; status: DeadlineDisplayStatus; chainChildren: Array<{ deadline: TrackedDeadline; status: DeadlineDisplayStatus; }>; }> = []; // First pass: identify chain children for (const dl of deadlines) { const def = getDeadlineType(dl.typeId); if (def?.backgroundOnly) continue; // skip background-only if (dl.chainParentId) { const children = chainChildMap.get(dl.chainParentId) ?? []; children.push({ deadline: dl, status: getDeadlineDisplayStatus(dl) }); chainChildMap.set(dl.chainParentId, children); } } // Second pass: build main deadline items for (const dl of deadlines) { const def = getDeadlineType(dl.typeId); if (def?.backgroundOnly) continue; if (dl.chainParentId) continue; // chain children are nested // Auto-tracked without chain parent: treat as main if pending, skip if resolved if (def?.autoTrack && dl.resolution !== "pending") continue; mainItems.push({ deadline: dl, status: getDeadlineDisplayStatus(dl), chainChildren: chainChildMap.get(dl.id) ?? [], }); } // Only include entries that have pending main deadlines const hasPending = mainItems.some((m) => m.deadline.resolution === "pending"); if (!hasPending) continue; groups.push({ entry, mainDeadlines: mainItems }); } // Sort: entries with overdue/urgent deadlines first groups.sort((a, b) => { const worstA = worstVariant(a.mainDeadlines.map((m) => m.status.variant)); const worstB = worstVariant(b.mainDeadlines.map((m) => m.status.variant)); return worstA - worstB; }); return groups; } function worstVariant(variants: string[]): number { const order: Record = { red: 0, yellow: 1, blue: 2, green: 3, gray: 4, }; let worst = 4; for (const v of variants) { const o = order[v] ?? 4; if (o < worst) worst = o; } return worst; } // ── Transmission status (background check) ── export type TransmissionStatus = "on-time" | "late" | null; /** * Compute whether a reply entry was transmitted within the legal 1-day window. * Returns null if recipient registration date is not available. */ export function computeTransmissionStatus( parentEntry: RegistryEntry, replyEntry: RegistryEntry, ): TransmissionStatus { if (!parentEntry.recipientRegDate) return null; const regDate = new Date(parentEntry.recipientRegDate); regDate.setHours(0, 0, 0, 0); // 1 working day after recipient registration date const deadline = addWorkingDays(regDate, 1); const replyDate = new Date(replyEntry.date); replyDate.setHours(0, 0, 0, 0); return replyDate.getTime() <= deadline.getTime() ? "on-time" : "late"; } // ── Auto-resolution of deadlines ── /** * Find deadlines on a parent entry that should be auto-resolved when a reply arrives. * Only returns pending deadlines that match the reply context. */ export function findAutoResolvableDeadlines( parentEntry: RegistryEntry, replyEntry: RegistryEntry, ): TrackedDeadline[] { const pending = (parentEntry.trackedDeadlines ?? []).filter( (dl) => dl.resolution === "pending", ); if (pending.length === 0) return []; // Only auto-resolve when reply is incoming (response to our outgoing request) if (replyEntry.direction !== "intrat") return []; if (parentEntry.direction !== "iesit") return []; // Filter: skip background-only and auto-tracked chain children return pending.filter((dl) => { const def = getDeadlineType(dl.typeId); if (!def) return false; if (def.backgroundOnly) return false; // Include main deadlines (no chain parent) — these are the ones the user cares about if (!dl.chainParentId) return true; return false; }); }