2cf433a01c
- 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
247 lines
8.9 KiB
TypeScript
247 lines
8.9 KiB
TypeScript
'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>
|
|
);
|
|
}
|