Files
ArchiTools/src/modules/registratura/components/registry-table.tsx
T
AI Assistant 2a25e4b160 fix(registratura): replace parentheses with en-dash in contact display
"Name – Company" instead of "Name (Company)" for sender/recipient.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-22 04:15:37 +02:00

651 lines
23 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"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;
}
}