feat(registratura): detail sheet side panel + configurable column visibility

- New registry-entry-detail.tsx: full entry visualization in Sheet (side panel)
  - Status badges, document info, parties, dates, thread links
  - Attachment preview: images inline, NAS paths with IP fallback
  - Legal deadlines, external tracking, tags, notes sections
  - Action buttons: Editează, Închide, Șterge
- Registry table rewrite:
  - 10 column defs with Romanian tooltip explanations on each header
  - Column visibility dropdown (Settings icon) with checkboxes
  - Default: Nr/Data/Dir/Subiect/Exped./Dest./Status (7/10)
  - Persisted in localStorage (registratura:visible-columns)
  - Row click opens detail sheet, actions reduced to Eye + Pencil
- Docs updated: CLAUDE.md, ROADMAP.md (3.03c), SESSION-LOG.md
This commit is contained in:
AI Assistant
2026-02-28 17:45:18 +02:00
parent f4b1d4b8dd
commit 4dae06be44
6 changed files with 1181 additions and 158 deletions
@@ -29,6 +29,7 @@ import { v4 as uuid } from "uuid";
import { RegistryFilters } from "./registry-filters";
import { RegistryTable } from "./registry-table";
import { RegistryEntryForm } from "./registry-entry-form";
import { RegistryEntryDetail } from "./registry-entry-detail";
import { DeadlineDashboard } from "./deadline-dashboard";
import { ThreadExplorer } from "./thread-explorer";
import { CloseGuardDialog } from "./close-guard-dialog";
@@ -62,6 +63,7 @@ export function RegistraturaModule() {
const [viewMode, setViewMode] = useState<ViewMode>("list");
const [editingEntry, setEditingEntry] = useState<RegistryEntry | null>(null);
const [viewingEntry, setViewingEntry] = useState<RegistryEntry | null>(null);
const [closingId, setClosingId] = useState<string | null>(null);
const [linkCheckId, setLinkCheckId] = useState<string | null>(null);
@@ -135,9 +137,15 @@ export function RegistraturaModule() {
// Load full entry with attachment data (list mode strips base64)
const full = await loadFullEntry(entry.id);
setEditingEntry(full ?? entry);
setViewingEntry(null); // Close detail sheet if open
setViewMode("edit");
};
const handleView = async (entry: RegistryEntry) => {
const full = await loadFullEntry(entry.id);
setViewingEntry(full ?? entry);
};
const handleNavigateEntry = async (entry: RegistryEntry) => {
const full = await loadFullEntry(entry.id);
setEditingEntry(full ?? entry);
@@ -305,6 +313,7 @@ export function RegistraturaModule() {
<RegistryTable
entries={entries}
loading={loading}
onView={handleView}
onEdit={handleEdit}
onDelete={handleDelete}
onClose={handleCloseRequest}
@@ -359,6 +368,19 @@ export function RegistraturaModule() {
</Card>
)}
{/* Entry detail side panel */}
<RegistryEntryDetail
entry={viewingEntry}
open={viewingEntry !== null}
onOpenChange={(open) => {
if (!open) setViewingEntry(null);
}}
onEdit={handleEdit}
onClose={handleCloseRequest}
onDelete={handleDelete}
allEntries={allEntries}
/>
{/* Universal close dialog — reason, attachment, linked entry */}
{closingEntry && (
<CloseGuardDialog
@@ -0,0 +1,727 @@
"use client";
import {
ArrowDownToLine,
ArrowUpFromLine,
Calendar,
CheckCircle2,
Clock,
Copy,
ExternalLink,
FileText,
FolderOpen,
GitBranch,
HardDrive,
Link2,
Paperclip,
Pencil,
Trash2,
User,
X,
Image as ImageIcon,
} 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 } from "../types";
import { getOverdueDays } from "../services/registry-service";
import {
toFileUrl,
toFileUrlByIp,
shortDisplayPath,
shareLabelFor,
} from "@/config/nas-paths";
import { cn } from "@/shared/lib/utils";
import { useState } from "react";
interface RegistryEntryDetailProps {
entry: RegistryEntry | null;
open: boolean;
onOpenChange: (open: boolean) => void;
onEdit: (entry: RegistryEntry) => void;
onClose: (id: string) => void;
onDelete: (id: 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",
},
} 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,
allEntries,
}: RegistryEntryDetailProps) {
const [previewAttachment, setPreviewAttachment] = useState<string | null>(
null,
);
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>
{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.id);
}}
>
<CheckCircle2 className="mr-1.5 h-3.5 w-3.5" /> Închide
</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 px-6">
<div className="space-y-5 py-4">
{/* ── 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 ── */}
{(threadParent ||
threadChildren.length > 0 ||
(entry.linkedEntryIds ?? []).length > 0) && (
<DetailSection title="Legături">
{threadParent && (
<div className="flex items-center gap-2 text-sm">
<GitBranch className="h-3.5 w-3.5 text-muted-foreground" />
<span className="text-muted-foreground">Răspuns la:</span>
<span className="font-mono text-xs">
{threadParent.number}
</span>
<span className="truncate text-muted-foreground">
{threadParent.subject}
</span>
</div>
)}
{threadChildren.length > 0 && (
<div className="mt-1 space-y-0.5">
<p className="text-xs text-muted-foreground">
Răspunsuri ({threadChildren.length}):
</p>
{threadChildren.map((child) => (
<div
key={child.id}
className="flex items-center gap-2 text-xs ml-4"
>
<span className="font-mono">{child.number}</span>
<span className="truncate text-muted-foreground">
{child.subject}
</span>
</div>
))}
</div>
)}
{(entry.linkedEntryIds ?? []).length > 0 && (
<div className="mt-1">
<p className="text-xs text-muted-foreground flex items-center gap-1">
<Link2 className="h-3 w-3" />
{entry.linkedEntryIds.length} înregistrăr
{entry.linkedEntryIds.length === 1 ? "e" : "i"} legat
{entry.linkedEntryIds.length === 1 ? "ă" : "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 ? (
<div
key={att.id}
className="flex items-center gap-2 rounded-md border border-blue-200 dark:border-blue-800 bg-blue-50/50 dark:bg-blue-950/20 px-3 py-2 group"
>
<HardDrive className="h-4 w-4 text-blue-600 dark:text-blue-400 shrink-0" />
<div className="flex-1 min-w-0">
<a
href={toFileUrl(att.networkPath)}
className="text-sm text-blue-700 dark:text-blue-300 hover:underline font-mono"
title={att.networkPath}
onClick={(e) => {
e.preventDefault();
window.open(
toFileUrl(att.networkPath!),
"_blank",
);
}}
>
<FolderOpen className="mr-1 inline h-3 w-3" />
{shortDisplayPath(att.networkPath)}
</a>
<p className="text-[10px] text-muted-foreground mt-0.5">
{att.networkPath}
</p>
</div>
<div className="flex items-center gap-1 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>
<Button
type="button"
variant="ghost"
size="icon"
className="h-6 w-6 opacity-0 group-hover:opacity-100 transition-opacity"
onClick={() =>
navigator.clipboard.writeText(att.networkPath!)
}
title="Copiază calea"
>
<Copy className="h-3 w-3" />
</Button>
<Button
type="button"
variant="ghost"
size="icon"
className="h-6 w-6 opacity-0 group-hover:opacity-100 transition-opacity text-blue-500"
onClick={() =>
window.open(
toFileUrlByIp(att.networkPath!),
"_blank",
)
}
title="Deschide prin IP (fallback)"
>
<ExternalLink className="h-3 w-3" />
</Button>
</div>
</div>
) : (
<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 for images */}
{att.type.startsWith("image/") &&
att.data &&
att.data !== "" &&
att.data !== "__network__" && (
<Button
type="button"
variant="ghost"
size="icon"
className="h-6 w-6"
onClick={() => setPreviewAttachment(att.id)}
title="Previzualizare"
>
<ImageIcon 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>
{/* Image preview modal */}
{previewAttachment && (
<div className="mt-3 rounded-md border p-2 bg-muted/30">
{(() => {
const att = entry.attachments.find(
(a) => a.id === previewAttachment,
);
if (
!att ||
!att.type.startsWith("image/") ||
!att.data ||
att.data === "" ||
att.data === "__network__"
)
return (
<p className="text-xs text-muted-foreground">
Previzualizare indisponibilă (fișierul nu conține
date inline).
</p>
);
return (
<div className="space-y-2">
<div className="flex items-center justify-between">
<p className="text-xs font-medium">{att.name}</p>
<Button
variant="ghost"
size="icon"
className="h-5 w-5"
onClick={() => setPreviewAttachment(null)}
>
<X className="h-3 w-3" />
</Button>
</div>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={att.data}
alt={att.name}
className="max-w-full max-h-64 rounded border object-contain"
/>
</div>
);
})()}
</div>
)}
</DetailSection>
)}
{/* ── Legal deadlines ── */}
{(entry.trackedDeadlines ?? []).length > 0 && (
<DetailSection
title={`Termene legale (${entry.trackedDeadlines!.length})`}
>
<div className="space-y-2">
{entry.trackedDeadlines!.map((dl) => (
<div
key={dl.id}
className={cn(
"rounded-md border px-3 py-2 text-xs",
dl.resolution === "pending" &&
new Date(dl.dueDate) < new Date()
? "border-destructive/50 bg-destructive/5"
: dl.resolution === "pending"
? "border-amber-300 dark:border-amber-700 bg-amber-50/50 dark:bg-amber-950/20"
: "border-muted",
)}
>
<div className="flex items-center justify-between">
<span className="font-medium">{dl.typeId}</span>
<Badge
variant="outline"
className={cn(
"text-[10px]",
dl.resolution === "pending" &&
"border-amber-400 text-amber-700 dark:text-amber-300",
dl.resolution === "completed" &&
"border-green-400 text-green-700 dark:text-green-300",
)}
>
{DEADLINE_RES_LABELS[dl.resolution] ?? dl.resolution}
</Badge>
</div>
<div className="flex gap-4 mt-1 text-muted-foreground">
<span>
<Calendar className="mr-0.5 inline h-2.5 w-2.5" />
Start: {formatDate(dl.startDate)}
</span>
<span>
<Clock className="mr-0.5 inline h-2.5 w-2.5" />
Scadent: {formatDate(dl.dueDate)}
</span>
</div>
{dl.resolutionNote && (
<p className="mt-1 text-muted-foreground">
{dl.resolutionNote}
</p>
)}
</div>
))}
</div>
</DetailSection>
)}
{/* ── External tracking ── */}
{(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>
</SheetContent>
</Sheet>
);
}
// ── Sub-components ──
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>
);
}
@@ -1,16 +1,32 @@
"use client";
import { useState, useEffect, useCallback } from "react";
import {
Eye,
Pencil,
Trash2,
CheckCircle2,
Link2,
Clock,
GitBranch,
User,
Settings2,
Paperclip,
} from "lucide-react";
import { Button } from "@/shared/components/ui/button";
import { Badge } from "@/shared/components/ui/badge";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/shared/components/ui/tooltip";
import {
DropdownMenu,
DropdownMenuCheckboxItem,
DropdownMenuContent,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/shared/components/ui/dropdown-menu";
import type { RegistryEntry } from "../types";
import { DEFAULT_DOC_TYPE_LABELS } from "../types";
import { getOverdueDays } from "../services/registry-service";
@@ -19,21 +35,117 @@ import { cn } from "@/shared/lib/utils";
interface RegistryTableProps {
entries: RegistryEntry[];
loading: boolean;
onView: (entry: RegistryEntry) => void;
onEdit: (entry: RegistryEntry) => void;
onDelete: (id: string) => void;
onClose: (id: string) => void;
}
// ── Column definitions ──
export type ColumnId =
| "number"
| "date"
| "direction"
| "type"
| "subject"
| "sender"
| "recipient"
| "assignee"
| "deadline"
| "status";
interface ColumnDef {
id: ColumnId;
/** Short header label */
label: string;
/** Full tooltip explanation */
tooltip: string;
/** Whether visible by default */
defaultVisible: boolean;
}
const COLUMNS: ColumnDef[] = [
{
id: "number",
label: "Nr.",
tooltip: "Număr de înregistrare (format: PREFIX-NNNN/AN)",
defaultVisible: true,
},
{
id: "date",
label: "Data",
tooltip: "Data documentului (nu data înregistrării în sistem)",
defaultVisible: true,
},
{
id: "direction",
label: "Dir.",
tooltip:
"Direcție: Intrat = primit de la terți, Ieșit = trimis către terți",
defaultVisible: true,
},
{
id: "type",
label: "Tip",
tooltip: "Tipul documentului (contract, cerere, aviz etc.)",
defaultVisible: false,
},
{
id: "subject",
label: "Subiect",
tooltip: "Subiectul sau descrierea pe scurt a documentului",
defaultVisible: true,
},
{
id: "sender",
label: "Exped.",
tooltip: "Expeditor — persoana sau instituția care a trimis documentul",
defaultVisible: true,
},
{
id: "recipient",
label: "Dest.",
tooltip:
"Destinatar — persoana sau instituția căreia i se adresează documentul",
defaultVisible: true,
},
{
id: "assignee",
label: "Resp.",
tooltip:
"Responsabil intern — persoana din echipă alocată pe acest document",
defaultVisible: false,
},
{
id: "deadline",
label: "Termen",
tooltip:
"Termen limită intern (nu termen legal — acela apare în tab-ul Termene)",
defaultVisible: false,
},
{
id: "status",
label: "Status",
tooltip: "Deschis = în lucru, Închis = finalizat/arhivat",
defaultVisible: true,
},
];
const DEFAULT_VISIBLE = new Set(
COLUMNS.filter((c) => c.defaultVisible).map((c) => c.id),
);
const STORAGE_KEY = "registratura:visible-columns";
const DIRECTION_LABELS: Record<string, string> = {
intrat: "Intrat",
iesit: "Ieșit",
};
/** Resolve doc type label from defaults or capitalize custom type */
function getDocTypeLabel(type: string): string {
const label = DEFAULT_DOC_TYPE_LABELS[type];
if (label) return label;
// For custom types, capitalize first letter
return type.replace(/-/g, " ").replace(/^\w/, (c) => c.toUpperCase());
}
@@ -42,13 +154,58 @@ const STATUS_LABELS: Record<string, string> = {
inchis: "Închis",
};
function loadVisibleColumns(): Set<ColumnId> {
try {
const raw = localStorage.getItem(STORAGE_KEY);
if (raw) {
const arr = JSON.parse(raw) as ColumnId[];
if (Array.isArray(arr) && arr.length > 0) return new Set(arr);
}
} catch {
// ignore
}
return new Set(DEFAULT_VISIBLE);
}
export function RegistryTable({
entries,
loading,
onView,
onEdit,
onDelete,
onClose,
}: RegistryTableProps) {
const [visibleCols, setVisibleCols] = useState<Set<ColumnId>>(
() => DEFAULT_VISIBLE,
);
// Load from localStorage on mount (client-only)
useEffect(() => {
setVisibleCols(loadVisibleColumns());
}, []);
const toggleColumn = useCallback((id: ColumnId) => {
setVisibleCols((prev) => {
const next = new Set(prev);
if (next.has(id)) {
// Don't allow hiding all columns
if (next.size > 2) next.delete(id);
} else {
next.add(id);
}
localStorage.setItem(STORAGE_KEY, JSON.stringify([...next]));
return next;
});
}, []);
const resetColumns = useCallback(() => {
const def = new Set(DEFAULT_VISIBLE);
setVisibleCols(def);
localStorage.setItem(STORAGE_KEY, JSON.stringify([...def]));
}, []);
const visibleColumns = COLUMNS.filter((c) => visibleCols.has(c.id));
if (loading) {
return (
<p className="py-8 text-center text-sm text-muted-foreground">
@@ -66,170 +223,250 @@ export function RegistryTable({
}
return (
<div className="overflow-x-auto rounded-lg border">
<table className="w-full text-sm">
<thead>
<tr className="border-b bg-muted/40">
<th className="px-3 py-2 text-left font-medium">Nr.</th>
<th className="px-3 py-2 text-left font-medium">Data doc.</th>
<th className="px-3 py-2 text-left font-medium">Dir.</th>
<th className="px-3 py-2 text-left font-medium">Tip</th>
<th className="px-3 py-2 text-left font-medium">Subiect</th>
<th className="px-3 py-2 text-left font-medium">Expeditor</th>
<th className="px-3 py-2 text-left font-medium">Destinatar</th>
<th className="px-3 py-2 text-left font-medium">Resp.</th>
<th className="px-3 py-2 text-left font-medium">Termen</th>
<th className="px-3 py-2 text-left font-medium">Status</th>
<th className="px-3 py-2 text-right font-medium">Acțiuni</th>
</tr>
</thead>
<tbody>
{entries.map((entry) => {
const overdueDays =
entry.status === "deschis" || !entry.status
? getOverdueDays(entry.deadline)
: null;
const isOverdue = overdueDays !== null && overdueDays > 0;
return (
<tr
key={entry.id}
className={cn(
"border-b transition-colors hover:bg-muted/20",
isOverdue && "bg-destructive/5",
)}
<div className="space-y-2">
{/* Column toggle button */}
<div className="flex justify-end">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="sm" className="h-7 text-xs gap-1">
<Settings2 className="h-3.5 w-3.5" />
Coloane ({visibleCols.size}/{COLUMNS.length})
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-56">
<DropdownMenuLabel className="text-xs">
Coloane vizibile
</DropdownMenuLabel>
<DropdownMenuSeparator />
{COLUMNS.map((col) => (
<DropdownMenuCheckboxItem
key={col.id}
checked={visibleCols.has(col.id)}
onCheckedChange={() => toggleColumn(col.id)}
onSelect={(e) => e.preventDefault()}
>
<td className="px-3 py-2 font-mono text-xs whitespace-nowrap">
{entry.number}
</td>
<td className="px-3 py-2 text-xs whitespace-nowrap">
{formatDate(entry.date)}
{entry.registrationDate &&
entry.registrationDate !== entry.date && (
<span
className="block text-[10px] text-muted-foreground"
title={`Înregistrat pe ${formatDate(entry.registrationDate)}`}
<span className="text-xs">
{col.label}{" "}
<span className="text-muted-foreground"> {col.tooltip}</span>
</span>
</DropdownMenuCheckboxItem>
))}
<DropdownMenuSeparator />
<DropdownMenuCheckboxItem
checked={false}
onCheckedChange={resetColumns}
onSelect={(e) => e.preventDefault()}
>
<span className="text-xs font-medium">Resetează la implicit</span>
</DropdownMenuCheckboxItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
{/* Table */}
<div className="overflow-x-auto rounded-lg border">
<table className="w-full text-sm">
<thead>
<tr className="border-b bg-muted/40">
{visibleColumns.map((col) => (
<TooltipProvider key={col.id}>
<Tooltip>
<TooltipTrigger asChild>
<th className="px-3 py-2 text-left font-medium cursor-help text-xs">
{col.label}
</th>
</TooltipTrigger>
<TooltipContent side="bottom" className="max-w-xs">
<p>{col.tooltip}</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
))}
{/* Actions column is always shown */}
<th className="px-3 py-2 text-right font-medium text-xs w-20">
Acț.
</th>
</tr>
</thead>
<tbody>
{entries.map((entry) => {
const overdueDays =
entry.status === "deschis" || !entry.status
? getOverdueDays(entry.deadline)
: null;
const isOverdue = overdueDays !== null && overdueDays > 0;
return (
<tr
key={entry.id}
className={cn(
"border-b transition-colors hover:bg-muted/20 cursor-pointer",
isOverdue && "bg-destructive/5",
)}
onClick={() => onView(entry)}
>
{visibleCols.has("number") && (
<td className="px-3 py-2 font-mono text-xs whitespace-nowrap">
{entry.number}
</td>
)}
{visibleCols.has("date") && (
<td className="px-3 py-2 text-xs whitespace-nowrap">
{formatDate(entry.date)}
{entry.registrationDate &&
entry.registrationDate !== entry.date && (
<span
className="block text-[10px] text-muted-foreground"
title={`Înregistrat pe ${formatDate(entry.registrationDate)}`}
>
(înr. {formatDate(entry.registrationDate)})
</span>
)}
</td>
)}
{visibleCols.has("direction") && (
<td className="px-3 py-2">
<Badge
variant={
entry.direction === "intrat" ? "default" : "secondary"
}
className="text-xs"
>
(înr. {formatDate(entry.registrationDate)})
{DIRECTION_LABELS[entry.direction] ??
entry.direction ??
"—"}
</Badge>
</td>
)}
{visibleCols.has("type") && (
<td className="px-3 py-2 text-xs">
{getDocTypeLabel(entry.documentType)}
</td>
)}
{visibleCols.has("subject") && (
<td className="px-3 py-2 max-w-[220px]">
<span className="truncate block">{entry.subject}</span>
{/* Inline indicators */}
<span className="flex items-center gap-1 mt-0.5 flex-wrap">
{entry.threadParentId && (
<GitBranch className="inline h-3 w-3 text-muted-foreground" />
)}
{(entry.linkedEntryIds ?? []).length > 0 && (
<Link2 className="inline h-3 w-3 text-muted-foreground" />
)}
{(entry.attachments ?? []).length > 0 && (
<Badge
variant="outline"
className="text-[10px] px-1 py-0"
>
<Paperclip className="mr-0.5 inline h-2.5 w-2.5" />
{entry.attachments.length}
</Badge>
)}
{(entry.trackedDeadlines ?? []).length > 0 && (
<Badge
variant="outline"
className="text-[10px] px-1 py-0"
>
<Clock className="mr-0.5 inline h-2.5 w-2.5" />
{(entry.trackedDeadlines ?? []).length}
</Badge>
)}
</span>
)}
</td>
<td className="px-3 py-2">
<Badge
variant={
entry.direction === "intrat" ? "default" : "secondary"
}
className="text-xs"
>
{DIRECTION_LABELS[entry.direction] ??
entry.direction ??
"—"}
</Badge>
</td>
<td className="px-3 py-2 text-xs">
{getDocTypeLabel(entry.documentType)}
</td>
<td className="px-3 py-2 max-w-[200px] truncate">
{entry.subject}
{entry.threadParentId && (
<GitBranch className="ml-1 inline h-3 w-3 text-muted-foreground" />
</td>
)}
{(entry.linkedEntryIds ?? []).length > 0 && (
<Link2 className="ml-1 inline h-3 w-3 text-muted-foreground" />
)}
{(entry.attachments ?? []).length > 0 && (
<Badge variant="outline" className="ml-1 text-[10px] px-1">
{entry.attachments.length} fișiere
</Badge>
)}
{(entry.trackedDeadlines ?? []).length > 0 && (
<Badge variant="outline" className="ml-1 text-[10px] px-1">
<Clock className="mr-0.5 inline h-2.5 w-2.5" />
{(entry.trackedDeadlines ?? []).length}
</Badge>
)}
</td>
<td className="px-3 py-2 max-w-[130px] truncate">
{entry.sender}
</td>
<td className="px-3 py-2 max-w-[130px] truncate">
{entry.recipient}
</td>
<td className="px-3 py-2 max-w-[100px] truncate text-xs">
{entry.assignee ? (
<span className="flex items-center gap-1">
<User className="h-3 w-3 text-muted-foreground shrink-0" />
{entry.assignee}
</span>
) : (
<span className="text-muted-foreground"></span>
)}
</td>
<td className="px-3 py-2 text-xs whitespace-nowrap">
{entry.deadline ? (
<span
className={cn(
isOverdue && "font-medium text-destructive",
{visibleCols.has("sender") && (
<td className="px-3 py-2 max-w-[130px] truncate text-xs">
{entry.sender || (
<span className="text-muted-foreground"></span>
)}
>
{formatDate(entry.deadline)}
{overdueDays !== null && overdueDays > 0 && (
<span className="ml-1 text-[10px]">
({overdueDays}z depășit)
</td>
)}
{visibleCols.has("recipient") && (
<td className="px-3 py-2 max-w-[130px] truncate text-xs">
{entry.recipient || (
<span className="text-muted-foreground"></span>
)}
</td>
)}
{visibleCols.has("assignee") && (
<td className="px-3 py-2 max-w-[100px] truncate text-xs">
{entry.assignee ? (
<span className="flex items-center gap-1">
<User className="h-3 w-3 text-muted-foreground shrink-0" />
{entry.assignee}
</span>
) : (
<span className="text-muted-foreground"></span>
)}
{overdueDays !== null && overdueDays < 0 && (
<span className="ml-1 text-[10px] text-muted-foreground">
({Math.abs(overdueDays)}z)
</span>
)}
</span>
) : (
<span className="text-muted-foreground"></span>
</td>
)}
</td>
<td className="px-3 py-2">
<Badge
variant={entry.status === "deschis" ? "default" : "outline"}
>
{STATUS_LABELS[entry.status]}
</Badge>
</td>
<td className="px-3 py-2 text-right">
<div className="flex justify-end gap-1">
{entry.status === "deschis" && (
{visibleCols.has("deadline") && (
<td className="px-3 py-2 text-xs whitespace-nowrap">
{entry.deadline ? (
<span
className={cn(
isOverdue && "font-medium text-destructive",
)}
>
{formatDate(entry.deadline)}
{overdueDays !== null && overdueDays > 0 && (
<span className="ml-1 text-[10px]">
({overdueDays}z)
</span>
)}
</span>
) : (
<span className="text-muted-foreground"></span>
)}
</td>
)}
{visibleCols.has("status") && (
<td className="px-3 py-2">
<Badge
variant={
entry.status === "deschis" ? "default" : "outline"
}
className="text-xs"
>
{STATUS_LABELS[entry.status]}
</Badge>
</td>
)}
{/* Actions — always visible */}
<td className="px-3 py-2 text-right">
<div className="flex justify-end gap-0.5">
<Button
variant="ghost"
size="icon"
className="h-7 w-7 text-green-600"
onClick={() => onClose(entry.id)}
title="Închide"
className="h-7 w-7"
onClick={(e) => {
e.stopPropagation();
onView(entry);
}}
title="Vizualizare detalii"
>
<CheckCircle2 className="h-3.5 w-3.5" />
<Eye className="h-3.5 w-3.5" />
</Button>
)}
<Button
variant="ghost"
size="icon"
className="h-7 w-7"
onClick={() => onEdit(entry)}
>
<Pencil className="h-3.5 w-3.5" />
</Button>
<Button
variant="ghost"
size="icon"
className="h-7 w-7 text-destructive"
onClick={() => onDelete(entry.id)}
>
<Trash2 className="h-3.5 w-3.5" />
</Button>
</div>
</td>
</tr>
);
})}
</tbody>
</table>
<Button
variant="ghost"
size="icon"
className="h-7 w-7"
onClick={(e) => {
e.stopPropagation();
onEdit(entry);
}}
title="Editează"
>
<Pencil className="h-3.5 w-3.5" />
</Button>
</div>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
</div>
);
}