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:
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user