Files
ArchiTools/src/modules/registratura/components/registry-table.tsx
T
2026-03-10 08:01:29 +02:00

495 lines
16 KiB
TypeScript

"use client";
import { useState, useEffect, useCallback } from "react";
import {
Eye,
Pencil,
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";
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, Ieșit = trimis",
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",
};
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());
}
const STATUS_LABELS: Record<string, string> = {
deschis: "Deschis",
inchis: "Închis",
reserved: "Rezervat",
};
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">
Se încarcă...
</p>
);
}
if (entries.length === 0) {
return (
<p className="py-8 text-center text-sm text-muted-foreground">
Nicio înregistrare găsită. Adaugă prima înregistrare.
</p>
);
}
return (
<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()}
>
<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"
>
{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>
)}
{visibleCols.has("sender") && (
<td className="px-3 py-2 max-w-[130px] truncate text-xs">
{entry.sender || (
<span className="text-muted-foreground"></span>
)}
</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>
)}
</td>
)}
{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"
: entry.status === "reserved"
? "secondary"
: "outline"
}
className={cn(
"text-xs",
entry.status === "reserved" && "border-dashed",
)}
>
{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"
onClick={(e) => {
e.stopPropagation();
onView(entry);
}}
title="Vizualizare detalii"
>
<Eye className="h-3.5 w-3.5" />
</Button>
<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>
);
}
function formatDate(iso: string): string {
try {
return new Date(iso).toLocaleDateString("ro-RO", {
day: "2-digit",
month: "2-digit",
year: "numeric",
});
} catch {
return iso;
}
}