Compare commits

..

2 Commits

Author SHA1 Message Date
AI Assistant 67fd88813a docs: mark task 1.09 complete in ROADMAP and SESSION-LOG 2026-02-19 06:58:11 +02:00
AI Assistant da33dc9b81 feat(address-book): vCard export and Registratura reverse lookup 2026-02-19 06:57:40 +02:00
4 changed files with 275 additions and 4 deletions
+2 -2
View File
@@ -169,7 +169,7 @@
---
### 1.09 `[STANDARD]` Address Book — vCard Export + Registratura Reverse Lookup
### 1.09 `[STANDARD]` Address Book — vCard Export + Registratura Reverse Lookup
**What:**
@@ -182,7 +182,7 @@
**Files to create:**
- `src/modules/address-book/services/vcard-export.ts`
---
**Status:** ✅ Done. Created `vcard-export.ts` generating vCard 3.0 (.vcf) with name, org, title, phones, emails, address, website, notes, contact persons. Added Download icon button on card hover. Added detail dialog (FileText icon) showing full contact info + scrollable table of all Registratura entries where this contact appears as sender or recipient (uses `allEntries` to bypass filters). Build ok, pushed.
### 1.10 `[STANDARD]` Word Templates — Placeholder Auto-Detection
+24
View File
@@ -4,6 +4,30 @@
---
## Session — 2026-02-19 (GitHub Copilot - Claude Sonnet 4.6)
### Completed
- **Task 1.09: Address Book — vCard Export + Registratura Reverse Lookup** ✅
- Created `src/modules/address-book/services/vcard-export.ts` — generates vCard 3.0 (`.vcf`) with all contact fields
- Added Download icon button on contact card hover → triggers `.vcf` file download
- Added FileText (detail) icon button → opens `ContactDetailDialog`
- `ContactDetailDialog` shows full contact info, contact persons, notes, and scrollable Registratura table
- Registratura reverse lookup uses `allEntries` (bypasses active filters) and matches `senderContactId` or `recipientContactId`
- Build passes zero errors
### Commits
- `da33dc9` feat(address-book): vCard export and Registratura reverse lookup
### Notes
- Build verified: `npx next build` → ✓ Compiled successfully
- Push pending — see below
- Next task: **1.10** — Word Templates Placeholder Auto-Detection
---
## Session — 2026-02-19 (GitHub Copilot - Haiku 4.5)
### Completed
@@ -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 }: {
@@ -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');
}