feat: address book - require name OR company, add department to contact persons

- Either name or company/organization is now required (not just name)
- When only company is set, it shows as primary display name
- Added department field to ContactPerson sub-entities
- Department shown as badge in card and detail views
- Updated vCard export to handle nameless contacts and department field
- Sort contacts by name or company (whichever is set)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
AI Assistant
2026-03-13 21:07:06 +02:00
parent 87ac81c6c9
commit 9d73697fb0
4 changed files with 40 additions and 13 deletions
@@ -286,9 +286,11 @@ function ContactCard({
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<div> <div>
<p className="font-medium">{contact.name}</p> <p className="font-medium">
{contact.name || contact.company}
</p>
<div className="flex flex-wrap items-center gap-1.5"> <div className="flex flex-wrap items-center gap-1.5">
{contact.company && ( {contact.company && contact.name && (
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
{contact.company} {contact.company}
</p> </p>
@@ -352,6 +354,7 @@ function ContactCard({
{contact.contactPersons.slice(0, 2).map((cp, i) => ( {contact.contactPersons.slice(0, 2).map((cp, i) => (
<p key={i} className="text-xs text-muted-foreground"> <p key={i} className="text-xs text-muted-foreground">
{cp.name} {cp.name}
{cp.department ? ` · ${cp.department}` : ""}
{cp.role ? `${cp.role}` : ""} {cp.role ? `${cp.role}` : ""}
</p> </p>
))} ))}
@@ -404,7 +407,7 @@ function ContactDetailDialog({
<DialogContent className="max-w-2xl max-h-[85vh] overflow-y-auto"> <DialogContent className="max-w-2xl max-h-[85vh] overflow-y-auto">
<DialogHeader> <DialogHeader>
<DialogTitle className="flex items-center gap-3"> <DialogTitle className="flex items-center gap-3">
<span>{contact.name}</span> <span>{contact.name || contact.company}</span>
<Badge variant="outline">{getTypeLabel(contact.type)}</Badge> <Badge variant="outline">{getTypeLabel(contact.type)}</Badge>
</DialogTitle> </DialogTitle>
</DialogHeader> </DialogHeader>
@@ -494,6 +497,11 @@ function ContactDetailDialog({
className="flex flex-wrap items-center gap-2 text-sm" className="flex flex-wrap items-center gap-2 text-sm"
> >
<span className="font-medium">{cp.name}</span> <span className="font-medium">{cp.name}</span>
{cp.department && (
<Badge variant="secondary" className="text-[10px]">
{cp.department}
</Badge>
)}
{cp.role && ( {cp.role && (
<span className="text-muted-foreground text-xs"> <span className="text-muted-foreground text-xs">
{cp.role} {cp.role}
@@ -720,7 +728,7 @@ function ContactForm({
const addContactPerson = () => { const addContactPerson = () => {
setContactPersons([ setContactPersons([
...contactPersons, ...contactPersons,
{ name: "", role: "", email: "", phone: "" }, { name: "", department: "", role: "", email: "", phone: "" },
]); ]);
}; };
@@ -752,6 +760,7 @@ function ContactForm({
<form <form
onSubmit={(e) => { onSubmit={(e) => {
e.preventDefault(); e.preventDefault();
if (!name.trim() && !company.trim()) return;
onSubmit({ onSubmit({
name, name,
company, company,
@@ -774,22 +783,28 @@ function ContactForm({
className="space-y-4" className="space-y-4"
> >
{/* Row 1: Name + Company + Type */} {/* Row 1: Name + Company + Type */}
{!name.trim() && !company.trim() && (
<p className="text-xs text-destructive">
Completează cel puțin Numele sau Compania/Organizația.
</p>
)}
<div className="grid gap-4 sm:grid-cols-3"> <div className="grid gap-4 sm:grid-cols-3">
<div> <div>
<Label>Nume *</Label> <Label>Nume {!company.trim() ? "*" : ""}</Label>
<Input <Input
value={name} value={name}
onChange={(e) => setName(e.target.value)} onChange={(e) => setName(e.target.value)}
className="mt-1" className="mt-1"
required required={!company.trim()}
/> />
</div> </div>
<div> <div>
<Label>Companie/Organizație</Label> <Label>Companie/Organizație {!name.trim() ? "*" : ""}</Label>
<Input <Input
value={company} value={company}
onChange={(e) => setCompany(e.target.value)} onChange={(e) => setCompany(e.target.value)}
className="mt-1" className="mt-1"
required={!name.trim()}
/> />
</div> </div>
<div> <div>
@@ -934,6 +949,14 @@ function ContactForm({
} }
className="min-w-[150px] flex-1 text-sm" className="min-w-[150px] flex-1 text-sm"
/> />
<Input
placeholder="Departament"
value={cp.department ?? ""}
onChange={(e) =>
updateContactPerson(i, "department", e.target.value)
}
className="w-[140px] text-sm"
/>
<Input <Input
placeholder="Funcție" placeholder="Funcție"
value={cp.role} value={cp.role}
@@ -30,7 +30,9 @@ export function useContacts() {
results.push(value as AddressContact); results.push(value as AddressContact);
} }
} }
results.sort((a, b) => a.name.localeCompare(b.name)); results.sort((a, b) =>
(a.name || a.company).localeCompare(b.name || b.company, "ro"),
);
setContacts(results); setContacts(results);
setLoading(false); setLoading(false);
}, [storage]); }, [storage]);
@@ -6,11 +6,12 @@ import type { AddressContact } from "../types";
export function downloadVCard(contact: AddressContact): void { export function downloadVCard(contact: AddressContact): void {
const lines: string[] = ["BEGIN:VCARD", "VERSION:3.0"]; const lines: string[] = ["BEGIN:VCARD", "VERSION:3.0"];
// Full name // Full name — use company as fallback if no personal name
lines.push(`FN:${esc(contact.name)}`); const displayName = contact.name || contact.company || "Contact";
lines.push(`FN:${esc(displayName)}`);
// Structured name — try to split first/last (best-effort) // Structured name — try to split first/last (best-effort)
const nameParts = contact.name.trim().split(/\s+/); const nameParts = displayName.trim().split(/\s+/);
const last = const last =
nameParts.length > 1 ? (nameParts[nameParts.length - 1] ?? "") : ""; nameParts.length > 1 ? (nameParts[nameParts.length - 1] ?? "") : "";
const first = const first =
@@ -64,7 +65,7 @@ export function downloadVCard(contact: AddressContact): void {
const cpNote = contact.contactPersons const cpNote = contact.contactPersons
.map( .map(
(cp) => (cp) =>
`${cp.name}${cp.role ? ` (${cp.role})` : ""}${cp.email ? ` <${cp.email}>` : ""}${cp.phone ? ` ${cp.phone}` : ""}`, `${cp.name}${cp.department ? ` [${cp.department}]` : ""}${cp.role ? ` (${cp.role})` : ""}${cp.email ? ` <${cp.email}>` : ""}${cp.phone ? ` ${cp.phone}` : ""}`,
) )
.join("; "); .join("; ");
lines.push(`NOTE:Persoane contact: ${esc(cpNote)}`); lines.push(`NOTE:Persoane contact: ${esc(cpNote)}`);
@@ -78,7 +79,7 @@ export function downloadVCard(contact: AddressContact): void {
const url = URL.createObjectURL(blob); const url = URL.createObjectURL(blob);
const a = document.createElement("a"); const a = document.createElement("a");
a.href = url; a.href = url;
a.download = `${contact.name a.download = `${displayName
.replace(/[^a-zA-Z0-9\s-]/g, "") .replace(/[^a-zA-Z0-9\s-]/g, "")
.trim() .trim()
.replace(/\s+/g, "_")}.vcf`; .replace(/\s+/g, "_")}.vcf`;
+1
View File
@@ -11,6 +11,7 @@ export type ContactType =
/** A contact person within an organization/entity */ /** A contact person within an organization/entity */
export interface ContactPerson { export interface ContactPerson {
name: string; name: string;
department: string;
role: string; role: string;
email: string; email: string;
phone: string; phone: string;