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:
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user