feat(registratura): 3.02 bidirectional integration, simplified status, threads

- Dynamic document types: string-based DocumentType synced with Tag Manager
  (new types auto-create tags under 'document-type' category)
- Added default types: 'Apel telefonic', 'Videoconferinta'
- Bidirectional Address Book: quick-create contacts from sender/recipient/
  assignee fields via QuickContactDialog popup
- Simplified status: Switch toggle replaces dropdown (default open)
- Responsabil (Assignee) field with contact autocomplete (ERP-ready)
- Entry threads: threadParentId links entries as replies, ThreadView shows
  parent/current/children tree with branching support
- Info tooltips on deadline, status, and assignee fields
- New Resp. column and thread icon in registry table
- All changes backward-compatible with existing data
This commit is contained in:
AI Assistant
2026-02-27 15:33:29 +02:00
parent b2618c041d
commit 2be0462e0d
10 changed files with 1199 additions and 273 deletions
@@ -1,22 +1,31 @@
"use client";
import { useState, useMemo, useRef } from "react";
import { Paperclip, X, Clock, Plus } from "lucide-react";
import { useState, useMemo, useRef, useCallback } from "react";
import {
Paperclip,
X,
Clock,
Plus,
UserPlus,
Info,
GitBranch,
} from "lucide-react";
import type { CompanyId } from "@/core/auth/types";
import type {
RegistryEntry,
RegistryDirection,
RegistryStatus,
DocumentType,
RegistryAttachment,
TrackedDeadline,
DeadlineResolution,
} from "../types";
import { DEFAULT_DOC_TYPE_LABELS } from "../types";
import { Input } from "@/shared/components/ui/input";
import { Label } from "@/shared/components/ui/label";
import { Textarea } from "@/shared/components/ui/textarea";
import { Button } from "@/shared/components/ui/button";
import { Badge } from "@/shared/components/ui/badge";
import { Switch } from "@/shared/components/ui/switch";
import {
Select,
SelectContent,
@@ -24,11 +33,21 @@ import {
SelectTrigger,
SelectValue,
} from "@/shared/components/ui/select";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/shared/components/ui/tooltip";
import { useContacts } from "@/modules/address-book/hooks/use-contacts";
import { useTags } from "@/core/tagging";
import type { AddressContact } from "@/modules/address-book/types";
import { v4 as uuid } from "uuid";
import { DeadlineCard } from "./deadline-card";
import { DeadlineAddDialog } from "./deadline-add-dialog";
import { DeadlineResolveDialog } from "./deadline-resolve-dialog";
import { QuickContactDialog } from "./quick-contact-dialog";
import { ThreadView } from "./thread-view";
import {
createTrackedDeadline,
resolveDeadline as resolveDeadlineFn,
@@ -42,35 +61,55 @@ interface RegistryEntryFormProps {
data: Omit<RegistryEntry, "id" | "number" | "createdAt" | "updatedAt">,
) => void;
onCancel: () => void;
/** Callback to create a new Address Book contact */
onCreateContact?: (data: {
name: string;
phone: string;
email: string;
}) => Promise<AddressContact | undefined>;
/** Callback to create a new document type tag in Tag Manager */
onCreateDocType?: (label: string) => Promise<void>;
/** Navigate to an entry (for thread clicks) */
onNavigateEntry?: (entry: RegistryEntry) => void;
}
const DOC_TYPE_LABELS: Record<DocumentType, string> = {
contract: "Contract",
oferta: "Ofertă",
factura: "Factură",
scrisoare: "Scrisoare",
aviz: "Aviz",
"nota-de-comanda": "Notă de comandă",
raport: "Raport",
cerere: "Cerere",
altele: "Altele",
};
export function RegistryEntryForm({
initial,
allEntries,
onSubmit,
onCancel,
onCreateContact,
onCreateDocType,
onNavigateEntry,
}: RegistryEntryFormProps) {
const { allContacts } = useContacts();
const { allContacts, refresh: refreshContacts } = useContacts();
const { tags: docTypeTags } = useTags("document-type");
const fileInputRef = useRef<HTMLInputElement>(null);
// ── Build dynamic doc type list from defaults + Tag Manager ──
const allDocTypes = useMemo(() => {
const map = new Map<string, string>();
// Add defaults
for (const [key, label] of Object.entries(DEFAULT_DOC_TYPE_LABELS)) {
map.set(key, label);
}
// Add from Tag Manager (document-type category)
for (const tag of docTypeTags) {
const key = tag.label.toLowerCase().replace(/\s+/g, "-");
if (!map.has(key)) {
map.set(key, tag.label);
}
}
return map;
}, [docTypeTags]);
const [direction, setDirection] = useState<RegistryDirection>(
initial?.direction ?? "intrat",
);
const [documentType, setDocumentType] = useState<DocumentType>(
initial?.documentType ?? "scrisoare",
);
const [customDocType, setCustomDocType] = useState("");
const [subject, setSubject] = useState(initial?.subject ?? "");
const [date, setDate] = useState(
initial?.date ?? new Date().toISOString().slice(0, 10),
@@ -86,10 +125,15 @@ export function RegistryEntryForm({
const [company, setCompany] = useState<CompanyId>(
initial?.company ?? "beletage",
);
const [status, setStatus] = useState<RegistryStatus>(
initial?.status ?? "deschis",
);
const [isClosed, setIsClosed] = useState(initial?.status === "inchis");
const [deadline, setDeadline] = useState(initial?.deadline ?? "");
const [assignee, setAssignee] = useState(initial?.assignee ?? "");
const [assigneeContactId, setAssigneeContactId] = useState(
initial?.assigneeContactId ?? "",
);
const [threadParentId, setThreadParentId] = useState(
initial?.threadParentId ?? "",
);
const [notes, setNotes] = useState(initial?.notes ?? "");
const [linkedEntryIds, setLinkedEntryIds] = useState<string[]>(
initial?.linkedEntryIds ?? [],
@@ -101,12 +145,20 @@ export function RegistryEntryForm({
initial?.trackedDeadlines ?? [],
);
const [linkedSearch, setLinkedSearch] = useState("");
const [threadSearch, setThreadSearch] = useState("");
// ── Deadline dialogs ──
const [deadlineAddOpen, setDeadlineAddOpen] = useState(false);
const [resolvingDeadline, setResolvingDeadline] =
useState<TrackedDeadline | null>(null);
// ── Quick contact creation ──
const [quickContactOpen, setQuickContactOpen] = useState(false);
const [quickContactField, setQuickContactField] = useState<
"sender" | "recipient" | "assignee"
>("sender");
const [quickContactName, setQuickContactName] = useState("");
const handleAddDeadline = (
typeId: string,
startDate: string,
@@ -127,7 +179,6 @@ export function RegistryEntryForm({
prev.map((d) => (d.id === resolved.id ? resolved : d)),
);
// Handle chain
if (chainNext) {
const def = getDeadlineType(resolvingDeadline.typeId);
if (def?.chainNextTypeId) {
@@ -147,33 +198,86 @@ export function RegistryEntryForm({
setTrackedDeadlines((prev) => prev.filter((d) => d.id !== deadlineId));
};
// ── Sender/Recipient autocomplete suggestions ──
// ── Contact autocomplete ──
const [senderFocused, setSenderFocused] = useState(false);
const [recipientFocused, setRecipientFocused] = useState(false);
const [assigneeFocused, setAssigneeFocused] = useState(false);
const senderSuggestions = useMemo(() => {
if (!sender || sender.length < 2) return [];
const q = sender.toLowerCase();
return allContacts
.filter(
(c) =>
c.name.toLowerCase().includes(q) ||
c.company.toLowerCase().includes(q),
)
.slice(0, 5);
}, [allContacts, sender]);
const filterContacts = useCallback(
(query: string) => {
if (!query || query.length < 2) return [];
const q = query.toLowerCase();
return allContacts
.filter(
(c) =>
c.name.toLowerCase().includes(q) ||
c.company.toLowerCase().includes(q),
)
.slice(0, 5);
},
[allContacts],
);
const recipientSuggestions = useMemo(() => {
if (!recipient || recipient.length < 2) return [];
const q = recipient.toLowerCase();
return allContacts
.filter(
(c) =>
c.name.toLowerCase().includes(q) ||
c.company.toLowerCase().includes(q),
)
.slice(0, 5);
}, [allContacts, recipient]);
const senderSuggestions = useMemo(
() => filterContacts(sender),
[filterContacts, sender],
);
const recipientSuggestions = useMemo(
() => filterContacts(recipient),
[filterContacts, recipient],
);
const assigneeSuggestions = useMemo(
() => filterContacts(assignee),
[filterContacts, assignee],
);
// ── Quick contact creation handler ──
const openQuickContact = (
field: "sender" | "recipient" | "assignee",
name: string,
) => {
setQuickContactField(field);
setQuickContactName(name);
setQuickContactOpen(true);
};
const handleQuickContactConfirm = async (data: {
name: string;
phone: string;
email: string;
}) => {
if (!onCreateContact) return;
const contact = await onCreateContact(data);
if (contact) {
const displayName = contact.company
? `${contact.name} (${contact.company})`
: contact.name;
if (quickContactField === "sender") {
setSender(displayName);
setSenderContactId(contact.id);
} else if (quickContactField === "recipient") {
setRecipient(displayName);
setRecipientContactId(contact.id);
} else {
setAssignee(displayName);
setAssigneeContactId(contact.id);
}
await refreshContacts();
}
setQuickContactOpen(false);
};
// ── Custom doc type creation ──
const handleAddCustomDocType = async () => {
const label = customDocType.trim();
if (!label) return;
const key = label.toLowerCase().replace(/\s+/g, "-");
setDocumentType(key);
setCustomDocType("");
if (onCreateDocType) {
await onCreateDocType(label);
}
};
const handleFileUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
const files = e.target.files;
@@ -215,8 +319,11 @@ export function RegistryEntryForm({
recipient,
recipientContactId: recipientContactId || undefined,
company,
status,
status: isClosed ? "inchis" : "deschis",
deadline: deadline || undefined,
assignee: assignee || undefined,
assigneeContactId: assigneeContactId || undefined,
threadParentId: threadParentId || undefined,
linkedEntryIds,
attachments,
trackedDeadlines:
@@ -227,8 +334,78 @@ export function RegistryEntryForm({
});
};
// ── Contact autocomplete dropdown renderer ──
const renderContactDropdown = (
suggestions: AddressContact[],
focused: boolean,
fieldName: "sender" | "recipient" | "assignee",
currentValue: string,
onSelect: (c: AddressContact) => void,
) => {
if (!focused) return null;
const hasExactMatch = suggestions.some(
(c) =>
c.name.toLowerCase() === currentValue.toLowerCase() ||
`${c.name} (${c.company})`.toLowerCase() === currentValue.toLowerCase(),
);
const showCreateButton =
currentValue.length >= 2 && !hasExactMatch && onCreateContact;
if (suggestions.length === 0 && !showCreateButton) return null;
return (
<div className="absolute z-10 mt-1 w-full rounded-md border bg-popover p-1 shadow-md">
{suggestions.map((c) => (
<button
key={c.id}
type="button"
className="w-full rounded px-2 py-1.5 text-left text-sm hover:bg-accent"
onMouseDown={() => onSelect(c)}
>
<span className="font-medium">{c.name}</span>
{c.company && (
<span className="ml-1 text-muted-foreground text-xs">
{c.company}
</span>
)}
</button>
))}
{showCreateButton && (
<button
type="button"
className="w-full rounded px-2 py-1.5 text-left text-sm hover:bg-accent text-primary flex items-center gap-1.5 border-t mt-1 pt-1.5"
onMouseDown={() => openQuickContact(fieldName, currentValue)}
>
<UserPlus className="h-3.5 w-3.5" />
Creează contact &quot;{currentValue}&quot;
</button>
)}
</div>
);
};
// Thread parent entry for display
const threadParent =
threadParentId && allEntries
? allEntries.find((e) => e.id === threadParentId)
: null;
return (
<form onSubmit={handleSubmit} className="space-y-4">
{/* Thread view (if editing an entry that's in a thread) */}
{initial &&
allEntries &&
(initial.threadParentId ||
allEntries.some((e) => e.threadParentId === initial.id)) && (
<ThreadView
entry={initial}
allEntries={allEntries}
onNavigate={onNavigateEntry}
/>
)}
{/* Row 1: Direction + Document type + Date */}
<div className="grid gap-4 sm:grid-cols-3">
<div>
@@ -248,23 +425,48 @@ export function RegistryEntryForm({
</div>
<div>
<Label>Tip document</Label>
<Select
value={documentType}
onValueChange={(v) => setDocumentType(v as DocumentType)}
>
<SelectTrigger className="mt-1">
<SelectValue />
</SelectTrigger>
<SelectContent>
{(
Object.entries(DOC_TYPE_LABELS) as [DocumentType, string][]
).map(([key, label]) => (
<SelectItem key={key} value={key}>
{label}
</SelectItem>
))}
</SelectContent>
</Select>
<div className="mt-1">
<Select
value={allDocTypes.has(documentType) ? documentType : "altele"}
onValueChange={(v) => setDocumentType(v as DocumentType)}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{Array.from(allDocTypes.entries()).map(([key, label]) => (
<SelectItem key={key} value={key}>
{label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* Add custom type inline */}
<div className="mt-1.5 flex gap-1">
<Input
value={customDocType}
onChange={(e) => setCustomDocType(e.target.value)}
placeholder="Tip nou..."
className="text-xs h-7"
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
handleAddCustomDocType();
}
}}
/>
<Button
type="button"
variant="outline"
size="sm"
className="h-7 px-2"
onClick={handleAddCustomDocType}
disabled={!customDocType.trim()}
>
<Plus className="h-3 w-3" />
</Button>
</div>
</div>
<div>
<Label>Data</Label>
@@ -288,8 +490,9 @@ export function RegistryEntryForm({
/>
</div>
{/* Sender / Recipient with autocomplete */}
{/* Sender / Recipient with autocomplete + quick create */}
<div className="grid gap-4 sm:grid-cols-2">
{/* Sender */}
<div className="relative">
<Label>Expeditor</Label>
<Input
@@ -303,30 +506,19 @@ export function RegistryEntryForm({
className="mt-1"
placeholder="Nume sau companie..."
/>
{senderFocused && senderSuggestions.length > 0 && (
<div className="absolute z-10 mt-1 w-full rounded-md border bg-popover p-1 shadow-md">
{senderSuggestions.map((c) => (
<button
key={c.id}
type="button"
className="w-full rounded px-2 py-1.5 text-left text-sm hover:bg-accent"
onMouseDown={() => {
setSender(c.company ? `${c.name} (${c.company})` : c.name);
setSenderContactId(c.id);
setSenderFocused(false);
}}
>
<span className="font-medium">{c.name}</span>
{c.company && (
<span className="ml-1 text-muted-foreground text-xs">
{c.company}
</span>
)}
</button>
))}
</div>
{renderContactDropdown(
senderSuggestions,
senderFocused,
"sender",
sender,
(c) => {
setSender(c.company ? `${c.name} (${c.company})` : c.name);
setSenderContactId(c.id);
setSenderFocused(false);
},
)}
</div>
{/* Recipient */}
<div className="relative">
<Label>Destinatar</Label>
<Input
@@ -340,35 +532,63 @@ export function RegistryEntryForm({
className="mt-1"
placeholder="Nume sau companie..."
/>
{recipientFocused && recipientSuggestions.length > 0 && (
<div className="absolute z-10 mt-1 w-full rounded-md border bg-popover p-1 shadow-md">
{recipientSuggestions.map((c) => (
<button
key={c.id}
type="button"
className="w-full rounded px-2 py-1.5 text-left text-sm hover:bg-accent"
onMouseDown={() => {
setRecipient(
c.company ? `${c.name} (${c.company})` : c.name,
);
setRecipientContactId(c.id);
setRecipientFocused(false);
}}
>
<span className="font-medium">{c.name}</span>
{c.company && (
<span className="ml-1 text-muted-foreground text-xs">
{c.company}
</span>
)}
</button>
))}
</div>
{renderContactDropdown(
recipientSuggestions,
recipientFocused,
"recipient",
recipient,
(c) => {
setRecipient(c.company ? `${c.name} (${c.company})` : c.name);
setRecipientContactId(c.id);
setRecipientFocused(false);
},
)}
</div>
</div>
{/* Company + Status + Deadline */}
{/* Assignee (Responsabil) */}
<div className="relative">
<Label className="flex items-center gap-1.5">
Responsabil
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Info className="h-3.5 w-3.5 text-muted-foreground cursor-help" />
</TooltipTrigger>
<TooltipContent side="right" className="max-w-xs">
<p className="text-xs">
Persoana din echipă responsabilă cu gestionarea acestei
înregistrări. Câmp pregătit pentru integrare viitoare cu ERP.
</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</Label>
<Input
value={assignee}
onChange={(e) => {
setAssignee(e.target.value);
setAssigneeContactId("");
}}
onFocus={() => setAssigneeFocused(true)}
onBlur={() => setTimeout(() => setAssigneeFocused(false), 200)}
className="mt-1"
placeholder="Persoana responsabilă..."
/>
{renderContactDropdown(
assigneeSuggestions,
assigneeFocused,
"assignee",
assignee,
(c) => {
setAssignee(c.company ? `${c.name} (${c.company})` : c.name);
setAssigneeContactId(c.id);
setAssigneeFocused(false);
},
)}
</div>
{/* Company + Closed switch + Deadline */}
<div className="grid gap-4 sm:grid-cols-3">
<div>
<Label>Companie</Label>
@@ -388,22 +608,47 @@ export function RegistryEntryForm({
</Select>
</div>
<div>
<Label>Status</Label>
<Select
value={status}
onValueChange={(v) => setStatus(v as RegistryStatus)}
>
<SelectTrigger className="mt-1">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="deschis">Deschis</SelectItem>
<SelectItem value="inchis">Închis</SelectItem>
</SelectContent>
</Select>
<Label className="flex items-center gap-1.5">
Închis
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Info className="h-3.5 w-3.5 text-muted-foreground cursor-help" />
</TooltipTrigger>
<TooltipContent side="right" className="max-w-xs">
<p className="text-xs">
Implicit, toate înregistrările sunt deschise. Bifează doar
când dosarul este finalizat.
</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</Label>
<div className="mt-2.5 flex items-center gap-2">
<Switch checked={isClosed} onCheckedChange={setIsClosed} />
<span className="text-sm text-muted-foreground">
{isClosed ? "Închis" : "Deschis"}
</span>
</div>
</div>
<div>
<Label>Termen limită</Label>
<Label className="flex items-center gap-1.5">
Termen limită intern
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Info className="h-3.5 w-3.5 text-muted-foreground cursor-help" />
</TooltipTrigger>
<TooltipContent side="right" className="max-w-xs">
<p className="text-xs">
Termen intern pentru a răspunde sau a acționa pe această
înregistrare. Nu este termen legal termenele legale se
adaugă separat mai jos.
</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</Label>
<Input
type="date"
value={deadline}
@@ -413,6 +658,105 @@ export function RegistryEntryForm({
</div>
</div>
{/* Thread parent — reply to another entry */}
{allEntries && allEntries.length > 0 && (
<div>
<Label className="flex items-center gap-1.5">
<GitBranch className="h-3.5 w-3.5" />
Răspuns la (Thread)
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Info className="h-3.5 w-3.5 text-muted-foreground cursor-help" />
</TooltipTrigger>
<TooltipContent side="right" className="max-w-xs">
<p className="text-xs">
Leagă această înregistrare ca răspuns la alta, creând un fir
de conversație instituțională. O intrare poate genera mai
multe ieșiri (branching).
</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</Label>
{threadParent && (
<div className="mt-1.5 flex items-center gap-2 rounded border border-primary/30 bg-primary/5 px-2 py-1.5 text-sm">
<Badge
variant={
threadParent.direction === "intrat" ? "default" : "secondary"
}
className="text-[10px]"
>
{threadParent.direction === "intrat" ? "↓" : "↑"}
</Badge>
<span className="font-mono text-xs">{threadParent.number}</span>
<span className="truncate text-muted-foreground text-xs">
{threadParent.subject}
</span>
<Button
type="button"
variant="ghost"
size="sm"
className="ml-auto h-6 w-6 p-0"
onClick={() => setThreadParentId("")}
>
<X className="h-3.5 w-3.5" />
</Button>
</div>
)}
{!threadParentId && (
<>
<Input
className="mt-1.5"
placeholder="Caută după număr sau subiect..."
value={threadSearch}
onChange={(e) => setThreadSearch(e.target.value)}
/>
{threadSearch.trim().length >= 2 && (
<div className="mt-1.5 max-h-32 overflow-y-auto space-y-1">
{allEntries
.filter((e) => {
if (e.id === initial?.id) return false;
const q = threadSearch.toLowerCase();
return (
e.number.toLowerCase().includes(q) ||
e.subject.toLowerCase().includes(q)
);
})
.slice(0, 8)
.map((e) => (
<button
key={e.id}
type="button"
onClick={() => {
setThreadParentId(e.id);
setThreadSearch("");
}}
className="flex w-full items-center gap-2 rounded border px-2 py-1 text-xs transition-colors hover:bg-accent"
>
<Badge
variant={
e.direction === "intrat" ? "default" : "secondary"
}
className="text-[9px] px-1 py-0"
>
{e.direction === "intrat" ? "↓" : "↑"}
</Badge>
<span className="font-mono">{e.number}</span>
<span className="truncate text-muted-foreground">
{e.subject.length > 40
? e.subject.slice(0, 40) + "…"
: e.subject}
</span>
</button>
))}
</div>
)}
</>
)}
</div>
)}
{/* Linked entries */}
{allEntries && allEntries.length > 0 && (
<div>
@@ -582,6 +926,14 @@ export function RegistryEntryForm({
</Button>
<Button type="submit">{initial ? "Actualizează" : "Adaugă"}</Button>
</div>
{/* Quick contact creation dialog */}
<QuickContactDialog
open={quickContactOpen}
onOpenChange={setQuickContactOpen}
initialName={quickContactName}
onConfirm={handleQuickContactConfirm}
/>
</form>
);
}