Files
ArchiTools/src/modules/registratura/components/registry-entry-detail.tsx
T
2026-03-30 09:52:59 +03:00

1246 lines
46 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"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>
);
}