Phase 1C: Frontend unified contact UI
This commit is contained in:
@@ -1,5 +1,8 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import React, { useEffect } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { z } from 'zod';
|
||||
import { Modal } from '@/components/ui/Modal';
|
||||
import { Input } from '@/components/ui/Input';
|
||||
import { Select } from '@/components/ui/Select';
|
||||
@@ -18,6 +21,46 @@ export interface ContactEditModalProps {
|
||||
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 ContactEditModal({ open, onClose, contact, onSaved }: ContactEditModalProps) {
|
||||
const { t } = useTranslation();
|
||||
const toast = useToast();
|
||||
@@ -25,83 +68,99 @@ export function ContactEditModal({ open, onClose, contact, onSaved }: ContactEdi
|
||||
const updateMutation = useUpdateUnifiedContact();
|
||||
const isEdit = !!contact;
|
||||
|
||||
const [type, setType] = useState<'company' | 'person'>('company');
|
||||
const [name, setName] = useState('');
|
||||
const [firstname, setFirstname] = useState('');
|
||||
const [surname, setSurname] = useState('');
|
||||
const [code, setCode] = useState('');
|
||||
const [email1, setEmail1] = useState('');
|
||||
const [email2, setEmail2] = useState('');
|
||||
const [phone1, setPhone1] = useState('');
|
||||
const [phone2, setPhone2] = useState('');
|
||||
const [website, setWebsite] = useState('');
|
||||
const [mailingStreet, setMailingStreet] = useState('');
|
||||
const [mailingNumber, setMailingNumber] = useState('');
|
||||
const [mailingPostalcode, setMailingPostalcode] = useState('');
|
||||
const [mailingCity, setMailingCity] = useState('');
|
||||
const [mailingCountry, setMailingCountry] = useState('');
|
||||
const [vatCode, setVatCode] = useState('');
|
||||
const [fiscalCode, setFiscalCode] = useState('');
|
||||
const [commerceCode, setCommerceCode] = useState('');
|
||||
const [bic, setBic] = useState('');
|
||||
const [bankAccount, setBankAccount] = useState('');
|
||||
const [tags, setTags] = useState('');
|
||||
const [projectnote, setProjectnote] = useState('');
|
||||
const [contactWarning, setContactWarning] = useState('');
|
||||
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');
|
||||
|
||||
// Reset form when modal opens
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setType((contact?.type as 'company' | 'person') || 'company');
|
||||
setName(contact?.name || '');
|
||||
setFirstname(contact?.firstname || '');
|
||||
setSurname(contact?.surname || '');
|
||||
setCode(contact?.code || '');
|
||||
setEmail1(contact?.email_1 || '');
|
||||
setEmail2(contact?.email_2 || '');
|
||||
setPhone1(contact?.phone_1 || '');
|
||||
setPhone2(contact?.phone_2 || '');
|
||||
setWebsite(contact?.website || '');
|
||||
setMailingStreet(contact?.mailing_street || '');
|
||||
setMailingNumber(contact?.mailing_number || '');
|
||||
setMailingPostalcode(contact?.mailing_postalcode || '');
|
||||
setMailingCity(contact?.mailing_city || '');
|
||||
setMailingCountry(contact?.mailing_country || '');
|
||||
setVatCode(contact?.vat_code || '');
|
||||
setFiscalCode(contact?.fiscal_code || '');
|
||||
setCommerceCode(contact?.commerce_code || '');
|
||||
setBic(contact?.bic || '');
|
||||
setBankAccount(contact?.bank_account || '');
|
||||
setTags(contact?.tags || '');
|
||||
setProjectnote(contact?.projectnote || '');
|
||||
setContactWarning(contact?.contact_warning || '');
|
||||
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 || '',
|
||||
});
|
||||
}
|
||||
}, [open, contact]);
|
||||
}, [open, contact, reset]);
|
||||
|
||||
const handleSave = async () => {
|
||||
const onSubmit = async (formData: ContactFormData) => {
|
||||
const data: Partial<UnifiedContact> = {
|
||||
type,
|
||||
name: type === 'company' ? name : null,
|
||||
firstname: type === 'person' ? firstname : null,
|
||||
surname: type === 'person' ? surname : null,
|
||||
code: code || null,
|
||||
email_1: email1 || null,
|
||||
email_2: email2 || null,
|
||||
phone_1: phone1 || null,
|
||||
phone_2: phone2 || null,
|
||||
website: website || null,
|
||||
mailing_street: mailingStreet || null,
|
||||
mailing_number: mailingNumber || null,
|
||||
mailing_postalcode: mailingPostalcode || null,
|
||||
mailing_city: mailingCity || null,
|
||||
mailing_country: mailingCountry || null,
|
||||
vat_code: vatCode || null,
|
||||
fiscal_code: fiscalCode || null,
|
||||
commerce_code: commerceCode || null,
|
||||
bic: bic || null,
|
||||
bank_account: bankAccount || null,
|
||||
tags: tags || null,
|
||||
projectnote: projectnote || null,
|
||||
contact_warning: contactWarning || null,
|
||||
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 {
|
||||
@@ -122,12 +181,11 @@ export function ContactEditModal({ open, onClose, contact, onSaved }: ContactEdi
|
||||
|
||||
return (
|
||||
<Modal open={open} onClose={onClose} title={isEdit ? t('contacts.edit') : t('contacts.create')} size="xl" fullScreenMobile>
|
||||
<div className="space-y-4">
|
||||
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
|
||||
{/* Type */}
|
||||
<Select
|
||||
label={t('contacts.type')}
|
||||
value={type}
|
||||
onChange={(e) => setType(e.target.value as 'company' | 'person')}
|
||||
{...register('type')}
|
||||
options={[
|
||||
{ value: 'company', label: t('contacts.companies') },
|
||||
{ value: 'person', label: t('contacts.persons') },
|
||||
@@ -136,27 +194,44 @@ export function ContactEditModal({ open, onClose, contact, onSaved }: ContactEdi
|
||||
/>
|
||||
|
||||
{/* Name fields */}
|
||||
{type === 'company' ? (
|
||||
<Input label={t('contacts.name')} value={name} onChange={(e) => setName(e.target.value)} required data-testid="contact-name-input" placeholder="TechCorp GmbH" />
|
||||
{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')} value={firstname} onChange={(e) => setFirstname(e.target.value)} data-testid="contact-first-name-input" />
|
||||
<Input label={t('contacts.lastName')} value={surname} onChange={(e) => setSurname(e.target.value)} data-testid="contact-last-name-input" />
|
||||
<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')} value={code} onChange={(e) => setCode(e.target.value)} placeholder="K-00123" />
|
||||
<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" value={email1} onChange={(e) => setEmail1(e.target.value)} />
|
||||
<Input label={t('contacts.email') + ' 2'} type="email" value={email2} onChange={(e) => setEmail2(e.target.value)} />
|
||||
<Input label={t('contacts.phone') + ' 1'} value={phone1} onChange={(e) => setPhone1(e.target.value)} />
|
||||
<Input label={t('contacts.phone') + ' 2'} value={phone2} onChange={(e) => setPhone2(e.target.value)} />
|
||||
<Input label={t('contacts.website')} value={website} onChange={(e) => setWebsite(e.target.value)} />
|
||||
<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>
|
||||
|
||||
@@ -164,11 +239,11 @@ export function ContactEditModal({ open, onClose, contact, onSaved }: ContactEdi
|
||||
<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')} value={mailingStreet} onChange={(e) => setMailingStreet(e.target.value)} />
|
||||
<Input label={t('address.streetNumber')} value={mailingNumber} onChange={(e) => setMailingNumber(e.target.value)} />
|
||||
<Input label={t('address.zip')} value={mailingPostalcode} onChange={(e) => setMailingPostalcode(e.target.value)} />
|
||||
<Input label={t('address.city')} value={mailingCity} onChange={(e) => setMailingCity(e.target.value)} />
|
||||
<Input label={t('address.country')} value={mailingCountry} onChange={(e) => setMailingCountry(e.target.value)} />
|
||||
<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>
|
||||
|
||||
@@ -176,11 +251,11 @@ export function ContactEditModal({ open, onClose, contact, onSaved }: ContactEdi
|
||||
<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')} value={vatCode} onChange={(e) => setVatCode(e.target.value)} />
|
||||
<Input label={t('contacts.fiscalCode')} value={fiscalCode} onChange={(e) => setFiscalCode(e.target.value)} />
|
||||
<Input label={t('contacts.commerceCode')} value={commerceCode} onChange={(e) => setCommerceCode(e.target.value)} />
|
||||
<Input label={t('contacts.bic')} value={bic} onChange={(e) => setBic(e.target.value)} />
|
||||
<Input label={t('contacts.bankAccount')} value={bankAccount} onChange={(e) => setBankAccount(e.target.value)} />
|
||||
<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>
|
||||
|
||||
@@ -188,20 +263,18 @@ export function ContactEditModal({ open, onClose, contact, onSaved }: ContactEdi
|
||||
<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')} value={tags} onChange={(e) => setTags(e.target.value)} placeholder="tag1, tag2" />
|
||||
<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
|
||||
value={projectnote}
|
||||
onChange={(e) => setProjectnote(e.target.value)}
|
||||
{...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
|
||||
value={contactWarning}
|
||||
onChange={(e) => setContactWarning(e.target.value)}
|
||||
{...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>
|
||||
@@ -211,11 +284,11 @@ export function ContactEditModal({ open, onClose, contact, onSaved }: ContactEdi
|
||||
{/* Actions */}
|
||||
<div className="flex justify-end gap-2 pt-2">
|
||||
<Button variant="secondary" onClick={onClose}>{t('common.cancel')}</Button>
|
||||
<Button onClick={handleSave} isLoading={createMutation.isPending || updateMutation.isPending} data-testid="contact-submit-btn">
|
||||
<Button type="submit" isLoading={isSubmitting || createMutation.isPending || updateMutation.isPending} data-testid="contact-submit-btn">
|
||||
{isEdit ? t('common.save') : t('common.create')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@ import React, { useState, useRef, useCallback } from 'react';
|
||||
import { Modal } from '@/components/ui/Modal';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { useToast } from '@/components/ui/Toast';
|
||||
import { useCompanyImport } from '@/api/hooks';
|
||||
import { apiClient } from '@/api/client';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
export interface CsvImportDialogProps {
|
||||
@@ -35,7 +35,7 @@ function parseCSV(text: string): { headers: string[]; rows: ParsedRow[] } {
|
||||
export function CsvImportDialog({ open, onClose, onSuccess }: CsvImportDialogProps) {
|
||||
const { t } = useTranslation();
|
||||
const toast = useToast();
|
||||
const importMutation = useCompanyImport();
|
||||
const [importing, setImporting] = useState(false);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const [selectedFile, setSelectedFile] = useState<File | null>(null);
|
||||
const [previewData, setPreviewData] = useState<{ headers: string[]; rows: ParsedRow[] } | null>(null);
|
||||
@@ -61,8 +61,13 @@ export function CsvImportDialog({ open, onClose, onSuccess }: CsvImportDialogPro
|
||||
|
||||
const handleImport = async () => {
|
||||
if (!selectedFile) return;
|
||||
setImporting(true);
|
||||
try {
|
||||
await importMutation.mutateAsync(selectedFile);
|
||||
const formData = new FormData();
|
||||
formData.append('file', selectedFile);
|
||||
await apiClient.post('/contacts/import', formData, {
|
||||
headers: { 'Content-Type': 'multipart/form-data' },
|
||||
});
|
||||
toast.success('Import erfolgreich abgeschlossen.');
|
||||
setSelectedFile(null);
|
||||
setPreviewData(null);
|
||||
@@ -71,6 +76,8 @@ export function CsvImportDialog({ open, onClose, onSuccess }: CsvImportDialogPro
|
||||
onClose();
|
||||
} catch (err: any) {
|
||||
toast.error(err.message || 'Import fehlgeschlagen.');
|
||||
} finally {
|
||||
setImporting(false);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -134,8 +141,8 @@ export function CsvImportDialog({ open, onClose, onSuccess }: CsvImportDialogPro
|
||||
<Button variant="secondary" onClick={handleClose}>{t('common.cancel')}</Button>
|
||||
<Button
|
||||
onClick={handleImport}
|
||||
disabled={!selectedFile || importMutation.isPending}
|
||||
isLoading={importMutation.isPending}
|
||||
disabled={!selectedFile || importing}
|
||||
isLoading={importing}
|
||||
data-testid="csv-import-button"
|
||||
>
|
||||
{t('common.save')}
|
||||
|
||||
Reference in New Issue
Block a user