291 lines
9.8 KiB
TypeScript
291 lines
9.8 KiB
TypeScript
|
|
'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>
|
||
|
|
);
|
||
|
|
}
|