feat(address-book): vCard export and Registratura reverse lookup

This commit is contained in:
AI Assistant
2026-02-19 06:57:40 +02:00
parent 35305e4389
commit da33dc9b81
2 changed files with 249 additions and 2 deletions

View File

@@ -3,7 +3,7 @@
import { useState } from 'react'; import { useState } from 'react';
import { import {
Plus, Pencil, Trash2, Search, Mail, Phone, MapPin, Plus, Pencil, Trash2, Search, Mail, Phone, MapPin,
Globe, Building2, UserPlus, X, Globe, Building2, UserPlus, X, Download, FileText,
} from 'lucide-react'; } from 'lucide-react';
import { Button } from '@/shared/components/ui/button'; import { Button } from '@/shared/components/ui/button';
import { Input } from '@/shared/components/ui/input'; import { Input } from '@/shared/components/ui/input';
@@ -14,9 +14,14 @@ import { Card, CardContent, CardHeader, CardTitle } from '@/shared/components/ui
import { import {
Select, SelectContent, SelectItem, SelectTrigger, SelectValue, Select, SelectContent, SelectItem, SelectTrigger, SelectValue,
} from '@/shared/components/ui/select'; } from '@/shared/components/ui/select';
import {
Dialog, DialogContent, DialogHeader, DialogTitle,
} from '@/shared/components/ui/dialog';
import type { AddressContact, ContactType, ContactPerson } from '../types'; import type { AddressContact, ContactType, ContactPerson } from '../types';
import { useContacts } from '../hooks/use-contacts'; import { useContacts } from '../hooks/use-contacts';
import { useTags } from '@/core/tagging'; import { useTags } from '@/core/tagging';
import { downloadVCard } from '../services/vcard-export';
import { useRegistry } from '@/modules/registratura/hooks/use-registry';
const TYPE_LABELS: Record<ContactType, string> = { const TYPE_LABELS: Record<ContactType, string> = {
client: 'Client', client: 'Client',
@@ -32,6 +37,7 @@ export function AddressBookModule() {
const { contacts, allContacts, loading, filters, updateFilter, addContact, updateContact, removeContact } = useContacts(); const { contacts, allContacts, loading, filters, updateFilter, addContact, updateContact, removeContact } = useContacts();
const [viewMode, setViewMode] = useState<ViewMode>('list'); const [viewMode, setViewMode] = useState<ViewMode>('list');
const [editingContact, setEditingContact] = useState<AddressContact | null>(null); const [editingContact, setEditingContact] = useState<AddressContact | null>(null);
const [viewingContact, setViewingContact] = useState<AddressContact | null>(null);
const handleSubmit = async (data: Omit<AddressContact, 'id' | 'createdAt' | 'updatedAt'>) => { const handleSubmit = async (data: Omit<AddressContact, 'id' | 'createdAt' | 'updatedAt'>) => {
if (viewMode === 'edit' && editingContact) { if (viewMode === 'edit' && editingContact) {
@@ -89,6 +95,7 @@ export function AddressBookModule() {
contact={contact} contact={contact}
onEdit={() => { setEditingContact(contact); setViewMode('edit'); }} onEdit={() => { setEditingContact(contact); setViewMode('edit'); }}
onDelete={() => removeContact(contact.id)} onDelete={() => removeContact(contact.id)}
onViewDetail={() => setViewingContact(contact)}
/> />
))} ))}
</div> </div>
@@ -108,21 +115,35 @@ export function AddressBookModule() {
</CardContent> </CardContent>
</Card> </Card>
)} )}
{/* Contact Detail Dialog */}
<ContactDetailDialog
contact={viewingContact}
onClose={() => setViewingContact(null)}
onEdit={(c) => { setViewingContact(null); setEditingContact(c); setViewMode('edit'); }}
/>
</div> </div>
); );
} }
// ── Contact Card ── // ── Contact Card ──
function ContactCard({ contact, onEdit, onDelete }: { function ContactCard({ contact, onEdit, onDelete, onViewDetail }: {
contact: AddressContact; contact: AddressContact;
onEdit: () => void; onEdit: () => void;
onDelete: () => void; onDelete: () => void;
onViewDetail: () => void;
}) { }) {
return ( return (
<Card className="group relative"> <Card className="group relative">
<CardContent className="p-4"> <CardContent className="p-4">
<div className="absolute right-2 top-2 flex gap-1 opacity-0 transition-opacity group-hover:opacity-100"> <div className="absolute right-2 top-2 flex gap-1 opacity-0 transition-opacity group-hover:opacity-100">
<Button variant="ghost" size="icon" className="h-7 w-7" title="Detalii" onClick={onViewDetail}>
<FileText className="h-3.5 w-3.5" />
</Button>
<Button variant="ghost" size="icon" className="h-7 w-7" title="Descarcă vCard" onClick={() => downloadVCard(contact)}>
<Download className="h-3.5 w-3.5" />
</Button>
<Button variant="ghost" size="icon" className="h-7 w-7" onClick={onEdit}> <Button variant="ghost" size="icon" className="h-7 w-7" onClick={onEdit}>
<Pencil className="h-3.5 w-3.5" /> <Pencil className="h-3.5 w-3.5" />
</Button> </Button>
@@ -197,6 +218,150 @@ function ContactCard({ contact, onEdit, onDelete }: {
); );
} }
// ── Contact Detail Dialog (with Registratura reverse lookup) ──
const DIRECTION_LABELS: Record<string, string> = {
intrat: 'Intrat',
iesit: 'Ieșit',
};
function ContactDetailDialog({ contact, onClose, onEdit }: {
contact: AddressContact | null;
onClose: () => void;
onEdit: (c: AddressContact) => void;
}) {
const { allEntries } = useRegistry();
if (!contact) return null;
// Find registratura entries linked to this contact (search all, ignoring active filters)
const linkedEntries = allEntries.filter(
(e) => e.senderContactId === contact.id || e.recipientContactId === contact.id
);
return (
<Dialog open={contact !== null} onOpenChange={(open) => { if (!open) onClose(); }}>
<DialogContent className="max-w-2xl max-h-[85vh] overflow-y-auto">
<DialogHeader>
<DialogTitle className="flex items-center gap-3">
<span>{contact.name}</span>
<Badge variant="outline">{TYPE_LABELS[contact.type]}</Badge>
</DialogTitle>
</DialogHeader>
<div className="space-y-4">
{/* Contact info */}
<div className="grid gap-2 text-sm">
{contact.company && (
<div className="flex items-center gap-2 text-muted-foreground">
<Building2 className="h-4 w-4 shrink-0" />
<span>{contact.company}{contact.department ? `${contact.department}` : ''}</span>
</div>
)}
{contact.role && <p className="text-xs italic text-muted-foreground pl-6">{contact.role}</p>}
{contact.email && (
<div className="flex items-center gap-2 text-muted-foreground">
<Mail className="h-4 w-4 shrink-0" />
<a href={`mailto:${contact.email}`} className="hover:text-foreground">{contact.email}</a>
</div>
)}
{contact.email2 && (
<div className="flex items-center gap-2 text-muted-foreground">
<Mail className="h-4 w-4 shrink-0" />
<a href={`mailto:${contact.email2}`} className="hover:text-foreground">{contact.email2}</a>
</div>
)}
{contact.phone && (
<div className="flex items-center gap-2 text-muted-foreground">
<Phone className="h-4 w-4 shrink-0" /><span>{contact.phone}</span>
</div>
)}
{contact.phone2 && (
<div className="flex items-center gap-2 text-muted-foreground">
<Phone className="h-4 w-4 shrink-0" /><span>{contact.phone2}</span>
</div>
)}
{contact.address && (
<div className="flex items-center gap-2 text-muted-foreground">
<MapPin className="h-4 w-4 shrink-0" /><span>{contact.address}</span>
</div>
)}
{contact.website && (
<div className="flex items-center gap-2 text-muted-foreground">
<Globe className="h-4 w-4 shrink-0" />
<a href={contact.website} target="_blank" rel="noopener noreferrer" className="hover:text-foreground truncate">{contact.website}</a>
</div>
)}
</div>
{/* Contact persons */}
{contact.contactPersons && contact.contactPersons.length > 0 && (
<div>
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wider mb-1.5">Persoane de contact</p>
<div className="space-y-1">
{contact.contactPersons.map((cp, i) => (
<div key={i} className="flex flex-wrap items-center gap-2 text-sm">
<span className="font-medium">{cp.name}</span>
{cp.role && <span className="text-muted-foreground text-xs">{cp.role}</span>}
{cp.email && <a href={`mailto:${cp.email}`} className="text-xs text-muted-foreground hover:text-foreground">{cp.email}</a>}
{cp.phone && <span className="text-xs text-muted-foreground">{cp.phone}</span>}
</div>
))}
</div>
</div>
)}
{contact.notes && (
<div>
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wider mb-1">Note</p>
<p className="text-sm text-muted-foreground whitespace-pre-wrap">{contact.notes}</p>
</div>
)}
{/* Registratura reverse lookup */}
<div>
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wider mb-2">
Registratură ({linkedEntries.length})
</p>
{linkedEntries.length === 0 ? (
<p className="text-xs text-muted-foreground">Nicio înregistrare în registratură pentru acest contact.</p>
) : (
<div className="space-y-1.5 max-h-52 overflow-y-auto pr-1">
{linkedEntries.map((entry) => (
<div key={entry.id} className="flex items-center gap-3 rounded border p-2 text-xs">
<Badge variant="outline" className="shrink-0 text-[10px]">
{DIRECTION_LABELS[entry.direction] ?? entry.direction}
</Badge>
<span className="font-mono shrink-0 text-muted-foreground">{entry.number}</span>
<span className="flex-1 truncate font-medium">{entry.subject}</span>
<span className="shrink-0 text-muted-foreground">{entry.date}</span>
<Badge
variant={entry.status === 'deschis' ? 'default' : 'secondary'}
className="shrink-0 text-[10px]"
>
{entry.status === 'deschis' ? 'Deschis' : 'Închis'}
</Badge>
</div>
))}
</div>
)}
</div>
{/* Actions */}
<div className="flex justify-end gap-2 pt-2 border-t">
<Button variant="outline" size="sm" onClick={() => downloadVCard(contact)}>
<Download className="mr-1.5 h-3.5 w-3.5" /> Descarcă vCard
</Button>
<Button variant="outline" size="sm" onClick={() => onEdit(contact)}>
<Pencil className="mr-1.5 h-3.5 w-3.5" /> Editează
</Button>
</div>
</div>
</DialogContent>
</Dialog>
);
}
// ── Contact Form ── // ── Contact Form ──
function ContactForm({ initial, onSubmit, onCancel }: { function ContactForm({ initial, onSubmit, onCancel }: {

View File

@@ -0,0 +1,82 @@
import type { AddressContact } from '../types';
/**
* Generates a vCard 3.0 string for a contact and triggers a file download.
*/
export function downloadVCard(contact: AddressContact): void {
const lines: string[] = ['BEGIN:VCARD', 'VERSION:3.0'];
// Full name
lines.push(`FN:${esc(contact.name)}`);
// Structured name — try to split first/last (best-effort)
const nameParts = contact.name.trim().split(/\s+/);
const last = nameParts.length > 1 ? (nameParts[nameParts.length - 1] ?? '') : '';
const first = nameParts.length > 1 ? nameParts.slice(0, -1).join(' ') : (nameParts[0] ?? '');
lines.push(`N:${esc(last)};${esc(first)};;;`);
if (contact.company) {
lines.push(`ORG:${esc(contact.company)}${contact.department ? `;${esc(contact.department)}` : ''}`);
}
if (contact.role) {
lines.push(`TITLE:${esc(contact.role)}`);
}
if (contact.phone) {
lines.push(`TEL;TYPE=WORK,VOICE:${esc(contact.phone)}`);
}
if (contact.phone2) {
lines.push(`TEL;TYPE=WORK,VOICE,pref:${esc(contact.phone2)}`);
}
if (contact.email) {
lines.push(`EMAIL;TYPE=WORK,INTERNET:${esc(contact.email)}`);
}
if (contact.email2) {
lines.push(`EMAIL;TYPE=WORK,INTERNET,pref:${esc(contact.email2)}`);
}
if (contact.address) {
// ADR: PO Box;Extended;Street;City;Region;PostCode;Country
lines.push(`ADR;TYPE=WORK:;;${esc(contact.address)};;;;`);
}
if (contact.website) {
lines.push(`URL:${esc(contact.website)}`);
}
if (contact.notes) {
// Fold long notes
lines.push(`NOTE:${esc(contact.notes)}`);
}
// Contact persons as additional notes
if (contact.contactPersons && contact.contactPersons.length > 0) {
const cpNote = contact.contactPersons
.map((cp) => `${cp.name}${cp.role ? ` (${cp.role})` : ''}${cp.email ? ` <${cp.email}>` : ''}${cp.phone ? ` ${cp.phone}` : ''}`)
.join('; ');
lines.push(`NOTE:Persoane contact: ${esc(cpNote)}`);
}
lines.push(`REV:${new Date().toISOString()}`);
lines.push('END:VCARD');
const vcfContent = lines.join('\r\n');
const blob = new Blob([vcfContent], { type: 'text/vcard;charset=utf-8' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `${contact.name.replace(/[^a-zA-Z0-9\s-]/g, '').trim().replace(/\s+/g, '_')}.vcf`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
}
/** Escape special characters for vCard values */
function esc(value: string): string {
return value.replace(/\\/g, '\\\\').replace(/,/g, '\\,').replace(/;/g, '\\;').replace(/\n/g, '\\n');
}