d8a10fadc0
Move threadChain useMemo before the `if (!entry) return null` early return to keep hook call order stable between renders. When entry was null, the hook was skipped, causing "Rendered more hooks than during the previous render" crash on subsequent renders with entry set. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
941 lines
34 KiB
TypeScript
941 lines
34 KiB
TypeScript
"use client";
|
|
|
|
import {
|
|
ArrowDownToLine,
|
|
ArrowUpFromLine,
|
|
Calendar,
|
|
CheckCircle2,
|
|
Clock,
|
|
Copy,
|
|
ExternalLink,
|
|
Eye,
|
|
FileText,
|
|
GitBranch,
|
|
HardDrive,
|
|
Link2,
|
|
Paperclip,
|
|
Pencil,
|
|
Trash2,
|
|
User,
|
|
X,
|
|
Image as ImageIcon,
|
|
Reply,
|
|
Radio,
|
|
RefreshCw,
|
|
ChevronDown,
|
|
ChevronUp,
|
|
} 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 { 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;
|
|
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,
|
|
allEntries,
|
|
}: RegistryEntryDetailProps) {
|
|
const [previewIndex, setPreviewIndex] = useState<number | null>(null);
|
|
const [copiedPath, setCopiedPath] = useState<string | null>(null);
|
|
const [monitorConfigOpen, setMonitorConfigOpen] = useState(false);
|
|
|
|
// Auto-detect if recipient matches a known authority
|
|
const matchedAuthority = useMemo(() => {
|
|
if (!entry) return undefined;
|
|
if (entry.externalStatusTracking?.active) 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>
|
|
|
|
{/* ── 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!} />
|
|
</DetailSection>
|
|
)}
|
|
|
|
{/* ── External status monitoring ── */}
|
|
{entry.externalStatusTracking?.active && (
|
|
<ExternalStatusSection
|
|
entry={entry}
|
|
/>
|
|
)}
|
|
|
|
{/* ── Auto-detect: suggest monitoring activation ── */}
|
|
{matchedAuthority && !entry.externalStatusTracking?.active && (
|
|
<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={() => setMonitorConfigOpen(true)}
|
|
>
|
|
Configureaza monitorizarea
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
|
|
<StatusMonitorConfig
|
|
open={monitorConfigOpen}
|
|
onOpenChange={setMonitorConfigOpen}
|
|
entry={entry}
|
|
authority={matchedAuthority}
|
|
onActivate={async (tracking) => {
|
|
// Save tracking to entry via API
|
|
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 }: { entry: RegistryEntry }) {
|
|
const tracking = entry.externalStatusTracking;
|
|
if (!tracking) return null;
|
|
|
|
const [checking, setChecking] = useState(false);
|
|
const [showHistory, setShowHistory] = useState(false);
|
|
const authority = getAuthority(tracking.authorityId);
|
|
|
|
const handleManualCheck = useCallback(async () => {
|
|
setChecking(true);
|
|
try {
|
|
await fetch("/api/registratura/status-check/single", {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ entryId: entry.id }),
|
|
});
|
|
// Reload page to show updated status
|
|
window.location.reload();
|
|
} catch {
|
|
// Ignore — user will see if it worked on reload
|
|
} 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`;
|
|
};
|
|
|
|
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
|
|
</h3>
|
|
<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 verifică..." : "Verifică acum"}
|
|
</Button>
|
|
</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 ?? tracking.authorityId}
|
|
</span>
|
|
<Badge className={cn("text-[10px] px-1.5 py-0", STATUS_COLORS[tracking.semanticStatus])}>
|
|
<Radio className="mr-0.5 inline h-2.5 w-2.5" />
|
|
{EXTERNAL_STATUS_LABELS[tracking.semanticStatus]}
|
|
</Badge>
|
|
</div>
|
|
|
|
{/* Last check time */}
|
|
{tracking.lastCheckAt && (
|
|
<p className="text-[10px] text-muted-foreground">
|
|
Ultima verificare: {relativeTime(tracking.lastCheckAt)}
|
|
</p>
|
|
)}
|
|
|
|
{/* Error state */}
|
|
{tracking.lastError && (
|
|
<p className="text-[10px] text-red-500">{tracking.lastError}</p>
|
|
)}
|
|
|
|
{/* Latest status row */}
|
|
{tracking.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>{" "}
|
|
{tracking.lastStatusRow.sursa}
|
|
</span>
|
|
<span>
|
|
<span className="text-muted-foreground">→</span>{" "}
|
|
{tracking.lastStatusRow.destinatie}
|
|
</span>
|
|
</div>
|
|
{tracking.lastStatusRow.modRezolvare && (
|
|
<div>
|
|
<span className="text-muted-foreground">Rezolvare:</span>{" "}
|
|
<span className="font-medium">{tracking.lastStatusRow.modRezolvare}</span>
|
|
</div>
|
|
)}
|
|
{tracking.lastStatusRow.comentarii && (
|
|
<div className="text-muted-foreground">
|
|
{tracking.lastStatusRow.comentarii}
|
|
</div>
|
|
)}
|
|
<div className="text-muted-foreground">
|
|
{tracking.lastStatusRow.dataVenire} {tracking.lastStatusRow.oraVenire}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* History toggle */}
|
|
{tracking.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 ({tracking.history.length} schimbări)
|
|
</button>
|
|
|
|
{showHistory && (
|
|
<div className="mt-1 space-y-1.5 max-h-48 overflow-y-auto">
|
|
{[...tracking.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>
|
|
);
|
|
}
|