35e87b5d51
- Window manager store (Zustand) for managing open/minimized/fullscreen windows - Window component with header controls: KI-Chat toggle, fullscreen, minimize, close - AI chat panel with streaming chat via existing /ai/sessions API - WindowContainer renders all non-minimized windows - TopBar shows minimized windows as clickable pills - ContactEditForm extracted from ContactEditModal for window rendering - ContactsList and ContactDetailPage use openWindow() instead of modal state - Draggable windows with z-index management
342 lines
13 KiB
TypeScript
342 lines
13 KiB
TypeScript
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<typeof contactSchema>;
|
|
|
|
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<ContactFormData>({
|
|
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<Record<string, any>>({});
|
|
|
|
React.useEffect(() => {
|
|
if (customFieldsData?.fields) {
|
|
const vals: Record<string, any> = {};
|
|
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<UnifiedContact> = {
|
|
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 (
|
|
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4 p-4">
|
|
{/* Type */}
|
|
<Select
|
|
label={t('contacts.type')}
|
|
{...register('type')}
|
|
options={[
|
|
{ value: 'company', label: t('contacts.companies') },
|
|
{ value: 'person', label: t('contacts.persons') },
|
|
]}
|
|
data-testid="contact-type-select"
|
|
/>
|
|
|
|
{/* Name fields */}
|
|
{currentType === 'company' ? (
|
|
<Input
|
|
label={t('contacts.name')}
|
|
{...register('name')}
|
|
error={errors.name?.message}
|
|
required
|
|
data-testid="contact-name-input"
|
|
placeholder="TechCorp GmbH"
|
|
/>
|
|
) : (
|
|
<div className="grid grid-cols-2 gap-3">
|
|
<Input
|
|
label={t('contacts.firstName')}
|
|
{...register('firstname')}
|
|
error={errors.firstname?.message}
|
|
data-testid="contact-first-name-input"
|
|
/>
|
|
<Input
|
|
label={t('contacts.lastName')}
|
|
{...register('surname')}
|
|
error={errors.surname?.message}
|
|
data-testid="contact-last-name-input"
|
|
/>
|
|
</div>
|
|
)}
|
|
|
|
{/* Code */}
|
|
<Input label={t('contacts.code')} {...register('code')} placeholder="K-00123" />
|
|
|
|
{/* Communication */}
|
|
<div className="border border-secondary-200 rounded-lg p-3">
|
|
<h3 className="text-sm font-semibold text-secondary-700 mb-2">{t('contacts.communication')}</h3>
|
|
<div className="grid grid-cols-2 gap-3">
|
|
<Input label={t('contacts.email') + ' 1'} type="email" {...register('email_1')} error={errors.email_1?.message} />
|
|
<Input label={t('contacts.email') + ' 2'} type="email" {...register('email_2')} error={errors.email_2?.message} />
|
|
<Input label={t('contacts.phone') + ' 1'} {...register('phone_1')} error={errors.phone_1?.message} />
|
|
<Input label={t('contacts.phone') + ' 2'} {...register('phone_2')} error={errors.phone_2?.message} />
|
|
<Input label={t('contacts.website')} {...register('website')} />
|
|
</div>
|
|
</div>
|
|
|
|
{/* Mailing Address */}
|
|
<div className="border border-secondary-200 rounded-lg p-3">
|
|
<h3 className="text-sm font-semibold text-secondary-700 mb-2">{t('contacts.mailingAddress')}</h3>
|
|
<div className="grid grid-cols-2 gap-3">
|
|
<Input label={t('address.street')} {...register('mailing_street')} />
|
|
<Input label={t('address.streetNumber')} {...register('mailing_number')} />
|
|
<Input label={t('address.zip')} {...register('mailing_postalcode')} />
|
|
<Input label={t('address.city')} {...register('mailing_city')} />
|
|
<Input label={t('address.country')} {...register('mailing_country')} />
|
|
</div>
|
|
</div>
|
|
|
|
{/* Financial */}
|
|
<div className="border border-secondary-200 rounded-lg p-3">
|
|
<h3 className="text-sm font-semibold text-secondary-700 mb-2">{t('contacts.financial')}</h3>
|
|
<div className="grid grid-cols-2 gap-3">
|
|
<Input label={t('contacts.vatCode')} {...register('vat_code')} />
|
|
<Input label={t('contacts.fiscalCode')} {...register('fiscal_code')} />
|
|
<Input label={t('contacts.commerceCode')} {...register('commerce_code')} />
|
|
<Input label={t('contacts.bic')} {...register('bic')} />
|
|
<Input label={t('contacts.bankAccount')} {...register('bank_account')} />
|
|
</div>
|
|
</div>
|
|
|
|
{/* Notes */}
|
|
<div className="border border-secondary-200 rounded-lg p-3">
|
|
<h3 className="text-sm font-semibold text-secondary-700 mb-2">{t('contacts.notes')}</h3>
|
|
<div className="space-y-3">
|
|
<Input label={t('contacts.tags')} {...register('tags')} placeholder="tag1, tag2" />
|
|
<div>
|
|
<label className="block text-sm font-medium text-secondary-700 mb-1">{t('contacts.projectnote')}</label>
|
|
<textarea
|
|
{...register('projectnote')}
|
|
className="w-full px-3 py-2 text-sm rounded-md border border-secondary-300 focus:outline-none focus:ring-2 focus:ring-primary-500 min-h-20"
|
|
/>
|
|
</div>
|
|
<div>
|
|
<label className="block text-sm font-medium text-secondary-700 mb-1">{t('contacts.contactWarning')}</label>
|
|
<textarea
|
|
{...register('contact_warning')}
|
|
className="w-full px-3 py-2 text-sm rounded-md border border-secondary-300 focus:outline-none focus:ring-2 focus:ring-primary-500 min-h-20"
|
|
/>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Custom Fields */}
|
|
{customFieldDefs.length > 0 && (
|
|
<div className="border border-secondary-200 rounded-lg p-3">
|
|
<h3 className="text-sm font-semibold text-secondary-700 mb-2">{t('contacts.customFields')}</h3>
|
|
<CustomFieldRenderer
|
|
fields={customFieldsData?.fields || customFieldDefs.map(d => ({ ...d, value: d.default_value, plugin: '' }))}
|
|
mode="edit"
|
|
values={customValues}
|
|
onChange={handleCustomFieldChange}
|
|
/>
|
|
</div>
|
|
)}
|
|
|
|
{/* Actions */}
|
|
<div className="flex justify-end gap-2 pt-2">
|
|
<Button variant="secondary" onClick={onClose}>{t('common.cancel')}</Button>
|
|
<Button type="submit" isLoading={isSubmitting || createMutation.isPending || updateMutation.isPending} data-testid="contact-submit-btn">
|
|
{isEdit ? t('common.save') : t('common.create')}
|
|
</Button>
|
|
</div>
|
|
</form>
|
|
);
|
|
}
|