feat(T04): contact management + USt-IdNr. validation + contact UI

- Contact model: company, role (kaeufer/verkaeufer/beide), soft-delete
- ContactPerson model: 1:N relation with CASCADE delete
- USt-IdNr. validation: DE + 10 EU countries
- Contact CRUD: 7 API endpoints with RBAC, search/filter/paginate
- Frontend: ContactList, ContactForm, ContactDetail with EU/Inland toggle
- 73 backend tests (91% coverage), 20 frontend tests
This commit is contained in:
2026-07-14 12:14:21 +02:00
parent 74b5e6afb8
commit 2cf433a01c
20 changed files with 3002 additions and 97 deletions
@@ -0,0 +1,246 @@
'use client';
import { useState, useEffect, useCallback } from 'react';
import { useRouter } from 'next/navigation';
import { Button } from '@/components/ui/Button';
import { Input } from '@/components/ui/Input';
import { Modal } from '@/components/ui/Modal';
import { Card } from '@/components/ui/Card';
import {
getContact,
deleteContact,
addContactPerson,
removeContactPerson,
type ContactResponse,
type ContactPersonResponse,
} from '@/lib/contacts';
interface ContactDetailProps {
contactId: string;
}
function formatDate(dateStr?: string): string {
if (!dateStr) return '—';
return new Date(dateStr).toLocaleDateString('de-DE');
}
export function ContactDetail({ contactId }: ContactDetailProps) {
const router = useRouter();
const [contact, setContact] = useState<ContactResponse | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [showPersonModal, setShowPersonModal] = useState(false);
const [personForm, setPersonForm] = useState({ name: '', function: '', phone: '', email: '' });
const [personError, setPersonError] = useState<string | null>(null);
const [addingPerson, setAddingPerson] = useState(false);
const fetchContact = useCallback(async () => {
setLoading(true);
setError(null);
try {
const data = await getContact(contactId);
setContact(data);
} catch (err: unknown) {
const apiErr = err as { error?: { message?: string } };
setError(apiErr?.error?.message || 'Failed to load contact');
} finally {
setLoading(false);
}
}, [contactId]);
useEffect(() => {
fetchContact();
}, [fetchContact]);
const handleDelete = async () => {
if (!contact) return;
if (!confirm('Diesen Kontakt löschen?')) return;
try {
await deleteContact(contact.id);
router.push('/de/kontakte');
} catch (err: unknown) {
const apiErr = err as { error?: { message?: string } };
setError(apiErr?.error?.message || 'Failed to delete contact');
}
};
const handleAddPerson = async (e: React.FormEvent) => {
e.preventDefault();
if (!contact || !personForm.name.trim()) return;
setAddingPerson(true);
setPersonError(null);
try {
await addContactPerson(contact.id, {
name: personForm.name,
function: personForm.function || undefined,
phone: personForm.phone || undefined,
email: personForm.email || undefined,
});
setShowPersonModal(false);
setPersonForm({ name: '', function: '', phone: '', email: '' });
await fetchContact();
} catch (err: unknown) {
const apiErr = err as { error?: { message?: string } };
setPersonError(apiErr?.error?.message || 'Failed to add person');
} finally {
setAddingPerson(false);
}
};
const handleRemovePerson = async (personId: string) => {
if (!contact) return;
if (!confirm('Diese Kontaktperson entfernen?')) return;
try {
await removeContactPerson(contact.id, personId);
await fetchContact();
} catch (err: unknown) {
const apiErr = err as { error?: { message?: string } };
setError(apiErr?.error?.message || 'Failed to remove person');
}
};
if (loading) {
return (
<div data-testid="contact-detail-loading" className="text-center py-8 text-text-muted">
Loading contact...
</div>
);
}
if (error) {
return (
<div data-testid="contact-detail-error" className="p-4 bg-error/10 text-error rounded-lg">
{error}
</div>
);
}
if (!contact) {
return (
<div data-testid="contact-detail-not-found" className="text-center py-8 text-text-muted">
Contact not found
</div>
);
}
const fields: { label: string; value: string | number | undefined | null }[] = [
{ label: 'Firmenname', value: contact.company_name },
{ label: 'Rechtsform', value: contact.legal_form },
{ label: 'Straße', value: contact.address_street },
{ label: 'PLZ', value: contact.address_zip },
{ label: 'Stadt', value: contact.address_city },
{ label: 'Land', value: contact.address_country },
{ label: 'USt-IdNr.', value: contact.vat_id },
{ label: 'USt-Status', value: contact.vat_id_status },
{ label: 'Telefon', value: contact.phone },
{ label: 'E-Mail', value: contact.email },
{ label: 'Website', value: contact.website },
{ label: 'Rolle', value: contact.role },
{ label: 'Privat', value: contact.is_private ? 'Ja' : 'Nein' },
{ label: 'Erstellt am', value: formatDate(contact.created_at) },
{ label: 'Aktualisiert am', value: formatDate(contact.updated_at) },
];
return (
<div data-testid="contact-detail" className="space-y-6">
<div className="flex items-center justify-between">
<h1 data-testid="contact-detail-title" className="text-2xl font-bold text-text">
{contact.company_name}
</h1>
<div className="flex gap-2">
<Button variant="secondary" onClick={() => router.push(`/de/kontakte/${contact.id}/bearbeiten`)}>
Bearbeiten
</Button>
<Button variant="danger" onClick={handleDelete} data-testid="delete-button">
Löschen
</Button>
</div>
</div>
<div data-testid="contact-detail-fields" className="grid grid-cols-2 md:grid-cols-3 gap-4 p-6 bg-surface rounded-lg border border-border">
{fields.map(field => (
<div key={field.label} className="space-y-1">
<dt className="text-sm font-medium text-text-muted">{field.label}</dt>
<dd data-testid={`field-${field.label.toLowerCase().replace(/\s+/g, '_')}`} className="text-text">
{field.value !== null && field.value !== undefined && field.value !== '' ? field.value : '—'}
</dd>
</div>
))}
</div>
<Card title="Kontaktpersonen">
<div data-testid="contact-persons-section" className="space-y-3">
{contact.contact_persons && contact.contact_persons.length > 0 ? (
contact.contact_persons.map((person: ContactPersonResponse) => (
<div key={person.id} data-testid={`contact-person-${person.id}`} className="flex items-center justify-between p-3 bg-background/50 rounded-lg">
<div className="space-y-1">
<p className="font-medium text-text">{person.name}</p>
{person.function && <p className="text-sm text-text-muted">{person.function}</p>}
{person.phone && <p className="text-sm text-text-muted">Tel: {person.phone}</p>}
{person.email && <p className="text-sm text-text-muted">E-Mail: {person.email}</p>}
</div>
<Button
variant="danger"
onClick={() => handleRemovePerson(person.id)}
data-testid={`remove-person-${person.id}`}
>
Entfernen
</Button>
</div>
))
) : (
<p data-testid="no-contact-persons" className="text-text-muted text-center py-4">
Keine Kontaktpersonen vorhanden.
</p>
)}
<Button onClick={() => setShowPersonModal(true)} data-testid="add-person-btn">
+ Kontaktperson hinzufügen
</Button>
</div>
</Card>
<Modal open={showPersonModal} onClose={() => setShowPersonModal(false)} title="Kontaktperson hinzufügen">
<form data-testid="person-form" onSubmit={handleAddPerson} className="space-y-4">
{personError && (
<div data-testid="person-form-error" className="p-3 bg-error/10 text-error rounded-lg">
{personError}
</div>
)}
<Input
label="Name *"
data-testid="person-input-name"
value={personForm.name}
onChange={e => setPersonForm(prev => ({ ...prev, name: e.target.value }))}
/>
<Input
label="Funktion"
data-testid="person-input-function"
value={personForm.function}
onChange={e => setPersonForm(prev => ({ ...prev, function: e.target.value }))}
/>
<Input
label="Telefon"
data-testid="person-input-phone"
value={personForm.phone}
onChange={e => setPersonForm(prev => ({ ...prev, phone: e.target.value }))}
/>
<Input
label="E-Mail"
data-testid="person-input-email"
type="email"
value={personForm.email}
onChange={e => setPersonForm(prev => ({ ...prev, email: e.target.value }))}
/>
<div className="flex gap-3">
<Button type="submit" loading={addingPerson} data-testid="person-submit">
Hinzufügen
</Button>
<Button type="button" variant="ghost" onClick={() => setShowPersonModal(false)}>
Abbrechen
</Button>
</div>
</form>
</Modal>
</div>
);
}