diff --git a/src/modules/address-book/components/address-book-module.tsx b/src/modules/address-book/components/address-book-module.tsx index 145c527..2a6bf3c 100644 --- a/src/modules/address-book/components/address-book-module.tsx +++ b/src/modules/address-book/components/address-book-module.tsx @@ -3,7 +3,7 @@ import { useState } from 'react'; import { Plus, Pencil, Trash2, Search, Mail, Phone, MapPin, - Globe, Building2, UserPlus, X, + Globe, Building2, UserPlus, X, Download, FileText, } from 'lucide-react'; import { Button } from '@/shared/components/ui/button'; import { Input } from '@/shared/components/ui/input'; @@ -14,9 +14,14 @@ import { Card, CardContent, CardHeader, CardTitle } from '@/shared/components/ui import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from '@/shared/components/ui/select'; +import { + Dialog, DialogContent, DialogHeader, DialogTitle, +} from '@/shared/components/ui/dialog'; import type { AddressContact, ContactType, ContactPerson } from '../types'; import { useContacts } from '../hooks/use-contacts'; import { useTags } from '@/core/tagging'; +import { downloadVCard } from '../services/vcard-export'; +import { useRegistry } from '@/modules/registratura/hooks/use-registry'; const TYPE_LABELS: Record = { client: 'Client', @@ -32,6 +37,7 @@ export function AddressBookModule() { const { contacts, allContacts, loading, filters, updateFilter, addContact, updateContact, removeContact } = useContacts(); const [viewMode, setViewMode] = useState('list'); const [editingContact, setEditingContact] = useState(null); + const [viewingContact, setViewingContact] = useState(null); const handleSubmit = async (data: Omit) => { if (viewMode === 'edit' && editingContact) { @@ -89,6 +95,7 @@ export function AddressBookModule() { contact={contact} onEdit={() => { setEditingContact(contact); setViewMode('edit'); }} onDelete={() => removeContact(contact.id)} + onViewDetail={() => setViewingContact(contact)} /> ))} @@ -108,21 +115,35 @@ export function AddressBookModule() { )} + + {/* Contact Detail Dialog */} + setViewingContact(null)} + onEdit={(c) => { setViewingContact(null); setEditingContact(c); setViewMode('edit'); }} + /> ); } // ── Contact Card ── -function ContactCard({ contact, onEdit, onDelete }: { +function ContactCard({ contact, onEdit, onDelete, onViewDetail }: { contact: AddressContact; onEdit: () => void; onDelete: () => void; + onViewDetail: () => void; }) { return (
+ + @@ -197,6 +218,150 @@ function ContactCard({ contact, onEdit, onDelete }: { ); } +// ── Contact Detail Dialog (with Registratura reverse lookup) ── + +const DIRECTION_LABELS: Record = { + 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 ( + { if (!open) onClose(); }}> + + + + {contact.name} + {TYPE_LABELS[contact.type]} + + + +
+ {/* Contact info */} +
+ {contact.company && ( +
+ + {contact.company}{contact.department ? ` — ${contact.department}` : ''} +
+ )} + {contact.role &&

{contact.role}

} + {contact.email && ( + + )} + {contact.email2 && ( + + )} + {contact.phone && ( +
+ {contact.phone} +
+ )} + {contact.phone2 && ( +
+ {contact.phone2} +
+ )} + {contact.address && ( +
+ {contact.address} +
+ )} + {contact.website && ( + + )} +
+ + {/* Contact persons */} + {contact.contactPersons && contact.contactPersons.length > 0 && ( +
+

Persoane de contact

+
+ {contact.contactPersons.map((cp, i) => ( +
+ {cp.name} + {cp.role && {cp.role}} + {cp.email && {cp.email}} + {cp.phone && {cp.phone}} +
+ ))} +
+
+ )} + + {contact.notes && ( +
+

Note

+

{contact.notes}

+
+ )} + + {/* Registratura reverse lookup */} +
+

+ Registratură ({linkedEntries.length}) +

+ {linkedEntries.length === 0 ? ( +

Nicio înregistrare în registratură pentru acest contact.

+ ) : ( +
+ {linkedEntries.map((entry) => ( +
+ + {DIRECTION_LABELS[entry.direction] ?? entry.direction} + + {entry.number} + {entry.subject} + {entry.date} + + {entry.status === 'deschis' ? 'Deschis' : 'Închis'} + +
+ ))} +
+ )} +
+ + {/* Actions */} +
+ + +
+
+
+
+ ); +} + // ── Contact Form ── function ContactForm({ initial, onSubmit, onCancel }: { diff --git a/src/modules/address-book/services/vcard-export.ts b/src/modules/address-book/services/vcard-export.ts new file mode 100644 index 0000000..8e502ee --- /dev/null +++ b/src/modules/address-book/services/vcard-export.ts @@ -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'); +}