Files
leocrm/frontend/src/pages/ContactForm.tsx
T

263 lines
9.4 KiB
TypeScript

import React, { useEffect, useState } from 'react';
import { useParams, useNavigate } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
import { useContact, useCreateContact, useUpdateContact, useCompanies, Company } from '@/api/hooks';
import { Input } from '@/components/ui/Input';
import { Button } from '@/components/ui/Button';
import { Card } from '@/components/ui/Card';
import { Skeleton } from '@/components/ui/Skeleton';
import { Badge } from '@/components/ui/Badge';
import { useToast } from '@/components/ui/Toast';
import { UnsavedChangesGuard } from '@/components/shared/UnsavedChangesGuard';
const contactSchema = z.object({
first_name: z.string().min(1, 'Vorname ist erforderlich').max(100),
last_name: z.string().min(1, 'Nachname ist erforderlich').max(100),
email: z.string().email('Ungültige E-Mail-Adresse').max(255).optional().or(z.literal('')),
phone: z.string().max(30).optional().or(z.literal('')),
position: z.string().max(100).optional().or(z.literal('')),
address_street: z.string().max(255).optional().or(z.literal('')),
address_city: z.string().max(100).optional().or(z.literal('')),
address_zip: z.string().max(20).optional().or(z.literal('')),
address_country: z.string().max(2).optional().or(z.literal('')),
address_state: z.string().max(100).optional().or(z.literal('')),
});
type ContactFormValues = z.infer<typeof contactSchema>;
export function ContactFormPage() {
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
const { t } = useTranslation();
const toast = useToast();
const isEdit = !!id;
const { data: contact, isLoading } = useContact(isEdit ? id : undefined);
const createMutation = useCreateContact();
const updateMutation = useUpdateContact();
const { data: companiesData } = useCompanies(1, 100);
const [selectedCompanyIds, setSelectedCompanyIds] = useState<string[]>([]);
const {
register,
handleSubmit,
reset,
formState: { errors, isDirty, isSubmitting },
} = useForm<ContactFormValues>({
resolver: zodResolver(contactSchema),
defaultValues: {
first_name: '',
last_name: '',
email: '',
phone: '',
position: '',
address_street: '',
address_city: '',
address_zip: '',
address_country: '',
address_state: '',
},
});
useEffect(() => {
if (isEdit && contact) {
reset({
first_name: contact.first_name || '',
last_name: contact.last_name || '',
email: contact.email || '',
phone: contact.phone || '',
position: contact.position || '',
address_street: (contact as any).address_street || '',
address_city: (contact as any).address_city || '',
address_zip: (contact as any).address_zip || '',
address_country: (contact as any).address_country || '',
address_state: (contact as any).address_state || '',
});
setSelectedCompanyIds(contact.company_ids || (contact.companies?.map((c) => c.id) || []));
}
}, [contact, isEdit, reset]);
const toggleCompany = (companyId: string) => {
setSelectedCompanyIds((prev) =>
prev.includes(companyId)
? prev.filter((cid) => cid !== companyId)
: [...prev, companyId]
);
};
const onSubmit = async (values: ContactFormValues) => {
const payload = { ...values, company_ids: selectedCompanyIds };
try {
if (isEdit && id) {
await updateMutation.mutateAsync({ id, data: payload });
toast.success(t('contacts.updated'));
navigate(`/contacts/${id}`);
} else {
const result = await createMutation.mutateAsync(payload) as { id: string };
toast.success(t('contacts.created'));
navigate(`/contacts/${result.id}`);
}
} catch (err: any) {
toast.error(err.message || (isEdit ? t('contacts.updateFailed') : t('contacts.createFailed')));
}
};
if (isEdit && isLoading) {
return (
<div className="p-6 max-w-4xl mx-auto" data-testid="contact-form-page">
<Skeleton className="h-8 w-48 mb-6" />
<Skeleton className="h-96" />
</div>
);
}
const availableCompanies: Company[] = companiesData?.items ?? [];
return (
<div className="p-6 max-w-4xl mx-auto" data-testid="contact-form-page">
<UnsavedChangesGuard isDirty={isDirty || selectedCompanyIds.length > 0} />
<div className="flex items-center justify-between mb-6">
<div className="flex items-center gap-4">
<Button variant="ghost" onClick={() => navigate(-1)}>
{t('common.back')}
</Button>
<h1 className="text-2xl font-bold text-secondary-900">
{isEdit ? t('contacts.edit') : t('contacts.create')}
</h1>
</div>
</div>
<form onSubmit={handleSubmit(onSubmit)} noValidate className="space-y-4">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<Card title={t('contacts.firstName')}>
<Input
{...register('first_name')}
label={t('contacts.firstName')}
required
error={errors.first_name?.message}
placeholder="Max"
data-testid="contact-first-name-input"
/>
</Card>
<Card title={t('contacts.lastName')}>
<Input
{...register('last_name')}
label={t('contacts.lastName')}
required
error={errors.last_name?.message}
placeholder="Mustermann"
data-testid="contact-last-name-input"
/>
</Card>
<Card title={t('contacts.email')}>
<Input
{...register('email')}
label={t('contacts.email')}
type="email"
error={errors.email?.message}
placeholder="max@mustermann.de"
/>
</Card>
<Card title={t('contacts.phone')}>
<Input
{...register('phone')}
label={t('contacts.phone')}
error={errors.phone?.message}
placeholder="+49 170 1234567"
/>
</Card>
</div>
<Card title={t('contacts.position')}>
<Input
{...register('position')}
label={t('contacts.position')}
error={errors.position?.message}
placeholder="Geschäftsführer"
/>
</Card>
<Card title={t('address.title')}>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<Input
{...register('address_street')}
label={t('address.street')}
error={errors.address_street?.message}
placeholder="Musterstraße 1"
/>
<Input
{...register('address_city')}
label={t('address.city')}
error={errors.address_city?.message}
placeholder="Berlin"
/>
<Input
{...register('address_zip')}
label={t('address.zip')}
error={errors.address_zip?.message}
placeholder="10115"
/>
<Input
{...register('address_country')}
label={t('address.country')}
error={errors.address_country?.message}
placeholder="DE"
maxLength={2}
/>
<Input
{...register('address_state')}
label={t('address.state')}
error={errors.address_state?.message}
placeholder="Berlin"
/>
</div>
</Card>
<Card title={t('contacts.assignCompanies')}>
{availableCompanies.length === 0 ? (
<p className="text-sm text-secondary-500">{t('companies.emptyTitle')}</p>
) : (
<div className="space-y-2" data-testid="company-assignment-list">
{availableCompanies.map((company) => (
<label
key={company.id}
className="flex items-center gap-3 p-2 rounded-md hover:bg-secondary-50 cursor-pointer min-h-touch"
>
<input
type="checkbox"
checked={selectedCompanyIds.includes(company.id)}
onChange={() => toggleCompany(company.id)}
className="w-4 h-4 rounded border-secondary-300 text-primary-600 focus:ring-primary-500"
aria-label={company.name}
/>
<span className="text-sm text-secondary-900">{company.name}</span>
{company.industry && (
<Badge variant="primary">{company.industry}</Badge>
)}
</label>
))}
</div>
)}
{selectedCompanyIds.length > 0 && (
<p className="mt-2 text-xs text-secondary-500">
{selectedCompanyIds.length} {selectedCompanyIds.length === 1 ? 'Firma zugewiesen' : 'Firmen zugewiesen'}
</p>
)}
</Card>
<div className="flex justify-end gap-3">
<Button variant="secondary" onClick={() => navigate(-1)}>
{t('common.cancel')}
</Button>
<Button type="submit" isLoading={isSubmitting} data-testid="contact-submit-btn">
{isEdit ? t('common.save') : t('common.create')}
</Button>
</div>
</form>
</div>
);
}