5b18cce5a3
Major UX overhaul of the "Termene legale" and thread tabs: Deadline dashboard: - Replace 6 KPI cards with simple summary bar (active/urgent/overdue) - Replace flat table with grouped list by entry (cards with progress bars) - Chain deadlines collapsed by default with expand toggle - Auto-tracked/background deadlines hidden from main list Flow diagram (new component): - CSS-only horizontal flow diagram showing document chains - Nodes with direction bar (blue=intrat, orange=iesit), number, subject, status - Solid arrows for thread links, dashed for conex/linked entries - Used in both "Dosare" tab (full) and detail panel (compact, max 5 nodes) Thread explorer → Dosare: - Renamed tab "Fire conversatie" → "Dosare" - Each thread shown as a card with flow diagram inside - Simplified stats (just active/finalized count) Background tracking: - comunicare-aviz-beneficiar marked as backgroundOnly (not shown in dashboard) - Transmission status computed and shown in detail panel (on-time/late) Auto-resolution: - When closing entry via reply, matching parent deadlines auto-resolve - Resolution note includes the reply entry number Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
382 lines
11 KiB
TypeScript
382 lines
11 KiB
TypeScript
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<string, number> = {
|
|
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;
|
|
});
|
|
}
|