feat: simplify deadline dashboard + add flow diagrams for document chains

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>
This commit is contained in:
AI Assistant
2026-03-11 23:25:08 +02:00
parent 34024404a5
commit 5b18cce5a3
8 changed files with 1026 additions and 653 deletions
@@ -1,28 +1,23 @@
"use client";
import { useState, useMemo } from "react";
import { Card, CardContent } from "@/shared/components/ui/card";
import { Badge } from "@/shared/components/ui/badge";
import { Label } from "@/shared/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/shared/components/ui/select";
import { Button } from "@/shared/components/ui/button";
import { CheckCircle2, ChevronDown, ChevronRight } from "lucide-react";
import type {
RegistryEntry,
TrackedDeadline,
DeadlineResolution,
DeadlineCategory,
} from "../types";
import { aggregateDeadlines } from "../services/deadline-service";
import { CATEGORY_LABELS, getDeadlineType } from "../services/deadline-catalog";
import { useDeadlineFilters } from "../hooks/use-deadline-filters";
import { DeadlineTable } from "./deadline-table";
import {
aggregateDeadlines,
groupDeadlinesByEntry,
type DeadlineEntryGroup,
type DeadlineDisplayStatus,
} from "../services/deadline-service";
import { getDeadlineType } from "../services/deadline-catalog";
import { DeadlineResolveDialog } from "./deadline-resolve-dialog";
import { cn } from "@/shared/lib/utils";
interface DeadlineDashboardProps {
entries: RegistryEntry[];
@@ -41,12 +36,26 @@ interface DeadlineDashboardProps {
) => void;
}
const RESOLUTION_LABELS: Record<string, string> = {
pending: "În așteptare",
completed: "Finalizat",
"aprobat-tacit": "Aprobat tacit",
respins: "Respins",
anulat: "Anulat",
const VARIANT_DOT: Record<string, string> = {
green: "bg-green-500",
yellow: "bg-amber-500",
red: "bg-red-500",
blue: "bg-blue-500",
gray: "bg-muted-foreground",
};
const VARIANT_TEXT: Record<string, string> = {
green: "text-green-700 dark:text-green-400",
yellow: "text-amber-700 dark:text-amber-400",
red: "text-red-700 dark:text-red-400",
blue: "text-blue-700 dark:text-blue-400",
gray: "text-muted-foreground",
};
const VARIANT_BG: Record<string, string> = {
red: "border-red-200 bg-red-50/50 dark:border-red-900 dark:bg-red-950/20",
yellow:
"border-amber-200 bg-amber-50/50 dark:border-amber-900 dark:bg-amber-950/20",
};
export function DeadlineDashboard({
@@ -54,34 +63,37 @@ export function DeadlineDashboard({
onResolveDeadline,
onAddChainedDeadline,
}: DeadlineDashboardProps) {
const { filters, updateFilter } = useDeadlineFilters();
const [urgentOnly, setUrgentOnly] = useState(false);
const [resolvingEntry, setResolvingEntry] = useState<string | null>(null);
const [resolvingDeadline, setResolvingDeadline] =
useState<TrackedDeadline | null>(null);
const [expandedChains, setExpandedChains] = useState<Set<string>>(new Set());
const stats = useMemo(() => aggregateDeadlines(entries), [entries]);
const groups = useMemo(() => groupDeadlinesByEntry(entries), [entries]);
const filteredRows = useMemo(() => {
return stats.all.filter((row) => {
if (filters.category !== "all") {
const def = getDeadlineType(row.deadline.typeId);
if (def && def.category !== filters.category) return false;
}
if (filters.resolution !== "all") {
// Map tacit display status to actual resolution filter
if (filters.resolution === "pending") {
if (row.deadline.resolution !== "pending") return false;
} else if (row.deadline.resolution !== filters.resolution) {
return false;
}
}
if (filters.urgentOnly) {
if (row.status.variant !== "yellow" && row.status.variant !== "red")
return false;
}
return true;
const filteredGroups = useMemo(() => {
if (!urgentOnly) return groups;
return groups
.map((g) => ({
...g,
mainDeadlines: g.mainDeadlines.filter(
(m) =>
m.deadline.resolution === "pending" &&
(m.status.variant === "yellow" || m.status.variant === "red"),
),
}))
.filter((g) => g.mainDeadlines.length > 0);
}, [groups, urgentOnly]);
const toggleChain = (deadlineId: string) => {
setExpandedChains((prev) => {
const next = new Set(prev);
if (next.has(deadlineId)) next.delete(deadlineId);
else next.add(deadlineId);
return next;
});
}, [stats.all, filters]);
};
const handleResolveClick = (entryId: string, deadline: TrackedDeadline) => {
setResolvingEntry(entryId);
@@ -102,7 +114,6 @@ export function DeadlineDashboard({
chainNext,
);
// Handle chain creation
if (chainNext) {
const def = getDeadlineType(resolvingDeadline.typeId);
if (def?.chainNextTypeId) {
@@ -122,116 +133,52 @@ export function DeadlineDashboard({
return (
<div className="space-y-4">
{/* Stats */}
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-6">
<StatCard label="Active" value={stats.active} />
<StatCard
label="Urgente"
value={stats.urgent}
variant={stats.urgent > 0 ? "destructive" : undefined}
{/* Summary bar */}
<div className="flex items-center gap-6 rounded-lg border bg-card px-4 py-3">
<SummaryItem
count={stats.active}
label="active"
variant="green"
showIfZero
/>
<StatCard
label="Depășit termen"
value={stats.overdue}
variant={stats.overdue > 0 ? "destructive" : undefined}
/>
<StatCard
label="Aprobat tacit"
value={stats.tacit}
variant={stats.tacit > 0 ? "blue" : undefined}
/>
<StatCard
label="Lipsă nr. dest."
value={stats.missingRecipientReg}
variant={stats.missingRecipientReg > 0 ? "destructive" : undefined}
/>
<StatCard
label="Expiră curând"
value={stats.expiringSoon}
variant={stats.expiringSoon > 0 ? "destructive" : undefined}
<SummaryItem count={stats.urgent} label="urgente" variant="yellow" />
<SummaryItem
count={stats.overdue}
label={stats.overdue === 1 ? "depasit" : "depasite"}
variant="red"
/>
<div className="ml-auto">
<Button
variant={urgentOnly ? "default" : "outline"}
size="sm"
onClick={() => setUrgentOnly(!urgentOnly)}
>
Doar urgente
</Button>
</div>
</div>
{/* Alert banners */}
{stats.missingRecipientReg > 0 && (
<div className="rounded-md border border-amber-500/30 bg-amber-500/5 px-4 py-2 text-xs text-amber-700 dark:text-amber-400">
{stats.missingRecipientReg} ieșir
{stats.missingRecipientReg === 1 ? "e" : "i"} cu termene legale nu{" "}
{stats.missingRecipientReg === 1 ? "are" : "au"} completat
numărul/data de înregistrare la destinatar. Termenele legale nu
pornesc fără aceste date.
{/* Grouped deadline list */}
{filteredGroups.length === 0 ? (
<p className="py-12 text-center text-sm text-muted-foreground">
{urgentOnly
? "Niciun termen urgent sau depasit."
: "Niciun termen legal activ."}
</p>
) : (
<div className="space-y-3">
{filteredGroups.map((group) => (
<EntryDeadlineCard
key={group.entry.id}
group={group}
expandedChains={expandedChains}
onToggleChain={toggleChain}
onResolve={handleResolveClick}
/>
))}
</div>
)}
{stats.expiringSoon > 0 && (
<div className="rounded-md border border-red-500/30 bg-red-500/5 px-4 py-2 text-xs text-red-700 dark:text-red-400">
{stats.expiringSoon} document{stats.expiringSoon === 1 ? "" : "e"}{" "}
cu termen de valabilitate se apropie de expirare sau{" "}
{stats.expiringSoon === 1 ? "a" : "au"} expirat. Inițiați procedurile
de prelungire.
</div>
)}
{/* Filters */}
<div className="flex flex-wrap items-end gap-3">
<div>
<Label className="text-xs">Categorie</Label>
<Select
value={filters.category}
onValueChange={(v) =>
updateFilter("category", v as DeadlineCategory | "all")
}
>
<SelectTrigger className="mt-1 w-[160px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">Toate</SelectItem>
{(
Object.entries(CATEGORY_LABELS) as [DeadlineCategory, string][]
).map(([key, label]) => (
<SelectItem key={key} value={key}>
{label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div>
<Label className="text-xs">Status</Label>
<Select
value={filters.resolution}
onValueChange={(v) =>
updateFilter("resolution", v as DeadlineResolution | "all")
}
>
<SelectTrigger className="mt-1 w-[160px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">Toate</SelectItem>
{Object.entries(RESOLUTION_LABELS).map(([key, label]) => (
<SelectItem key={key} value={key}>
{label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<Button
variant={filters.urgentOnly ? "default" : "outline"}
size="sm"
onClick={() => updateFilter("urgentOnly", !filters.urgentOnly)}
>
Doar urgente
</Button>
</div>
{/* Table */}
<DeadlineTable rows={filteredRows} onResolve={handleResolveClick} />
<p className="text-xs text-muted-foreground">
{filteredRows.length} din {stats.all.length} termene afișate
</p>
<DeadlineResolveDialog
open={resolvingDeadline !== null}
@@ -248,27 +195,261 @@ export function DeadlineDashboard({
);
}
function StatCard({
// ── Summary item ──
function SummaryItem({
count,
label,
value,
variant,
showIfZero,
}: {
count: number;
label: string;
value: number;
variant?: "destructive" | "blue";
variant: string;
showIfZero?: boolean;
}) {
if (count === 0 && !showIfZero) return null;
return (
<Card>
<CardContent className="p-4">
<p className="text-xs text-muted-foreground">{label}</p>
<p
className={`text-2xl font-bold ${
variant === "destructive" && value > 0 ? "text-destructive" : ""
}${variant === "blue" && value > 0 ? "text-blue-600" : ""}`}
>
{value}
</p>
</CardContent>
</Card>
<div className="flex items-center gap-2">
<span
className={cn("h-2.5 w-2.5 rounded-full", VARIANT_DOT[variant])}
/>
<span className={cn("text-sm font-semibold", VARIANT_TEXT[variant])}>
{count}
</span>
<span className="text-sm text-muted-foreground">{label}</span>
</div>
);
}
// ── Entry card with its deadlines ──
function EntryDeadlineCard({
group,
expandedChains,
onToggleChain,
onResolve,
}: {
group: DeadlineEntryGroup;
expandedChains: Set<string>;
onToggleChain: (id: string) => void;
onResolve: (entryId: string, deadline: TrackedDeadline) => void;
}) {
const { entry, mainDeadlines } = group;
// Worst status for card border color
const worstVariant = mainDeadlines.reduce<string>((worst, m) => {
if (m.deadline.resolution !== "pending") return worst;
const order: Record<string, number> = {
red: 0,
yellow: 1,
blue: 2,
green: 3,
gray: 4,
};
const cur = order[m.status.variant] ?? 4;
const w = order[worst] ?? 4;
return cur < w ? m.status.variant : worst;
}, "green");
return (
<div
className={cn(
"rounded-lg border bg-card",
VARIANT_BG[worstVariant] ?? "",
)}
>
{/* Entry header */}
<div className="flex items-center gap-3 border-b px-4 py-2.5">
<span className="font-mono text-xs font-semibold">
{entry.number}
</span>
<span className="text-xs text-muted-foreground">|</span>
<span className="truncate text-xs">{entry.subject}</span>
<Badge
variant="outline"
className={cn(
"ml-auto shrink-0 text-[10px]",
entry.direction === "intrat"
? "border-blue-300 text-blue-700 dark:border-blue-800 dark:text-blue-400"
: "border-orange-300 text-orange-700 dark:border-orange-800 dark:text-orange-400",
)}
>
{entry.direction === "intrat" ? "Intrat" : "Iesit"}
</Badge>
</div>
{/* Deadlines */}
<div className="divide-y">
{mainDeadlines.map((item) => {
const isExpanded = expandedChains.has(item.deadline.id);
const chainCount = item.chainChildren.length;
return (
<div key={item.deadline.id}>
<DeadlineRow
deadline={item.deadline}
status={item.status}
entryId={entry.id}
chainCount={chainCount}
isExpanded={isExpanded}
onToggleChain={() => onToggleChain(item.deadline.id)}
onResolve={onResolve}
/>
{/* Chain children (expanded) */}
{isExpanded &&
chainCount > 0 &&
item.chainChildren.map((child) => (
<DeadlineRow
key={child.deadline.id}
deadline={child.deadline}
status={child.status}
entryId={entry.id}
isChainChild
onResolve={onResolve}
/>
))}
</div>
);
})}
</div>
</div>
);
}
// ── Single deadline row ──
function DeadlineRow({
deadline,
status,
entryId,
chainCount,
isExpanded,
isChainChild,
onToggleChain,
onResolve,
}: {
deadline: TrackedDeadline;
status: DeadlineDisplayStatus;
entryId: string;
chainCount?: number;
isExpanded?: boolean;
isChainChild?: boolean;
onToggleChain?: () => void;
onResolve: (entryId: string, deadline: TrackedDeadline) => void;
}) {
const def = getDeadlineType(deadline.typeId);
const isPending = deadline.resolution === "pending";
// Progress calculation
const totalDays = (() => {
const start = new Date(deadline.startDate).getTime();
const due = new Date(deadline.dueDate).getTime();
return Math.max(1, Math.ceil((due - start) / (1000 * 60 * 60 * 24)));
})();
const elapsedDays = (() => {
const start = new Date(deadline.startDate).getTime();
const now = Date.now();
return Math.ceil((now - start) / (1000 * 60 * 60 * 24));
})();
const progressPct = isPending
? Math.min(100, Math.max(0, (elapsedDays / totalDays) * 100))
: 100;
return (
<div
className={cn(
"flex items-center gap-3 px-4 py-2",
isChainChild && "bg-muted/30 pl-8",
)}
>
{/* Chain expand toggle */}
{!isChainChild && (chainCount ?? 0) > 0 ? (
<button
onClick={onToggleChain}
className="flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground"
>
{isExpanded ? (
<ChevronDown className="h-3.5 w-3.5" />
) : (
<ChevronRight className="h-3.5 w-3.5" />
)}
</button>
) : isChainChild ? (
<span className="text-muted-foreground/40 text-xs"></span>
) : (
<span className="w-3.5" />
)}
{/* Type label */}
<div className="min-w-0 flex-1">
<span className={cn("text-xs", isChainChild && "text-muted-foreground")}>
{def?.label ?? deadline.typeId}
</span>
{!isChainChild && (chainCount ?? 0) > 0 && (
<span className="ml-2 text-[10px] text-muted-foreground">
(+{chainCount} etap{chainCount === 1 ? "a" : "e"})
</span>
)}
</div>
{/* Progress bar (only for pending) */}
{isPending && (
<div className="hidden w-24 sm:block">
<div className="h-1.5 rounded-full bg-muted">
<div
className={cn(
"h-1.5 rounded-full transition-all",
status.variant === "red"
? "bg-red-500"
: status.variant === "yellow"
? "bg-amber-500"
: "bg-green-500",
)}
style={{ width: `${progressPct}%` }}
/>
</div>
</div>
)}
{/* Countdown / status */}
<div className="shrink-0 text-right">
{isPending && status.daysRemaining !== null ? (
<span
className={cn(
"text-xs font-medium",
VARIANT_TEXT[status.variant],
)}
>
{status.daysRemaining < 0
? `${Math.abs(status.daysRemaining)}z depasit`
: status.daysRemaining === 0
? "azi"
: `${status.daysRemaining}z ramase`}
</span>
) : (
<span className="text-[10px] text-muted-foreground">
{status.label}
</span>
)}
</div>
{/* Resolve button */}
<div className="w-7 shrink-0">
{isPending && (
<Button
variant="ghost"
size="icon"
className="h-7 w-7 text-green-600"
onClick={() => onResolve(entryId, deadline)}
title="Rezolva"
>
<CheckCircle2 className="h-3.5 w-3.5" />
</Button>
)}
</div>
</div>
);
}
@@ -0,0 +1,315 @@
"use client";
import { useMemo } from "react";
import { Badge } from "@/shared/components/ui/badge";
import type { RegistryEntry } from "../types";
import { cn } from "@/shared/lib/utils";
// ── Types ──
interface FlowNode {
entry: RegistryEntry;
/** Direct thread children (replied via "Inchide") */
threadChildren: FlowNode[];
/** Linked/conex entries (not part of thread chain) */
linkedEntries: RegistryEntry[];
}
export interface FlowDiagramProps {
/** The chain of entries (thread root + descendants), chronologically ordered */
chain: RegistryEntry[];
/** All entries in the system (for resolving linkedEntryIds) */
allEntries: RegistryEntry[];
/** Highlight this entry (current selection in detail panel) */
highlightEntryId?: string;
/** Compact mode for detail panel (smaller nodes) */
compact?: boolean;
/** Click handler for a node */
onNodeClick?: (entry: RegistryEntry) => void;
/** Max nodes to show (for compact mode) */
maxNodes?: number;
}
// ── Build tree from flat chain ──
function buildFlowTree(
chain: RegistryEntry[],
allEntries: RegistryEntry[],
): FlowNode | null {
if (chain.length === 0) return null;
// Find root (entry without threadParentId in the chain, or first entry)
const chainIds = new Set(chain.map((e) => e.id));
const root =
chain.find((e) => !e.threadParentId || !chainIds.has(e.threadParentId)) ??
chain[0];
if (!root) return null;
const entryMap = new Map(chain.map((e) => [e.id, e]));
function buildNode(entry: RegistryEntry): FlowNode {
// Thread children: entries whose threadParentId is this entry
const threadChildren = chain
.filter((e) => e.threadParentId === entry.id)
.sort((a, b) => a.date.localeCompare(b.date))
.map((child) => buildNode(child));
// Linked entries: resolve linkedEntryIds that aren't in the thread chain
const linkedEntries = (entry.linkedEntryIds ?? [])
.filter((id) => !entryMap.has(id)) // exclude entries already in the chain
.map((id) => allEntries.find((e) => e.id === id))
.filter((e): e is RegistryEntry => !!e);
return { entry, threadChildren, linkedEntries };
}
return buildNode(root);
}
// ── Flatten tree to a linear main chain + branches ──
interface FlattenedChain {
mainChain: RegistryEntry[];
branches: Map<string, RegistryEntry[]>; // parentId -> linked entries
}
function flattenTree(root: FlowNode): FlattenedChain {
const mainChain: RegistryEntry[] = [];
const branches = new Map<string, RegistryEntry[]>();
function walkMain(node: FlowNode) {
mainChain.push(node.entry);
if (node.linkedEntries.length > 0) {
branches.set(node.entry.id, node.linkedEntries);
}
// Follow the first thread child as "main" chain, others as branches
if (node.threadChildren.length > 0) {
walkMain(node.threadChildren[0]!);
// Additional thread children treated as branches
for (let i = 1; i < node.threadChildren.length; i++) {
const child = node.threadChildren[i]!;
const branchEntries = branches.get(node.entry.id) ?? [];
branchEntries.push(child.entry);
branches.set(node.entry.id, branchEntries);
}
}
}
walkMain(root);
return { mainChain, branches };
}
// ── Component ──
export function FlowDiagram({
chain,
allEntries,
highlightEntryId,
compact = false,
onNodeClick,
maxNodes,
}: FlowDiagramProps) {
const { mainChain, branches, truncated } = useMemo(() => {
const tree = buildFlowTree(chain, allEntries);
if (!tree) return { mainChain: [], branches: new Map(), truncated: 0 };
const flat = flattenTree(tree);
let mc = flat.mainChain;
let trunc = 0;
if (maxNodes && mc.length > maxNodes) {
// Show entries around the highlighted one, or the last N
if (highlightEntryId) {
const idx = mc.findIndex((e) => e.id === highlightEntryId);
if (idx >= 0) {
const start = Math.max(0, idx - Math.floor(maxNodes / 2));
const end = Math.min(mc.length, start + maxNodes);
trunc = mc.length - (end - start);
mc = mc.slice(start, end);
} else {
trunc = mc.length - maxNodes;
mc = mc.slice(-maxNodes);
}
} else {
trunc = mc.length - maxNodes;
mc = mc.slice(-maxNodes);
}
}
return { mainChain: mc, branches: flat.branches, truncated: trunc };
}, [chain, allEntries, highlightEntryId, maxNodes]);
if (mainChain.length === 0) {
return (
<p className="text-xs text-muted-foreground">Niciun document in lant.</p>
);
}
return (
<div className={cn("flex items-start gap-0 overflow-x-auto py-2", compact && "py-1")}>
{truncated > 0 && (
<div className="flex shrink-0 items-center self-center px-2">
<span className="text-xs text-muted-foreground">
+{truncated} ...
</span>
<Arrow />
</div>
)}
{mainChain.map((entry, i) => {
const branchEntries: RegistryEntry[] = branches.get(entry.id) ?? [];
const isHighlighted = entry.id === highlightEntryId;
const isLast = i === mainChain.length - 1;
return (
<div key={entry.id} className="flex shrink-0 items-start">
<div className="flex flex-col items-center gap-1">
{/* Main node */}
<FlowNode
entry={entry}
isHighlighted={isHighlighted}
compact={compact}
onClick={() => onNodeClick?.(entry)}
/>
{/* Branch entries below */}
{branchEntries.length > 0 && (
<div className="flex flex-col items-center gap-1">
<div className="h-3 w-px border-l border-dashed border-muted-foreground/40" />
{branchEntries.map((be) => (
<FlowNode
key={be.id}
entry={be}
isHighlighted={be.id === highlightEntryId}
compact={compact}
isBranch
onClick={() => onNodeClick?.(be)}
/>
))}
</div>
)}
</div>
{/* Arrow to next */}
{!isLast && (
<div className="flex items-center self-center">
<Arrow />
</div>
)}
</div>
);
})}
</div>
);
}
// ── Arrow connector ──
function Arrow() {
return (
<div className="flex items-center px-1">
<div className="h-px w-6 bg-border" />
<div className="border-y-[4px] border-l-[6px] border-y-transparent border-l-border" />
</div>
);
}
// ── Single flow node ──
function FlowNode({
entry,
isHighlighted,
compact,
isBranch,
onClick,
}: {
entry: RegistryEntry;
isHighlighted?: boolean;
compact?: boolean;
isBranch?: boolean;
onClick?: () => void;
}) {
const isIntrat = entry.direction === "intrat";
const isOpen = entry.status === "deschis";
const hasDeadlines = (entry.trackedDeadlines ?? []).some(
(dl) => dl.resolution === "pending",
);
return (
<button
onClick={onClick}
className={cn(
"group relative flex flex-col rounded-lg border text-left transition-all hover:shadow-sm",
compact ? "w-[130px] p-2" : "w-[160px] p-2.5",
isHighlighted
? "border-primary ring-2 ring-primary/30"
: "border-border hover:border-foreground/20",
isBranch && "border-dashed",
)}
>
{/* Direction bar */}
<div
className={cn(
"absolute top-0 left-0 right-0 h-1 rounded-t-lg",
isIntrat
? "bg-blue-500"
: "bg-orange-500",
)}
/>
{/* Number */}
<div className="mt-1 flex items-center gap-1.5">
<span
className={cn(
"font-mono font-semibold",
compact ? "text-[10px]" : "text-xs",
)}
>
{entry.number}
</span>
{hasDeadlines && (
<span className="h-1.5 w-1.5 rounded-full bg-amber-500" />
)}
</div>
{/* Subject */}
<p
className={cn(
"mt-1 text-muted-foreground",
compact
? "line-clamp-1 text-[9px]"
: "line-clamp-2 text-[10px] leading-tight",
)}
>
{entry.subject || "—"}
</p>
{/* Date + status */}
<div className="mt-1.5 flex items-center justify-between">
<span className="text-[9px] text-muted-foreground">
{formatShortDate(entry.date)}
</span>
<Badge
variant="outline"
className={cn(
"h-4 px-1 text-[8px]",
isOpen
? "border-green-300 text-green-700 dark:border-green-800 dark:text-green-400"
: "border-muted text-muted-foreground",
)}
>
{isOpen ? "Deschis" : "Inchis"}
</Badge>
</div>
</button>
);
}
function formatShortDate(iso: string): string {
try {
const d = new Date(iso);
return `${String(d.getDate()).padStart(2, "0")}.${String(d.getMonth() + 1).padStart(2, "0")}.${d.getFullYear()}`;
} catch {
return iso;
}
}
@@ -41,7 +41,11 @@ import { DeadlineDashboard } from "./deadline-dashboard";
import { ThreadExplorer } from "./thread-explorer";
import { CloseGuardDialog } from "./close-guard-dialog";
import { getOverdueDays } from "../services/registry-service";
import { aggregateDeadlines } from "../services/deadline-service";
import {
aggregateDeadlines,
findAutoResolvableDeadlines,
resolveDeadline as resolveDeadlinePure,
} from "../services/deadline-service";
import type { RegistryEntry, DeadlineResolution, ClosureInfo } from "../types";
import type { AddressContact } from "@/modules/address-book/types";
@@ -142,22 +146,39 @@ export function RegistraturaModule() {
const handleAdd = async (
data: Omit<RegistryEntry, "id" | "number" | "createdAt" | "updatedAt">,
) => {
await addEntry(data);
const newEntry = await addEntry(data);
// If this new entry closes a parent, close the parent automatically
if (closesEntryId) {
const parentEntry = allEntries.find((e) => e.id === closesEntryId);
if (parentEntry && parentEntry.status === "deschis") {
// Auto-resolve matching deadlines on the parent
const replyProxy = { ...data, id: "", number: "", createdAt: "", updatedAt: "" } as RegistryEntry;
const resolvable = findAutoResolvableDeadlines(parentEntry, replyProxy);
let updatedDeadlines = parentEntry.trackedDeadlines ?? [];
if (resolvable.length > 0) {
const resolveIds = new Set(resolvable.map((d) => d.id));
updatedDeadlines = updatedDeadlines.map((dl) =>
resolveIds.has(dl.id)
? resolveDeadlinePure(
dl,
"completed",
`Rezolvat automat — primit raspuns ${newEntry?.number ?? "conex"}`,
)
: dl,
);
}
const closureInfo: ClosureInfo = {
resolution: "finalizat",
reason: "Inchis prin inregistrare conex",
closedBy: "",
closedAt: new Date().toISOString(),
hadActiveDeadlines:
(parentEntry.trackedDeadlines ?? []).some(
(d) => d.resolution === "pending",
),
hadActiveDeadlines: resolvable.length > 0,
};
await updateEntry(closesEntryId, { closureInfo });
await updateEntry(closesEntryId, {
closureInfo,
trackedDeadlines: updatedDeadlines,
});
await closeEntry(closesEntryId, false);
}
setClosesEntryId(null);
@@ -320,7 +341,7 @@ export function RegistraturaModule() {
<Tabs defaultValue="registru">
<TabsList>
<TabsTrigger value="registru">Registru</TabsTrigger>
<TabsTrigger value="fire">Fire conversație</TabsTrigger>
<TabsTrigger value="fire">Dosare</TabsTrigger>
<TabsTrigger value="termene">
Termene legale
{alertCount > 0 && (
@@ -49,7 +49,9 @@ import {
getPreviewableAttachments,
} from "./attachment-preview";
import { findAuthorityForContact } from "../services/authority-catalog";
import { computeTransmissionStatus } from "../services/deadline-service";
import { StatusMonitorConfig } from "./status-monitor-config";
import { FlowDiagram } from "./flow-diagram";
interface RegistryEntryDetailProps {
entry: RegistryEntry | null;
@@ -191,6 +193,36 @@ export function RegistryEntryDetail({
(e) => e.threadParentId === entry.id,
);
// Build full chain for mini flow diagram
const threadChain = useMemo(() => {
if (!threadParent && threadChildren.length === 0) return [];
const byId = new Map(allEntries.map((e) => [e.id, e]));
// Walk up to root
let root = entry;
while (root.threadParentId) {
const parent = byId.get(root.threadParentId);
if (!parent) break;
root = parent;
}
// BFS down from root
const chain: RegistryEntry[] = [];
const queue = [root.id];
const visited = new Set<string>();
while (queue.length > 0) {
const id = queue.shift()!;
if (visited.has(id)) continue;
visited.add(id);
const e = byId.get(id);
if (!e) continue;
chain.push(e);
for (const child of allEntries.filter((c) => c.threadParentId === id)) {
queue.push(child.id);
}
}
chain.sort((a, b) => a.date.localeCompare(b.date));
return chain;
}, [entry, threadParent, threadChildren, allEntries]);
return (
<Sheet open={open} onOpenChange={onOpenChange}>
<SheetContent
@@ -387,48 +419,52 @@ export function RegistryEntryDetail({
)}
</DetailSection>
{/* ── Thread links ── */}
{(threadParent ||
threadChildren.length > 0 ||
{/* ── Thread links (flow diagram) ── */}
{(threadChain.length >= 2 ||
(entry.linkedEntryIds ?? []).length > 0) && (
<DetailSection title="Legături">
{threadParent && (
<div className="flex items-center gap-2 text-sm">
<GitBranch className="h-3.5 w-3.5 text-muted-foreground" />
<span className="text-muted-foreground">Răspuns la:</span>
<span className="font-mono text-xs">
{threadParent.number}
</span>
<span className="truncate text-muted-foreground">
{threadParent.subject}
</span>
</div>
<DetailSection title="Dosar">
{threadChain.length >= 2 && (
<FlowDiagram
chain={threadChain}
allEntries={allEntries}
highlightEntryId={entry.id}
compact
maxNodes={5}
/>
)}
{/* Transmission status for thread children */}
{threadChildren.length > 0 && (
<div className="mt-1 space-y-0.5">
<p className="text-xs text-muted-foreground">
Răspunsuri ({threadChildren.length}):
</p>
{threadChildren.map((child) => (
<div
key={child.id}
className="flex items-center gap-2 text-xs ml-4"
>
<span className="font-mono">{child.number}</span>
<span className="truncate text-muted-foreground">
{child.subject}
</span>
</div>
))}
<div className="mt-2 space-y-1">
{threadChildren.map((child) => {
const txStatus = computeTransmissionStatus(entry, child);
if (!txStatus) return null;
return (
<div
key={child.id}
className="flex items-center gap-2 text-[10px]"
>
<span className="font-mono">{child.number}</span>
{txStatus === "on-time" ? (
<span className="text-green-600 dark:text-green-400">
Transmis in termen
</span>
) : (
<span className="text-amber-600 dark:text-amber-400">
Transmis cu intarziere
</span>
)}
</div>
);
})}
</div>
)}
{(entry.linkedEntryIds ?? []).length > 0 && (
<div className="mt-1">
<div className="mt-2">
<p className="text-xs text-muted-foreground flex items-center gap-1">
<Link2 className="h-3 w-3" />
{entry.linkedEntryIds.length} înregistrăr
{entry.linkedEntryIds.length} inregistrar
{entry.linkedEntryIds.length === 1 ? "e" : "i"} legat
{entry.linkedEntryIds.length === 1 ? "ă" : "e"}
{entry.linkedEntryIds.length === 1 ? "a" : "e"}
</p>
</div>
)}
@@ -1,32 +1,12 @@
"use client";
import { useState, useMemo } from "react";
import {
GitBranch,
Search,
ArrowDown,
ArrowUp,
Calendar,
Clock,
Building2,
FileDown,
ChevronDown,
ChevronRight,
} from "lucide-react";
import { Card, CardContent } from "@/shared/components/ui/card";
import { GitBranch, Search, Calendar, Clock } from "lucide-react";
import { Badge } from "@/shared/components/ui/badge";
import { Button } from "@/shared/components/ui/button";
import { Input } from "@/shared/components/ui/input";
import { Label } from "@/shared/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/shared/components/ui/select";
import type { RegistryEntry } from "../types";
import { DEFAULT_DOC_TYPE_LABELS } from "../types";
import { FlowDiagram } from "./flow-diagram";
import { cn } from "@/shared/lib/utils";
interface ThreadExplorerProps {
@@ -37,23 +17,12 @@ interface ThreadExplorerProps {
/** A thread is a chain of entries linked by threadParentId */
interface Thread {
id: string;
/** Root entry (no parent) */
root: RegistryEntry;
/** All entries in the thread, ordered chronologically */
chain: RegistryEntry[];
/** Total calendar days from first to last entry */
totalDays: number;
/** Days spent "at us" vs "at institution" */
daysAtUs: number;
daysAtInstitution: number;
/** Whether thread is still open (has open entries) */
isActive: boolean;
}
function getDocTypeLabel(type: string): string {
return DEFAULT_DOC_TYPE_LABELS[type] ?? type;
}
function daysBetween(d1: string, d2: string): number {
const a = new Date(d1);
const b = new Date(d2);
@@ -65,18 +34,6 @@ function daysBetween(d1: string, d2: string): number {
);
}
function formatDate(iso: string): string {
try {
return new Date(iso).toLocaleDateString("ro-RO", {
day: "2-digit",
month: "2-digit",
year: "numeric",
});
} catch {
return iso;
}
}
function formatShortDate(iso: string): string {
try {
return new Date(iso).toLocaleDateString("ro-RO", {
@@ -94,7 +51,6 @@ function buildThreads(entries: RegistryEntry[]): Thread[] {
const childrenMap = new Map<string, RegistryEntry[]>();
const rootIds = new Set<string>();
// Build parent->children map
for (const entry of entries) {
if (entry.threadParentId) {
const existing = childrenMap.get(entry.threadParentId) ?? [];
@@ -103,12 +59,10 @@ function buildThreads(entries: RegistryEntry[]): Thread[] {
}
}
// Find roots: entries that are parents or have children, but no parent themselves
for (const entry of entries) {
if (!entry.threadParentId && childrenMap.has(entry.id)) {
rootIds.add(entry.id);
}
// Also find the root of entries that have parents
if (entry.threadParentId) {
let current = entry;
while (current.threadParentId) {
@@ -126,7 +80,6 @@ function buildThreads(entries: RegistryEntry[]): Thread[] {
const root = byId.get(rootId);
if (!root) continue;
// Collect all entries in this thread (BFS)
const chain: RegistryEntry[] = [];
const queue = [rootId];
const visited = new Set<string>();
@@ -144,49 +97,20 @@ function buildThreads(entries: RegistryEntry[]): Thread[] {
}
}
// Sort chronologically
chain.sort(
(a, b) => new Date(a.date).getTime() - new Date(b.date).getTime(),
);
if (chain.length < 2) continue; // Only show actual threads (2+ entries)
if (chain.length < 2) continue;
// Calculate days
const firstDate = chain[0]!.date;
const lastDate = chain[chain.length - 1]!.date;
const totalDays = daysBetween(firstDate, lastDate);
// Calculate days at us vs institution
let daysAtUs = 0;
let daysAtInstitution = 0;
for (let i = 0; i < chain.length - 1; i++) {
const current = chain[i]!;
const next = chain[i + 1]!;
const gap = daysBetween(current.date, next.date);
// If current is outgoing (iesit), the document is at the institution
// If current is incoming (intrat), the document is at us
if (current.direction === "iesit") {
daysAtInstitution += gap;
} else {
daysAtUs += gap;
}
}
const isActive = chain.some((e) => e.status === "deschis");
threads.push({
id: rootId,
root,
chain,
totalDays,
daysAtUs,
daysAtInstitution,
isActive,
});
threads.push({ id: rootId, root, chain, totalDays, isActive });
}
// Sort threads: active first, then by most recent activity
threads.sort((a, b) => {
if (a.isActive !== b.isActive) return a.isActive ? -1 : 1;
const aLast = a.chain[a.chain.length - 1]!.date;
@@ -197,92 +121,18 @@ function buildThreads(entries: RegistryEntry[]): Thread[] {
return threads;
}
/** Generate a text report for a thread */
function generateThreadReport(thread: Thread): string {
const lines: string[] = [];
lines.push("═══════════════════════════════════════════════════════");
lines.push(`RAPORT FIR CONVERSAȚIE — ${thread.root.subject}`);
lines.push("═══════════════════════════════════════════════════════");
lines.push("");
lines.push(`Nr. înregistrare inițial: ${thread.root.number}`);
lines.push(
`Perioada: ${formatDate(thread.chain[0]!.date)}${formatDate(thread.chain[thread.chain.length - 1]!.date)}`,
);
lines.push(`Durată totală: ${thread.totalDays} zile`);
lines.push(
`Zile la noi: ${thread.daysAtUs} | Zile la instituție: ${thread.daysAtInstitution}`,
);
lines.push(`Documente în fir: ${thread.chain.length}`);
lines.push(`Status: ${thread.isActive ? "Activ" : "Finalizat"}`);
lines.push("");
lines.push("───────────────────────────────────────────────────────");
lines.push("TIMELINE");
lines.push("───────────────────────────────────────────────────────");
for (let i = 0; i < thread.chain.length; i++) {
const entry = thread.chain[i]!;
const prev = i > 0 ? thread.chain[i - 1] : null;
const gap = prev ? daysBetween(prev.date, entry.date) : 0;
const location = prev
? prev.direction === "iesit"
? "la instituție"
: "la noi"
: "";
if (prev && gap > 0) {
lines.push(`${gap} zile ${location}`);
}
const arrow = entry.direction === "intrat" ? "↓ INTRAT" : "↑ IEȘIT";
const status = entry.status === "inchis" ? "[ÎNCHIS]" : "[DESCHIS]";
lines.push("");
lines.push(` ${arrow}${formatDate(entry.date)} ${status}`);
lines.push(` Nr: ${entry.number}`);
lines.push(` Tip: ${getDocTypeLabel(entry.documentType)}`);
lines.push(` Subiect: ${entry.subject}`);
if (entry.sender) lines.push(` Expeditor: ${entry.sender}`);
if (entry.recipient) lines.push(` Destinatar: ${entry.recipient}`);
if (entry.assignee) lines.push(` Responsabil: ${entry.assignee}`);
if (entry.notes) lines.push(` Note: ${entry.notes}`);
}
lines.push("");
lines.push("═══════════════════════════════════════════════════════");
lines.push(`Generat la: ${new Date().toLocaleString("ro-RO")}`);
lines.push("ArchiTools — Registratură");
return lines.join("\n");
}
function downloadReport(thread: Thread) {
const text = generateThreadReport(thread);
const blob = new Blob([text], { type: "text/plain;charset=utf-8" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = `thread-${thread.root.number.replace(/[/\\]/g, "-")}.txt`;
a.click();
URL.revokeObjectURL(url);
}
export function ThreadExplorer({
entries,
onNavigateEntry,
}: ThreadExplorerProps) {
const [search, setSearch] = useState("");
const [statusFilter, setStatusFilter] = useState<"all" | "active" | "closed">(
"all",
);
const [expandedThreads, setExpandedThreads] = useState<Set<string>>(
new Set(),
);
const [activeOnly, setActiveOnly] = useState(false);
const threads = useMemo(() => buildThreads(entries), [entries]);
const filtered = useMemo(() => {
return threads.filter((t) => {
if (statusFilter === "active" && !t.isActive) return false;
if (statusFilter === "closed" && t.isActive) return false;
if (activeOnly && !t.isActive) return false;
if (search.trim()) {
const q = search.toLowerCase();
return t.chain.some(
@@ -295,317 +145,124 @@ export function ThreadExplorer({
}
return true;
});
}, [threads, search, statusFilter]);
}, [threads, search, activeOnly]);
const toggleExpand = (id: string) => {
setExpandedThreads((prev) => {
const next = new Set(prev);
if (next.has(id)) next.delete(id);
else next.add(id);
return next;
});
};
// Stats
const totalThreads = threads.length;
const activeThreads = threads.filter((t) => t.isActive).length;
const avgDuration =
totalThreads > 0
? Math.round(
threads.reduce((sum, t) => sum + t.totalDays, 0) / totalThreads,
)
: 0;
const activeCount = threads.filter((t) => t.isActive).length;
const closedCount = threads.length - activeCount;
return (
<div className="space-y-4">
{/* Stats */}
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
<Card>
<CardContent className="p-4">
<p className="text-xs text-muted-foreground">Total fire</p>
<p className="text-2xl font-bold">{totalThreads}</p>
</CardContent>
</Card>
<Card>
<CardContent className="p-4">
<p className="text-xs text-muted-foreground">Fire active</p>
<p className="text-2xl font-bold">{activeThreads}</p>
</CardContent>
</Card>
<Card>
<CardContent className="p-4">
<p className="text-xs text-muted-foreground">Finalizate</p>
<p className="text-2xl font-bold">{totalThreads - activeThreads}</p>
</CardContent>
</Card>
<Card>
<CardContent className="p-4">
<p className="text-xs text-muted-foreground">Durată medie</p>
<p className="text-2xl font-bold">
{avgDuration}{" "}
<span className="text-sm font-normal text-muted-foreground">
zile
</span>
</p>
</CardContent>
</Card>
</div>
{/* Summary + filters */}
<div className="flex items-center gap-4">
<p className="text-sm text-muted-foreground">
<span className="font-medium text-foreground">{activeCount}</span>{" "}
{activeCount === 1 ? "dosar activ" : "dosare active"},{" "}
<span className="font-medium text-foreground">{closedCount}</span>{" "}
finalizate
</p>
{/* Filters */}
<div className="flex flex-wrap items-end gap-3">
<div className="relative min-w-[200px] flex-1">
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
<Input
placeholder="Caută după nr., subiect, expeditor..."
value={search}
onChange={(e) => setSearch(e.target.value)}
className="pl-9"
/>
</div>
<div>
<Label className="text-xs">Status</Label>
<Select
value={statusFilter}
onValueChange={(v) =>
setStatusFilter(v as "all" | "active" | "closed")
}
<div className="ml-auto flex items-center gap-2">
<div className="relative">
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
<Input
placeholder="Cauta..."
value={search}
onChange={(e) => setSearch(e.target.value)}
className="h-9 w-[200px] pl-9"
/>
</div>
<Button
variant={activeOnly ? "default" : "outline"}
size="sm"
onClick={() => setActiveOnly(!activeOnly)}
>
<SelectTrigger className="mt-1 w-[140px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">Toate</SelectItem>
<SelectItem value="active">Active</SelectItem>
<SelectItem value="closed">Finalizate</SelectItem>
</SelectContent>
</Select>
Doar active
</Button>
</div>
</div>
{/* Thread list */}
{/* Thread list with flow diagrams */}
{filtered.length === 0 ? (
<div className="py-12 text-center">
<GitBranch className="mx-auto h-10 w-10 text-muted-foreground/40" />
<p className="mt-2 text-sm text-muted-foreground">
{totalThreads === 0
? "Niciun fir de conversație. Creați legături între înregistrări (Răspuns la) pentru a forma fire."
: "Niciun fir corespunde filtrelor."}
{threads.length === 0
? "Niciun dosar. Inchideti o inregistrare cu raspuns (Inchide) pentru a forma un dosar."
: "Niciun dosar corespunde filtrelor."}
</p>
</div>
) : (
<div className="space-y-3">
{filtered.map((thread) => {
const isExpanded = expandedThreads.has(thread.id);
return (
<Card key={thread.id} className="overflow-hidden">
{/* Thread header */}
<button
type="button"
onClick={() => toggleExpand(thread.id)}
className="w-full text-left"
>
<CardContent className="p-4">
<div className="flex items-start gap-3">
<div className="mt-0.5 shrink-0">
{isExpanded ? (
<ChevronDown className="h-4 w-4 text-muted-foreground" />
) : (
<ChevronRight className="h-4 w-4 text-muted-foreground" />
)}
</div>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2 flex-wrap">
<GitBranch className="h-4 w-4 text-primary shrink-0" />
<span className="font-medium truncate">
{thread.root.subject}
</span>
<Badge
variant={thread.isActive ? "default" : "secondary"}
className="text-[10px]"
>
{thread.isActive ? "Activ" : "Finalizat"}
</Badge>
<Badge variant="outline" className="text-[10px]">
{thread.chain.length} doc.
</Badge>
</div>
<div className="mt-1 flex flex-wrap items-center gap-x-4 gap-y-1 text-xs text-muted-foreground">
<span className="flex items-center gap-1">
<Calendar className="h-3 w-3" />
{formatShortDate(thread.chain[0]!.date)} {" "}
{formatShortDate(
thread.chain[thread.chain.length - 1]!.date,
)}
</span>
<span className="flex items-center gap-1">
<Clock className="h-3 w-3" />
{thread.totalDays} zile total
</span>
<span className="flex items-center gap-1">
<Building2 className="h-3 w-3" />
{thread.daysAtUs}z la noi ·{" "}
{thread.daysAtInstitution}z la inst.
</span>
</div>
</div>
</div>
</CardContent>
</button>
{/* Expanded: Timeline */}
{isExpanded && (
<div className="border-t bg-muted/20 px-4 pb-4 pt-3">
{/* Timeline */}
<div className="relative ml-6">
{/* Vertical line */}
<div className="absolute left-3 top-0 bottom-0 w-0.5 bg-border" />
{thread.chain.map((entry, i) => {
const prev = i > 0 ? thread.chain[i - 1] : null;
const gap = prev
? daysBetween(prev.date, entry.date)
: 0;
const isIncoming = entry.direction === "intrat";
const location = prev
? prev.direction === "iesit"
? "la instituție"
: "la noi"
: "";
return (
<div key={entry.id}>
{/* Gap indicator */}
{prev && gap > 0 && (
<div className="relative flex items-center py-1.5 pl-8">
<div className="absolute left-[9px] h-5 w-3 border-l-2 border-dashed border-muted-foreground/30" />
<span className="text-[10px] text-muted-foreground italic">
{gap} zile {location}
</span>
</div>
)}
{/* Entry node */}
<div className="relative flex items-start gap-3 py-1.5">
{/* Node dot */}
<div
className={cn(
"relative z-10 mt-1.5 h-6 w-6 rounded-full border-2 flex items-center justify-center shrink-0",
isIncoming
? "border-blue-500 bg-blue-50 dark:bg-blue-950"
: "border-orange-500 bg-orange-50 dark:bg-orange-950",
)}
>
{isIncoming ? (
<ArrowDown className="h-3 w-3 text-blue-600 dark:text-blue-400" />
) : (
<ArrowUp className="h-3 w-3 text-orange-600 dark:text-orange-400" />
)}
</div>
{/* Entry content */}
<button
type="button"
onClick={(e) => {
e.stopPropagation();
onNavigateEntry?.(entry);
}}
className={cn(
"flex-1 rounded-lg border p-3 text-left transition-colors hover:bg-accent/50",
entry.status === "inchis" && "opacity-70",
)}
>
<div className="flex items-center gap-2 flex-wrap">
<Badge
variant={
isIncoming ? "default" : "secondary"
}
className="text-[10px]"
>
{isIncoming ? "↓ Intrat" : "↑ Ieșit"}
</Badge>
<span className="font-mono text-xs">
{entry.number}
</span>
<span className="text-xs text-muted-foreground">
{formatDate(entry.date)}
</span>
{entry.status === "inchis" && (
<Badge
variant="outline"
className="text-[10px]"
>
Închis
</Badge>
)}
</div>
<p className="mt-1 text-sm font-medium">
{entry.subject}
</p>
<div className="mt-1 flex flex-wrap gap-x-3 text-xs text-muted-foreground">
<span>
Tip: {getDocTypeLabel(entry.documentType)}
</span>
{entry.sender && (
<span>De la: {entry.sender}</span>
)}
{entry.recipient && (
<span>Către: {entry.recipient}</span>
)}
</div>
</button>
</div>
</div>
);
})}
</div>
{/* Day summary + Export */}
<div className="mt-4 flex items-center justify-between border-t pt-3">
<div className="flex gap-4 text-xs">
<span className="flex items-center gap-1.5">
<div className="h-2.5 w-2.5 rounded-full bg-blue-500" />
Intrate:{" "}
{
thread.chain.filter((e) => e.direction === "intrat")
.length
}
</span>
<span className="flex items-center gap-1.5">
<div className="h-2.5 w-2.5 rounded-full bg-orange-500" />
Ieșite:{" "}
{
thread.chain.filter((e) => e.direction === "iesit")
.length
}
</span>
<span className="text-muted-foreground">
La noi: {thread.daysAtUs}z · La instituție:{" "}
{thread.daysAtInstitution}z
</span>
</div>
<Button
variant="outline"
size="sm"
onClick={(e) => {
e.stopPropagation();
downloadReport(thread);
}}
>
<FileDown className="mr-1.5 h-3.5 w-3.5" />
Export raport
</Button>
</div>
</div>
)}
</Card>
);
})}
<div className="space-y-4">
{filtered.map((thread) => (
<ThreadCard
key={thread.id}
thread={thread}
allEntries={entries}
onNodeClick={onNavigateEntry}
/>
))}
</div>
)}
<p className="text-xs text-muted-foreground">
{filtered.length} din {totalThreads} fire afișate
</p>
</div>
);
}
// ── Thread card with flow diagram ──
function ThreadCard({
thread,
allEntries,
onNodeClick,
}: {
thread: Thread;
allEntries: RegistryEntry[];
onNodeClick?: (entry: RegistryEntry) => void;
}) {
return (
<div
className={cn(
"rounded-lg border bg-card",
thread.isActive
? "border-border"
: "border-muted bg-muted/20",
)}
>
{/* Header */}
<div className="flex items-center gap-3 border-b px-4 py-3">
<GitBranch className="h-4 w-4 shrink-0 text-primary" />
<span className="truncate text-sm font-medium">
{thread.root.subject}
</span>
<Badge
variant={thread.isActive ? "default" : "secondary"}
className="shrink-0 text-[10px]"
>
{thread.isActive ? "Activ" : "Finalizat"}
</Badge>
<div className="ml-auto flex items-center gap-3 text-xs text-muted-foreground">
<span className="flex items-center gap-1">
<Calendar className="h-3 w-3" />
{formatShortDate(thread.chain[0]!.date)} {" "}
{formatShortDate(thread.chain[thread.chain.length - 1]!.date)}
</span>
<span className="flex items-center gap-1">
<Clock className="h-3 w-3" />
{thread.totalDays}z
</span>
<Badge variant="outline" className="text-[10px]">
{thread.chain.length} doc.
</Badge>
</div>
</div>
{/* Flow diagram */}
<div className="px-4 py-3">
<FlowDiagram
chain={thread.chain}
allEntries={allEntries}
onNodeClick={onNodeClick}
/>
</div>
</div>
);
}
@@ -94,6 +94,7 @@ export const DEADLINE_CATALOG: DeadlineTypeDef[] = [
category: "certificat",
legalReference: "Legea 350/2001, art. 44 alin. (4)",
autoTrack: true,
backgroundOnly: true,
directionFilter: ["iesit"],
},
@@ -6,7 +6,7 @@ import type {
DeadlineAuditEntry,
} from "../types";
import { getDeadlineType } from "./deadline-catalog";
import { computeDueDate } from "./working-days";
import { computeDueDate, addWorkingDays } from "./working-days";
export interface DeadlineDisplayStatus {
label: string;
@@ -219,3 +219,163 @@ function formatDate(d: Date): string {
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;
});
}
+2
View File
@@ -157,6 +157,8 @@ export interface DeadlineTypeDef {
autoTrack?: boolean;
/** Which directions this category/type applies to (undefined = both) */
directionFilter?: RegistryDirection[];
/** If true, this deadline is purely background — never shown in dashboard */
backgroundOnly?: boolean;
}
/** Audit log entry for deadline changes */