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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,290 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { Input } from '@/components/ui/Input';
|
||||
import {
|
||||
createContact,
|
||||
updateContact,
|
||||
validateVatIdFormat,
|
||||
type ContactResponse,
|
||||
type ContactCreateData,
|
||||
} from '@/lib/contacts';
|
||||
|
||||
const ROLE_OPTIONS = ['kaeufer', 'verkaeufer', 'beide'];
|
||||
const LEGAL_FORMS = ['GmbH', 'AG', 'KG', 'OHG', 'GbR', 'e.K.', 'UG', 'SE', 'Einzelunternehmen'];
|
||||
const COUNTRY_OPTIONS = [
|
||||
'DE', 'AT', 'BE', 'BG', 'CY', 'CZ', 'DK', 'EE', 'ES', 'FI', 'FR', 'GR',
|
||||
'HR', 'HU', 'IE', 'IT', 'LT', 'LU', 'LV', 'MT', 'NL', 'PL', 'PT', 'RO',
|
||||
'SE', 'SI', 'SK',
|
||||
];
|
||||
|
||||
interface ContactFormProps {
|
||||
contact?: ContactResponse;
|
||||
mode?: 'create' | 'edit';
|
||||
}
|
||||
|
||||
interface FormErrors {
|
||||
[key: string]: string | undefined;
|
||||
}
|
||||
|
||||
export function ContactForm({ contact, mode = 'create' }: ContactFormProps) {
|
||||
const router = useRouter();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [errors, setErrors] = useState<FormErrors>({});
|
||||
const [submitError, setSubmitError] = useState<string | null>(null);
|
||||
|
||||
const [formData, setFormData] = useState<ContactCreateData>({
|
||||
company_name: contact?.company_name || '',
|
||||
legal_form: contact?.legal_form,
|
||||
address_street: contact?.address_street,
|
||||
address_zip: contact?.address_zip,
|
||||
address_city: contact?.address_city,
|
||||
address_country: contact?.address_country || 'DE',
|
||||
vat_id: contact?.vat_id,
|
||||
phone: contact?.phone,
|
||||
email: contact?.email,
|
||||
website: contact?.website,
|
||||
role: contact?.role || 'kaeufer',
|
||||
is_private: contact?.is_private || false,
|
||||
});
|
||||
|
||||
const isEu = formData.address_country !== 'DE';
|
||||
|
||||
const validate = (): boolean => {
|
||||
const newErrors: FormErrors = {};
|
||||
|
||||
if (!formData.company_name || formData.company_name.trim().length === 0) {
|
||||
newErrors.company_name = 'Company name is required';
|
||||
}
|
||||
if (!formData.role) {
|
||||
newErrors.role = 'Role is required';
|
||||
}
|
||||
if (!formData.address_country || formData.address_country.length !== 2) {
|
||||
newErrors.address_country = 'Country code is required (2 letters)';
|
||||
}
|
||||
|
||||
// VAT ID validation
|
||||
if (formData.vat_id) {
|
||||
const vatError = validateVatIdFormat(formData.vat_id, formData.address_country);
|
||||
if (vatError) {
|
||||
newErrors.vat_id = vatError;
|
||||
}
|
||||
}
|
||||
|
||||
// EU contacts should have a VAT ID (warning, not error)
|
||||
if (isEu && !formData.vat_id) {
|
||||
// Soft warning - don't block submission
|
||||
}
|
||||
|
||||
setErrors(newErrors);
|
||||
return Object.keys(newErrors).length === 0;
|
||||
};
|
||||
|
||||
const handleChange = (field: keyof ContactCreateData, value: string | boolean | undefined) => {
|
||||
setFormData(prev => ({ ...prev, [field]: value }));
|
||||
if (errors[field]) {
|
||||
setErrors(prev => { const next = { ...prev }; delete next[field]; return next; });
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!validate()) return;
|
||||
|
||||
setLoading(true);
|
||||
setSubmitError(null);
|
||||
try {
|
||||
const submitData = { ...formData };
|
||||
// Remove undefined empty strings
|
||||
Object.keys(submitData).forEach(key => {
|
||||
if (submitData[key as keyof ContactCreateData] === '') {
|
||||
(submitData as Record<string, unknown>)[key] = undefined;
|
||||
}
|
||||
});
|
||||
|
||||
if (mode === 'edit' && contact) {
|
||||
await updateContact(contact.id, submitData);
|
||||
router.push(`/de/kontakte/${contact.id}`);
|
||||
} else {
|
||||
const created = await createContact(submitData);
|
||||
router.push(`/de/kontakte/${created.id}`);
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
const apiErr = err as { error?: { message?: string } };
|
||||
setSubmitError(apiErr?.error?.message || 'Failed to save contact');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const inputClass = 'w-full px-3 py-2 border rounded-lg bg-surface text-text border-border focus:outline-none focus:ring-2 focus:ring-primary';
|
||||
|
||||
return (
|
||||
<form data-testid="contact-form" onSubmit={handleSubmit} className="space-y-6">
|
||||
{submitError && (
|
||||
<div data-testid="form-submit-error" className="p-4 bg-error/10 text-error rounded-lg">
|
||||
{submitError}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* EU / Inland Toggle */}
|
||||
<div data-testid="eu-inland-toggle" className="flex gap-3 p-4 bg-surface rounded-lg border border-border">
|
||||
<label className="flex items-center gap-2 cursor-pointer">
|
||||
<input
|
||||
type="radio"
|
||||
data-testid="toggle-inland"
|
||||
name="region"
|
||||
value="DE"
|
||||
checked={formData.address_country === 'DE'}
|
||||
onChange={() => handleChange('address_country', 'DE')}
|
||||
/>
|
||||
<span className="text-text font-medium">Inland (DE)</span>
|
||||
</label>
|
||||
<label className="flex items-center gap-2 cursor-pointer">
|
||||
<input
|
||||
type="radio"
|
||||
data-testid="toggle-eu"
|
||||
name="region"
|
||||
value="EU"
|
||||
checked={formData.address_country !== 'DE'}
|
||||
onChange={() => handleChange('address_country', 'AT')}
|
||||
/>
|
||||
<span className="text-text font-medium">EU (Ausland)</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<Input
|
||||
label="Firmenname *"
|
||||
data-testid="input-company_name"
|
||||
value={formData.company_name}
|
||||
onChange={e => handleChange('company_name', e.target.value)}
|
||||
error={errors.company_name}
|
||||
/>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-text mb-1">Rechtsform</label>
|
||||
<select
|
||||
data-testid="input-legal_form"
|
||||
className={inputClass}
|
||||
value={formData.legal_form || ''}
|
||||
onChange={e => handleChange('legal_form', e.target.value || undefined)}
|
||||
>
|
||||
<option value="">—</option>
|
||||
{LEGAL_FORMS.map(lf => <option key={lf} value={lf}>{lf}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-text mb-1">Rolle *</label>
|
||||
<select
|
||||
data-testid="input-role"
|
||||
className={inputClass}
|
||||
value={formData.role}
|
||||
onChange={e => handleChange('role', e.target.value)}
|
||||
>
|
||||
{ROLE_OPTIONS.map(r => <option key={r} value={r}>{r}</option>)}
|
||||
</select>
|
||||
{errors.role && <p className="mt-1 text-sm text-error">{errors.role}</p>}
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-text mb-1">Land *</label>
|
||||
<select
|
||||
data-testid="input-address_country"
|
||||
className={inputClass}
|
||||
value={formData.address_country}
|
||||
onChange={e => handleChange('address_country', e.target.value)}
|
||||
>
|
||||
{COUNTRY_OPTIONS.map(c => <option key={c} value={c}>{c}</option>)}
|
||||
</select>
|
||||
{errors.address_country && <p className="mt-1 text-sm text-error">{errors.address_country}</p>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<Input
|
||||
label="Straße"
|
||||
data-testid="input-address_street"
|
||||
value={formData.address_street || ''}
|
||||
onChange={e => handleChange('address_street', e.target.value || undefined)}
|
||||
/>
|
||||
<Input
|
||||
label="PLZ"
|
||||
data-testid="input-address_zip"
|
||||
value={formData.address_zip || ''}
|
||||
onChange={e => handleChange('address_zip', e.target.value || undefined)}
|
||||
/>
|
||||
<Input
|
||||
label="Stadt"
|
||||
data-testid="input-address_city"
|
||||
value={formData.address_city || ''}
|
||||
onChange={e => handleChange('address_city', e.target.value || undefined)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<Input
|
||||
label={isEu ? 'USt-IdNr. (empfohlen)' : 'USt-IdNr.'}
|
||||
data-testid="input-vat_id"
|
||||
value={formData.vat_id || ''}
|
||||
onChange={e => handleChange('vat_id', e.target.value || undefined)}
|
||||
error={errors.vat_id}
|
||||
placeholder={isEu ? 'z.B. ATU12345678' : 'z.B. DE123456789'}
|
||||
/>
|
||||
{isEu && !formData.vat_id && (
|
||||
<p data-testid="vat-id-hint" className="mt-1 text-sm text-text-muted">
|
||||
Für EU-Kontakte wird eine USt-IdNr. empfohlen.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<Input
|
||||
label="Telefon"
|
||||
data-testid="input-phone"
|
||||
value={formData.phone || ''}
|
||||
onChange={e => handleChange('phone', e.target.value || undefined)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<Input
|
||||
label="E-Mail"
|
||||
data-testid="input-email"
|
||||
type="email"
|
||||
value={formData.email || ''}
|
||||
onChange={e => handleChange('email', e.target.value || undefined)}
|
||||
/>
|
||||
<Input
|
||||
label="Website"
|
||||
data-testid="input-website"
|
||||
value={formData.website || ''}
|
||||
onChange={e => handleChange('website', e.target.value || undefined)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<label className="flex items-center gap-2 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
data-testid="input-is_private"
|
||||
checked={formData.is_private || false}
|
||||
onChange={e => handleChange('is_private', e.target.checked)}
|
||||
/>
|
||||
<span className="text-sm text-text">Privatkontakt</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-3">
|
||||
<Button type="submit" loading={loading} data-testid="submit-button">
|
||||
{mode === 'edit' ? 'Kontakt aktualisieren' : 'Kontakt erstellen'}
|
||||
</Button>
|
||||
<Button type="button" variant="ghost" onClick={() => router.back()}>
|
||||
Abbrechen
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,228 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { Table } from '@/components/ui/Table';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { Input } from '@/components/ui/Input';
|
||||
import {
|
||||
listContacts,
|
||||
type ContactResponse,
|
||||
type ContactListParams,
|
||||
} from '@/lib/contacts';
|
||||
import type { PaginatedResponse } from '@/lib/api';
|
||||
|
||||
const ROLE_OPTIONS = ['kaeufer', 'verkaeufer', 'beide'];
|
||||
const SORT_OPTIONS = [
|
||||
{ value: '-created_at', label: 'Newest First' },
|
||||
{ value: 'created_at', label: 'Oldest First' },
|
||||
{ value: 'company_name', label: 'Company A-Z' },
|
||||
{ value: '-company_name', label: 'Company Z-A' },
|
||||
{ value: 'address_city', label: 'City A-Z' },
|
||||
];
|
||||
|
||||
export function ContactList() {
|
||||
const router = useRouter();
|
||||
const [contacts, setContacts] = useState<ContactResponse[]>([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [page, setPage] = useState(1);
|
||||
const [pageSize] = useState(20);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const [filters, setFilters] = useState<ContactListParams>({
|
||||
page: 1,
|
||||
page_size: 20,
|
||||
});
|
||||
|
||||
const fetchContacts = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const data: PaginatedResponse<ContactResponse> = await listContacts(filters);
|
||||
setContacts(data.items);
|
||||
setTotal(data.total);
|
||||
setPage(data.page);
|
||||
} catch (err: unknown) {
|
||||
const apiErr = err as { error?: { message?: string } };
|
||||
setError(apiErr?.error?.message || 'Failed to load contacts');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [filters]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchContacts();
|
||||
}, [fetchContacts]);
|
||||
|
||||
const handleFilterChange = (key: keyof ContactListParams, value: string) => {
|
||||
setFilters(prev => ({
|
||||
...prev,
|
||||
page: 1,
|
||||
[key]: value || undefined,
|
||||
}));
|
||||
};
|
||||
|
||||
const handleEuToggle = (value: string) => {
|
||||
if (value === '') {
|
||||
setFilters(prev => ({ ...prev, page: 1, is_eu: undefined }));
|
||||
} else {
|
||||
setFilters(prev => ({ ...prev, page: 1, is_eu: value === 'eu' }));
|
||||
}
|
||||
};
|
||||
|
||||
const handlePageChange = (newPage: number) => {
|
||||
setFilters(prev => ({ ...prev, page: newPage }));
|
||||
};
|
||||
|
||||
const columns = [
|
||||
{
|
||||
key: 'company_name',
|
||||
label: 'Company',
|
||||
render: (row: ContactResponse) => (
|
||||
<button
|
||||
data-testid={`contact-row-${row.id}`}
|
||||
onClick={() => router.push(`/de/kontakte/${row.id}`)}
|
||||
className="text-primary hover:underline"
|
||||
>
|
||||
{row.company_name}
|
||||
</button>
|
||||
),
|
||||
},
|
||||
{ key: 'address_city', label: 'City' },
|
||||
{ key: 'address_country', label: 'Country' },
|
||||
{ key: 'role', label: 'Role' },
|
||||
{
|
||||
key: 'vat_id',
|
||||
label: 'VAT ID',
|
||||
render: (row: ContactResponse) => row.vat_id || '—',
|
||||
},
|
||||
{
|
||||
key: 'vat_id_status',
|
||||
label: 'VAT Status',
|
||||
render: (row: ContactResponse) => (
|
||||
<span
|
||||
className={`px-2 py-1 rounded text-xs ${
|
||||
row.vat_id_status === 'geprueft' || row.vat_id_status === 'manuell_bestaetigt'
|
||||
? 'bg-success/20 text-success'
|
||||
: row.vat_id_status === 'ungueltig'
|
||||
? 'bg-error/20 text-error'
|
||||
: 'bg-secondary/20 text-secondary'
|
||||
}`}
|
||||
>
|
||||
{row.vat_id_status}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'contact_persons',
|
||||
label: 'Persons',
|
||||
render: (row: ContactResponse) => String(row.contact_persons?.length || 0),
|
||||
},
|
||||
];
|
||||
|
||||
const totalPages = Math.ceil(total / pageSize);
|
||||
|
||||
return (
|
||||
<div data-testid="contact-list" className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-2xl font-bold text-text">Kontakte</h1>
|
||||
<Button onClick={() => router.push('/de/kontakte/neu')} data-testid="new-contact-btn">
|
||||
+ Neuer Kontakt
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div data-testid="contact-filters" className="flex flex-wrap gap-3 p-4 bg-surface rounded-lg border border-border">
|
||||
<div className="w-48">
|
||||
<label className="block text-sm font-medium text-text mb-1">Search</label>
|
||||
<Input
|
||||
data-testid="filter-search"
|
||||
type="text"
|
||||
placeholder="Company, city, email..."
|
||||
value={filters.search || ''}
|
||||
onChange={e => handleFilterChange('search', e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="w-40">
|
||||
<label className="block text-sm font-medium text-text mb-1">Role</label>
|
||||
<select
|
||||
data-testid="filter-role"
|
||||
className="w-full px-3 py-2 border rounded-lg bg-surface text-text border-border"
|
||||
value={filters.role || ''}
|
||||
onChange={e => handleFilterChange('role', e.target.value)}
|
||||
>
|
||||
<option value="">All</option>
|
||||
{ROLE_OPTIONS.map(r => (
|
||||
<option key={r} value={r}>{r}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="w-40">
|
||||
<label className="block text-sm font-medium text-text mb-1">Region</label>
|
||||
<select
|
||||
data-testid="filter-eu"
|
||||
className="w-full px-3 py-2 border rounded-lg bg-surface text-text border-border"
|
||||
value={filters.is_eu === undefined ? '' : filters.is_eu ? 'eu' : 'inland'}
|
||||
onChange={e => handleEuToggle(e.target.value)}
|
||||
>
|
||||
<option value="">All</option>
|
||||
<option value="inland">Inland (DE)</option>
|
||||
<option value="eu">EU (non-DE)</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="w-40">
|
||||
<label className="block text-sm font-medium text-text mb-1">Sort</label>
|
||||
<select
|
||||
data-testid="filter-sort"
|
||||
className="w-full px-3 py-2 border rounded-lg bg-surface text-text border-border"
|
||||
value={filters.sort || ''}
|
||||
onChange={e => handleFilterChange('sort', e.target.value)}
|
||||
>
|
||||
<option value="">Default</option>
|
||||
{SORT_OPTIONS.map(s => (
|
||||
<option key={s.value} value={s.value}>{s.label}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div data-testid="contact-error" className="p-4 bg-error/10 text-error rounded-lg">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{loading ? (
|
||||
<div data-testid="contact-loading" className="text-center py-8 text-text-muted">
|
||||
Loading contacts...
|
||||
</div>
|
||||
) : (
|
||||
<Table columns={columns} data={contacts} rowKey={row => row.id} />
|
||||
)}
|
||||
|
||||
{totalPages > 1 && (
|
||||
<div data-testid="contact-pagination" className="flex items-center justify-between">
|
||||
<span className="text-sm text-text-muted">
|
||||
Page {page} of {totalPages} ({total} total)
|
||||
</span>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="secondary"
|
||||
disabled={page <= 1}
|
||||
onClick={() => handlePageChange(page - 1)}
|
||||
>
|
||||
Previous
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
disabled={page >= totalPages}
|
||||
onClick={() => handlePageChange(page + 1)}
|
||||
>
|
||||
Next
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user