263 lines
9.4 KiB
TypeScript
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>
|
|
);
|
|
}
|