Files
opencrm/frontend/src/pages/customers/CustomerForm.tsx
T
duffyduckandClaude Opus 4.8 b8128616f2 Kunde: E-Mail Pflichtfeld + keine verwaltete Provider-Domain erlaubt
Die private Kunden-E-Mail (Customer.email) darf nicht auf einer der bei den
E-Mail-Providern konfigurierten Domains (bzw. Subdomains) liegen - sonst
traegt man versehentlich eine unserer verwalteten Weiterleitungs-/Mailbox-
Adressen als private Adresse ein. Zudem ist E-Mail jetzt Pflichtfeld.

Backend: createCustomer/updateCustomer pruefen Pflicht + Domain (neue Helper
getConfiguredEmailDomains/emailUsesDomain im emailProvider-Service);
email aus nullableFields entfernt. Frontend: E-Mail-Feld required + Hinweis.

Verifiziert: Provider-Domain + Subdomain (case-insensitiv) verboten,
Fremd-Domains erlaubt.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-18 14:08:12 +02:00

282 lines
9.7 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useEffect } from 'react';
import { useNavigate, useParams, useLocation } from 'react-router-dom';
import { popHistory } from '../../utils/navigation';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useForm } from 'react-hook-form';
import { customerApi } from '../../services/api';
import Card from '../../components/ui/Card';
import Button from '../../components/ui/Button';
import Input from '../../components/ui/Input';
import Select from '../../components/ui/Select';
import type { Customer } from '../../types';
type CustomerFormData = Omit<Customer, 'id' | 'customerNumber' | 'createdAt' | 'updatedAt' | 'addresses' | 'bankCards' | 'identityDocuments' | 'meters' | 'contracts'>;
export default function CustomerForm() {
const { id } = useParams();
const navigate = useNavigate();
const location = useLocation();
const queryClient = useQueryClient();
const isEdit = !!id;
const back = popHistory(location.state, isEdit ? `/customers/${id}` : '/customers');
const { register, handleSubmit, reset, watch, setValue, formState: { errors } } = useForm<CustomerFormData>();
const customerType = watch('type');
const { data: customer } = useQuery({
queryKey: ['customer', id],
queryFn: () => customerApi.getById(parseInt(id!)),
enabled: isEdit,
});
useEffect(() => {
if (customer?.data) {
const data = { ...customer.data };
// Convert date strings to YYYY-MM-DD format for date inputs
if (data.birthDate) {
data.birthDate = data.birthDate.split('T')[0] as any;
}
if (data.foundingDate) {
data.foundingDate = data.foundingDate.split('T')[0] as any;
}
// Boolean → String für <select>-Wert ('true'/'false')
(data as any).useInformalAddress = data.useInformalAddress === true ? 'true' : 'false';
reset(data);
}
}, [customer, reset]);
const createMutation = useMutation({
mutationFn: customerApi.create,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['customers'] });
navigate('/customers');
},
});
const updateMutation = useMutation({
mutationFn: (data: Partial<Customer>) => customerApi.update(parseInt(id!), data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['customers'] });
queryClient.invalidateQueries({ queryKey: ['customer', id] });
navigate(`/customers/${id}`);
},
});
const onSubmit = (data: CustomerFormData) => {
// Only include the fields that can be updated - exclude relations and read-only fields
const submitData: any = {
type: data.type,
salutation: data.salutation || '',
useInformalAddress:
data.useInformalAddress === true ||
(data.useInformalAddress as any) === 'true',
firstName: data.firstName,
lastName: data.lastName,
companyName: data.companyName || '',
email: data.email || '',
phone: data.phone || '',
mobile: data.mobile || '',
taxNumber: data.taxNumber || '',
vatExempt: data.vatExempt === true || (data.vatExempt as any) === 'true',
commercialRegisterNumber: data.commercialRegisterNumber || '',
notes: data.notes || '',
birthPlace: data.birthPlace || '',
};
// Handle birthDate - convert non-empty string to ISO string, or null to clear
if (data.birthDate && typeof data.birthDate === 'string' && data.birthDate.trim() !== '') {
submitData.birthDate = new Date(data.birthDate).toISOString();
} else {
submitData.birthDate = null;
}
// Handle foundingDate for business customers - or null to clear
if (data.foundingDate && typeof data.foundingDate === 'string' && data.foundingDate.trim() !== '') {
submitData.foundingDate = new Date(data.foundingDate).toISOString();
} else {
submitData.foundingDate = null;
}
if (isEdit) {
updateMutation.mutate(submitData);
} else {
createMutation.mutate(submitData);
}
};
const isLoading = createMutation.isPending || updateMutation.isPending;
const error = createMutation.error || updateMutation.error;
return (
<div>
<h1 className="text-2xl font-bold mb-6">
{isEdit ? 'Kunde bearbeiten' : 'Neuer Kunde'}
</h1>
{error && (
<div className="mb-4 p-4 bg-red-50 border border-red-200 text-red-700 rounded-lg">
{error instanceof Error ? error.message : 'Ein Fehler ist aufgetreten'}
</div>
)}
<form onSubmit={handleSubmit(onSubmit)}>
<Card className="mb-6" title="Stammdaten">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<Select
label="Kundentyp"
{...register('type')}
options={[
{ value: 'PRIVATE', label: 'Privatkunde' },
{ value: 'BUSINESS', label: 'Geschäftskunde' },
]}
/>
<Select
label="Anrede"
{...register('salutation')}
options={[
{ value: 'Herr', label: 'Herr' },
{ value: 'Frau', label: 'Frau' },
{ value: 'Divers', label: 'Divers' },
]}
/>
<Select
label="Anrede per *"
{...register('useInformalAddress', {
setValueAs: (v) => v === 'true' || v === true,
})}
options={[
{ value: 'false', label: 'Sie (formell)' },
{ value: 'true', label: 'Du (informell)' },
]}
/>
<Input
label="Vorname"
{...register('firstName', { required: 'Vorname erforderlich' })}
error={errors.firstName?.message}
/>
<Input
label="Nachname"
{...register('lastName', { required: 'Nachname erforderlich' })}
error={errors.lastName?.message}
/>
{customerType === 'BUSINESS' && (
<>
<Input
label="Firmenname"
{...register('companyName')}
className="md:col-span-2"
/>
<Input
label="Gründungsdatum"
type="date"
{...register('foundingDate')}
value={watch('foundingDate') || ''}
onClear={() => setValue('foundingDate', '' as any)}
/>
</>
)}
<Input
label="Geburtsdatum"
type="date"
{...register('birthDate')}
value={watch('birthDate') || ''}
onClear={() => setValue('birthDate', '' as any)}
/>
<Input
label="Geburtsort"
{...register('birthPlace')}
/>
</div>
</Card>
<Card className="mb-6" title="Kontaktdaten">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<Input
label="E-Mail *"
type="email"
{...register('email', { required: 'E-Mail ist erforderlich' })}
error={errors.email?.message}
/>
<p className="mt-1 text-xs text-gray-500">
Private E-Mail des Kunden keine verwaltete Weiterleitungs-/Mailbox-Domain.
</p>
</div>
<Input
label="Telefon"
{...register('phone')}
/>
<Input
label="Mobil"
{...register('mobile')}
/>
</div>
</Card>
{customerType === 'BUSINESS' && (
<Card className="mb-6" title="Geschäftsdaten">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<Input
label="Steuernummer"
{...register('taxNumber')}
/>
<Input
label="Handelsregisternummer"
{...register('commercialRegisterNumber')}
placeholder="z.B. HRB 12345"
/>
</div>
<label className="mt-4 flex items-start gap-2 text-sm">
<input
type="checkbox"
{...register('vatExempt')}
className="mt-0.5 h-4 w-4 rounded border-gray-300 text-blue-600 focus:ring-blue-500"
/>
<span>
<span className="font-medium">Kleinunternehmer / USt-befreit (§19 UStG)</span>
<span className="block text-gray-500">
Wird bei Gutschriften wie ein Privatkunde behandelt (keine USt-Vorbelegung).
Ändert nur neue Gutschriften bereits erstellte bleiben unberührt.
</span>
</span>
</label>
{isEdit && (
<p className="mt-4 text-sm text-gray-500">
Dokumente (Gewerbeanmeldung, Handelsregisterauszug) können nach dem Speichern in der Kundendetailansicht hochgeladen werden.
</p>
)}
</Card>
)}
<Card className="mb-6" title="Notizen">
<textarea
{...register('notes')}
rows={4}
className="block w-full px-3 py-2 border border-gray-300 rounded-lg shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
placeholder="Interne Notizen..."
/>
</Card>
<div className="flex justify-end gap-4">
<Button type="button" variant="secondary" onClick={() => navigate(back.to, { state: back.state })}>
Abbrechen
</Button>
<Button type="submit" disabled={isLoading}>
{isLoading ? 'Speichern...' : 'Speichern'}
</Button>
</div>
</form>
</div>
);
}