2a25e4b160
"Name – Company" instead of "Name (Company)" for sender/recipient. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
651 lines
23 KiB
TypeScript
651 lines
23 KiB
TypeScript
"use client";
|
||
|
||
import { useState, useEffect, useCallback } from "react";
|
||
import {
|
||
Eye,
|
||
Pencil,
|
||
Link2,
|
||
Clock,
|
||
GitBranch,
|
||
User,
|
||
Settings2,
|
||
Paperclip,
|
||
Reply,
|
||
CheckCircle2,
|
||
Copy,
|
||
Check,
|
||
ArrowDownLeft,
|
||
ArrowUpRight,
|
||
Radio,
|
||
} 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, EXTERNAL_STATUS_LABELS } from "../types";
|
||
import { getOverdueDays } from "../services/registry-service";
|
||
import { cn } from "@/shared/lib/utils";
|
||
|
||
/** Format a contact for display: "Name – Company" / "Company" / "Name" */
|
||
function formatContactLabel(c: { name?: string; company?: string }): string {
|
||
const name = (c.name ?? "").trim();
|
||
const company = (c.company ?? "").trim();
|
||
if (name && company) return `${name} – ${company}`;
|
||
return name || company || "";
|
||
}
|
||
|
||
interface ContactInfo {
|
||
name: string;
|
||
company: string;
|
||
}
|
||
|
||
interface RegistryTableProps {
|
||
entries: RegistryEntry[];
|
||
loading: boolean;
|
||
onView: (entry: RegistryEntry) => void;
|
||
onEdit: (entry: RegistryEntry) => void;
|
||
onDelete: (id: string) => void;
|
||
onClose: (entry: RegistryEntry) => void;
|
||
/** Create a new entry linked as reply (conex) to this entry */
|
||
onReply?: (entry: RegistryEntry) => void;
|
||
/** Live contact data map: contactId → { name, company } for live sync */
|
||
contactMap?: Map<string, ContactInfo>;
|
||
}
|
||
|
||
// ── 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,
|
||
onReply,
|
||
contactMap,
|
||
}: 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 text-xs whitespace-nowrap">
|
||
<CompactNumber entry={entry} />
|
||
</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>
|
||
)}
|
||
{entry.externalStatusTracking?.active && (
|
||
<TooltipProvider>
|
||
<Tooltip>
|
||
<TooltipTrigger asChild>
|
||
<Badge
|
||
variant="outline"
|
||
className={`text-[10px] px-1 py-0 ${
|
||
entry.externalStatusTracking.semanticStatus === "solutionat"
|
||
? "border-green-400 text-green-600"
|
||
: entry.externalStatusTracking.semanticStatus === "trimis"
|
||
? "border-blue-400 text-blue-600"
|
||
: entry.externalStatusTracking.semanticStatus === "in-operare"
|
||
? "border-amber-400 text-amber-600"
|
||
: entry.externalStatusTracking.semanticStatus === "respins"
|
||
? "border-red-400 text-red-600"
|
||
: "border-muted-foreground"
|
||
}`}
|
||
>
|
||
<Radio className="mr-0.5 inline h-2.5 w-2.5" />
|
||
</Badge>
|
||
</TooltipTrigger>
|
||
<TooltipContent>
|
||
Status extern: {EXTERNAL_STATUS_LABELS[entry.externalStatusTracking.semanticStatus]}
|
||
</TooltipContent>
|
||
</Tooltip>
|
||
</TooltipProvider>
|
||
)}
|
||
</span>
|
||
</td>
|
||
)}
|
||
{visibleCols.has("sender") && (
|
||
<td className="px-3 py-2 max-w-[130px] truncate text-xs">
|
||
{(() => {
|
||
const live = entry.senderContactId && contactMap?.get(entry.senderContactId);
|
||
return (live ? formatContactLabel(live) : entry.sender) || (
|
||
<span className="text-muted-foreground">—</span>
|
||
);
|
||
})()}
|
||
</td>
|
||
)}
|
||
{visibleCols.has("recipient") && (
|
||
<td className="px-3 py-2 max-w-[130px] truncate text-xs">
|
||
{(() => {
|
||
const live = entry.recipientContactId && contactMap?.get(entry.recipientContactId);
|
||
return (live ? formatContactLabel(live) : 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="Editeaza"
|
||
>
|
||
<Pencil className="h-3.5 w-3.5" />
|
||
</Button>
|
||
{onReply && (
|
||
<Button
|
||
variant="ghost"
|
||
size="icon"
|
||
className="h-7 w-7 text-blue-600"
|
||
onClick={(e) => {
|
||
e.stopPropagation();
|
||
onReply(entry);
|
||
}}
|
||
title="Conex — clarificare / solicitare"
|
||
>
|
||
<Reply className="h-3.5 w-3.5" />
|
||
</Button>
|
||
)}
|
||
{entry.status === "deschis" && (
|
||
<Button
|
||
variant="ghost"
|
||
size="icon"
|
||
className="h-7 w-7 text-green-600"
|
||
onClick={(e) => {
|
||
e.stopPropagation();
|
||
onClose(entry);
|
||
}}
|
||
title="Inchide"
|
||
>
|
||
<CheckCircle2 className="h-3.5 w-3.5" />
|
||
</Button>
|
||
)}
|
||
</div>
|
||
</td>
|
||
</tr>
|
||
);
|
||
})}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function CopyNumberButton({ entry }: { entry: RegistryEntry }) {
|
||
const [copied, setCopied] = useState(false);
|
||
|
||
const handleCopy = (e: React.MouseEvent) => {
|
||
e.stopPropagation();
|
||
// Extract plain number: "BTG-0042/2026" → "42", "US-0123/2026" → "123"
|
||
const plain = entry.number.replace(/^[A-Z]+-0*/, "").replace(/\/.*$/, "");
|
||
const text = `nr. ${plain} din ${formatDate(entry.date)}`;
|
||
void navigator.clipboard.writeText(text).then(() => {
|
||
setCopied(true);
|
||
setTimeout(() => setCopied(false), 1500);
|
||
});
|
||
};
|
||
|
||
const Icon = copied ? Check : Copy;
|
||
|
||
return (
|
||
<button
|
||
type="button"
|
||
onClick={handleCopy}
|
||
className={cn(
|
||
"inline-flex items-center justify-center h-4 w-4 rounded transition-colors",
|
||
copied
|
||
? "text-green-600"
|
||
: "text-muted-foreground/40 hover:text-muted-foreground",
|
||
)}
|
||
title="Copiaza nr. din data de..."
|
||
>
|
||
<Icon className="h-3 w-3" />
|
||
</button>
|
||
);
|
||
}
|
||
|
||
// ── Company badge colors ──
|
||
const COMPANY_BADGE: Record<string, { label: string; className: string }> = {
|
||
beletage: { label: "B", className: "bg-blue-600 text-white" },
|
||
"urban-switch": { label: "U", className: "bg-violet-600 text-white" },
|
||
"studii-de-teren": { label: "S", className: "bg-emerald-600 text-white" },
|
||
group: { label: "G", className: "bg-zinc-500 text-white" },
|
||
};
|
||
|
||
function CompactNumber({ entry }: { entry: RegistryEntry }) {
|
||
// Extract plain number: "B-2026-00001" → "2026-00001"
|
||
const plain = (entry.number ?? "").replace(/^[A-Z]+-/, "");
|
||
const badge = COMPANY_BADGE[entry.company ?? ""] ?? { label: "B", className: "bg-blue-600 text-white" };
|
||
|
||
// Direction icon (only intrat / iesit)
|
||
const DirIcon = entry.direction === "intrat" ? ArrowDownLeft : ArrowUpRight;
|
||
const dirColor =
|
||
entry.direction === "intrat" ? "text-green-600" : "text-orange-500";
|
||
|
||
return (
|
||
<span className="inline-flex items-center gap-1">
|
||
<span
|
||
className={cn(
|
||
"inline-flex items-center justify-center rounded text-[9px] font-bold leading-none px-1 py-0.5 shrink-0",
|
||
badge.className,
|
||
)}
|
||
title={entry.company ?? ""}
|
||
>
|
||
{badge.label}
|
||
</span>
|
||
<DirIcon className={cn("h-3 w-3 shrink-0", dirColor)} />
|
||
<span className="font-mono">{plain}</span>
|
||
<CopyNumberButton entry={entry} />
|
||
</span>
|
||
);
|
||
}
|
||
|
||
function formatDate(iso: string): string {
|
||
try {
|
||
return new Date(iso).toLocaleDateString("ro-RO", {
|
||
day: "2-digit",
|
||
month: "2-digit",
|
||
year: "numeric",
|
||
});
|
||
} catch {
|
||
return iso;
|
||
}
|
||
}
|