feat(address-book): vCard export and Registratura reverse lookup
This commit is contained in:
@@ -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<ContactType, string> = {
|
||||
client: 'Client',
|
||||
@@ -32,6 +37,7 @@ export function AddressBookModule() {
|
||||
const { contacts, allContacts, loading, filters, updateFilter, addContact, updateContact, removeContact } = useContacts();
|
||||
const [viewMode, setViewMode] = useState<ViewMode>('list');
|
||||
const [editingContact, setEditingContact] = useState<AddressContact | null>(null);
|
||||
const [viewingContact, setViewingContact] = useState<AddressContact | null>(null);
|
||||
|
||||
const handleSubmit = async (data: Omit<AddressContact, 'id' | 'createdAt' | 'updatedAt'>) => {
|
||||
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)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
@@ -108,21 +115,35 @@ export function AddressBookModule() {
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Contact Detail Dialog */}
|
||||
<ContactDetailDialog
|
||||
contact={viewingContact}
|
||||
onClose={() => setViewingContact(null)}
|
||||
onEdit={(c) => { setViewingContact(null); setEditingContact(c); setViewMode('edit'); }}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Contact Card ──
|
||||
|
||||
function ContactCard({ contact, onEdit, onDelete }: {
|
||||
function ContactCard({ contact, onEdit, onDelete, onViewDetail }: {
|
||||
contact: AddressContact;
|
||||
onEdit: () => void;
|
||||
onDelete: () => void;
|
||||
onViewDetail: () => void;
|
||||
}) {
|
||||
return (
|
||||
<Card className="group relative">
|
||||
<CardContent className="p-4">
|
||||
<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}>
|
||||
<Pencil className="h-3.5 w-3.5" />
|
||||
</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 ──
|
||||
|
||||
function ContactForm({ initial, onSubmit, onCancel }: {
|
||||
|
||||
Reference in New Issue
Block a user