Files
ArchiTools/src/modules/registratura/components/thread-explorer.tsx
T
AI Assistant 5b18cce5a3 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>
2026-03-11 23:25:08 +02:00

269 lines
7.6 KiB
TypeScript

"use client";
import { useState, useMemo } from "react";
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 type { RegistryEntry } from "../types";
import { FlowDiagram } from "./flow-diagram";
import { cn } from "@/shared/lib/utils";
interface ThreadExplorerProps {
entries: RegistryEntry[];
onNavigateEntry?: (entry: RegistryEntry) => void;
}
/** A thread is a chain of entries linked by threadParentId */
interface Thread {
id: string;
root: RegistryEntry;
chain: RegistryEntry[];
totalDays: number;
isActive: boolean;
}
function daysBetween(d1: string, d2: string): number {
const a = new Date(d1);
const b = new Date(d2);
a.setHours(0, 0, 0, 0);
b.setHours(0, 0, 0, 0);
return Math.max(
0,
Math.round(Math.abs(b.getTime() - a.getTime()) / (1000 * 60 * 60 * 24)),
);
}
function formatShortDate(iso: string): string {
try {
return new Date(iso).toLocaleDateString("ro-RO", {
day: "2-digit",
month: "short",
});
} catch {
return iso;
}
}
/** Build threads from all entries */
function buildThreads(entries: RegistryEntry[]): Thread[] {
const byId = new Map(entries.map((e) => [e.id, e]));
const childrenMap = new Map<string, RegistryEntry[]>();
const rootIds = new Set<string>();
for (const entry of entries) {
if (entry.threadParentId) {
const existing = childrenMap.get(entry.threadParentId) ?? [];
existing.push(entry);
childrenMap.set(entry.threadParentId, existing);
}
}
for (const entry of entries) {
if (!entry.threadParentId && childrenMap.has(entry.id)) {
rootIds.add(entry.id);
}
if (entry.threadParentId) {
let current = entry;
while (current.threadParentId) {
const parent = byId.get(current.threadParentId);
if (!parent) break;
current = parent;
}
rootIds.add(current.id);
}
}
const threads: Thread[] = [];
for (const rootId of rootIds) {
const root = byId.get(rootId);
if (!root) continue;
const chain: RegistryEntry[] = [];
const queue = [rootId];
const visited = new Set<string>();
while (queue.length > 0) {
const id = queue.shift()!;
if (visited.has(id)) continue;
visited.add(id);
const entry = byId.get(id);
if (!entry) continue;
chain.push(entry);
const children = childrenMap.get(id) ?? [];
for (const child of children) {
queue.push(child.id);
}
}
chain.sort(
(a, b) => new Date(a.date).getTime() - new Date(b.date).getTime(),
);
if (chain.length < 2) continue;
const firstDate = chain[0]!.date;
const lastDate = chain[chain.length - 1]!.date;
const totalDays = daysBetween(firstDate, lastDate);
const isActive = chain.some((e) => e.status === "deschis");
threads.push({ id: rootId, root, chain, totalDays, isActive });
}
threads.sort((a, b) => {
if (a.isActive !== b.isActive) return a.isActive ? -1 : 1;
const aLast = a.chain[a.chain.length - 1]!.date;
const bLast = b.chain[b.chain.length - 1]!.date;
return bLast.localeCompare(aLast);
});
return threads;
}
export function ThreadExplorer({
entries,
onNavigateEntry,
}: ThreadExplorerProps) {
const [search, setSearch] = useState("");
const [activeOnly, setActiveOnly] = useState(false);
const threads = useMemo(() => buildThreads(entries), [entries]);
const filtered = useMemo(() => {
return threads.filter((t) => {
if (activeOnly && !t.isActive) return false;
if (search.trim()) {
const q = search.toLowerCase();
return t.chain.some(
(e) =>
e.number.toLowerCase().includes(q) ||
e.subject.toLowerCase().includes(q) ||
e.sender.toLowerCase().includes(q) ||
e.recipient.toLowerCase().includes(q),
);
}
return true;
});
}, [threads, search, activeOnly]);
const activeCount = threads.filter((t) => t.isActive).length;
const closedCount = threads.length - activeCount;
return (
<div className="space-y-4">
{/* 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>
<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)}
>
Doar active
</Button>
</div>
</div>
{/* 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">
{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-4">
{filtered.map((thread) => (
<ThreadCard
key={thread.id}
thread={thread}
allEntries={entries}
onNodeClick={onNavigateEntry}
/>
))}
</div>
)}
</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>
);
}