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,10 @@
import { ContactDetail } from '@/components/contacts/ContactDetail';
export default async function KontaktDetailPage({
params,
}: {
params: { locale: string; id: string };
}) {
const { id } = params;
return <ContactDetail contactId={id} />;
}
@@ -0,0 +1,10 @@
import { ContactForm } from '@/components/contacts/ContactForm';
export default function NeuerKontaktPage() {
return (
<div className="space-y-6">
<h1 className="text-2xl font-bold text-text">Neuer Kontakt</h1>
<ContactForm mode="create" />
</div>
);
}
+5
View File
@@ -0,0 +1,5 @@
import { ContactList } from '@/components/contacts/ContactList';
export default function KontaktePage() {
return <ContactList />;
}
@@ -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>
);
}
+163
View File
@@ -0,0 +1,163 @@
import { apiFetch, type PaginatedResponse } from './api';
export interface ContactPersonResponse {
id: string;
contact_id: string;
name: string;
function?: string;
phone?: string;
email?: string;
created_at?: string;
}
export interface ContactResponse {
id: string;
company_name: string;
legal_form?: string;
address_street?: string;
address_zip?: string;
address_city?: string;
address_country: string;
vat_id?: string;
phone?: string;
email?: string;
website?: string;
role: string;
vat_id_status: string;
is_private: boolean;
created_at?: string;
updated_at?: string;
deleted_at?: string | null;
contact_persons: ContactPersonResponse[];
}
export interface ContactCreateData {
company_name: string;
legal_form?: string;
address_street?: string;
address_zip?: string;
address_city?: string;
address_country: string;
vat_id?: string;
phone?: string;
email?: string;
website?: string;
role: string;
is_private?: boolean;
contact_persons?: ContactPersonCreateData[];
}
export interface ContactUpdateData {
company_name?: string;
legal_form?: string;
address_street?: string;
address_zip?: string;
address_city?: string;
address_country?: string;
vat_id?: string;
phone?: string;
email?: string;
website?: string;
role?: string;
is_private?: boolean;
}
export interface ContactPersonCreateData {
name: string;
function?: string;
phone?: string;
email?: string;
}
export interface ContactListParams {
page?: number;
page_size?: number;
search?: string;
role?: string;
is_eu?: boolean;
is_private?: boolean;
sort?: string;
}
export async function listContacts(params: ContactListParams = {}): Promise<PaginatedResponse<ContactResponse>> {
const query = new URLSearchParams();
if (params.page) query.set('page', String(params.page));
if (params.page_size) query.set('page_size', String(params.page_size));
if (params.search) query.set('search', params.search);
if (params.role) query.set('role', params.role);
if (params.is_eu !== undefined) query.set('is_eu', String(params.is_eu));
if (params.is_private !== undefined) query.set('is_private', String(params.is_private));
if (params.sort) query.set('sort', params.sort);
const qs = query.toString();
return apiFetch<PaginatedResponse<ContactResponse>>(`/contacts/${qs ? `?${qs}` : ''}`);
}
export async function getContact(id: string): Promise<ContactResponse> {
return apiFetch<ContactResponse>(`/contacts/${id}`);
}
export async function createContact(data: ContactCreateData): Promise<ContactResponse> {
return apiFetch<ContactResponse>('/contacts/', {
method: 'POST',
body: JSON.stringify(data),
});
}
export async function updateContact(id: string, data: ContactUpdateData): Promise<ContactResponse> {
return apiFetch<ContactResponse>(`/contacts/${id}`, {
method: 'PUT',
body: JSON.stringify(data),
});
}
export async function deleteContact(id: string): Promise<ContactResponse> {
return apiFetch<ContactResponse>(`/contacts/${id}`, { method: 'DELETE' });
}
export async function addContactPerson(contactId: string, data: ContactPersonCreateData): Promise<ContactPersonResponse> {
return apiFetch<ContactPersonResponse>(`/contacts/${contactId}/persons`, {
method: 'POST',
body: JSON.stringify(data),
});
}
export async function removeContactPerson(contactId: string, personId: string): Promise<void> {
await apiFetch<void>(`/contacts/${contactId}/persons/${personId}`, { method: 'DELETE' });
}
/**
* Validate VAT ID format on the frontend (mirrors backend ust_validation.py).
* Returns error message or null if valid.
*/
export function validateVatIdFormat(vatId: string, countryCode: string): string | null {
if (!vatId) return null;
const normalized = vatId.trim().toUpperCase().replace(/\s+/g, '');
const patterns: Record<string, RegExp> = {
DE: /^DE\d{9}$/,
AT: /^ATU\d{8}$/,
FR: /^FR[A-Za-z0-9]{2}\d{9}$/,
NL: /^NL\d{9}B\d{2}$/,
PL: /^PL\d{10}$/,
CZ: /^CZ\d{8,10}$/,
IT: /^IT\d{11}$/,
ES: /^ES[A-Za-z0-9]\d{7}[A-Za-z0-9]$/,
BE: /^BE\d{10}$/,
DK: /^DK\d{8}$/,
SE: /^SE\d{10}$/,
};
const euCountries = new Set([
'AT', 'BE', 'BG', 'CY', 'CZ', 'DE', 'DK', 'EE', 'ES', 'FI', 'FR', 'GR',
'HR', 'HU', 'IE', 'IT', 'LT', 'LU', 'LV', 'MT', 'NL', 'PL', 'PT', 'RO',
'SE', 'SI', 'SK',
]);
const cc = normalized.substring(0, 2);
if (!cc.match(/^[A-Z]{2}$/)) return 'Invalid country code';
const pattern = patterns[cc];
if (pattern) {
return pattern.test(normalized) ? null : `Invalid VAT ID format for ${cc}`;
}
if (euCountries.has(cc)) {
return /^[A-Z]{2}[A-Za-z0-9]{5,15}$/.test(normalized) ? null : `Invalid VAT ID format for ${cc}`;
}
return `VAT ID validation not supported for country ${cc}`;
}
+243
View File
@@ -0,0 +1,243 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import { ContactList } from '@/components/contacts/ContactList';
import { ContactForm } from '@/components/contacts/ContactForm';
import { validateVatIdFormat } from '@/lib/contacts';
// Mock the contacts API module
vi.mock('@/lib/contacts', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/lib/contacts')>();
return {
...actual,
listContacts: vi.fn(),
createContact: vi.fn(),
updateContact: vi.fn(),
};
});
import { listContacts, createContact } from '@/lib/contacts';
const mockContacts = {
items: [
{
id: 'test-id-1',
company_name: 'Müller Transport GmbH',
address_city: 'Berlin',
address_country: 'DE',
role: 'kaeufer',
vat_id: 'DE123456789',
vat_id_status: 'ungeprueft',
is_private: false,
contact_persons: [],
},
{
id: 'test-id-2',
company_name: 'Van der Berg B.V.',
address_city: 'Amsterdam',
address_country: 'NL',
role: 'verkaeufer',
vat_id: 'NL123456789B01',
vat_id_status: 'geprueft',
is_private: false,
contact_persons: [{ id: 'p1', contact_id: 'test-id-2', name: 'Jan' }],
},
],
total: 2,
page: 1,
page_size: 20,
};
describe('ContactList', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('renders contact list with search and filter controls', async () => {
vi.mocked(listContacts).mockResolvedValue(mockContacts);
render(<ContactList />);
await waitFor(() => {
expect(screen.getByTestId('contact-list')).toBeInTheDocument();
});
expect(screen.getByTestId('filter-search')).toBeInTheDocument();
expect(screen.getByTestId('filter-role')).toBeInTheDocument();
expect(screen.getByTestId('filter-eu')).toBeInTheDocument();
expect(screen.getByTestId('filter-sort')).toBeInTheDocument();
});
it('displays contacts in table after loading', async () => {
vi.mocked(listContacts).mockResolvedValue(mockContacts);
render(<ContactList />);
await waitFor(() => {
expect(screen.getByText('Müller Transport GmbH')).toBeInTheDocument();
});
expect(screen.getByText('Van der Berg B.V.')).toBeInTheDocument();
expect(screen.getByText('Berlin')).toBeInTheDocument();
expect(screen.getByText('Amsterdam')).toBeInTheDocument();
});
it('shows new contact button', async () => {
vi.mocked(listContacts).mockResolvedValue(mockContacts);
render(<ContactList />);
await waitFor(() => {
expect(screen.getByTestId('new-contact-btn')).toBeInTheDocument();
});
});
it('shows error message on API failure', async () => {
vi.mocked(listContacts).mockRejectedValue({
error: { message: 'Network error' },
});
render(<ContactList />);
await waitFor(() => {
expect(screen.getByTestId('contact-error')).toBeInTheDocument();
});
expect(screen.getByText('Network error')).toBeInTheDocument();
});
it('shows loading state initially', async () => {
vi.mocked(listContacts).mockImplementation(
() => new Promise((resolve) => setTimeout(() => resolve(mockContacts), 100))
);
render(<ContactList />);
expect(screen.getByTestId('contact-loading')).toBeInTheDocument();
await waitFor(() => {
expect(screen.getByText('Müller Transport GmbH')).toBeInTheDocument();
});
});
});
describe('ContactForm', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('renders form with all required fields', () => {
render(<ContactForm mode="create" />);
expect(screen.getByTestId('contact-form')).toBeInTheDocument();
expect(screen.getByTestId('input-company_name')).toBeInTheDocument();
expect(screen.getByTestId('input-role')).toBeInTheDocument();
expect(screen.getByTestId('input-address_country')).toBeInTheDocument();
expect(screen.getByTestId('input-vat_id')).toBeInTheDocument();
expect(screen.getByTestId('input-phone')).toBeInTheDocument();
expect(screen.getByTestId('input-email')).toBeInTheDocument();
expect(screen.getByTestId('submit-button')).toBeInTheDocument();
});
it('shows EU/Inland toggle', () => {
render(<ContactForm mode="create" />);
expect(screen.getByTestId('eu-inland-toggle')).toBeInTheDocument();
expect(screen.getByTestId('toggle-inland')).toBeInTheDocument();
expect(screen.getByTestId('toggle-eu')).toBeInTheDocument();
});
it('defaults to Inland (DE) and shows DE placeholder for VAT ID', () => {
render(<ContactForm mode="create" />);
const inlandRadio = screen.getByTestId('toggle-inland') as HTMLInputElement;
expect(inlandRadio.checked).toBe(true);
});
it('switches to EU mode and shows VAT ID hint when EU is selected', () => {
render(<ContactForm mode="create" />);
const euRadio = screen.getByTestId('toggle-eu');
fireEvent.click(euRadio);
expect(screen.getByTestId('vat-id-hint')).toBeInTheDocument();
});
it('validates VAT ID format and shows error for invalid DE VAT', () => {
render(<ContactForm mode="create" />);
const vatInput = screen.getByTestId('input-vat_id') as HTMLInputElement;
fireEvent.change(vatInput, { target: { value: 'DE123' } });
const submitBtn = screen.getByTestId('submit-button');
fireEvent.click(submitBtn);
expect(screen.getByText(/Invalid VAT ID format/)).toBeInTheDocument();
});
it('accepts valid DE VAT ID without error', async () => {
vi.mocked(createContact).mockResolvedValue({
id: 'new-id',
company_name: 'Test GmbH',
address_country: 'DE',
role: 'kaeufer',
vat_id_status: 'ungeprueft',
is_private: false,
contact_persons: [],
});
render(<ContactForm mode="create" />);
const companyInput = screen.getByTestId('input-company_name');
fireEvent.change(companyInput, { target: { value: 'Test GmbH' } });
const vatInput = screen.getByTestId('input-vat_id');
fireEvent.change(vatInput, { target: { value: 'DE123456789' } });
const submitBtn = screen.getByTestId('submit-button');
fireEvent.click(submitBtn);
await waitFor(() => {
expect(createContact).toHaveBeenCalled();
});
});
it('requires company name', () => {
render(<ContactForm mode="create" />);
const submitBtn = screen.getByTestId('submit-button');
fireEvent.click(submitBtn);
expect(screen.getByText('Company name is required')).toBeInTheDocument();
});
});
describe('validateVatIdFormat', () => {
it('returns null for empty VAT ID', () => {
expect(validateVatIdFormat('', 'DE')).toBeNull();
});
it('returns null for valid DE VAT ID', () => {
expect(validateVatIdFormat('DE123456789', 'DE')).toBeNull();
});
it('returns error for invalid DE VAT ID (too short)', () => {
const result = validateVatIdFormat('DE12345678', 'DE');
expect(result).toContain('Invalid');
});
it('returns null for valid AT VAT ID', () => {
expect(validateVatIdFormat('ATU12345678', 'AT')).toBeNull();
});
it('returns null for valid NL VAT ID', () => {
expect(validateVatIdFormat('NL123456789B01', 'NL')).toBeNull();
});
it('returns error for non-EU country', () => {
const result = validateVatIdFormat('US123456789', 'US');
expect(result).toContain('not supported');
});
it('handles lowercase input', () => {
expect(validateVatIdFormat('de123456789', 'DE')).toBeNull();
});
it('handles spaces in VAT ID', () => {
expect(validateVatIdFormat('DE 123 456 789', 'DE')).toBeNull();
});
});