4d1883b459
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1246 lines
46 KiB
TypeScript
1246 lines
46 KiB
TypeScript
"use client";
|
||
|
||
import {
|
||
ArrowDownToLine,
|
||
ArrowUpFromLine,
|
||
Bell,
|
||
BellOff,
|
||
Calendar,
|
||
CheckCircle2,
|
||
Clock,
|
||
Copy,
|
||
ExternalLink,
|
||
Eye,
|
||
FileText,
|
||
GitBranch,
|
||
HardDrive,
|
||
Link2,
|
||
Paperclip,
|
||
Pencil,
|
||
Trash2,
|
||
User,
|
||
X,
|
||
Image as ImageIcon,
|
||
Reply,
|
||
Radio,
|
||
RefreshCw,
|
||
ChevronDown,
|
||
ChevronUp,
|
||
AlertTriangle,
|
||
ShieldAlert,
|
||
Timer,
|
||
} from "lucide-react";
|
||
import { Button } from "@/shared/components/ui/button";
|
||
import { Badge } from "@/shared/components/ui/badge";
|
||
import { Separator } from "@/shared/components/ui/separator";
|
||
import { ScrollArea } from "@/shared/components/ui/scroll-area";
|
||
import {
|
||
Sheet,
|
||
SheetContent,
|
||
SheetHeader,
|
||
SheetTitle,
|
||
SheetDescription,
|
||
} from "@/shared/components/ui/sheet";
|
||
import type { RegistryEntry } from "../types";
|
||
import { DEFAULT_DOC_TYPE_LABELS, EXTERNAL_STATUS_LABELS } from "../types";
|
||
import type { ExternalDocStatus } from "../types";
|
||
import { getAuthority } from "../services/authority-catalog";
|
||
import { getOverdueDays } from "../services/registry-service";
|
||
import { pathFileName, shareLabelFor } from "@/config/nas-paths";
|
||
import { cn } from "@/shared/lib/utils";
|
||
import { useState, useCallback, useMemo } from "react";
|
||
import {
|
||
AttachmentPreview,
|
||
getPreviewableAttachments,
|
||
} from "./attachment-preview";
|
||
import { findAuthorityForContact } from "../services/authority-catalog";
|
||
import { computeTransmissionStatus } from "../services/deadline-service";
|
||
import { getDeadlineType } from "../services/deadline-catalog";
|
||
import { StatusMonitorConfig } from "./status-monitor-config";
|
||
import { FlowDiagram } from "./flow-diagram";
|
||
import { DeadlineTimeline } from "./deadline-timeline";
|
||
|
||
interface RegistryEntryDetailProps {
|
||
entry: RegistryEntry | null;
|
||
open: boolean;
|
||
onOpenChange: (open: boolean) => void;
|
||
onEdit: (entry: RegistryEntry) => void;
|
||
onClose: (entry: RegistryEntry) => void;
|
||
onDelete: (id: string) => void;
|
||
/** Create a new entry linked as reply (conex) to this entry */
|
||
onReply?: (entry: RegistryEntry) => void;
|
||
/** Resolve a tracked deadline inline */
|
||
onResolveDeadline?: (entryId: string, deadlineId: string, resolution: string, note: string) => void;
|
||
allEntries: RegistryEntry[];
|
||
}
|
||
|
||
const DIRECTION_CONFIG = {
|
||
intrat: {
|
||
label: "Intrat",
|
||
icon: ArrowDownToLine,
|
||
class: "bg-blue-100 text-blue-800 dark:bg-blue-900/40 dark:text-blue-300",
|
||
},
|
||
iesit: {
|
||
label: "Ieșit",
|
||
icon: ArrowUpFromLine,
|
||
class:
|
||
"bg-orange-100 text-orange-800 dark:bg-orange-900/40 dark:text-orange-300",
|
||
},
|
||
} as const;
|
||
|
||
const STATUS_CONFIG = {
|
||
deschis: {
|
||
label: "Deschis",
|
||
class:
|
||
"bg-emerald-100 text-emerald-800 dark:bg-emerald-900/40 dark:text-emerald-300",
|
||
},
|
||
inchis: {
|
||
label: "Închis",
|
||
class: "bg-gray-100 text-gray-600 dark:bg-gray-800 dark:text-gray-400",
|
||
},
|
||
reserved: {
|
||
label: "Rezervat",
|
||
class: "bg-amber-100 text-amber-800 dark:bg-amber-900/40 dark:text-amber-300",
|
||
},
|
||
} as const;
|
||
|
||
const RESOLUTION_LABELS: Record<string, string> = {
|
||
finalizat: "Finalizat",
|
||
"aprobat-tacit": "Aprobat tacit",
|
||
respins: "Respins",
|
||
retras: "Retras",
|
||
altele: "Altele",
|
||
};
|
||
|
||
const DEADLINE_RES_LABELS: Record<string, string> = {
|
||
pending: "În așteptare",
|
||
completed: "Finalizat",
|
||
"aprobat-tacit": "Aprobat tacit",
|
||
respins: "Respins",
|
||
anulat: "Anulat",
|
||
};
|
||
|
||
function getDocTypeLabel(type: string): string {
|
||
const label = DEFAULT_DOC_TYPE_LABELS[type];
|
||
if (label) return label;
|
||
return type.replace(/-/g, " ").replace(/^\w/, (c) => c.toUpperCase());
|
||
}
|
||
|
||
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 formatDateTime(iso: string): string {
|
||
try {
|
||
return new Date(iso).toLocaleString("ro-RO", {
|
||
day: "2-digit",
|
||
month: "2-digit",
|
||
year: "numeric",
|
||
hour: "2-digit",
|
||
minute: "2-digit",
|
||
});
|
||
} catch {
|
||
return iso;
|
||
}
|
||
}
|
||
|
||
export function RegistryEntryDetail({
|
||
entry,
|
||
open,
|
||
onOpenChange,
|
||
onEdit,
|
||
onClose,
|
||
onDelete,
|
||
onReply,
|
||
onResolveDeadline,
|
||
allEntries,
|
||
}: RegistryEntryDetailProps) {
|
||
const [previewIndex, setPreviewIndex] = useState<number | null>(null);
|
||
const [copiedPath, setCopiedPath] = useState<string | null>(null);
|
||
const [monitorConfigOpen, setMonitorConfigOpen] = useState(false);
|
||
const [monitorEditMode, setMonitorEditMode] = useState(false);
|
||
|
||
// Authority for existing tracking or auto-detected from recipient
|
||
const trackingAuthority = useMemo(() => {
|
||
if (!entry) return undefined;
|
||
if (entry.externalStatusTracking) {
|
||
return getAuthority(entry.externalStatusTracking.authorityId) ?? undefined;
|
||
}
|
||
return undefined;
|
||
}, [entry]);
|
||
|
||
// Auto-detect if recipient matches a known authority (only when no tracking)
|
||
const matchedAuthority = useMemo(() => {
|
||
if (!entry) return undefined;
|
||
if (entry.externalStatusTracking) return undefined;
|
||
if (!entry.recipientRegNumber) return undefined;
|
||
return findAuthorityForContact(entry.recipient);
|
||
}, [entry]);
|
||
|
||
const previewableAtts = useMemo(
|
||
() => (entry ? getPreviewableAttachments(entry.attachments) : []),
|
||
[entry],
|
||
);
|
||
|
||
const copyPath = useCallback(async (path: string) => {
|
||
await navigator.clipboard.writeText(path);
|
||
setCopiedPath(path);
|
||
setTimeout(() => setCopiedPath(null), 2000);
|
||
}, []);
|
||
|
||
// Build full chain for mini flow diagram (must be before early return to keep hook order stable)
|
||
const threadChain = useMemo(() => {
|
||
if (!entry) return [];
|
||
const threadParentEntry = entry.threadParentId
|
||
? allEntries.find((e) => e.id === entry.threadParentId)
|
||
: null;
|
||
const threadChildEntries = allEntries.filter(
|
||
(e) => e.threadParentId === entry.id,
|
||
);
|
||
if (!threadParentEntry && threadChildEntries.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, allEntries]);
|
||
|
||
if (!entry) return null;
|
||
|
||
const dir = DIRECTION_CONFIG[entry.direction] ?? DIRECTION_CONFIG.intrat;
|
||
const DirIcon = dir.icon;
|
||
const status = STATUS_CONFIG[entry.status] ?? STATUS_CONFIG.deschis;
|
||
|
||
const overdueDays =
|
||
entry.status === "deschis" ? getOverdueDays(entry.deadline) : null;
|
||
const isOverdue = overdueDays !== null && overdueDays > 0;
|
||
|
||
const threadParent = entry.threadParentId
|
||
? allEntries.find((e) => e.id === entry.threadParentId)
|
||
: null;
|
||
const threadChildren = allEntries.filter(
|
||
(e) => e.threadParentId === entry.id,
|
||
);
|
||
|
||
return (
|
||
<Sheet open={open} onOpenChange={onOpenChange}>
|
||
<SheetContent
|
||
side="right"
|
||
className="w-full sm:max-w-lg md:max-w-xl p-0 flex flex-col"
|
||
>
|
||
<SheetHeader className="px-6 pt-6 pb-0">
|
||
<div className="flex items-start justify-between gap-3 pr-8">
|
||
<div className="space-y-1">
|
||
<SheetTitle className="text-lg font-bold font-mono">
|
||
{entry.number}
|
||
</SheetTitle>
|
||
<SheetDescription className="text-sm">
|
||
{entry.subject}
|
||
</SheetDescription>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Action bar */}
|
||
<div className="flex gap-2 mt-3">
|
||
<Button
|
||
size="sm"
|
||
variant="outline"
|
||
onClick={() => {
|
||
onOpenChange(false);
|
||
onEdit(entry);
|
||
}}
|
||
>
|
||
<Pencil className="mr-1.5 h-3.5 w-3.5" /> Editează
|
||
</Button>
|
||
{onReply && (
|
||
<Button
|
||
size="sm"
|
||
variant="outline"
|
||
className="text-blue-600 border-blue-300 hover:bg-blue-50 dark:border-blue-700 dark:hover:bg-blue-950/30"
|
||
onClick={() => {
|
||
onOpenChange(false);
|
||
onReply(entry);
|
||
}}
|
||
>
|
||
<Reply className="mr-1.5 h-3.5 w-3.5" /> Conex
|
||
</Button>
|
||
)}
|
||
{entry.status === "deschis" && (
|
||
<Button
|
||
size="sm"
|
||
variant="outline"
|
||
className="text-green-600 border-green-300 hover:bg-green-50 dark:border-green-700 dark:hover:bg-green-950/30"
|
||
onClick={() => {
|
||
onOpenChange(false);
|
||
onClose(entry);
|
||
}}
|
||
>
|
||
<CheckCircle2 className="mr-1.5 h-3.5 w-3.5" /> Inchide
|
||
</Button>
|
||
)}
|
||
<Button
|
||
size="sm"
|
||
variant="ghost"
|
||
className="text-destructive ml-auto"
|
||
onClick={() => {
|
||
onOpenChange(false);
|
||
onDelete(entry.id);
|
||
}}
|
||
>
|
||
<Trash2 className="mr-1.5 h-3.5 w-3.5" /> Șterge
|
||
</Button>
|
||
</div>
|
||
</SheetHeader>
|
||
|
||
<Separator className="mt-4" />
|
||
|
||
<ScrollArea className="flex-1 min-h-0 overflow-hidden [&>[data-slot=scroll-area-viewport]]:!overflow-x-hidden">
|
||
<div className="space-y-5 py-4 px-6">
|
||
{/* ── Status row ── */}
|
||
<div className="flex flex-wrap gap-2">
|
||
<Badge className={cn("text-xs px-2.5 py-0.5", dir.class)}>
|
||
<DirIcon className="mr-1 h-3 w-3" />
|
||
{dir.label}
|
||
</Badge>
|
||
<Badge className={cn("text-xs px-2.5 py-0.5", status.class)}>
|
||
{status.label}
|
||
</Badge>
|
||
<Badge variant="outline" className="text-xs">
|
||
{getDocTypeLabel(entry.documentType)}
|
||
</Badge>
|
||
{entry.company && (
|
||
<Badge variant="outline" className="text-xs uppercase">
|
||
{entry.company}
|
||
</Badge>
|
||
)}
|
||
</div>
|
||
|
||
{/* ── Closure info ── */}
|
||
{entry.closureInfo && (
|
||
<DetailSection title="Închidere">
|
||
<div className="rounded-md border border-muted p-3 space-y-1 text-sm">
|
||
<div className="flex items-center gap-2">
|
||
<span className="text-muted-foreground">Rezoluție:</span>
|
||
<Badge variant="outline" className="text-xs">
|
||
{RESOLUTION_LABELS[entry.closureInfo.resolution] ??
|
||
entry.closureInfo.resolution}
|
||
</Badge>
|
||
</div>
|
||
{entry.closureInfo.reason && (
|
||
<p>
|
||
<span className="text-muted-foreground">Motiv:</span>{" "}
|
||
{entry.closureInfo.reason}
|
||
</p>
|
||
)}
|
||
{entry.closureInfo.closedBy && (
|
||
<p className="text-xs text-muted-foreground">
|
||
Închis de {entry.closureInfo.closedBy} la{" "}
|
||
{formatDateTime(entry.closureInfo.closedAt)}
|
||
</p>
|
||
)}
|
||
</div>
|
||
</DetailSection>
|
||
)}
|
||
|
||
{/* ── Date ── */}
|
||
<DetailSection title="Date">
|
||
<div className="grid grid-cols-2 gap-3">
|
||
<DetailField
|
||
label="Data document"
|
||
value={formatDate(entry.date)}
|
||
/>
|
||
{entry.registrationDate &&
|
||
entry.registrationDate !== entry.date && (
|
||
<DetailField
|
||
label="Data înregistrare"
|
||
value={formatDate(entry.registrationDate)}
|
||
/>
|
||
)}
|
||
{entry.deadline && (
|
||
<DetailField
|
||
label="Termen limită"
|
||
value={formatDate(entry.deadline)}
|
||
className={cn(isOverdue && "text-destructive font-medium")}
|
||
extra={
|
||
overdueDays !== null && overdueDays > 0
|
||
? `(${overdueDays} zile depășit)`
|
||
: overdueDays !== null && overdueDays < 0
|
||
? `(mai sunt ${Math.abs(overdueDays)} zile)`
|
||
: undefined
|
||
}
|
||
/>
|
||
)}
|
||
{entry.expiryDate && (
|
||
<DetailField
|
||
label="Valabilitate"
|
||
value={formatDate(entry.expiryDate)}
|
||
/>
|
||
)}
|
||
<DetailField
|
||
label="Creat"
|
||
value={formatDateTime(entry.createdAt)}
|
||
/>
|
||
<DetailField
|
||
label="Modificat"
|
||
value={formatDateTime(entry.updatedAt)}
|
||
/>
|
||
</div>
|
||
</DetailSection>
|
||
|
||
{/* ── Reminders / Alerts summary ── */}
|
||
{(() => {
|
||
const alerts: { icon: React.ReactNode; label: string; detail: string; color: string }[] = [];
|
||
|
||
// AC validity reminder
|
||
if (entry.acValidity?.enabled) {
|
||
const ac = entry.acValidity;
|
||
const validityMonths = ac.validityMonths ?? 12;
|
||
const issuance = new Date(ac.issuanceDate);
|
||
const validityEnd = new Date(issuance);
|
||
validityEnd.setMonth(validityEnd.getMonth() + validityMonths);
|
||
const now = new Date();
|
||
now.setHours(0, 0, 0, 0);
|
||
validityEnd.setHours(0, 0, 0, 0);
|
||
const daysLeft = Math.ceil((validityEnd.getTime() - now.getTime()) / (1000 * 60 * 60 * 24));
|
||
const monthsLeft = Math.ceil(daysLeft / 30);
|
||
const currentMonth = Math.max(1, validityMonths - monthsLeft + 1);
|
||
|
||
if (ac.reminder.dismissed) {
|
||
alerts.push({
|
||
icon: <BellOff className="h-3.5 w-3.5" />,
|
||
label: `AC — Remindere dezactivate`,
|
||
detail: `Valabilitate ${validityMonths} luni, luna ${currentMonth}/${validityMonths}`,
|
||
color: "text-muted-foreground",
|
||
});
|
||
} else if (daysLeft <= 30) {
|
||
alerts.push({
|
||
icon: <ShieldAlert className="h-3.5 w-3.5" />,
|
||
label: `AC — Expiră în ${daysLeft} zile!`,
|
||
detail: `Luna ${currentMonth}/${validityMonths} — acțiune imediată`,
|
||
color: "text-red-600 dark:text-red-400",
|
||
});
|
||
} else if (daysLeft <= 90) {
|
||
alerts.push({
|
||
icon: <Bell className="h-3.5 w-3.5" />,
|
||
label: `AC — Reminder activ (luna ${currentMonth}/${validityMonths})`,
|
||
detail: `Mai sunt ${daysLeft} zile (${monthsLeft} luni)`,
|
||
color: "text-amber-600 dark:text-amber-400",
|
||
});
|
||
} else {
|
||
alerts.push({
|
||
icon: <Bell className="h-3.5 w-3.5" />,
|
||
label: `AC — Reminder activ (luna ${currentMonth}/${validityMonths})`,
|
||
detail: `Mai sunt ${daysLeft} zile`,
|
||
color: "text-emerald-600 dark:text-emerald-400",
|
||
});
|
||
}
|
||
if (ac.reminder.snoozeCount > 0) {
|
||
const lastAlert = alerts[alerts.length - 1];
|
||
if (lastAlert) {
|
||
lastAlert.detail += ` · Amânat de ${ac.reminder.snoozeCount}×`;
|
||
}
|
||
}
|
||
}
|
||
|
||
// Expiry date alert
|
||
if (entry.expiryDate) {
|
||
const expiry = new Date(entry.expiryDate);
|
||
const now = new Date();
|
||
now.setHours(0, 0, 0, 0);
|
||
expiry.setHours(0, 0, 0, 0);
|
||
const daysLeft = Math.ceil((expiry.getTime() - now.getTime()) / (1000 * 60 * 60 * 24));
|
||
const alertDays = entry.expiryAlertDays ?? 30;
|
||
|
||
if (daysLeft < 0) {
|
||
alerts.push({
|
||
icon: <AlertTriangle className="h-3.5 w-3.5" />,
|
||
label: `Document expirat de ${Math.abs(daysLeft)} zile`,
|
||
detail: `Expirat: ${formatDate(entry.expiryDate)}`,
|
||
color: "text-red-600 dark:text-red-400",
|
||
});
|
||
} else if (daysLeft <= alertDays) {
|
||
alerts.push({
|
||
icon: <Timer className="h-3.5 w-3.5" />,
|
||
label: `Expiră în ${daysLeft} zile`,
|
||
detail: `Alertă setată la ${alertDays} zile înainte`,
|
||
color: "text-amber-600 dark:text-amber-400",
|
||
});
|
||
} else {
|
||
alerts.push({
|
||
icon: <Timer className="h-3.5 w-3.5" />,
|
||
label: `Valabil — expiră ${formatDate(entry.expiryDate)}`,
|
||
detail: `Mai sunt ${daysLeft} zile (alertă la ${alertDays} zile)`,
|
||
color: "text-emerald-600 dark:text-emerald-400",
|
||
});
|
||
}
|
||
}
|
||
|
||
// Tracked deadlines summary
|
||
const pendingDeadlines = (entry.trackedDeadlines ?? []).filter(d => d.resolution === "pending");
|
||
if (pendingDeadlines.length > 0) {
|
||
const overdueCount = pendingDeadlines.filter(d => {
|
||
const due = new Date(d.dueDate);
|
||
due.setHours(0, 0, 0, 0);
|
||
return due.getTime() < new Date().setHours(0, 0, 0, 0);
|
||
}).length;
|
||
|
||
if (overdueCount > 0) {
|
||
alerts.push({
|
||
icon: <AlertTriangle className="h-3.5 w-3.5" />,
|
||
label: `${overdueCount} ${overdueCount === 1 ? "termen depășit" : "termene depășite"}`,
|
||
detail: `${pendingDeadlines.length} ${pendingDeadlines.length === 1 ? "termen activ" : "termene active"} total`,
|
||
color: "text-red-600 dark:text-red-400",
|
||
});
|
||
} else {
|
||
alerts.push({
|
||
icon: <Clock className="h-3.5 w-3.5" />,
|
||
label: `${pendingDeadlines.length} ${pendingDeadlines.length === 1 ? "termen activ" : "termene active"}`,
|
||
detail: pendingDeadlines.map(d => `${getDeadlineType(d.typeId)?.label ?? d.typeId} (${formatDate(d.dueDate)})`).join(", "),
|
||
color: "text-blue-600 dark:text-blue-400",
|
||
});
|
||
}
|
||
}
|
||
|
||
if (alerts.length === 0) return null;
|
||
|
||
return (
|
||
<DetailSection title="Remindere & Alerte">
|
||
<div className="space-y-2">
|
||
{alerts.map((alert, i) => (
|
||
<div
|
||
key={i}
|
||
className={cn(
|
||
"flex items-start gap-2.5 rounded-md border px-3 py-2",
|
||
alert.color.includes("red") && "border-red-200 bg-red-50/50 dark:border-red-900 dark:bg-red-950/20",
|
||
alert.color.includes("amber") && "border-amber-200 bg-amber-50/50 dark:border-amber-900 dark:bg-amber-950/20",
|
||
alert.color.includes("emerald") && "border-emerald-200 bg-emerald-50/50 dark:border-emerald-900 dark:bg-emerald-950/20",
|
||
alert.color.includes("blue") && "border-blue-200 bg-blue-50/50 dark:border-blue-900 dark:bg-blue-950/20",
|
||
alert.color.includes("muted") && "border-muted",
|
||
)}
|
||
>
|
||
<span className={cn("mt-0.5 shrink-0", alert.color)}>{alert.icon}</span>
|
||
<div className="min-w-0">
|
||
<p className={cn("text-sm font-medium", alert.color)}>{alert.label}</p>
|
||
<p className="text-[10px] text-muted-foreground">{alert.detail}</p>
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</DetailSection>
|
||
);
|
||
})()}
|
||
|
||
{/* ── Parties ── */}
|
||
<DetailSection title="Părți">
|
||
<div className="grid grid-cols-2 gap-3">
|
||
{entry.sender && (
|
||
<DetailField label="Expeditor" value={entry.sender} />
|
||
)}
|
||
{entry.recipient && (
|
||
<DetailField label="Destinatar" value={entry.recipient} />
|
||
)}
|
||
{entry.assignee && (
|
||
<DetailField
|
||
label="Responsabil"
|
||
value={entry.assignee}
|
||
icon={<User className="h-3 w-3 text-muted-foreground" />}
|
||
/>
|
||
)}
|
||
</div>
|
||
{(entry.recipientRegNumber || entry.recipientRegDate) && (
|
||
<div className="mt-2 rounded border border-dashed p-2 text-xs text-muted-foreground">
|
||
{entry.recipientRegNumber && (
|
||
<span>Nr. destinatar: {entry.recipientRegNumber}</span>
|
||
)}
|
||
{entry.recipientRegDate && (
|
||
<span className="ml-3">
|
||
Data: {formatDate(entry.recipientRegDate)}
|
||
</span>
|
||
)}
|
||
</div>
|
||
)}
|
||
</DetailSection>
|
||
|
||
{/* ── Thread links (flow diagram) ── */}
|
||
{(threadChain.length >= 2 ||
|
||
(entry.linkedEntryIds ?? []).length > 0) && (
|
||
<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-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-2">
|
||
<p className="text-xs text-muted-foreground flex items-center gap-1">
|
||
<Link2 className="h-3 w-3" />
|
||
{entry.linkedEntryIds.length} inregistrar
|
||
{entry.linkedEntryIds.length === 1 ? "e" : "i"} legat
|
||
{entry.linkedEntryIds.length === 1 ? "a" : "e"}
|
||
</p>
|
||
</div>
|
||
)}
|
||
</DetailSection>
|
||
)}
|
||
|
||
{/* ── Attachments ── */}
|
||
{entry.attachments.length > 0 && (
|
||
<DetailSection title={`Atașamente (${entry.attachments.length})`}>
|
||
<div className="space-y-2">
|
||
{entry.attachments.map((att) =>
|
||
att.networkPath ? (
|
||
<button
|
||
type="button"
|
||
key={att.id}
|
||
className="w-full text-left rounded-md border border-blue-200 dark:border-blue-800 bg-blue-50/50 dark:bg-blue-950/20 px-3 py-2 group cursor-pointer hover:bg-blue-100/50 dark:hover:bg-blue-900/30 transition-colors"
|
||
onClick={() => copyPath(att.networkPath!)}
|
||
title={att.networkPath}
|
||
>
|
||
<div className="flex items-center gap-2">
|
||
<HardDrive className="h-4 w-4 text-blue-600 dark:text-blue-400 shrink-0" />
|
||
<span className="text-sm text-blue-700 dark:text-blue-300 truncate block">
|
||
{pathFileName(att.networkPath)}
|
||
</span>
|
||
<span className="ml-auto flex items-center gap-1.5 shrink-0">
|
||
<Badge
|
||
variant="outline"
|
||
className="text-[10px] border-blue-300 dark:border-blue-700 text-blue-600 dark:text-blue-400"
|
||
>
|
||
{shareLabelFor(att.networkPath) ?? "NAS"}
|
||
</Badge>
|
||
{copiedPath === att.networkPath ? (
|
||
<Badge className="text-[10px] bg-green-600 text-white animate-in fade-in">
|
||
Copiat!
|
||
</Badge>
|
||
) : (
|
||
<Badge
|
||
variant="outline"
|
||
className="text-[10px] border-blue-300 dark:border-blue-700 text-blue-600 dark:text-blue-400 opacity-60 group-hover:opacity-100"
|
||
>
|
||
<Copy className="mr-1 h-3 w-3" />
|
||
Copiază
|
||
</Badge>
|
||
)}
|
||
</span>
|
||
</div>
|
||
</button>
|
||
) : (
|
||
<div
|
||
key={att.id}
|
||
className="flex items-center gap-2 rounded-md border px-3 py-2 group"
|
||
>
|
||
{att.type.startsWith("image/") ? (
|
||
<ImageIcon className="h-4 w-4 text-muted-foreground shrink-0" />
|
||
) : (
|
||
<FileText className="h-4 w-4 text-muted-foreground shrink-0" />
|
||
)}
|
||
<div className="flex-1 min-w-0">
|
||
<p className="text-sm truncate">{att.name}</p>
|
||
<p className="text-[10px] text-muted-foreground">
|
||
{(att.size / 1024).toFixed(0)} KB •{" "}
|
||
{att.type.split("/")[1]?.toUpperCase() ?? att.type}
|
||
</p>
|
||
</div>
|
||
<div className="flex items-center gap-1 shrink-0">
|
||
{/* Preview button (images + PDFs) */}
|
||
{att.data &&
|
||
att.data !== "" &&
|
||
att.data !== "__network__" &&
|
||
(att.type.startsWith("image/") ||
|
||
att.type === "application/pdf") && (
|
||
<Button
|
||
type="button"
|
||
variant="ghost"
|
||
size="icon"
|
||
className="h-6 w-6"
|
||
onClick={() => {
|
||
const idx = previewableAtts.findIndex(
|
||
(a) => a.id === att.id,
|
||
);
|
||
if (idx >= 0) setPreviewIndex(idx);
|
||
}}
|
||
title="Previzualizare"
|
||
>
|
||
<Eye className="h-3 w-3" />
|
||
</Button>
|
||
)}
|
||
{/* Download for files with data */}
|
||
{att.data &&
|
||
att.data !== "" &&
|
||
att.data !== "__network__" && (
|
||
<Button
|
||
type="button"
|
||
variant="ghost"
|
||
size="icon"
|
||
className="h-6 w-6"
|
||
onClick={() => {
|
||
const a = document.createElement("a");
|
||
a.href = att.data;
|
||
a.download = att.name;
|
||
a.click();
|
||
}}
|
||
title="Descarcă"
|
||
>
|
||
<ExternalLink className="h-3 w-3" />
|
||
</Button>
|
||
)}
|
||
<Badge variant="outline" className="text-[10px]">
|
||
<Paperclip className="mr-0.5 h-2.5 w-2.5" />
|
||
Fișier
|
||
</Badge>
|
||
</div>
|
||
</div>
|
||
),
|
||
)}
|
||
</div>
|
||
</DetailSection>
|
||
)}
|
||
|
||
{/* ── Legal deadlines (timeline view) ── */}
|
||
{(entry.trackedDeadlines ?? []).length > 0 && (
|
||
<DetailSection title="Termene legale">
|
||
<DeadlineTimeline
|
||
deadlines={entry.trackedDeadlines!}
|
||
onResolveInline={onResolveDeadline ? (deadlineId, note) => {
|
||
onResolveDeadline(entry.id, deadlineId, "completed", note);
|
||
} : undefined}
|
||
/>
|
||
</DetailSection>
|
||
)}
|
||
|
||
{/* ── External status monitoring ── */}
|
||
{entry.externalStatusTracking && (
|
||
<>
|
||
<ExternalStatusSection
|
||
entry={entry}
|
||
onEdit={() => {
|
||
setMonitorEditMode(true);
|
||
setMonitorConfigOpen(true);
|
||
}}
|
||
/>
|
||
{trackingAuthority && (
|
||
<StatusMonitorConfig
|
||
open={monitorConfigOpen && monitorEditMode}
|
||
onOpenChange={(open) => {
|
||
setMonitorConfigOpen(open);
|
||
if (!open) setMonitorEditMode(false);
|
||
}}
|
||
entry={entry}
|
||
authority={trackingAuthority}
|
||
editMode
|
||
onActivate={async (tracking) => {
|
||
try {
|
||
await fetch("/api/registratura", {
|
||
method: "PUT",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({
|
||
id: entry.id,
|
||
updates: { externalStatusTracking: tracking },
|
||
}),
|
||
});
|
||
window.location.reload();
|
||
} catch {
|
||
// Best effort
|
||
}
|
||
}}
|
||
/>
|
||
)}
|
||
</>
|
||
)}
|
||
|
||
{/* ── Auto-detect: suggest monitoring activation ── */}
|
||
{matchedAuthority && !entry.externalStatusTracking && (
|
||
<div className="rounded-lg border border-dashed border-blue-300 dark:border-blue-700 bg-blue-50/50 dark:bg-blue-950/20 p-3">
|
||
<div className="flex items-start gap-2">
|
||
<Radio className="h-4 w-4 text-blue-500 mt-0.5 shrink-0" />
|
||
<div className="flex-1">
|
||
<p className="text-xs font-medium">
|
||
{matchedAuthority.name} suporta monitorizare automata
|
||
</p>
|
||
<p className="text-[10px] text-muted-foreground mt-0.5">
|
||
Se poate verifica automat statusul cererii nr.{" "}
|
||
{entry.recipientRegNumber} de 4 ori pe zi.
|
||
</p>
|
||
<Button
|
||
variant="outline"
|
||
size="sm"
|
||
className="mt-2 h-6 text-xs"
|
||
onClick={() => {
|
||
setMonitorEditMode(false);
|
||
setMonitorConfigOpen(true);
|
||
}}
|
||
>
|
||
Configureaza monitorizarea
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
|
||
<StatusMonitorConfig
|
||
open={monitorConfigOpen && !monitorEditMode}
|
||
onOpenChange={setMonitorConfigOpen}
|
||
entry={entry}
|
||
authority={matchedAuthority}
|
||
onActivate={async (tracking) => {
|
||
try {
|
||
await fetch("/api/registratura", {
|
||
method: "PUT",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({
|
||
id: entry.id,
|
||
updates: { externalStatusTracking: tracking },
|
||
}),
|
||
});
|
||
window.location.reload();
|
||
} catch {
|
||
// Best effort
|
||
}
|
||
}}
|
||
/>
|
||
</div>
|
||
)}
|
||
|
||
{/* ── External tracking (legacy fields) ── */}
|
||
{!entry.externalStatusTracking?.active &&
|
||
(entry.externalStatusUrl || entry.externalTrackingId) && (
|
||
<DetailSection title="Urmărire externă">
|
||
<div className="grid grid-cols-2 gap-3">
|
||
{entry.externalTrackingId && (
|
||
<DetailField
|
||
label="ID extern"
|
||
value={entry.externalTrackingId}
|
||
/>
|
||
)}
|
||
{entry.externalStatusUrl && (
|
||
<div>
|
||
<p className="text-[10px] text-muted-foreground mb-0.5">
|
||
URL verificare
|
||
</p>
|
||
<a
|
||
href={entry.externalStatusUrl}
|
||
target="_blank"
|
||
rel="noopener noreferrer"
|
||
className="text-xs text-blue-600 dark:text-blue-400 hover:underline break-all"
|
||
>
|
||
{entry.externalStatusUrl}
|
||
</a>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</DetailSection>
|
||
)}
|
||
|
||
{/* ── Tags ── */}
|
||
{entry.tags.length > 0 && (
|
||
<DetailSection title="Etichete">
|
||
<div className="flex flex-wrap gap-1">
|
||
{entry.tags.map((tag) => (
|
||
<Badge key={tag} variant="outline" className="text-xs">
|
||
{tag}
|
||
</Badge>
|
||
))}
|
||
</div>
|
||
</DetailSection>
|
||
)}
|
||
|
||
{/* ── Notes ── */}
|
||
{entry.notes && (
|
||
<DetailSection title="Note">
|
||
<p className="text-sm whitespace-pre-wrap text-muted-foreground">
|
||
{entry.notes}
|
||
</p>
|
||
</DetailSection>
|
||
)}
|
||
</div>
|
||
</ScrollArea>
|
||
{/* QuickLook-style attachment preview */}
|
||
{previewIndex !== null && (
|
||
<AttachmentPreview
|
||
key={previewIndex}
|
||
attachments={previewableAtts}
|
||
initialIndex={previewIndex}
|
||
open
|
||
onClose={() => setPreviewIndex(null)}
|
||
/>
|
||
)}
|
||
</SheetContent>
|
||
</Sheet>
|
||
);
|
||
}
|
||
|
||
// ── Sub-components ──
|
||
|
||
// ── External Status Monitoring Section ──
|
||
|
||
const STATUS_COLORS: Record<ExternalDocStatus, string> = {
|
||
"in-operare": "bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-400",
|
||
trimis: "bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400",
|
||
solutionat: "bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400",
|
||
respins: "bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400",
|
||
necunoscut: "bg-muted text-muted-foreground",
|
||
};
|
||
|
||
function ExternalStatusSection({
|
||
entry,
|
||
onEdit,
|
||
}: {
|
||
entry: RegistryEntry;
|
||
onEdit: () => void;
|
||
}) {
|
||
const tracking = entry.externalStatusTracking;
|
||
if (!tracking) return null;
|
||
|
||
const [checking, setChecking] = useState(false);
|
||
const [toggling, setToggling] = useState(false);
|
||
const [checkResult, setCheckResult] = useState<{
|
||
changed: boolean;
|
||
error: string | null;
|
||
newStatus?: string;
|
||
} | null>(null);
|
||
const [showHistory, setShowHistory] = useState(false);
|
||
const [liveTracking, setLiveTracking] = useState(tracking);
|
||
const authority = getAuthority(tracking.authorityId);
|
||
|
||
const handleManualCheck = useCallback(async () => {
|
||
setChecking(true);
|
||
setCheckResult(null);
|
||
try {
|
||
const res = await fetch("/api/registratura/status-check/single", {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({ entryId: entry.id }),
|
||
});
|
||
const data = (await res.json()) as {
|
||
changed: boolean;
|
||
error: string | null;
|
||
newStatus?: string;
|
||
tracking?: typeof tracking;
|
||
};
|
||
setCheckResult({
|
||
changed: data.changed,
|
||
error: data.error,
|
||
newStatus: data.newStatus,
|
||
});
|
||
if (data.tracking) {
|
||
setLiveTracking(data.tracking);
|
||
}
|
||
} catch (err) {
|
||
setCheckResult({
|
||
changed: false,
|
||
error: err instanceof Error ? err.message : "Eroare conexiune",
|
||
});
|
||
} finally {
|
||
setChecking(false);
|
||
}
|
||
}, [entry.id]);
|
||
|
||
const relativeTime = (iso: string) => {
|
||
const diff = Date.now() - new Date(iso).getTime();
|
||
const mins = Math.floor(diff / 60000);
|
||
if (mins < 1) return "chiar acum";
|
||
if (mins < 60) return `acum ${mins} min`;
|
||
const hrs = Math.floor(mins / 60);
|
||
if (hrs < 24) return `acum ${hrs}h`;
|
||
const days = Math.floor(hrs / 24);
|
||
return `acum ${days}z`;
|
||
};
|
||
|
||
const handleToggleActive = useCallback(async () => {
|
||
setToggling(true);
|
||
try {
|
||
const updated = { ...liveTracking, active: !liveTracking.active };
|
||
await fetch("/api/registratura", {
|
||
method: "PUT",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({
|
||
id: entry.id,
|
||
updates: { externalStatusTracking: updated },
|
||
}),
|
||
});
|
||
setLiveTracking(updated);
|
||
} catch {
|
||
// Best effort
|
||
} finally {
|
||
setToggling(false);
|
||
}
|
||
}, [entry.id, liveTracking]);
|
||
|
||
const t = liveTracking;
|
||
|
||
return (
|
||
<div>
|
||
<div className="flex items-center justify-between mb-2">
|
||
<h3 className="text-xs font-semibold text-muted-foreground uppercase tracking-wider">
|
||
Monitorizare status extern
|
||
{!t.active && (
|
||
<span className="ml-1.5 text-[10px] font-normal normal-case">(oprita)</span>
|
||
)}
|
||
</h3>
|
||
<div className="flex gap-1">
|
||
<Button
|
||
variant="ghost"
|
||
size="sm"
|
||
className="h-6 px-2 text-xs"
|
||
onClick={onEdit}
|
||
>
|
||
<Pencil className="h-3 w-3 mr-1" />
|
||
Modifica
|
||
</Button>
|
||
<Button
|
||
variant="ghost"
|
||
size="sm"
|
||
className={cn("h-6 px-2 text-xs", !t.active && "text-green-600")}
|
||
onClick={handleToggleActive}
|
||
disabled={toggling}
|
||
>
|
||
{t.active ? (
|
||
<><BellOff className="h-3 w-3 mr-1" />Opreste</>
|
||
) : (
|
||
<><Bell className="h-3 w-3 mr-1" />Reactiveaza</>
|
||
)}
|
||
</Button>
|
||
{t.active && (
|
||
<Button
|
||
variant="ghost"
|
||
size="sm"
|
||
className="h-6 px-2 text-xs"
|
||
onClick={handleManualCheck}
|
||
disabled={checking}
|
||
>
|
||
<RefreshCw className={cn("h-3 w-3 mr-1", checking && "animate-spin")} />
|
||
{checking ? "Se verifica..." : "Verifica acum"}
|
||
</Button>
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
{/* Inline check result */}
|
||
{checkResult && (
|
||
<div className={cn(
|
||
"rounded border px-2.5 py-1.5 text-xs mb-2",
|
||
checkResult.error
|
||
? "border-red-200 bg-red-50 text-red-700 dark:border-red-800 dark:bg-red-950/30 dark:text-red-400"
|
||
: checkResult.changed
|
||
? "border-green-200 bg-green-50 text-green-700 dark:border-green-800 dark:bg-green-950/30 dark:text-green-400"
|
||
: "border-muted bg-muted/30 text-muted-foreground",
|
||
)}>
|
||
{checkResult.error
|
||
? `Eroare: ${checkResult.error}`
|
||
: checkResult.changed
|
||
? `Status actualizat: ${EXTERNAL_STATUS_LABELS[checkResult.newStatus as ExternalDocStatus] ?? checkResult.newStatus}`
|
||
: "Nicio schimbare detectata"}
|
||
</div>
|
||
)}
|
||
|
||
<div className="space-y-2">
|
||
{/* Authority + status badge */}
|
||
<div className="flex items-center gap-2 flex-wrap">
|
||
<span className="text-xs text-muted-foreground">
|
||
{authority?.name ?? t.authorityId}
|
||
</span>
|
||
<Badge className={cn("text-[10px] px-1.5 py-0", STATUS_COLORS[t.semanticStatus])}>
|
||
<Radio className="mr-0.5 inline h-2.5 w-2.5" />
|
||
{EXTERNAL_STATUS_LABELS[t.semanticStatus]}
|
||
</Badge>
|
||
</div>
|
||
|
||
{/* Last check time */}
|
||
{t.lastCheckAt && (
|
||
<p className="text-[10px] text-muted-foreground">
|
||
Ultima verificare: {relativeTime(t.lastCheckAt)}
|
||
</p>
|
||
)}
|
||
|
||
{/* Error state */}
|
||
{t.lastError && (
|
||
<p className="text-[10px] text-red-500">{t.lastError}</p>
|
||
)}
|
||
|
||
{/* Latest status row */}
|
||
{t.lastStatusRow && (
|
||
<div className="rounded border bg-muted/30 p-2 text-xs space-y-1">
|
||
<div className="flex gap-3">
|
||
<span>
|
||
<span className="text-muted-foreground">Sursa:</span>{" "}
|
||
{t.lastStatusRow.sursa}
|
||
</span>
|
||
<span>
|
||
<span className="text-muted-foreground">→</span>{" "}
|
||
{t.lastStatusRow.destinatie}
|
||
</span>
|
||
</div>
|
||
{t.lastStatusRow.modRezolvare && (
|
||
<div>
|
||
<span className="text-muted-foreground">Rezolvare:</span>{" "}
|
||
<span className="font-medium">{t.lastStatusRow.modRezolvare}</span>
|
||
</div>
|
||
)}
|
||
{t.lastStatusRow.comentarii && (
|
||
<div className="text-muted-foreground">
|
||
{t.lastStatusRow.comentarii}
|
||
</div>
|
||
)}
|
||
<div className="text-muted-foreground">
|
||
{t.lastStatusRow.dataVenire} {t.lastStatusRow.oraVenire}
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* Tracking config info */}
|
||
<div className="text-[10px] text-muted-foreground">
|
||
Nr: {t.regNumber} | Data: {t.regDate} | Deponent: {t.petitionerName}
|
||
</div>
|
||
|
||
{/* History toggle */}
|
||
{t.history.length > 0 && (
|
||
<div>
|
||
<button
|
||
className="flex items-center gap-1 text-[10px] text-muted-foreground hover:text-foreground transition-colors"
|
||
onClick={() => setShowHistory(!showHistory)}
|
||
>
|
||
{showHistory ? (
|
||
<ChevronUp className="h-3 w-3" />
|
||
) : (
|
||
<ChevronDown className="h-3 w-3" />
|
||
)}
|
||
Istoric ({t.history.length} schimbari)
|
||
</button>
|
||
|
||
{showHistory && (
|
||
<div className="mt-1 space-y-1.5 max-h-48 overflow-y-auto">
|
||
{[...t.history].reverse().map((change, i) => (
|
||
<div
|
||
key={`${change.timestamp}-${i}`}
|
||
className="rounded border bg-muted/20 p-1.5 text-[10px]"
|
||
>
|
||
<div className="flex items-center gap-2">
|
||
<Badge
|
||
className={cn(
|
||
"text-[8px] px-1 py-0",
|
||
STATUS_COLORS[change.semanticStatus],
|
||
)}
|
||
>
|
||
{EXTERNAL_STATUS_LABELS[change.semanticStatus]}
|
||
</Badge>
|
||
<span className="text-muted-foreground">
|
||
{new Date(change.timestamp).toLocaleString("ro-RO")}
|
||
</span>
|
||
</div>
|
||
<div className="mt-0.5 text-muted-foreground">
|
||
{change.row.sursa} → {change.row.destinatie}
|
||
{change.row.modRezolvare ? ` (${change.row.modRezolvare})` : ""}
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
)}
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function DetailSection({
|
||
title,
|
||
children,
|
||
}: {
|
||
title: string;
|
||
children: React.ReactNode;
|
||
}) {
|
||
return (
|
||
<div>
|
||
<h3 className="text-xs font-semibold text-muted-foreground uppercase tracking-wider mb-2">
|
||
{title}
|
||
</h3>
|
||
{children}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function DetailField({
|
||
label,
|
||
value,
|
||
className,
|
||
extra,
|
||
icon,
|
||
}: {
|
||
label: string;
|
||
value: string;
|
||
className?: string;
|
||
extra?: string;
|
||
icon?: React.ReactNode;
|
||
}) {
|
||
return (
|
||
<div>
|
||
<p className="text-[10px] text-muted-foreground mb-0.5">{label}</p>
|
||
<p className={cn("text-sm", className)}>
|
||
{icon && <span className="mr-1 inline-flex align-middle">{icon}</span>}
|
||
{value}
|
||
{extra && (
|
||
<span className="ml-1 text-[10px] text-muted-foreground">
|
||
{extra}
|
||
</span>
|
||
)}
|
||
</p>
|
||
</div>
|
||
);
|
||
}
|