6786ac07d1
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
495 lines
16 KiB
TypeScript
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;
|
|
}
|
|
}
|