Phase 1C: Frontend unified contact UI

This commit is contained in:
Agent Zero
2026-07-23 17:17:32 +02:00
parent a8331fbc2b
commit 879106c4eb
62 changed files with 552 additions and 1276 deletions
@@ -7,7 +7,7 @@ import { GlobalSearchResultsPage } from '@/pages/GlobalSearchResults';
vi.mock('@/api/hooks', () => ({
useGlobalSearch: () => ({
data: [
{ id: '1', type: 'company', name: 'TestCorp GmbH', description: 'IT company', url: '/companies/1' },
{ id: '1', type: 'contact', name: 'TestCorp GmbH', description: 'IT company', url: '/contacts/1' },
{ id: '2', type: 'contact', name: 'Max Mustermann', description: 'CEO', url: '/contacts/2' },
],
isLoading: false,
@@ -6,7 +6,7 @@ import { MemoryRouter } from 'react-router-dom';
vi.mock('@/api/hooks', () => ({
useGlobalSearch: () => ({
data: [
{ id: '1', type: 'company', name: 'TestCorp GmbH', description: 'IT company', url: '/companies/1' },
{ id: '1', type: 'contact', name: 'TestCorp GmbH', description: 'IT company', url: '/contacts/1' },
{ id: '2', type: 'contact', name: 'Max Mustermann', description: 'CEO', url: '/contacts/2' },
{ id: '3', type: 'mail', name: 'Test Email', description: 'Subject: Hello', url: '/mail/3' },
{ id: '4', type: 'file', name: 'Document.pdf', description: 'PDF file', url: '/dms/4' },
@@ -27,17 +27,17 @@ beforeEach(() => {
describe('BulkTagDialog', () => {
it('renders dialog when open', async () => {
render(<BulkTagDialog open={true} entityType="company" entityIds={['c1', 'c2']} onClose={vi.fn()} onAssigned={vi.fn()} />);
render(<BulkTagDialog open={true} entityType="contact" entityIds={['c1', 'c2']} onClose={vi.fn()} onAssigned={vi.fn()} />);
expect(screen.getByRole('dialog')).toBeInTheDocument();
});
it('shows selected entity count', async () => {
render(<BulkTagDialog open={true} entityType="company" entityIds={['c1', 'c2', 'c3']} onClose={vi.fn()} onAssigned={vi.fn()} />);
render(<BulkTagDialog open={true} entityType="contact" entityIds={['c1', 'c2', 'c3']} onClose={vi.fn()} onAssigned={vi.fn()} />);
expect(screen.getByText('3 ausgewaehlte Eintraege')).toBeInTheDocument();
});
it('loads and displays available tags', async () => {
render(<BulkTagDialog open={true} entityType="company" entityIds={['c1']} onClose={vi.fn()} onAssigned={vi.fn()} />);
render(<BulkTagDialog open={true} entityType="contact" entityIds={['c1']} onClose={vi.fn()} onAssigned={vi.fn()} />);
await waitFor(() => {
expect(fetchTags).toHaveBeenCalled();
});
@@ -48,7 +48,7 @@ describe('BulkTagDialog', () => {
});
it('toggles tag selection on click', async () => {
render(<BulkTagDialog open={true} entityType="company" entityIds={['c1']} onClose={vi.fn()} onAssigned={vi.fn()} />);
render(<BulkTagDialog open={true} entityType="contact" entityIds={['c1']} onClose={vi.fn()} onAssigned={vi.fn()} />);
await waitFor(() => {
expect(fetchTags).toHaveBeenCalled();
});
@@ -62,7 +62,7 @@ describe('BulkTagDialog', () => {
it('calls bulkAssignTags on assign button click', async () => {
const onAssigned = vi.fn();
const onClose = vi.fn();
render(<BulkTagDialog open={true} entityType="company" entityIds={['c1', 'c2']} onClose={onClose} onAssigned={onAssigned} />);
render(<BulkTagDialog open={true} entityType="contact" entityIds={['c1', 'c2']} onClose={onClose} onAssigned={onAssigned} />);
await waitFor(() => {
expect(fetchTags).toHaveBeenCalled();
});
@@ -73,14 +73,14 @@ describe('BulkTagDialog', () => {
await waitFor(() => {
expect(bulkAssignTags).toHaveBeenCalledWith({
tag_ids: ['t1'],
entity_type: 'company',
entity_type: 'contact',
entity_ids: ['c1', 'c2'],
});
});
});
it('does not render when closed', () => {
render(<BulkTagDialog open={false} entityType="company" entityIds={['c1']} onClose={vi.fn()} onAssigned={vi.fn()} />);
render(<BulkTagDialog open={false} entityType="contact" entityIds={['c1']} onClose={vi.fn()} onAssigned={vi.fn()} />);
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
});
});
+21 -24
View File
@@ -1,13 +1,6 @@
import React from 'react';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import { TagPicker } from '@/components/tags/TagPicker';
const mockTags = [
{ id: 't1', name: 'VIP-Kunde', color: '#EF4444', created_by: 'u1', usage_count: 5 },
{ id: 't2', name: 'Lead', color: '#3B82F6', created_by: 'u1', usage_count: 12 },
{ id: 't3', name: 'Aktiv', color: '#10B981', created_by: 'u1', usage_count: 3 },
];
import { render, screen, waitFor, fireEvent } from '@testing-library/react';
const mockFetchTags = vi.fn();
const mockAssignTag = vi.fn();
@@ -15,44 +8,48 @@ const mockUnassignTag = vi.fn();
const mockCreateTag = vi.fn();
vi.mock('@/api/tags', () => ({
fetchTags: (...args: unknown[]) => mockFetchTags(...args),
assignTag: (...args: unknown[]) => mockAssignTag(...args),
unassignTag: (...args: unknown[]) => mockUnassignTag(...args),
createTag: (...args: unknown[]) => mockCreateTag(...args),
fetchTags: (...args: any[]) => mockFetchTags(...args),
assignTag: (...args: any[]) => mockAssignTag(...args),
unassignTag: (...args: any[]) => mockUnassignTag(...args),
createTag: (...args: any[]) => mockCreateTag(...args),
}));
vi.mock('@/components/ui/Toast', () => ({
useToast: () => ({ success: vi.fn(), error: vi.fn(), info: vi.fn(), warning: vi.fn() }),
}));
import { TagPicker } from '@/components/tags/TagPicker';
const mockTags = [
{ id: 't1', name: 'VIP-Kunde', color: '#10B981', created_by: 'u1' },
{ id: 't2', name: 'Lead', color: '#3B82F6', created_by: 'u1' },
{ id: 't3', name: 'Archiv', color: '#6B7280', created_by: 'u1' },
];
beforeEach(() => {
vi.clearAllMocks();
mockFetchTags.mockResolvedValue(mockTags);
mockAssignTag.mockResolvedValue({ id: 'a1', tag_id: 't1', entity_type: 'company', entity_id: 'c1' });
mockAssignTag.mockResolvedValue({ id: 'a1', tag_id: 't1', entity_type: 'contact', entity_id: 'c1' });
mockUnassignTag.mockResolvedValue(undefined);
mockCreateTag.mockResolvedValue({ id: 't4', name: 'Neu', color: '#F59E0B', created_by: 'u1' });
});
describe('TagPicker', () => {
it('renders the tag picker container', async () => {
render(<TagPicker entityType="company" entityId="c1" />);
render(<TagPicker entityType="contact" entityId="c1" />);
expect(screen.getByTestId('tag-picker')).toBeInTheDocument();
});
it('renders assigned tags section', async () => {
render(<TagPicker entityType="company" entityId="c1" />);
render(<TagPicker entityType="contact" entityId="c1" />);
expect(screen.getByText('Zugewiesene Tags')).toBeInTheDocument();
});
it('shows no tags assigned message when empty', async () => {
render(<TagPicker entityType="company" entityId="c1" />);
render(<TagPicker entityType="contact" entityId="c1" />);
await waitFor(() => {
expect(screen.getByText('Keine Tags zugewiesen')).toBeInTheDocument();
});
});
it('loads and displays available tags', async () => {
render(<TagPicker entityType="company" entityId="c1" />);
render(<TagPicker entityType="contact" entityId="c1" />);
await waitFor(() => {
expect(mockFetchTags).toHaveBeenCalled();
});
@@ -64,18 +61,18 @@ describe('TagPicker', () => {
});
it('assigns a tag when clicking available tag', async () => {
render(<TagPicker entityType="company" entityId="c1" />);
render(<TagPicker entityType="contact" entityId="c1" />);
await waitFor(() => {
expect(screen.getByText('VIP-Kunde')).toBeInTheDocument();
});
fireEvent.click(screen.getByText('VIP-Kunde'));
await waitFor(() => {
expect(mockAssignTag).toHaveBeenCalledWith({ tag_id: 't1', entity_type: 'company', entity_id: 'c1' });
expect(mockAssignTag).toHaveBeenCalledWith({ tag_id: 't1', entity_type: 'contact', entity_id: 'c1' });
});
});
it('shows create tag form when clicking create button', async () => {
render(<TagPicker entityType="company" entityId="c1" />);
render(<TagPicker entityType="contact" entityId="c1" />);
await waitFor(() => {
expect(screen.getByText('Neues Tag')).toBeInTheDocument();
});
@@ -86,7 +83,7 @@ describe('TagPicker', () => {
});
it('renders search input for tags', async () => {
render(<TagPicker entityType="company" entityId="c1" />);
render(<TagPicker entityType="contact" entityId="c1" />);
expect(screen.getByLabelText('Tag suchen')).toBeInTheDocument();
});
});
+1 -1
View File
@@ -53,7 +53,7 @@ export interface CalendarEntry {
export interface CalendarEntryLink {
id: string;
entity_type: 'company' | 'contact';
entity_type: 'contact' | 'company';
entity_id: string;
}
+2 -92
View File
@@ -1,15 +1,11 @@
/**
* hooks.ts — Re-Export Hub
* hooks.ts — Re-Export Hub (Company hooks removed in Phase 1C)
*
* This file re-exports all hooks from their dedicated modules so that
* existing imports `from '@/api/hooks'` continue to work without change.
*
* Company hooks remain inline here — they will be removed in Phase 1.
*/
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { apiGet, apiPost, apiPatch, apiDelete, apiClient } from './client';
import { PaginatedResponse, Company, CompanyDetail } from './types';
import { useQuery } from '@tanstack/react-query';
// ── Re-exports from dedicated modules ──
export * from './types';
@@ -48,89 +44,3 @@ export function useGlobalSearch(query: string, entityTypes?: string[]) {
staleTime: 30 * 1000,
});
}
// ── Company hooks (will be removed in Phase 1) ──
export function useCompanies(page = 1, pageSize = 25, search?: string, industry?: string, sortBy?: string, sortOrder?: string) {
const params = new URLSearchParams({ page: String(page), page_size: String(pageSize) });
if (search) params.set('search', search);
if (industry) params.set('industry', industry);
if (sortBy) params.set('sort_by', sortBy);
if (sortOrder) params.set('sort_order', sortOrder);
return useQuery({
queryKey: ['companies', page, pageSize, search, industry, sortBy, sortOrder],
queryFn: () =>
apiGet<PaginatedResponse<Company>>(`/companies?${params.toString()}`),
});
}
export function useCompany(id?: string) {
return useQuery({
queryKey: ['companies', id],
queryFn: () => apiGet<CompanyDetail>(`/companies/${id}`),
enabled: !!id,
});
}
export function useCreateCompany() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (data: Partial<Company>) => apiPost('/companies', data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['companies'] });
},
});
}
export function useUpdateCompany() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ id, data }: { id: string; data: Partial<Company> }) =>
apiPatch(`/companies/${id}`, data),
onSuccess: (_data, variables) => {
queryClient.invalidateQueries({ queryKey: ['companies'] });
queryClient.invalidateQueries({ queryKey: ['companies', variables.id] });
},
});
}
export function useDeleteCompany() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (id: string) => apiDelete(`/companies/${id}`),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['companies'] });
},
});
}
export function useCompanyExport(search?: string, industry?: string) {
return useMutation({
mutationFn: async () => {
const params = new URLSearchParams({ format: 'csv' });
if (search) params.set('search', search);
if (industry) params.set('industry', industry);
const response = await apiClient.get(`/companies/export?${params.toString()}`, {
responseType: 'blob',
});
return response.data;
},
});
}
export function useCompanyImport() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (file: File) => {
const formData = new FormData();
formData.append('file', file);
const response = await apiClient.post('/companies/import', formData, {
headers: { 'Content-Type': 'multipart/form-data' },
});
return response.data;
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['companies'] });
},
});
}
-1
View File
@@ -272,7 +272,6 @@ export interface FlagUpdatePayload {
export interface LinkMailPayload {
contact_id?: string | null;
company_id?: string | null;
}
export interface CreateEventFromMailPayload {
+1 -1
View File
@@ -1,7 +1,7 @@
import { apiClient } from './client';
export interface SearchResult {
type: 'company' | 'contact' | 'mail' | 'file' | 'event';
type: 'contact' | 'mail' | 'file' | 'event';
id: string;
name: string;
description?: string;
+1 -1
View File
@@ -9,7 +9,7 @@ import { apiDelete, apiGet, apiPatch, apiPost } from './client';
// ─── Types ─────────────────────────────────────────────────────────────────
export type EntityType = 'company' | 'contact' | 'file' | 'calendar_entry';
export type EntityType = 'contact' | 'file' | 'calendar_entry';
export interface Tag {
id: string;
+1 -18
View File
@@ -9,23 +9,6 @@ export interface PaginatedResponse<T> {
page_size: number;
}
export interface Company {
id: string;
name: string;
account_number?: string | null;
industry?: string | null;
phone?: string | null;
email?: string | null;
website?: string | null;
description?: string | null;
created_at?: string;
updated_at?: string;
}
export interface CompanyDetail extends Company {
contacts?: Contact[];
}
export interface Contact {
id: string;
first_name: string;
@@ -39,5 +22,5 @@ export interface Contact {
}
export interface ContactDetail extends Contact {
companies?: Company[];
companies?: Array<{ id: string; name: string }>;
}
@@ -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')}
+66
View File
@@ -0,0 +1,66 @@
/**
* Contact Detail Page — loads a single contact by ID from route params.
*/
import React from 'react';
import { useParams, useNavigate } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { ContactDetail } from '@/components/contacts/ContactDetail';
import { ContactEditModal } from '@/components/contacts/ContactEditModal';
import { useUnifiedContact, type UnifiedContact } from '@/api/hooks';
import { Button } from '@/components/ui/Button';
import { ChevronLeft } from 'lucide-react';
export function ContactDetailPage() {
const { t } = useTranslation();
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
const { data: contact, isLoading } = useUnifiedContact(id);
const [editModalOpen, setEditModalOpen] = React.useState(false);
const [editingContact, setEditingContact] = React.useState<UnifiedContact | null>(null);
const handleEdit = () => {
if (contact) {
setEditingContact(contact);
setEditModalOpen(true);
}
};
const handleDeleted = () => {
navigate('/contacts');
};
const handleSaved = () => {
setEditModalOpen(false);
setEditingContact(null);
};
return (
<div className="flex flex-col h-full" data-testid="contact-detail-page">
<div className="flex items-center gap-2 px-4 py-2 border-b border-secondary-200 bg-white">
<button
onClick={() => navigate('/contacts')}
className="inline-flex items-center gap-1 px-2 py-1 rounded text-sm text-secondary-700 hover:bg-secondary-100 min-h-touch"
aria-label={t('common.back')}
>
<ChevronLeft className="w-4 h-4" aria-hidden="true" strokeWidth={2} />
<span>{t('contacts.title')}</span>
</button>
</div>
<div className="flex-1 overflow-y-auto">
<ContactDetail
contact={contact ?? null}
loading={isLoading}
onEdit={handleEdit}
onDeleted={handleDeleted}
/>
</div>
<ContactEditModal
open={editModalOpen}
onClose={() => setEditModalOpen(false)}
contact={editingContact}
onSaved={handleSaved}
/>
</div>
);
}
+24
View File
@@ -264,6 +264,30 @@ export function ContactsListPage() {
<div className="flex-1" />
{/* Type filter toggle */}
<div className="flex items-center gap-0.5 bg-secondary-100 rounded-md p-0.5 mr-1">
{[
{ value: 'all' as ContactFilter, label: t('common.all') },
{ value: 'company' as ContactFilter, label: t('contacts.companies') },
{ value: 'person' as ContactFilter, label: t('contacts.persons') },
].map((opt) => (
<button
key={opt.value}
onClick={() => handleSelectFilter(opt.value)}
className={clsx(
'px-2 py-1 rounded text-xs font-medium transition-colors min-h-touch',
selectedFilter === opt.value
? 'bg-white text-primary-600 shadow-sm'
: 'text-secondary-500 hover:text-secondary-700',
)}
aria-label={opt.label}
aria-pressed={selectedFilter === opt.value}
>
{opt.label}
</button>
))}
</div>
{/* View mode toggle */}
<div className="flex items-center gap-0.5 bg-secondary-100 rounded-md p-0.5">
{viewModeOptions.map((opt) => (
+2
View File
@@ -10,6 +10,7 @@ import { Loader2 } from 'lucide-react';
// Lazy-loaded pages (code-splitting)
const DashboardPage = React.lazy(() => import('@/pages/Dashboard').then(m => ({ default: m.DashboardPage })));
const ContactsListPage = React.lazy(() => import('@/pages/ContactsList').then(m => ({ default: m.ContactsListPage })));
const ContactDetailPage = React.lazy(() => import('@/pages/ContactDetailPage').then(m => ({ default: m.ContactDetailPage })));
const AuditLogPage = React.lazy(() => import('@/pages/AuditLog').then(m => ({ default: m.AuditLogPage })));
const GlobalSearchResultsPage = React.lazy(() => import('@/pages/GlobalSearchResults').then(m => ({ default: m.GlobalSearchResultsPage })));
const SettingsPage = React.lazy(() => import('@/pages/Settings').then(m => ({ default: m.SettingsPage })));
@@ -71,6 +72,7 @@ const router = createBrowserRouter([
{ path: '/', element: withSuspense(<DashboardPage />) },
{ path: '/dashboard', element: withSuspense(<DashboardPage />) },
{ path: '/contacts', element: withSuspense(<ContactsListPage />) },
{ path: '/contacts/:id', element: withSuspense(<ContactDetailPage />) },
{ path: '/audit-log', element: withSuspense(<AuditLogPage />) },
{ path: '/search', element: withSuspense(<GlobalSearchResultsPage />) },
{ path: '/calendar', element: withSuspense(<CalendarPage />) },