fix: BUG-080/082 (20 unused frontend components deleted), BUG-083 (useTenant.ts deleted), BUG-011 (playwright baseURL), BUG-069 (unused python modules deleted), BUG-065 (already has eager loading), BUG-026 (already fixed 422), BUG-023 (no sync I/O found)
Check Cross-Plugin Imports / check (push) Has been cancelled

This commit is contained in:
Agent Zero
2026-08-22 07:37:11 +02:00
parent 40cc99af5c
commit db4701bae7
30 changed files with 5 additions and 5076 deletions
@@ -1,349 +0,0 @@
import { asError } from '@/utils/errorTypes';
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 { Modal } from '@/components/ui/Modal';
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 ContactEditModalProps {
open: boolean;
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 ContactEditModal({ open, onClose, contact, onSaved }: ContactEditModalProps) {
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 (open && customFieldsData?.fields) {
const vals: Record<string, any> = {};
for (const f of customFieldsData.fields) {
vals[f.name] = f.value ?? f.default_value ?? null;
}
setCustomValues(vals);
}
}, [open, customFieldsData]);
const handleCustomFieldChange = (name: string, value: any) => {
setCustomValues(prev => ({ ...prev, [name]: value }));
};
// Reset form when modal opens
useEffect(() => {
if (open) {
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, 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: unknown) { const errObj = asError(cfErr);
// Don't fail the whole save if custom fields fail
console.error('Custom fields save failed:', errObj);
}
}
onClose();
} catch (err: unknown) { const errObj = asError(err);
toast.error(errObj.message || (isEdit ? t('contacts.updateFailed') : t('contacts.createFailed')));
}
};
return (
<Modal open={open} onClose={onClose} title={isEdit ? t('contacts.edit') : t('contacts.create')} size="xl" fullScreenMobile>
<form onSubmit={handleSubmit(onSubmit)} className="space-y-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>
</Modal>
);
}
@@ -1256,7 +1256,7 @@ export function ContactList({
};
return (
<div data-testid="contact-list" className="flex flex-col h-full" data-testid="contact-cards-view">
<div data-testid="contact-cards-view" className="flex flex-col h-full">
<div ref={scrollRef} className="flex-1 overflow-y-auto p-3">
{isGrouped && groupedContacts ? (
<div className="space-y-4">
@@ -1,195 +0,0 @@
/**
* DedupDialog — UI for finding and merging duplicate contacts (Task 5.23).
*/
import React, { useState, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { Modal } from '@/components/ui/Modal';
import { Button } from '@/components/ui/Button';
import { Badge } from '@/components/ui/Badge';
import { useFindDuplicates, useMergeContacts, type DuplicatePair } from '@/api/dedup';
import { useToast } from '@/components/ui/Toast';
import { GitMerge, Search, AlertTriangle, Check } from 'lucide-react';
export function DedupDialog({ open, onClose }: { open: boolean; onClose: () => void }) {
const { t } = useTranslation();
const { success, error: showError } = useToast();
const findDuplicates = useFindDuplicates();
const mergeContacts = useMergeContacts();
const [duplicates, setDuplicates] = useState<DuplicatePair[]>([]);
const [selectedPair, setSelectedPair] = useState<number | null>(null);
const [fieldOverrides, setFieldOverrides] = useState<Record<string, string>>({});
const handleSearch = useCallback(async () => {
try {
const result = await findDuplicates.mutateAsync({ threshold: 0.7, limit: 50 });
setDuplicates(result || []);
setSelectedPair(null);
} catch {
showError(t('dedup.searchFailed'));
}
}, [findDuplicates, showError, t]);
const handleMerge = useCallback(async () => {
if (selectedPair === null) return;
const pair = duplicates[selectedPair];
if (!pair) return;
try {
const overrides: Record<string, unknown> = {};
for (const [field, value] of Object.entries(fieldOverrides)) {
if (value === 'source') {
overrides[field] = (pair.source_contact as unknown as Record<string, unknown>)[field];
} else if (value === 'target') {
overrides[field] = (pair.target_contact as unknown as Record<string, unknown>)[field];
}
}
await mergeContacts.mutateAsync({
source_contact_id: pair.source_contact.id,
target_contact_id: pair.target_contact.id,
field_overrides: Object.keys(overrides).length > 0 ? overrides : undefined,
});
success(t('dedup.mergeSuccess'));
setDuplicates((prev) => prev.filter((_, i) => i !== selectedPair));
setSelectedPair(null);
setFieldOverrides({});
} catch {
showError(t('dedup.mergeFailed'));
}
}, [duplicates, selectedPair, fieldOverrides, mergeContacts, success, showError, t]);
const compareFields = [
{ key: 'displayname', label: t('dedup.fields.displayName') },
{ key: 'email_1', label: t('dedup.fields.email') },
{ key: 'phone_1', label: t('dedup.fields.phone') },
{ key: 'mailing_city', label: t('dedup.fields.city') },
{ key: 'mailing_postalcode', label: t('dedup.fields.postalCode') },
];
return (
<Modal open={open} onClose={onClose} title={t('dedup.title')} size="xl">
<div className="space-y-4" data-testid="dedup-dialog">
<div className="flex items-center gap-3">
<Button
variant="primary"
icon={<Search className="w-4 h-4" />}
onClick={handleSearch}
isLoading={findDuplicates.isPending}
data-testid="dedup-search-btn"
>
{t('dedup.findDuplicates')}
</Button>
{duplicates.length > 0 && (
<Badge variant="info">{duplicates.length} {t('dedup.pairsFound')}</Badge>
)}
</div>
{duplicates.length === 0 && !findDuplicates.isPending && (
<p className="text-sm text-secondary-500" data-testid="dedup-empty">
{t('dedup.noDuplicates')}
</p>
)}
{duplicates.map((pair, idx) => (
<div
key={`${pair.source_contact.id}-${pair.target_contact.id}`}
className={`border rounded-lg p-4 cursor-pointer transition-colors ${
selectedPair === idx ? 'border-primary-500 bg-primary-50' : 'border-secondary-200'
}`}
onClick={() => setSelectedPair(idx)}
data-testid={`dedup-pair-${idx}`}
>
<div className="flex items-center justify-between mb-3">
<div className="flex items-center gap-2">
<AlertTriangle className="w-4 h-4 text-warning-500" />
<span className="font-medium">
{t('dedup.similarity')}: {Math.round(pair.similarity_score * 100)}%
</span>
</div>
<div className="flex gap-1">
{pair.match_reasons.map((reason) => (
<Badge key={reason} variant="warning">{reason}</Badge>
))}
</div>
</div>
{selectedPair === idx && (
<div className="mt-4 space-y-3">
<div className="grid grid-cols-3 gap-2 text-sm font-medium text-secondary-600">
<div>{t('dedup.field')}</div>
<div className="text-center">{t('dedup.source')}</div>
<div className="text-center">{t('dedup.target')}</div>
</div>
{compareFields.map((field) => {
const sourceVal = (pair.source_contact as unknown as Record<string, unknown>)[field.key] as string | null;
const targetVal = (pair.target_contact as unknown as Record<string, unknown>)[field.key] as string | null;
return (
<div key={field.key} className="grid grid-cols-3 gap-2 text-sm">
<div className="text-secondary-700">{field.label}</div>
<div className="text-center">
<button
className={`px-2 py-1 rounded ${
fieldOverrides[field.key] === 'source' ? 'bg-primary-100 text-primary-700' : 'hover:bg-secondary-100'
}`}
onClick={(e) => {
e.stopPropagation();
setFieldOverrides((prev) => ({ ...prev, [field.key]: 'source' }));
}}
data-testid={`dedup-field-${field.key}-source`}
>
{sourceVal || '—'}
</button>
</div>
<div className="text-center">
<button
className={`px-2 py-1 rounded ${
fieldOverrides[field.key] === 'target' ? 'bg-primary-100 text-primary-700' : 'hover:bg-secondary-100'
}`}
onClick={(e) => {
e.stopPropagation();
setFieldOverrides((prev) => ({ ...prev, [field.key]: 'target' }));
}}
data-testid={`dedup-field-${field.key}-target`}
>
{targetVal || '—'}
</button>
</div>
</div>
);
})}
<div className="flex justify-end gap-2 pt-2">
<Button
variant="primary"
icon={<GitMerge className="w-4 h-4" />}
onClick={handleMerge}
isLoading={mergeContacts.isPending}
data-testid="dedup-merge-btn"
>
{t('dedup.merge')}
</Button>
</div>
</div>
)}
{selectedPair !== idx && (
<div className="grid grid-cols-2 gap-4 text-sm">
<div>
<div className="font-medium text-secondary-800">{pair.source_contact.displayname}</div>
<div className="text-secondary-500">{pair.source_contact.email_1 || '—'}</div>
</div>
<div>
<div className="font-medium text-secondary-800">{pair.target_contact.displayname}</div>
<div className="text-secondary-500">{pair.target_contact.email_1 || '—'}</div>
</div>
</div>
)}
</div>
))}
</div>
</Modal>
);
}