feat(address-book): vCard export and Registratura reverse lookup
This commit is contained in:
@@ -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 }: {
|
||||||
|
|||||||
82
src/modules/address-book/services/vcard-export.ts
Normal file
82
src/modules/address-book/services/vcard-export.ts
Normal 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');
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user