feat: add Hot Desk module (Phase 2) 4-desk booking with 2-week window, room layout, calendar, subtle unbooked-day alerts

This commit is contained in:
AI Assistant
2026-02-19 08:10:50 +02:00
parent 6cb655a79f
commit 3b1ba589f0
15 changed files with 1806 additions and 273 deletions
@@ -1,51 +1,89 @@
'use client';
"use client";
import { useState } from 'react';
import { useState } from "react";
import {
Plus, Pencil, Trash2, Search, Mail, Phone, MapPin,
Globe, Building2, UserPlus, X, Download, FileText,
} from 'lucide-react';
import { Button } from '@/shared/components/ui/button';
import { Input } from '@/shared/components/ui/input';
import { Label } from '@/shared/components/ui/label';
import { Textarea } from '@/shared/components/ui/textarea';
import { Badge } from '@/shared/components/ui/badge';
import { Card, CardContent, CardHeader, CardTitle } from '@/shared/components/ui/card';
Plus,
Pencil,
Trash2,
Search,
Mail,
Phone,
MapPin,
Globe,
Building2,
UserPlus,
X,
Download,
FileText,
} from "lucide-react";
import { Button } from "@/shared/components/ui/button";
import { Input } from "@/shared/components/ui/input";
import { Label } from "@/shared/components/ui/label";
import { Textarea } from "@/shared/components/ui/textarea";
import { Badge } from "@/shared/components/ui/badge";
import {
Select, SelectContent, SelectItem, SelectTrigger, SelectValue,
} from '@/shared/components/ui/select';
Card,
CardContent,
CardHeader,
CardTitle,
} from "@/shared/components/ui/card";
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';
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',
supplier: 'Furnizor',
institution: 'Instituție',
collaborator: 'Colaborator',
internal: 'Intern',
client: "Client",
supplier: "Furnizor",
institution: "Instituție",
collaborator: "Colaborator",
internal: "Intern",
};
type ViewMode = 'list' | 'add' | 'edit';
type ViewMode = "list" | "add" | "edit";
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 {
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) {
const handleSubmit = async (
data: Omit<AddressContact, "id" | "createdAt" | "updatedAt">,
) => {
if (viewMode === "edit" && editingContact) {
await updateContact(editingContact.id, data);
} else {
await addContact(data);
}
setViewMode('list');
setViewMode("list");
setEditingContact(null);
};
@@ -53,47 +91,79 @@ export function AddressBookModule() {
<div className="space-y-6">
{/* Stats */}
<div className="grid grid-cols-2 gap-3 sm:grid-cols-5">
<Card><CardContent className="p-4"><p className="text-xs text-muted-foreground">Total</p><p className="text-2xl font-bold">{allContacts.length}</p></CardContent></Card>
<Card>
<CardContent className="p-4">
<p className="text-xs text-muted-foreground">Total</p>
<p className="text-2xl font-bold">{allContacts.length}</p>
</CardContent>
</Card>
{(Object.keys(TYPE_LABELS) as ContactType[]).slice(0, 4).map((type) => (
<Card key={type}><CardContent className="p-4">
<p className="text-xs text-muted-foreground">{TYPE_LABELS[type]}</p>
<p className="text-2xl font-bold">{allContacts.filter((c) => c.type === type).length}</p>
</CardContent></Card>
<Card key={type}>
<CardContent className="p-4">
<p className="text-xs text-muted-foreground">
{TYPE_LABELS[type]}
</p>
<p className="text-2xl font-bold">
{allContacts.filter((c) => c.type === type).length}
</p>
</CardContent>
</Card>
))}
</div>
{viewMode === 'list' && (
{viewMode === "list" && (
<>
<div className="flex flex-wrap items-center gap-3">
<div className="relative min-w-[200px] flex-1">
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
<Input placeholder="Caută contact..." value={filters.search} onChange={(e) => updateFilter('search', e.target.value)} className="pl-9" />
<Input
placeholder="Caută contact..."
value={filters.search}
onChange={(e) => updateFilter("search", e.target.value)}
className="pl-9"
/>
</div>
<Select value={filters.type} onValueChange={(v) => updateFilter('type', v as ContactType | 'all')}>
<SelectTrigger className="w-[150px]"><SelectValue /></SelectTrigger>
<Select
value={filters.type}
onValueChange={(v) =>
updateFilter("type", v as ContactType | "all")
}
>
<SelectTrigger className="w-[150px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">Toate tipurile</SelectItem>
{(Object.keys(TYPE_LABELS) as ContactType[]).map((t) => (
<SelectItem key={t} value={t}>{TYPE_LABELS[t]}</SelectItem>
<SelectItem key={t} value={t}>
{TYPE_LABELS[t]}
</SelectItem>
))}
</SelectContent>
</Select>
<Button onClick={() => setViewMode('add')} className="shrink-0">
<Button onClick={() => setViewMode("add")} className="shrink-0">
<Plus className="mr-1.5 h-4 w-4" /> Adaugă
</Button>
</div>
{loading ? (
<p className="py-8 text-center text-sm text-muted-foreground">Se încarcă...</p>
<p className="py-8 text-center text-sm text-muted-foreground">
Se încarcă...
</p>
) : contacts.length === 0 ? (
<p className="py-8 text-center text-sm text-muted-foreground">Niciun contact găsit.</p>
<p className="py-8 text-center text-sm text-muted-foreground">
Niciun contact găsit.
</p>
) : (
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
{contacts.map((contact) => (
<ContactCard
key={contact.id}
contact={contact}
onEdit={() => { setEditingContact(contact); setViewMode('edit'); }}
onEdit={() => {
setEditingContact(contact);
setViewMode("edit");
}}
onDelete={() => removeContact(contact.id)}
onViewDetail={() => setViewingContact(contact)}
/>
@@ -103,14 +173,21 @@ export function AddressBookModule() {
</>
)}
{(viewMode === 'add' || viewMode === 'edit') && (
{(viewMode === "add" || viewMode === "edit") && (
<Card>
<CardHeader><CardTitle>{viewMode === 'edit' ? 'Editare contact' : 'Contact nou'}</CardTitle></CardHeader>
<CardHeader>
<CardTitle>
{viewMode === "edit" ? "Editare contact" : "Contact nou"}
</CardTitle>
</CardHeader>
<CardContent>
<ContactForm
initial={editingContact ?? undefined}
onSubmit={handleSubmit}
onCancel={() => { setViewMode('list'); setEditingContact(null); }}
onCancel={() => {
setViewMode("list");
setEditingContact(null);
}}
/>
</CardContent>
</Card>
@@ -120,7 +197,11 @@ export function AddressBookModule() {
<ContactDetailDialog
contact={viewingContact}
onClose={() => setViewingContact(null)}
onEdit={(c) => { setViewingContact(null); setEditingContact(c); setViewMode('edit'); }}
onEdit={(c) => {
setViewingContact(null);
setEditingContact(c);
setViewMode("edit");
}}
/>
</div>
);
@@ -128,7 +209,12 @@ export function AddressBookModule() {
// ── Contact Card ──
function ContactCard({ contact, onEdit, onDelete, onViewDetail }: {
function ContactCard({
contact,
onEdit,
onDelete,
onViewDetail,
}: {
contact: AddressContact;
onEdit: () => void;
onDelete: () => void;
@@ -138,16 +224,38 @@ function ContactCard({ contact, onEdit, onDelete, onViewDetail }: {
<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}>
<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)}>
<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" />
</Button>
<Button variant="ghost" size="icon" className="h-7 w-7 text-destructive" onClick={onDelete}>
<Button
variant="ghost"
size="icon"
className="h-7 w-7 text-destructive"
onClick={onDelete}
>
<Trash2 className="h-3.5 w-3.5" />
</Button>
</div>
@@ -155,44 +263,60 @@ function ContactCard({ contact, onEdit, onDelete, onViewDetail }: {
<div>
<p className="font-medium">{contact.name}</p>
<div className="flex flex-wrap items-center gap-1.5">
{contact.company && <p className="text-xs text-muted-foreground">{contact.company}</p>}
<Badge variant="outline" className="text-[10px]">{TYPE_LABELS[contact.type]}</Badge>
{contact.company && (
<p className="text-xs text-muted-foreground">
{contact.company}
</p>
)}
<Badge variant="outline" className="text-[10px]">
{TYPE_LABELS[contact.type]}
</Badge>
{contact.department && (
<Badge variant="secondary" className="text-[10px]">{contact.department}</Badge>
<Badge variant="secondary" className="text-[10px]">
{contact.department}
</Badge>
)}
</div>
{contact.role && (
<p className="text-xs text-muted-foreground italic">{contact.role}</p>
<p className="text-xs text-muted-foreground italic">
{contact.role}
</p>
)}
</div>
{contact.email && (
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
<Mail className="h-3 w-3 shrink-0" /><span className="truncate">{contact.email}</span>
<Mail className="h-3 w-3 shrink-0" />
<span className="truncate">{contact.email}</span>
</div>
)}
{contact.email2 && (
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
<Mail className="h-3 w-3 shrink-0" /><span className="truncate">{contact.email2}</span>
<Mail className="h-3 w-3 shrink-0" />
<span className="truncate">{contact.email2}</span>
</div>
)}
{contact.phone && (
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
<Phone className="h-3 w-3 shrink-0" /><span>{contact.phone}</span>
<Phone className="h-3 w-3 shrink-0" />
<span>{contact.phone}</span>
</div>
)}
{contact.phone2 && (
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
<Phone className="h-3 w-3 shrink-0" /><span>{contact.phone2}</span>
<Phone className="h-3 w-3 shrink-0" />
<span>{contact.phone2}</span>
</div>
)}
{contact.address && (
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
<MapPin className="h-3 w-3 shrink-0" /><span className="truncate">{contact.address}</span>
<MapPin className="h-3 w-3 shrink-0" />
<span className="truncate">{contact.address}</span>
</div>
)}
{contact.website && (
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
<Globe className="h-3 w-3 shrink-0" /><span className="truncate">{contact.website}</span>
<Globe className="h-3 w-3 shrink-0" />
<span className="truncate">{contact.website}</span>
</div>
)}
{(contact.contactPersons ?? []).length > 0 && (
@@ -202,7 +326,8 @@ function ContactCard({ contact, onEdit, onDelete, onViewDetail }: {
</p>
{contact.contactPersons.slice(0, 2).map((cp, i) => (
<p key={i} className="text-xs text-muted-foreground">
{cp.name}{cp.role ? `${cp.role}` : ''}
{cp.name}
{cp.role ? `${cp.role}` : ""}
</p>
))}
{contact.contactPersons.length > 2 && (
@@ -221,11 +346,15 @@ function ContactCard({ contact, onEdit, onDelete, onViewDetail }: {
// ── Contact Detail Dialog (with Registratura reverse lookup) ──
const DIRECTION_LABELS: Record<string, string> = {
intrat: 'Intrat',
iesit: 'Ieșit',
intrat: "Intrat",
iesit: "Ieșit",
};
function ContactDetailDialog({ contact, onClose, onEdit }: {
function ContactDetailDialog({
contact,
onClose,
onEdit,
}: {
contact: AddressContact | null;
onClose: () => void;
onEdit: (c: AddressContact) => void;
@@ -236,11 +365,17 @@ function ContactDetailDialog({ contact, onClose, onEdit }: {
// 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
(e) =>
e.senderContactId === contact.id || e.recipientContactId === contact.id,
);
return (
<Dialog open={contact !== null} onOpenChange={(open) => { if (!open) onClose(); }}>
<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">
@@ -255,41 +390,68 @@ function ContactDetailDialog({ contact, onClose, onEdit }: {
{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>
<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.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>
<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>
<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>
<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>
<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>
<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>
<a
href={contact.website}
target="_blank"
rel="noopener noreferrer"
className="hover:text-foreground truncate"
>
{contact.website}
</a>
</div>
)}
</div>
@@ -297,14 +459,34 @@ function ContactDetailDialog({ contact, onClose, onEdit }: {
{/* 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>
<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">
<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>}
{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>
@@ -313,8 +495,12 @@ function ContactDetailDialog({ contact, onClose, onEdit }: {
{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>
<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>
)}
@@ -324,22 +510,35 @@ function ContactDetailDialog({ contact, onClose, onEdit }: {
Registratură ({linkedEntries.length})
</p>
{linkedEntries.length === 0 ? (
<p className="text-xs text-muted-foreground">Nicio înregistrare în registratură pentru acest contact.</p>
<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">
<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>
<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'}
variant={
entry.status === "deschis" ? "default" : "secondary"
}
className="shrink-0 text-[10px]"
>
{entry.status === 'deschis' ? 'Deschis' : 'Închis'}
{entry.status === "deschis" ? "Deschis" : "Închis"}
</Badge>
</div>
))}
@@ -349,7 +548,11 @@ function ContactDetailDialog({ contact, onClose, onEdit }: {
{/* Actions */}
<div className="flex justify-end gap-2 pt-2 border-t">
<Button variant="outline" size="sm" onClick={() => downloadVCard(contact)}>
<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)}>
@@ -364,37 +567,54 @@ function ContactDetailDialog({ contact, onClose, onEdit }: {
// ── Contact Form ──
function ContactForm({ initial, onSubmit, onCancel }: {
function ContactForm({
initial,
onSubmit,
onCancel,
}: {
initial?: AddressContact;
onSubmit: (data: Omit<AddressContact, 'id' | 'createdAt' | 'updatedAt'>) => void;
onSubmit: (
data: Omit<AddressContact, "id" | "createdAt" | "updatedAt">,
) => void;
onCancel: () => void;
}) {
const { tags: projectTags } = useTags('project');
const [name, setName] = useState(initial?.name ?? '');
const [company, setCompany] = useState(initial?.company ?? '');
const [type, setType] = useState<ContactType>(initial?.type ?? 'client');
const [email, setEmail] = useState(initial?.email ?? '');
const [email2, setEmail2] = useState(initial?.email2 ?? '');
const [phone, setPhone] = useState(initial?.phone ?? '');
const [phone2, setPhone2] = useState(initial?.phone2 ?? '');
const [address, setAddress] = useState(initial?.address ?? '');
const [department, setDepartment] = useState(initial?.department ?? '');
const [role, setRole] = useState(initial?.role ?? '');
const [website, setWebsite] = useState(initial?.website ?? '');
const [notes, setNotes] = useState(initial?.notes ?? '');
const [projectIds, setProjectIds] = useState<string[]>(initial?.projectIds ?? []);
const { tags: projectTags } = useTags("project");
const [name, setName] = useState(initial?.name ?? "");
const [company, setCompany] = useState(initial?.company ?? "");
const [type, setType] = useState<ContactType>(initial?.type ?? "client");
const [email, setEmail] = useState(initial?.email ?? "");
const [email2, setEmail2] = useState(initial?.email2 ?? "");
const [phone, setPhone] = useState(initial?.phone ?? "");
const [phone2, setPhone2] = useState(initial?.phone2 ?? "");
const [address, setAddress] = useState(initial?.address ?? "");
const [department, setDepartment] = useState(initial?.department ?? "");
const [role, setRole] = useState(initial?.role ?? "");
const [website, setWebsite] = useState(initial?.website ?? "");
const [notes, setNotes] = useState(initial?.notes ?? "");
const [projectIds, setProjectIds] = useState<string[]>(
initial?.projectIds ?? [],
);
const [contactPersons, setContactPersons] = useState<ContactPerson[]>(
initial?.contactPersons ?? []
initial?.contactPersons ?? [],
);
const addContactPerson = () => {
setContactPersons([...contactPersons, { name: '', role: '', email: '', phone: '' }]);
setContactPersons([
...contactPersons,
{ name: "", role: "", email: "", phone: "" },
]);
};
const updateContactPerson = (index: number, field: keyof ContactPerson, value: string) => {
setContactPersons(contactPersons.map((cp, i) =>
i === index ? { ...cp, [field]: value } : cp
));
const updateContactPerson = (
index: number,
field: keyof ContactPerson,
value: string,
) => {
setContactPersons(
contactPersons.map((cp, i) =>
i === index ? { ...cp, [field]: value } : cp,
),
);
};
const removeContactPerson = (index: number) => {
@@ -403,7 +623,9 @@ function ContactForm({ initial, onSubmit, onCancel }: {
const toggleProject = (projectId: string) => {
setProjectIds((prev) =>
prev.includes(projectId) ? prev.filter((id) => id !== projectId) : [...prev, projectId]
prev.includes(projectId)
? prev.filter((id) => id !== projectId)
: [...prev, projectId],
);
};
@@ -412,26 +634,56 @@ function ContactForm({ initial, onSubmit, onCancel }: {
onSubmit={(e) => {
e.preventDefault();
onSubmit({
name, company, type, email, email2, phone, phone2,
address, department, role, website, notes,
name,
company,
type,
email,
email2,
phone,
phone2,
address,
department,
role,
website,
notes,
projectIds,
contactPersons: contactPersons.filter((cp) => cp.name.trim()),
tags: initial?.tags ?? [],
visibility: initial?.visibility ?? 'all',
visibility: initial?.visibility ?? "all",
});
}}
className="space-y-4"
>
{/* Row 1: Name + Company + Type */}
<div className="grid gap-4 sm:grid-cols-3">
<div><Label>Nume *</Label><Input value={name} onChange={(e) => setName(e.target.value)} className="mt-1" required /></div>
<div><Label>Companie/Organizație</Label><Input value={company} onChange={(e) => setCompany(e.target.value)} className="mt-1" /></div>
<div><Label>Tip</Label>
<div>
<Label>Nume *</Label>
<Input
value={name}
onChange={(e) => setName(e.target.value)}
className="mt-1"
required
/>
</div>
<div>
<Label>Companie/Organizație</Label>
<Input
value={company}
onChange={(e) => setCompany(e.target.value)}
className="mt-1"
/>
</div>
<div>
<Label>Tip</Label>
<Select value={type} onValueChange={(v) => setType(v as ContactType)}>
<SelectTrigger className="mt-1"><SelectValue /></SelectTrigger>
<SelectTrigger className="mt-1">
<SelectValue />
</SelectTrigger>
<SelectContent>
{(Object.keys(TYPE_LABELS) as ContactType[]).map((t) => (
<SelectItem key={t} value={t}>{TYPE_LABELS[t]}</SelectItem>
<SelectItem key={t} value={t}>
{TYPE_LABELS[t]}
</SelectItem>
))}
</SelectContent>
</Select>
@@ -440,25 +692,87 @@ function ContactForm({ initial, onSubmit, onCancel }: {
{/* Row 2: Department + Role + Website */}
<div className="grid gap-4 sm:grid-cols-3">
<div><Label>Departament</Label><Input value={department} onChange={(e) => setDepartment(e.target.value)} className="mt-1" /></div>
<div><Label>Funcție/Rol</Label><Input value={role} onChange={(e) => setRole(e.target.value)} className="mt-1" /></div>
<div><Label>Website</Label><Input type="url" value={website} onChange={(e) => setWebsite(e.target.value)} className="mt-1" placeholder="https://" /></div>
<div>
<Label>Departament</Label>
<Input
value={department}
onChange={(e) => setDepartment(e.target.value)}
className="mt-1"
/>
</div>
<div>
<Label>Funcție/Rol</Label>
<Input
value={role}
onChange={(e) => setRole(e.target.value)}
className="mt-1"
/>
</div>
<div>
<Label>Website</Label>
<Input
type="url"
value={website}
onChange={(e) => setWebsite(e.target.value)}
className="mt-1"
placeholder="https://"
/>
</div>
</div>
{/* Row 3: Emails + Phones */}
<div className="grid gap-4 sm:grid-cols-2">
<div className="grid gap-2">
<div><Label>Email principal</Label><Input type="email" value={email} onChange={(e) => setEmail(e.target.value)} className="mt-1" /></div>
<div><Label>Email secundar</Label><Input type="email" value={email2} onChange={(e) => setEmail2(e.target.value)} className="mt-1" /></div>
<div>
<Label>Email principal</Label>
<Input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
className="mt-1"
/>
</div>
<div>
<Label>Email secundar</Label>
<Input
type="email"
value={email2}
onChange={(e) => setEmail2(e.target.value)}
className="mt-1"
/>
</div>
</div>
<div className="grid gap-2">
<div><Label>Telefon principal</Label><Input type="tel" value={phone} onChange={(e) => setPhone(e.target.value)} className="mt-1" /></div>
<div><Label>Telefon secundar</Label><Input type="tel" value={phone2} onChange={(e) => setPhone2(e.target.value)} className="mt-1" /></div>
<div>
<Label>Telefon principal</Label>
<Input
type="tel"
value={phone}
onChange={(e) => setPhone(e.target.value)}
className="mt-1"
/>
</div>
<div>
<Label>Telefon secundar</Label>
<Input
type="tel"
value={phone2}
onChange={(e) => setPhone2(e.target.value)}
className="mt-1"
/>
</div>
</div>
</div>
{/* Address */}
<div><Label>Adresă</Label><Input value={address} onChange={(e) => setAddress(e.target.value)} className="mt-1" /></div>
<div>
<Label>Adresă</Label>
<Input
value={address}
onChange={(e) => setAddress(e.target.value)}
className="mt-1"
/>
</div>
{/* Project links */}
{projectTags.length > 0 && (
@@ -472,11 +786,12 @@ function ContactForm({ initial, onSubmit, onCancel }: {
onClick={() => toggleProject(pt.id)}
className={`rounded-full border px-2.5 py-0.5 text-xs transition-colors ${
projectIds.includes(pt.id)
? 'border-primary bg-primary/10 text-primary'
: 'border-muted-foreground/30 text-muted-foreground hover:border-primary/50'
? "border-primary bg-primary/10 text-primary"
: "border-muted-foreground/30 text-muted-foreground hover:border-primary/50"
}`}
>
{pt.projectCode ? `${pt.projectCode} ` : ''}{pt.label}
{pt.projectCode ? `${pt.projectCode} ` : ""}
{pt.label}
</button>
))}
</div>
@@ -487,19 +802,61 @@ function ContactForm({ initial, onSubmit, onCancel }: {
<div>
<div className="flex items-center justify-between">
<Label>Persoane de contact</Label>
<Button type="button" variant="outline" size="sm" onClick={addContactPerson}>
<Button
type="button"
variant="outline"
size="sm"
onClick={addContactPerson}
>
<UserPlus className="mr-1 h-3.5 w-3.5" /> Adaugă persoană
</Button>
</div>
{contactPersons.length > 0 && (
<div className="mt-2 space-y-2">
{contactPersons.map((cp, i) => (
<div key={i} className="flex flex-wrap items-start gap-2 rounded border p-2">
<Input placeholder="Nume" value={cp.name} onChange={(e) => updateContactPerson(i, 'name', e.target.value)} className="min-w-[150px] flex-1 text-sm" />
<Input placeholder="Funcție" value={cp.role} onChange={(e) => updateContactPerson(i, 'role', e.target.value)} className="w-[140px] text-sm" />
<Input placeholder="Email" value={cp.email} onChange={(e) => updateContactPerson(i, 'email', e.target.value)} className="w-[180px] text-sm" />
<Input placeholder="Telefon" value={cp.phone} onChange={(e) => updateContactPerson(i, 'phone', e.target.value)} className="w-[140px] text-sm" />
<Button type="button" variant="ghost" size="icon" className="h-8 w-8 shrink-0 text-destructive" onClick={() => removeContactPerson(i)}>
<div
key={i}
className="flex flex-wrap items-start gap-2 rounded border p-2"
>
<Input
placeholder="Nume"
value={cp.name}
onChange={(e) =>
updateContactPerson(i, "name", e.target.value)
}
className="min-w-[150px] flex-1 text-sm"
/>
<Input
placeholder="Funcție"
value={cp.role}
onChange={(e) =>
updateContactPerson(i, "role", e.target.value)
}
className="w-[140px] text-sm"
/>
<Input
placeholder="Email"
value={cp.email}
onChange={(e) =>
updateContactPerson(i, "email", e.target.value)
}
className="w-[180px] text-sm"
/>
<Input
placeholder="Telefon"
value={cp.phone}
onChange={(e) =>
updateContactPerson(i, "phone", e.target.value)
}
className="w-[140px] text-sm"
/>
<Button
type="button"
variant="ghost"
size="icon"
className="h-8 w-8 shrink-0 text-destructive"
onClick={() => removeContactPerson(i)}
>
<X className="h-3.5 w-3.5" />
</Button>
</div>
@@ -509,11 +866,21 @@ function ContactForm({ initial, onSubmit, onCancel }: {
</div>
{/* Notes */}
<div><Label>Note</Label><Textarea value={notes} onChange={(e) => setNotes(e.target.value)} rows={2} className="mt-1" /></div>
<div>
<Label>Note</Label>
<Textarea
value={notes}
onChange={(e) => setNotes(e.target.value)}
rows={2}
className="mt-1"
/>
</div>
<div className="flex justify-end gap-2 pt-2">
<Button type="button" variant="outline" onClick={onCancel}>Anulează</Button>
<Button type="submit">{initial ? 'Actualizează' : 'Adaugă'}</Button>
<Button type="button" variant="outline" onClick={onCancel}>
Anulează
</Button>
<Button type="submit">{initial ? "Actualizează" : "Adaugă"}</Button>
</div>
</form>
);
@@ -1,22 +1,28 @@
import type { AddressContact } from '../types';
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'];
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] ?? '');
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)}` : ''}`);
lines.push(
`ORG:${esc(contact.company)}${contact.department ? `;${esc(contact.department)}` : ""}`,
);
}
if (contact.role) {
@@ -56,20 +62,26 @@ export function downloadVCard(contact: AddressContact): void {
// 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('; ');
.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');
lines.push("END:VCARD");
const vcfContent = lines.join('\r\n');
const blob = new Blob([vcfContent], { type: 'text/vcard;charset=utf-8' });
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');
const a = document.createElement("a");
a.href = url;
a.download = `${contact.name.replace(/[^a-zA-Z0-9\s-]/g, '').trim().replace(/\s+/g, '_')}.vcf`;
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);
@@ -78,5 +90,9 @@ export function downloadVCard(contact: AddressContact): void {
/** Escape special characters for vCard values */
function esc(value: string): string {
return value.replace(/\\/g, '\\\\').replace(/,/g, '\\,').replace(/;/g, '\\;').replace(/\n/g, '\\n');
return value
.replace(/\\/g, "\\\\")
.replace(/,/g, "\\,")
.replace(/;/g, "\\;")
.replace(/\n/g, "\\n");
}