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>
269 lines
7.6 KiB
TypeScript
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>
|
|
);
|
|
}
|