diff --git a/frontend/src/components/contacts/ContactEditForm.tsx b/frontend/src/components/contacts/ContactEditForm.tsx new file mode 100644 index 0000000..4847a35 --- /dev/null +++ b/frontend/src/components/contacts/ContactEditForm.tsx @@ -0,0 +1,341 @@ +import React, { useEffect, useMemo } from 'react'; +import { useTranslation } from 'react-i18next'; +import { useForm } from 'react-hook-form'; +import { zodResolver } from '@hookform/resolvers/zod'; +import { z } from 'zod'; +import { Input } from '@/components/ui/Input'; +import { Select } from '@/components/ui/Select'; +import { Button } from '@/components/ui/Button'; +import { useToast } from '@/components/ui/Toast'; +import { + type UnifiedContact, + useCreateUnifiedContact, + useUpdateUnifiedContact, +} from '@/api/hooks'; +import { CustomFieldRenderer } from '@/components/contacts/CustomFieldRenderer'; +import { useCustomFields, useUpdateCustomFields } from '@/api/customFields'; +import { usePluginStore } from '@/store/pluginStore'; + +export interface ContactEditFormProps { + onClose: () => void; + contact?: UnifiedContact | null; + onSaved?: (id: string) => void; +} + +// ── Zod Schema ── + +const phoneRegex = /^[+]?[\d\s\-().]{6,20}$/; + +const contactSchema = z.object({ + type: z.enum(['company', 'person']), + name: z.string().optional().default(''), + firstname: z.string().optional().default(''), + surname: z.string().optional().default(''), + code: z.string().optional().default(''), + email_1: z.string().email('Invalid email').or(z.literal('')).optional().default(''), + email_2: z.string().email('Invalid email').or(z.literal('')).optional().default(''), + phone_1: z.string().regex(phoneRegex, 'Invalid phone').or(z.literal('')).optional().default(''), + phone_2: z.string().regex(phoneRegex, 'Invalid phone').or(z.literal('')).optional().default(''), + website: z.string().optional().default(''), + mailing_street: z.string().optional().default(''), + mailing_number: z.string().optional().default(''), + mailing_postalcode: z.string().optional().default(''), + mailing_city: z.string().optional().default(''), + mailing_country: z.string().optional().default(''), + vat_code: z.string().optional().default(''), + fiscal_code: z.string().optional().default(''), + commerce_code: z.string().optional().default(''), + bic: z.string().optional().default(''), + bank_account: z.string().optional().default(''), + tags: z.string().optional().default(''), + projectnote: z.string().optional().default(''), + contact_warning: z.string().optional().default(''), +}).superRefine((data, ctx) => { + if (data.type === 'company' && !data.name?.trim()) { + ctx.addIssue({ code: z.ZodIssueCode.custom, message: 'Required', path: ['name'] }); + } + if (data.type === 'person' && !data.firstname?.trim() && !data.surname?.trim()) { + ctx.addIssue({ code: z.ZodIssueCode.custom, message: 'Required', path: ['firstname'] }); + ctx.addIssue({ code: z.ZodIssueCode.custom, message: 'Required', path: ['surname'] }); + } +}); + +type ContactFormData = z.infer; + +export function ContactEditForm({ onClose, contact, onSaved }: ContactEditFormProps) { + const { t } = useTranslation(); + const toast = useToast(); + const createMutation = useCreateUnifiedContact(); + const updateMutation = useUpdateUnifiedContact(); + const isEdit = !!contact; + + const { + register, + handleSubmit, + reset, + watch, + formState: { errors, isSubmitting }, + } = useForm({ + resolver: zodResolver(contactSchema), + defaultValues: { + type: 'company', + name: '', + firstname: '', + surname: '', + code: '', + email_1: '', + email_2: '', + phone_1: '', + phone_2: '', + website: '', + mailing_street: '', + mailing_number: '', + mailing_postalcode: '', + mailing_city: '', + mailing_country: '', + vat_code: '', + fiscal_code: '', + commerce_code: '', + bic: '', + bank_account: '', + tags: '', + projectnote: '', + contact_warning: '', + }, + }); + + const currentType = watch('type'); + + // Custom fields + const manifests = usePluginStore(s => s.manifests); + const customFieldDefs = useMemo( + () => manifests + .flatMap((m) => m.custom_fields || []) + .filter((cf) => cf.entity === 'contact'), + [manifests] + ); + const { data: customFieldsData } = useCustomFields(contact?.id); + const updateCustomFields = useUpdateCustomFields(); + const [customValues, setCustomValues] = React.useState>({}); + + React.useEffect(() => { + if (customFieldsData?.fields) { + const vals: Record = {}; + for (const f of customFieldsData.fields) { + vals[f.name] = f.value ?? f.default_value ?? null; + } + setCustomValues(vals); + } + }, [customFieldsData]); + + const handleCustomFieldChange = (name: string, value: any) => { + setCustomValues(prev => ({ ...prev, [name]: value })); + }; + + // Reset form when contact changes + useEffect(() => { + reset({ + type: (contact?.type as 'company' | 'person') || 'company', + name: contact?.name || '', + firstname: contact?.firstname || '', + surname: contact?.surname || '', + code: contact?.code || '', + email_1: contact?.email_1 || '', + email_2: contact?.email_2 || '', + phone_1: contact?.phone_1 || '', + phone_2: contact?.phone_2 || '', + website: contact?.website || '', + mailing_street: contact?.mailing_street || '', + mailing_number: contact?.mailing_number || '', + mailing_postalcode: contact?.mailing_postalcode || '', + mailing_city: contact?.mailing_city || '', + mailing_country: contact?.mailing_country || '', + vat_code: contact?.vat_code || '', + fiscal_code: contact?.fiscal_code || '', + commerce_code: contact?.commerce_code || '', + bic: contact?.bic || '', + bank_account: contact?.bank_account || '', + tags: contact?.tags || '', + projectnote: contact?.projectnote || '', + contact_warning: contact?.contact_warning || '', + }); + }, [contact, reset]); + + const onSubmit = async (formData: ContactFormData) => { + const data: Partial = { + type: formData.type, + name: formData.type === 'company' ? formData.name || null : null, + firstname: formData.type === 'person' ? formData.firstname || null : null, + surname: formData.type === 'person' ? formData.surname || null : null, + code: formData.code || null, + email_1: formData.email_1 || null, + email_2: formData.email_2 || null, + phone_1: formData.phone_1 || null, + phone_2: formData.phone_2 || null, + website: formData.website || null, + mailing_street: formData.mailing_street || null, + mailing_number: formData.mailing_number || null, + mailing_postalcode: formData.mailing_postalcode || null, + mailing_city: formData.mailing_city || null, + mailing_country: formData.mailing_country || null, + vat_code: formData.vat_code || null, + fiscal_code: formData.fiscal_code || null, + commerce_code: formData.commerce_code || null, + bic: formData.bic || null, + bank_account: formData.bank_account || null, + tags: formData.tags || null, + projectnote: formData.projectnote || null, + contact_warning: formData.contact_warning || null, + }; + + try { + let savedId: string | undefined; + if (isEdit && contact) { + await updateMutation.mutateAsync({ id: contact.id, data }); + savedId = contact.id; + toast.success(t('contacts.updated')); + onSaved?.(contact.id); + } else { + const result = await createMutation.mutateAsync(data) as { id: string }; + savedId = result.id; + toast.success(t('contacts.created')); + onSaved?.(result.id); + } + // Save custom fields if any definitions exist and we have a contact ID + if (savedId && customFieldDefs.length > 0 && Object.keys(customValues).length > 0) { + try { + await updateCustomFields.mutateAsync({ contactId: savedId, values: customValues }); + } catch (cfErr: any) { + console.error('Custom fields save failed:', cfErr); + } + } + onClose(); + } catch (err: any) { + toast.error(err.message || (isEdit ? t('contacts.updateFailed') : t('contacts.createFailed'))); + } + }; + + return ( +
+ {/* Type */} + + ) : ( +
+ + +
+ )} + + {/* Code */} + + + {/* Communication */} +
+

{t('contacts.communication')}

+
+ + + + + +
+
+ + {/* Mailing Address */} +
+

{t('contacts.mailingAddress')}

+
+ + + + + +
+
+ + {/* Financial */} +
+

{t('contacts.financial')}

+
+ + + + + +
+
+ + {/* Notes */} +
+

{t('contacts.notes')}

+
+ +
+ +