feat: Registratura thread explorer, AC validity tracker, interactive I/O toggle + Password Vault rework

Registratura improvements:
- Thread Explorer: new 'Fire conversatie' tab with timeline view, search, stats, gap tracking (la noi/la institutie), export to text report
- Interactive I/O toggle: replaced direction dropdown with visual blue/orange button group (Intrat/Iesit with icons)
- Doc type UX: alphabetical sort + immediate selection after adding custom type
- AC Validity Tracker: full Autorizatie de Construire lifecycle workflow (12mo validity, execution phases, extension request, required docs checklist, monthly reminders, abandonment/expiry tracking)

Password Vault rework (renamed to 'Parole Uzuale' v0.3.0):
- New categories: WiFi, Portale Primarii, Avize Online, PIN Semnatura, Software, Hardware (replaced server/database/api)
- Category icons (lucide-react) throughout list and form
- WiFi QR code dialog with connection string copy
- Context-aware form (PIN vs password label, hide email for WiFi/PIN, hide URL for WiFi, hide generator for PIN)
- Dynamic stat cards showing top 3 categories by count
- Removed encryption banner
- Updated i18n, flags, config
This commit is contained in:
AI Assistant
2026-02-28 16:33:36 +02:00
parent 25338ea4d8
commit 3abf0d189c
13 changed files with 2575 additions and 830 deletions
@@ -0,0 +1,611 @@
"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 { 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 { 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 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);
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 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", {
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>();
// Build parent->children map
for (const entry of entries) {
if (entry.threadParentId) {
const existing = childrenMap.get(entry.threadParentId) ?? [];
existing.push(entry);
childrenMap.set(entry.threadParentId, existing);
}
}
// 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) {
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;
// Collect all entries in this thread (BFS)
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);
}
}
// 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)
// 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,
});
}
// 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;
const bLast = b.chain[b.chain.length - 1]!.date;
return bLast.localeCompare(aLast);
});
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 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 (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, statusFilter]);
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;
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>
{/* 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")
}
>
<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>
</div>
</div>
{/* Thread list */}
{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."}
</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>
)}
<p className="text-xs text-muted-foreground">
{filtered.length} din {totalThreads} fire afișate
</p>
</div>
);
}