feat(registratura): rework with company-prefixed numbering, directions, deadlines, attachments

- Company-specific numbering (B-0001/2026, US-0001/2026, SDT-0001/2026)
- Direction: Intrat/Ieșit replaces old 3-way type
- 9 document types: Contract, Ofertă, Factură, Scrisoare, etc.
- Status simplified to Deschis/Închis with cascade close for linked entries
- Address Book autocomplete for sender/recipient
- Deadline tracking with overdue day counter
- File attachment support (base64 encoding)
- Linked entries system

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Marius Tarau
2026-02-18 06:35:23 +02:00
parent 84d9db4515
commit 98eda56035
8 changed files with 550 additions and 109 deletions

View File

@@ -1,40 +1,116 @@
'use client';
import { useState } from 'react';
import { useState, useMemo, useRef } from 'react';
import { Paperclip, X } from 'lucide-react';
import type { CompanyId } from '@/core/auth/types';
import type { RegistryEntry, RegistryEntryType, RegistryEntryStatus } from '../types';
import type { RegistryEntry, RegistryDirection, RegistryStatus, DocumentType, RegistryAttachment } 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 { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/shared/components/ui/select';
import { useContacts } from '@/modules/address-book/hooks/use-contacts';
import { v4 as uuid } from 'uuid';
interface RegistryEntryFormProps {
initial?: RegistryEntry;
allEntries?: RegistryEntry[];
onSubmit: (data: Omit<RegistryEntry, 'id' | 'number' | 'createdAt' | 'updatedAt'>) => void;
onCancel: () => void;
}
export function RegistryEntryForm({ initial, onSubmit, onCancel }: RegistryEntryFormProps) {
const [type, setType] = useState<RegistryEntryType>(initial?.type ?? 'incoming');
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 }: RegistryEntryFormProps) {
const { allContacts } = useContacts();
const fileInputRef = useRef<HTMLInputElement>(null);
const [direction, setDirection] = useState<RegistryDirection>(initial?.direction ?? 'intrat');
const [documentType, setDocumentType] = useState<DocumentType>(initial?.documentType ?? 'scrisoare');
const [subject, setSubject] = useState(initial?.subject ?? '');
const [date, setDate] = useState(initial?.date ?? new Date().toISOString().slice(0, 10));
const [sender, setSender] = useState(initial?.sender ?? '');
const [senderContactId, setSenderContactId] = useState(initial?.senderContactId ?? '');
const [recipient, setRecipient] = useState(initial?.recipient ?? '');
const [recipientContactId, setRecipientContactId] = useState(initial?.recipientContactId ?? '');
const [company, setCompany] = useState<CompanyId>(initial?.company ?? 'beletage');
const [status, setStatus] = useState<RegistryEntryStatus>(initial?.status ?? 'registered');
const [status, setStatus] = useState<RegistryStatus>(initial?.status ?? 'deschis');
const [deadline, setDeadline] = useState(initial?.deadline ?? '');
const [notes, setNotes] = useState(initial?.notes ?? '');
const [linkedEntryIds, setLinkedEntryIds] = useState<string[]>(initial?.linkedEntryIds ?? []);
const [attachments, setAttachments] = useState<RegistryAttachment[]>(initial?.attachments ?? []);
// ── Sender/Recipient autocomplete suggestions ──
const [senderFocused, setSenderFocused] = useState(false);
const [recipientFocused, setRecipientFocused] = 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 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 handleFileUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
const files = e.target.files;
if (!files) return;
for (const file of Array.from(files)) {
const reader = new FileReader();
reader.onload = () => {
const base64 = reader.result as string;
setAttachments((prev) => [
...prev,
{
id: uuid(),
name: file.name,
data: base64,
type: file.type,
size: file.size,
addedAt: new Date().toISOString(),
},
]);
};
reader.readAsDataURL(file);
}
if (fileInputRef.current) fileInputRef.current.value = '';
};
const removeAttachment = (id: string) => {
setAttachments((prev) => prev.filter((a) => a.id !== id));
};
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
onSubmit({
type,
direction,
documentType,
subject,
date,
sender,
senderContactId: senderContactId || undefined,
recipient,
recipientContactId: recipientContactId || undefined,
company,
status,
deadline: deadline || undefined,
linkedEntryIds,
attachments,
notes,
tags: initial?.tags ?? [],
visibility: initial?.visibility ?? 'all',
@@ -43,15 +119,26 @@ export function RegistryEntryForm({ initial, onSubmit, onCancel }: RegistryEntry
return (
<form onSubmit={handleSubmit} className="space-y-4">
<div className="grid gap-4 sm:grid-cols-2">
{/* Row 1: Direction + Document type + Date */}
<div className="grid gap-4 sm:grid-cols-3">
<div>
<Label>Tip document</Label>
<Select value={type} onValueChange={(v) => setType(v as RegistryEntryType)}>
<Label>Direcție</Label>
<Select value={direction} onValueChange={(v) => setDirection(v as RegistryDirection)}>
<SelectTrigger className="mt-1"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="incoming">Intrare</SelectItem>
<SelectItem value="outgoing">Ieșire</SelectItem>
<SelectItem value="internal">Intern</SelectItem>
<SelectItem value="intrat">Intrat</SelectItem>
<SelectItem value="iesit">Ieșit</SelectItem>
</SelectContent>
</Select>
</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>
@@ -61,23 +148,78 @@ export function RegistryEntryForm({ initial, onSubmit, onCancel }: RegistryEntry
</div>
</div>
{/* Subject */}
<div>
<Label>Subiect</Label>
<Label>Subiect *</Label>
<Input value={subject} onChange={(e) => setSubject(e.target.value)} className="mt-1" required />
</div>
{/* Sender / Recipient with autocomplete */}
<div className="grid gap-4 sm:grid-cols-2">
<div>
<div className="relative">
<Label>Expeditor</Label>
<Input value={sender} onChange={(e) => setSender(e.target.value)} className="mt-1" />
<Input
value={sender}
onChange={(e) => { setSender(e.target.value); setSenderContactId(''); }}
onFocus={() => setSenderFocused(true)}
onBlur={() => setTimeout(() => setSenderFocused(false), 200)}
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>
)}
</div>
<div>
<div className="relative">
<Label>Destinatar</Label>
<Input value={recipient} onChange={(e) => setRecipient(e.target.value)} className="mt-1" />
<Input
value={recipient}
onChange={(e) => { setRecipient(e.target.value); setRecipientContactId(''); }}
onFocus={() => setRecipientFocused(true)}
onBlur={() => setTimeout(() => setRecipientFocused(false), 200)}
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>
)}
</div>
</div>
<div className="grid gap-4 sm:grid-cols-2">
{/* Company + Status + Deadline */}
<div className="grid gap-4 sm:grid-cols-3">
<div>
<Label>Companie</Label>
<Select value={company} onValueChange={(v) => setCompany(v as CompanyId)}>
@@ -92,18 +234,85 @@ export function RegistryEntryForm({ initial, onSubmit, onCancel }: RegistryEntry
</div>
<div>
<Label>Status</Label>
<Select value={status} onValueChange={(v) => setStatus(v as RegistryEntryStatus)}>
<Select value={status} onValueChange={(v) => setStatus(v as RegistryStatus)}>
<SelectTrigger className="mt-1"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="registered">Înregistrat</SelectItem>
<SelectItem value="in-progress">În lucru</SelectItem>
<SelectItem value="completed">Finalizat</SelectItem>
<SelectItem value="archived">Arhivat</SelectItem>
<SelectItem value="deschis">Deschis</SelectItem>
<SelectItem value="inchis">Închis</SelectItem>
</SelectContent>
</Select>
</div>
<div>
<Label>Termen limită</Label>
<Input type="date" value={deadline} onChange={(e) => setDeadline(e.target.value)} className="mt-1" />
</div>
</div>
{/* Linked entries */}
{allEntries && allEntries.length > 0 && (
<div>
<Label>Înregistrări legate</Label>
<div className="mt-1.5 flex flex-wrap gap-1.5">
{allEntries
.filter((e) => e.id !== initial?.id)
.slice(0, 20)
.map((e) => (
<button
key={e.id}
type="button"
onClick={() => {
setLinkedEntryIds((prev) =>
prev.includes(e.id) ? prev.filter((id) => id !== e.id) : [...prev, e.id]
);
}}
className={`rounded border px-2 py-0.5 text-xs transition-colors ${
linkedEntryIds.includes(e.id)
? 'border-primary bg-primary/10 text-primary'
: 'border-muted-foreground/30 text-muted-foreground hover:border-primary/50'
}`}
>
{e.number}
</button>
))}
</div>
</div>
)}
{/* Attachments */}
<div>
<div className="flex items-center justify-between">
<Label>Atașamente</Label>
<Button type="button" variant="outline" size="sm" onClick={() => fileInputRef.current?.click()}>
<Paperclip className="mr-1 h-3.5 w-3.5" /> Adaugă fișier
</Button>
<input
ref={fileInputRef}
type="file"
multiple
accept=".pdf,.jpg,.jpeg,.png,.doc,.docx,.xls,.xlsx"
onChange={handleFileUpload}
className="hidden"
/>
</div>
{attachments.length > 0 && (
<div className="mt-2 space-y-1">
{attachments.map((att) => (
<div key={att.id} className="flex items-center gap-2 rounded border px-2 py-1 text-sm">
<Paperclip className="h-3 w-3 text-muted-foreground" />
<span className="flex-1 truncate">{att.name}</span>
<Badge variant="outline" className="text-[10px]">
{(att.size / 1024).toFixed(0)} KB
</Badge>
<button type="button" onClick={() => removeAttachment(att.id)} className="text-destructive">
<X className="h-3.5 w-3.5" />
</button>
</div>
))}
</div>
)}
</div>
{/* Notes */}
<div>
<Label>Note</Label>
<Textarea value={notes} onChange={(e) => setNotes(e.target.value)} rows={3} className="mt-1" />