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 className="space-y-2">
<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">
{contact.company && (
{contact.company && contact.name && (
<p className="text-xs text-muted-foreground">
{contact.company}
</p>
@@ -352,6 +354,7 @@ function ContactCard({
{contact.contactPersons.slice(0, 2).map((cp, i) => (
<p key={i} className="text-xs text-muted-foreground">
{cp.name}
{cp.department ? ` · ${cp.department}` : ""}
{cp.role ? `${cp.role}` : ""}
</p>
))}
@@ -404,7 +407,7 @@ function ContactDetailDialog({
<DialogContent className="max-w-2xl max-h-[85vh] overflow-y-auto">
<DialogHeader>
<DialogTitle className="flex items-center gap-3">
<span>{contact.name}</span>
<span>{contact.name || contact.company}</span>
<Badge variant="outline">{getTypeLabel(contact.type)}</Badge>
</DialogTitle>
</DialogHeader>
@@ -494,6 +497,11 @@ function ContactDetailDialog({
className="flex flex-wrap items-center gap-2 text-sm"
>
<span className="font-medium">{cp.name}</span>
{cp.department && (
<Badge variant="secondary" className="text-[10px]">
{cp.department}
</Badge>
)}
{cp.role && (
<span className="text-muted-foreground text-xs">
{cp.role}
@@ -720,7 +728,7 @@ function ContactForm({
const addContactPerson = () => {
setContactPersons([
...contactPersons,
{ name: "", role: "", email: "", phone: "" },
{ name: "", department: "", role: "", email: "", phone: "" },
]);
};
@@ -752,6 +760,7 @@ function ContactForm({
<form
onSubmit={(e) => {
e.preventDefault();
if (!name.trim() && !company.trim()) return;
onSubmit({
name,
company,
@@ -774,22 +783,28 @@ function ContactForm({
className="space-y-4"
>
{/* 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>
<Label>Nume *</Label>
<Label>Nume {!company.trim() ? "*" : ""}</Label>
<Input
value={name}
onChange={(e) => setName(e.target.value)}
className="mt-1"
required
required={!company.trim()}
/>
</div>
<div>
<Label>Companie/Organizație</Label>
<Label>Companie/Organizație {!name.trim() ? "*" : ""}</Label>
<Input
value={company}
onChange={(e) => setCompany(e.target.value)}
className="mt-1"
required={!name.trim()}
/>
</div>
<div>
@@ -934,6 +949,14 @@ function ContactForm({
}
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
placeholder="Funcție"
value={cp.role}
@@ -30,7 +30,9 @@ export function useContacts() {
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);
setLoading(false);
}, [storage]);
@@ -6,11 +6,12 @@ import type { AddressContact } from "../types";
export function downloadVCard(contact: AddressContact): void {
const lines: string[] = ["BEGIN:VCARD", "VERSION:3.0"];
// Full name
lines.push(`FN:${esc(contact.name)}`);
// Full name — use company as fallback if no personal name
const displayName = contact.name || contact.company || "Contact";
lines.push(`FN:${esc(displayName)}`);
// Structured name — try to split first/last (best-effort)
const nameParts = contact.name.trim().split(/\s+/);
const nameParts = displayName.trim().split(/\s+/);
const last =
nameParts.length > 1 ? (nameParts[nameParts.length - 1] ?? "") : "";
const first =
@@ -64,7 +65,7 @@ export function downloadVCard(contact: AddressContact): void {
const cpNote = contact.contactPersons
.map(
(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("; ");
lines.push(`NOTE:Persoane contact: ${esc(cpNote)}`);
@@ -78,7 +79,7 @@ export function downloadVCard(contact: AddressContact): void {
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = `${contact.name
a.download = `${displayName
.replace(/[^a-zA-Z0-9\s-]/g, "")
.trim()
.replace(/\s+/g, "_")}.vcf`;
+1
View File
@@ -11,6 +11,7 @@ export type ContactType =
/** A contact person within an organization/entity */
export interface ContactPerson {
name: string;
department: string;
role: string;
email: string;
phone: string;