Frontend: unified 3-column contacts page (folder tree | list/table/cards | detail), all Rentman fields, ContactPerson CRUD, removed old Contact/Company pages

This commit is contained in:
Agent Zero
2026-07-19 21:30:26 +02:00
parent cf75680583
commit 3177daf47f
25 changed files with 1869 additions and 1592 deletions
@@ -1,120 +0,0 @@
import React from 'react';
import { describe, it, expect, vi, afterEach, beforeEach } from 'vitest';
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import { MemoryRouter } from 'react-router-dom';
import { CompaniesListPage } from '@/pages/CompaniesList';
const mockCompanies = [
{ id: '1', name: 'TestCorp GmbH', email: 'info@test.de', phone: '+49 30 123', industry: 'IT', account_number: 'K-001', created_at: '2025-01-01T00:00:00Z', updated_at: '2025-01-01T00:00:00Z' },
{ id: '2', name: 'Müller AG', email: 'kontakt@mueller.de', phone: '+49 89 987', industry: 'Logistik', account_number: 'K-002', created_at: '2025-01-02T00:00:00Z', updated_at: '2025-01-02T00:00:00Z' },
];
let mockData: any = { items: mockCompanies, total: 2 };
let mockIsLoading = false;
vi.mock('@/api/hooks', () => ({
useCompanies: () => ({ data: mockData, isLoading: mockIsLoading }),
useCompanyExport: () => ({ mutateAsync: vi.fn().mockResolvedValue(new Blob(['csv'], { type: 'text/csv' })), isPending: false }),
useDeleteCompany: () => ({ mutateAsync: vi.fn(), isPending: false }),
useCompanyImport: () => ({ mutateAsync: vi.fn(), isPending: false }),
}));
vi.mock('@/components/ui/Toast', () => ({
useToast: () => ({ success: vi.fn(), error: vi.fn(), info: vi.fn() }),
}));
vi.mock('@/components/shared/UnsavedChangesGuard', () => ({
UnsavedChangesGuard: () => null,
}));
function renderWithRouter() {
return render(<MemoryRouter><CompaniesListPage /></MemoryRouter>);
}
beforeEach(() => {
mockData = { items: mockCompanies, total: 2 };
mockIsLoading = false;
});
afterEach(() => {
mockData = { items: mockCompanies, total: 2 };
mockIsLoading = false;
});
describe('CompaniesListPage', () => {
it('renders the page with title', () => {
renderWithRouter();
expect(screen.getByTestId('companies-list-page')).toBeInTheDocument();
});
it('renders DataGrid for company list (TanStack Table)', () => {
renderWithRouter();
expect(screen.getByTestId('companies-grid')).toBeInTheDocument();
});
it('renders company data in grid', () => {
renderWithRouter();
expect(screen.getByText('TestCorp GmbH')).toBeInTheDocument();
expect(screen.getByText('Müller AG')).toBeInTheDocument();
});
it('renders search input', () => {
renderWithRouter();
expect(screen.getByLabelText(/Suchen/)).toBeInTheDocument();
});
it('renders create button', () => {
renderWithRouter();
expect(screen.getByText('Firma erstellen')).toBeInTheDocument();
});
it('renders import button', () => {
renderWithRouter();
expect(screen.getByText('CSV importieren')).toBeInTheDocument();
});
it('renders export button', () => {
renderWithRouter();
expect(screen.getByText('CSV exportieren')).toBeInTheDocument();
});
it('renders table headers with sortable columns', () => {
renderWithRouter();
expect(screen.getByText('Firmenname')).toBeInTheDocument();
expect(screen.getByText('Branche')).toBeInTheDocument();
});
it('shows empty state when no companies and no search', () => {
mockData = { items: [], total: 0 };
renderWithRouter();
expect(screen.getByText('Keine Firmen vorhanden')).toBeInTheDocument();
expect(screen.getByText('Firma erstellen')).toBeInTheDocument();
mockData = { items: mockCompanies, total: 2 };
});
it('shows no results message when search yields nothing', async () => {
renderWithRouter();
const searchInput = screen.getByLabelText(/Suchen/);
fireEvent.change(searchInput, { target: { value: 'xyz' } });
mockData = { items: [], total: 0 };
// With search text but no results, grid should show no results message
await waitFor(() => {
expect(screen.getByText('Keine Ergebnisse gefunden')).toBeInTheDocument();
});
});
it('clicking sort header toggles sort direction', () => {
renderWithRouter();
const nameHeader = screen.getByText('Firmenname');
const th = nameHeader.closest('th');
expect(th).toHaveAttribute('aria-sort', 'none');
fireEvent.click(nameHeader);
expect(th).toHaveAttribute('aria-sort', 'ascending');
});
it('opens import dialog when import button clicked', () => {
renderWithRouter();
fireEvent.click(screen.getByText('CSV importieren'));
expect(screen.getByTestId('csv-import-dialog')).toBeInTheDocument();
});
});
@@ -1,88 +0,0 @@
import React from 'react';
import { describe, it, expect, vi } from 'vitest';
import { render, screen, fireEvent } from '@testing-library/react';
import { MemoryRouter } from 'react-router-dom';
import { CompanyDetailPage } from '@/pages/CompanyDetail';
const mockCompany = {
id: '1',
name: 'TestCorp GmbH',
email: 'info@testcorp.de',
phone: '+49 30 12345678',
website: 'https://testcorp.de',
industry: 'IT & Software',
account_number: 'K-00123',
description: 'Ein führendes IT-Unternehmen',
created_at: '2025-01-01T00:00:00Z',
updated_at: '2025-01-01T00:00:00Z',
contacts: [
{ id: 'c1', first_name: 'Max', last_name: 'Mustermann', email: 'max@testcorp.de', position: 'CEO' },
{ id: 'c2', first_name: 'Anna', last_name: 'Schmidt', email: 'anna@testcorp.de', position: 'CTO' },
],
};
let mockReturnData: any = { data: mockCompany, isLoading: false, isError: false };
vi.mock('@/api/hooks', () => ({
useCompany: () => mockReturnData,
}));
describe('CompanyDetailPage', () => {
it('renders the detail page', () => {
render(<MemoryRouter initialEntries={['/companies/1']}><CompanyDetailPage /></MemoryRouter>);
expect(screen.getByTestId('company-detail-page')).toBeInTheDocument();
});
it('renders company name as heading', () => {
render(<MemoryRouter initialEntries={['/companies/1']}><CompanyDetailPage /></MemoryRouter>);
expect(screen.getByRole('heading', { level: 1, name: 'TestCorp GmbH' })).toBeInTheDocument();
});
it('renders tabs for detail sections', () => {
render(<MemoryRouter initialEntries={['/companies/1']}><CompanyDetailPage /></MemoryRouter>);
expect(screen.getByRole('tablist')).toBeInTheDocument();
expect(screen.getByRole('tab', { name: /Übersicht/ })).toBeInTheDocument();
expect(screen.getByRole('tab', { name: /Kontakte/ })).toBeInTheDocument();
expect(screen.getByRole('tab', { name: /Dateien/ })).toBeInTheDocument();
expect(screen.getByRole('tab', { name: /Aktivität/ })).toBeInTheDocument();
});
it('renders overview tab content by default', () => {
render(<MemoryRouter initialEntries={['/companies/1']}><CompanyDetailPage /></MemoryRouter>);
expect(screen.getByText('K-00123')).toBeInTheDocument();
expect(screen.getByText('IT & Software')).toBeInTheDocument();
expect(screen.getByText('+49 30 12345678')).toBeInTheDocument();
});
it('renders contacts tab with contact list and badge count', () => {
render(<MemoryRouter initialEntries={['/companies/1']}><CompanyDetailPage /></MemoryRouter>);
const contactsTab = screen.getByRole('tab', { name: /Kontakte/ });
fireEvent.click(contactsTab);
expect(screen.getByText('Max Mustermann')).toBeInTheDocument();
expect(screen.getByText('Anna Schmidt')).toBeInTheDocument();
});
it('renders edit button', () => {
render(<MemoryRouter initialEntries={['/companies/1']}><CompanyDetailPage /></MemoryRouter>);
expect(screen.getByText('Bearbeiten')).toBeInTheDocument();
});
it('renders back button', () => {
render(<MemoryRouter initialEntries={['/companies/1']}><CompanyDetailPage /></MemoryRouter>);
expect(screen.getByText(/Zurück/)).toBeInTheDocument();
});
it('shows error state when company not found', () => {
mockReturnData = { data: undefined, isLoading: false, isError: true };
render(<MemoryRouter initialEntries={['/companies/999']}><CompanyDetailPage /></MemoryRouter>);
expect(screen.getByText('Firma nicht gefunden.')).toBeInTheDocument();
mockReturnData = { data: mockCompany, isLoading: false, isError: false };
});
it('shows skeleton loading state', () => {
mockReturnData = { data: undefined, isLoading: true, isError: false };
const { container } = render(<MemoryRouter initialEntries={['/companies/1']}><CompanyDetailPage /></MemoryRouter>);
expect(screen.getByTestId('company-detail-page')).toBeInTheDocument();
mockReturnData = { data: mockCompany, isLoading: false, isError: false };
});
});
@@ -1,93 +0,0 @@
import React from 'react';
import { describe, it, expect, vi } from 'vitest';
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import { MemoryRouter } from 'react-router-dom';
import { CompanyFormPage } from '@/pages/CompanyForm';
const mockMutateAsync = vi.fn().mockResolvedValue({ id: 'new-1' });
vi.mock('@/api/hooks', () => ({
useCompany: () => ({ data: undefined, isLoading: false }),
useCreateCompany: () => ({
mutateAsync: mockMutateAsync,
isPending: false,
}),
useUpdateCompany: () => ({ mutateAsync: vi.fn(), isPending: false }),
}));
vi.mock('@/components/ui/Toast', () => ({
useToast: () => ({ success: vi.fn(), error: vi.fn(), info: vi.fn() }),
}));
vi.mock('@/components/shared/UnsavedChangesGuard', () => ({
UnsavedChangesGuard: () => null,
}));
describe('CompanyFormPage', () => {
it('renders form with name field', () => {
render(<MemoryRouter><CompanyFormPage /></MemoryRouter>);
expect(screen.getByLabelText(/Firmenname/)).toBeInTheDocument();
});
it('renders email and phone fields', () => {
render(<MemoryRouter><CompanyFormPage /></MemoryRouter>);
expect(screen.getByLabelText(/E-Mail/)).toBeInTheDocument();
expect(screen.getByLabelText(/Telefon/)).toBeInTheDocument();
});
it('renders website and description fields', () => {
render(<MemoryRouter><CompanyFormPage /></MemoryRouter>);
expect(screen.getByLabelText(/Website/)).toBeInTheDocument();
expect(screen.getByLabelText(/Beschreibung/)).toBeInTheDocument();
});
it('renders account number and industry fields', () => {
render(<MemoryRouter><CompanyFormPage /></MemoryRouter>);
expect(screen.getByLabelText(/Kontonummer/)).toBeInTheDocument();
expect(screen.getByLabelText(/Branche/)).toBeInTheDocument();
});
it('submit button is present', () => {
render(<MemoryRouter><CompanyFormPage /></MemoryRouter>);
expect(screen.getByTestId('company-submit-btn')).toBeInTheDocument();
});
it('renders cancel button', () => {
render(<MemoryRouter><CompanyFormPage /></MemoryRouter>);
expect(screen.getByText('Abbrechen')).toBeInTheDocument();
});
it('renders form page with correct test id', () => {
render(<MemoryRouter><CompanyFormPage /></MemoryRouter>);
expect(screen.getByTestId('company-form-page')).toBeInTheDocument();
});
it('renders create title for new company', () => {
render(<MemoryRouter><CompanyFormPage /></MemoryRouter>);
expect(screen.getByText('Firma erstellen')).toBeInTheDocument();
});
it('name field is marked as required', () => {
render(<MemoryRouter><CompanyFormPage /></MemoryRouter>);
const nameInput = screen.getByTestId('company-name-input');
expect(nameInput).toHaveAttribute('required');
});
it('shows inline validation error when submitting empty form', async () => {
render(<MemoryRouter><CompanyFormPage /></MemoryRouter>);
const submitBtn = screen.getByTestId('company-submit-btn');
fireEvent.click(submitBtn);
await waitFor(() => {
expect(screen.getByText(/Name ist erforderlich/)).toBeInTheDocument();
});
});
it('accepts valid form data and submits', async () => {
render(<MemoryRouter><CompanyFormPage /></MemoryRouter>);
fireEvent.change(screen.getByTestId('company-name-input'), { target: { value: 'Acme Corp' } });
fireEvent.click(screen.getByTestId('company-submit-btn'));
await waitFor(() => {
expect(mockMutateAsync).toHaveBeenCalledWith(expect.objectContaining({ name: 'Acme Corp' }));
});
});
});
@@ -1,80 +0,0 @@
import React from 'react';
import { describe, it, expect, vi } from 'vitest';
import { render, screen, fireEvent } from '@testing-library/react';
import { MemoryRouter } from 'react-router-dom';
import { ContactDetailPage } from '@/pages/ContactDetail';
const mockContact = {
id: '1',
first_name: 'Max',
last_name: 'Mustermann',
email: 'max@example.com',
phone: '+49 30 12345678',
position: 'CEO',
company_ids: ['1', '2'],
companies: [
{ id: '1', name: 'TestCorp GmbH', industry: 'IT', email: 'info@testcorp.de' },
{ id: '2', name: 'Müller AG', industry: 'Logistik', email: 'kontakt@mueller.de' },
],
created_at: '2025-01-01T00:00:00Z',
updated_at: '2025-01-01T00:00:00Z',
};
let mockReturnData: any = { data: mockContact, isLoading: false, isError: false };
vi.mock('@/api/hooks', () => ({
useContact: () => mockReturnData,
}));
describe('ContactDetailPage', () => {
it('renders the detail page', () => {
render(<MemoryRouter initialEntries={['/contacts/1']}><ContactDetailPage /></MemoryRouter>);
expect(screen.getByTestId('contact-detail-page')).toBeInTheDocument();
});
it('renders contact name as heading', () => {
render(<MemoryRouter initialEntries={['/contacts/1']}><ContactDetailPage /></MemoryRouter>);
expect(screen.getByText('Max Mustermann')).toBeInTheDocument();
});
it('renders tabs for detail sections', () => {
render(<MemoryRouter initialEntries={['/contacts/1']}><ContactDetailPage /></MemoryRouter>);
expect(screen.getByRole('tablist')).toBeInTheDocument();
expect(screen.getByRole('tab', { name: /Übersicht/ })).toBeInTheDocument();
expect(screen.getByRole('tab', { name: /Firmen/ })).toBeInTheDocument();
expect(screen.getByRole('tab', { name: /Dateien/ })).toBeInTheDocument();
expect(screen.getByRole('tab', { name: /Aktivität/ })).toBeInTheDocument();
});
it('renders overview tab content by default', () => {
render(<MemoryRouter initialEntries={['/contacts/1']}><ContactDetailPage /></MemoryRouter>);
expect(screen.getByText('CEO')).toBeInTheDocument();
expect(screen.getByText('max@example.com')).toBeInTheDocument();
expect(screen.getByText('+49 30 12345678')).toBeInTheDocument();
});
it('renders companies tab with assigned companies', () => {
render(<MemoryRouter initialEntries={['/contacts/1']}><ContactDetailPage /></MemoryRouter>);
const companiesTab = screen.getByRole('tab', { name: /Firmen/ });
fireEvent.click(companiesTab);
expect(screen.getByText('TestCorp GmbH')).toBeInTheDocument();
expect(screen.getByText('Müller AG')).toBeInTheDocument();
});
it('renders edit button', () => {
render(<MemoryRouter initialEntries={['/contacts/1']}><ContactDetailPage /></MemoryRouter>);
expect(screen.getByText('Bearbeiten')).toBeInTheDocument();
});
it('renders back button', () => {
render(<MemoryRouter initialEntries={['/contacts/1']}><ContactDetailPage /></MemoryRouter>);
expect(screen.getByText(/Zurück/)).toBeInTheDocument();
});
it('shows error state when contact not found', () => {
mockReturnData = { data: undefined, isLoading: false, isError: true };
render(<MemoryRouter initialEntries={['/contacts/999']}><ContactDetailPage /></MemoryRouter>);
expect(screen.getByText('Kontakt nicht gefunden.')).toBeInTheDocument();
mockReturnData = { data: mockContact, isLoading: false, isError: false };
});
});
@@ -1,116 +0,0 @@
import React from 'react';
import { describe, it, expect, vi } from 'vitest';
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import { MemoryRouter } from 'react-router-dom';
import { ContactFormPage } from '@/pages/ContactForm';
vi.mock('@/api/hooks', () => ({
useContact: () => ({ data: undefined, isLoading: false }),
useCreateContact: () => ({
mutateAsync: vi.fn().mockResolvedValue({ id: 'new-1' }),
isPending: false,
}),
useUpdateContact: () => ({ mutateAsync: vi.fn(), isPending: false }),
useCompanies: () => ({
data: {
items: [
{ id: '1', name: 'TestCorp GmbH', industry: 'IT' },
{ id: '2', name: 'Müller AG', industry: 'Logistik' },
{ id: '3', name: 'Schmidt GmbH', industry: 'Handel' },
],
total: 3,
},
isLoading: false,
}),
}));
vi.mock('@/components/ui/Toast', () => ({
useToast: () => ({ success: vi.fn(), error: vi.fn(), info: vi.fn() }),
}));
vi.mock('@/components/shared/UnsavedChangesGuard', () => ({
UnsavedChangesGuard: () => null,
}));
describe('ContactFormPage', () => {
it('renders form with first name field', () => {
render(<MemoryRouter><ContactFormPage /></MemoryRouter>);
expect(screen.getByLabelText(/Vorname/)).toBeInTheDocument();
});
it('renders form with last name field', () => {
render(<MemoryRouter><ContactFormPage /></MemoryRouter>);
expect(screen.getByLabelText(/Nachname/)).toBeInTheDocument();
});
it('renders email field', () => {
render(<MemoryRouter><ContactFormPage /></MemoryRouter>);
expect(screen.getByLabelText(/E-Mail/)).toBeInTheDocument();
});
it('renders phone and position fields', () => {
render(<MemoryRouter><ContactFormPage /></MemoryRouter>);
expect(screen.getByLabelText(/Telefon/)).toBeInTheDocument();
expect(screen.getByLabelText(/Position/)).toBeInTheDocument();
});
it('renders submit button', () => {
render(<MemoryRouter><ContactFormPage /></MemoryRouter>);
expect(screen.getByTestId('contact-submit-btn')).toBeInTheDocument();
});
it('renders cancel button', () => {
render(<MemoryRouter><ContactFormPage /></MemoryRouter>);
expect(screen.getByText('Abbrechen')).toBeInTheDocument();
});
it('renders form page with correct test id', () => {
render(<MemoryRouter><ContactFormPage /></MemoryRouter>);
expect(screen.getByTestId('contact-form-page')).toBeInTheDocument();
});
it('renders create title for new contact', () => {
render(<MemoryRouter><ContactFormPage /></MemoryRouter>);
expect(screen.getByText('Kontakt erstellen')).toBeInTheDocument();
});
it('first name field is marked as required', () => {
render(<MemoryRouter><ContactFormPage /></MemoryRouter>);
const firstNameInput = screen.getByTestId('contact-first-name-input');
expect(firstNameInput).toHaveAttribute('required');
});
it('last name field is marked as required', () => {
render(<MemoryRouter><ContactFormPage /></MemoryRouter>);
const lastNameInput = screen.getByTestId('contact-last-name-input');
expect(lastNameInput).toHaveAttribute('required');
});
it('renders company assignment section with available companies', () => {
render(<MemoryRouter><ContactFormPage /></MemoryRouter>);
expect(screen.getByTestId('company-assignment-list')).toBeInTheDocument();
expect(screen.getByText('TestCorp GmbH')).toBeInTheDocument();
expect(screen.getByText('Müller AG')).toBeInTheDocument();
expect(screen.getByText('Schmidt GmbH')).toBeInTheDocument();
});
it('can select multiple companies for assignment', () => {
render(<MemoryRouter><ContactFormPage /></MemoryRouter>);
const checkboxes = screen.getAllByRole('checkbox');
expect(checkboxes).toHaveLength(3);
fireEvent.click(checkboxes[0]);
fireEvent.click(checkboxes[1]);
expect(checkboxes[0]).toBeChecked();
expect(checkboxes[1]).toBeChecked();
expect(checkboxes[2]).not.toBeChecked();
});
it('shows inline validation error when submitting empty form', async () => {
render(<MemoryRouter><ContactFormPage /></MemoryRouter>);
fireEvent.click(screen.getByTestId('contact-submit-btn'));
await waitFor(() => {
expect(screen.getByText(/Vorname ist erforderlich/)).toBeInTheDocument();
expect(screen.getByText(/Nachname ist erforderlich/)).toBeInTheDocument();
});
});
});
@@ -1,94 +1,191 @@
import React from 'react';
import { describe, it, expect, vi } from 'vitest';
import { render, screen, fireEvent } from '@testing-library/react';
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { render, screen, fireEvent, waitFor, within } from '@testing-library/react';
import { MemoryRouter } from 'react-router-dom';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { ContactsListPage } from '@/pages/ContactsList';
const mockContacts = [
{ id: '1', first_name: 'Max', last_name: 'Mustermann', email: 'max@test.de', phone: '+49 30 123', position: 'CEO', created_at: '2025-01-01T00:00:00Z', updated_at: '2025-01-01T00:00:00Z' },
{ id: '2', first_name: 'Anna', last_name: 'Schmidt', email: 'anna@test.de', phone: '+49 89 987', position: 'CTO', created_at: '2025-01-02T00:00:00Z', updated_at: '2025-01-02T00:00:00Z' },
{
id: '1',
type: 'company',
displayname: 'TestCorp GmbH',
name: 'TestCorp GmbH',
email_1: 'info@test.de',
phone_1: '+49 30 123',
mailing_city: 'Berlin',
tags: 'VIP, IT',
discount_crew: 0,
discount_transport: 0,
discount_rental: 0,
discount_sale: 0,
discount_subrent: 0,
discount_total: 0,
created_at: '2025-01-01T00:00:00Z',
updated_at: '2025-01-01T00:00:00Z',
},
{
id: '2',
type: 'person',
displayname: 'Max Mustermann',
firstname: 'Max',
surname: 'Mustermann',
email_1: 'max@test.de',
phone_1: '+49 89 987',
mailing_city: 'München',
tags: null,
discount_crew: 0,
discount_transport: 0,
discount_rental: 0,
discount_sale: 0,
discount_subrent: 0,
discount_total: 0,
created_at: '2025-01-02T00:00:00Z',
updated_at: '2025-01-02T00:00:00Z',
},
];
let mockData: any = { items: mockContacts, total: 2 };
let mockListData: any = { items: mockContacts, total: 2, page: 1, page_size: 25 };
let mockIsLoading = false;
let mockDetailData: any = undefined;
let mockDetailLoading = false;
vi.mock('@/api/hooks', () => ({
useContacts: () => ({ data: mockData, isLoading: mockIsLoading }),
useDeleteContact: () => ({ mutateAsync: vi.fn(), isPending: false }),
useUnifiedContacts: () => ({ data: mockListData, isLoading: mockIsLoading }),
useUnifiedContact: () => ({ data: mockDetailData, isLoading: mockDetailLoading }),
useCreateUnifiedContact: () => ({ mutateAsync: vi.fn().mockResolvedValue({ id: 'new-1' }), isPending: false }),
useUpdateUnifiedContact: () => ({ mutateAsync: vi.fn(), isPending: false }),
useDeleteUnifiedContact: () => ({ mutateAsync: vi.fn(), isPending: false }),
useCreateContactPerson: () => ({ mutateAsync: vi.fn(), isPending: false }),
useUpdateContactPerson: () => ({ mutateAsync: vi.fn(), isPending: false }),
useDeleteContactPerson: () => ({ mutateAsync: vi.fn(), isPending: false }),
}));
vi.mock('@/components/ui/Toast', () => ({
useToast: () => ({ success: vi.fn(), error: vi.fn(), info: vi.fn() }),
}));
function renderWithRouter() {
return render(<MemoryRouter><ContactsListPage /></MemoryRouter>);
function renderWithProviders() {
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
return render(
<QueryClientProvider client={queryClient}>
<MemoryRouter>
<ContactsListPage />
</MemoryRouter>
</QueryClientProvider>,
);
}
describe('ContactsListPage', () => {
// jsdom renders both desktop (hidden md:flex) and mobile (flex md:hidden) views
// because it doesn't process CSS media queries. Use getAllByTestId to handle duplicates.
function getFirstByTestId(testId: string) {
const elements = screen.getAllByTestId(testId);
return elements[0];
}
// Helper: click 'Alle Kontakte' folder to switch to list view (mobile)
function goToList() {
const folderTree = getFirstByTestId('contact-folder-tree');
fireEvent.click(within(folderTree).getByText('Alle Kontakte'));
}
describe('ContactsListPage (Unified)', () => {
beforeEach(() => {
mockListData = { items: mockContacts, total: 2, page: 1, page_size: 25 };
mockIsLoading = false;
mockDetailData = undefined;
mockDetailLoading = false;
});
afterEach(() => {
mockListData = { items: mockContacts, total: 2, page: 1, page_size: 25 };
mockIsLoading = false;
mockDetailData = undefined;
mockDetailLoading = false;
});
it('renders the page', () => {
renderWithRouter();
expect(screen.getByTestId('contacts-list-page')).toBeInTheDocument();
renderWithProviders();
expect(getFirstByTestId('contacts-list-page')).toBeInTheDocument();
});
it('renders DataGrid for contacts (TanStack Table)', () => {
renderWithRouter();
expect(screen.getByTestId('contacts-grid')).toBeInTheDocument();
it('renders folder tree with all contacts filter', () => {
renderWithProviders();
const folderTree = getFirstByTestId('contact-folder-tree');
expect(folderTree).toBeInTheDocument();
expect(within(folderTree).getByText('Alle Kontakte')).toBeInTheDocument();
expect(within(folderTree).getByText('Firmen')).toBeInTheDocument();
expect(within(folderTree).getByText('Privatpersonen')).toBeInTheDocument();
});
it('renders contact data in grid', () => {
renderWithRouter();
expect(screen.getByText('Max Mustermann')).toBeInTheDocument();
expect(screen.getByText('Anna Schmidt')).toBeInTheDocument();
it('renders contact list with contacts', () => {
renderWithProviders();
goToList();
expect(screen.getAllByText('TestCorp GmbH').length).toBeGreaterThan(0);
expect(screen.getAllByText('Max Mustermann').length).toBeGreaterThan(0);
});
it('renders new button', () => {
renderWithProviders();
goToList();
expect(screen.getAllByTestId('contacts-new-btn').length).toBeGreaterThan(0);
});
it('renders search input', () => {
renderWithRouter();
expect(screen.getByLabelText(/Suchen/)).toBeInTheDocument();
renderWithProviders();
goToList();
expect(screen.getAllByPlaceholderText('Kontakte durchsuchen...').length).toBeGreaterThan(0);
});
it('renders create button', () => {
renderWithRouter();
expect(screen.getByText('Kontakt erstellen')).toBeInTheDocument();
it('shows empty detail panel when no contact selected', () => {
renderWithProviders();
expect(screen.getAllByText('Kein Kontakt ausgewählt').length).toBeGreaterThan(0);
});
it('renders table headers', () => {
renderWithRouter();
expect(screen.getByText('Name')).toBeInTheDocument();
expect(screen.getByText('E-Mail')).toBeInTheDocument();
expect(screen.getByText('Telefon')).toBeInTheDocument();
expect(screen.getByText('Position')).toBeInTheDocument();
it('shows contact detail when contact is selected', async () => {
mockDetailData = {
...mockContacts[0],
contact_persons: [],
};
renderWithProviders();
goToList();
// Click on first contact in list (mobile view)
const contactButtons = screen.getAllByText('TestCorp GmbH');
fireEvent.click(contactButtons[0]);
await waitFor(() => {
expect(screen.getAllByTestId('contact-detail').length).toBeGreaterThan(0);
});
});
it('renders email as mailto link', () => {
renderWithRouter();
const emailLink = screen.getByText('max@test.de');
expect(emailLink.closest('a')).toHaveAttribute('href', 'mailto:max@test.de');
it('renders tags in folder tree from contacts', () => {
renderWithProviders();
const folderTree = getFirstByTestId('contact-folder-tree');
expect(within(folderTree).getByText('VIP')).toBeInTheDocument();
expect(within(folderTree).getByText('IT')).toBeInTheDocument();
});
it('shows empty state when no contacts and no search', () => {
mockData = { items: [], total: 0 };
renderWithRouter();
expect(screen.getByText('Keine Kontakte vorhanden')).toBeInTheDocument();
mockData = { items: mockContacts, total: 2 };
});
it('clicking sort header toggles sort direction', () => {
renderWithRouter();
const emailHeader = screen.getByText('E-Mail');
const th = emailHeader.closest('th');
expect(th).toHaveAttribute('aria-sort', 'none');
fireEvent.click(emailHeader);
expect(th).toHaveAttribute('aria-sort', 'ascending');
});
it('shows loading state in DataGrid when loading', () => {
it('shows loading state', () => {
mockIsLoading = true;
mockData = undefined;
renderWithRouter();
expect(screen.getByTestId('contacts-grid')).toBeInTheDocument();
expect(screen.getByText(/Wird geladen/)).toBeInTheDocument();
mockIsLoading = false;
mockData = { items: mockContacts, total: 2 };
mockListData = undefined;
renderWithProviders();
goToList();
expect(screen.getAllByTestId('contact-list-loading').length).toBeGreaterThan(0);
});
it('opens edit modal when new button clicked', () => {
renderWithProviders();
goToList();
const newBtns = screen.getAllByTestId('contacts-new-btn');
fireEvent.click(newBtns[0]);
expect(screen.getByTestId('contact-type-select')).toBeInTheDocument();
});
it('shows empty state when no contacts', () => {
mockListData = { items: [], total: 0, page: 1, page_size: 25 };
renderWithProviders();
goToList();
expect(screen.getAllByTestId('contact-list-empty').length).toBeGreaterThan(0);
});
});
@@ -5,8 +5,10 @@ import { MemoryRouter } from 'react-router-dom';
import { DashboardPage } from '@/pages/Dashboard';
vi.mock('@/api/hooks', () => ({
useCompanies: () => ({ data: { items: [], total: 24 }, isLoading: false }),
useContacts: () => ({ data: { items: [], total: 156 }, isLoading: false }),
useUnifiedContacts: (page: number, pageSize: number, search: any, type: string) => ({
data: { items: [], total: type === 'company' ? 24 : 156, page: 1, page_size: 25 },
isLoading: false,
}),
useAuditLog: () => ({
data: {
items: [
@@ -52,7 +52,6 @@ describe('AppShell', () => {
renderWithRouter();
const sidebar = screen.getByTestId('sidebar');
expect(within(sidebar).getByText('Dashboard')).toBeInTheDocument();
expect(within(sidebar).getByText('Firmen')).toBeInTheDocument();
expect(within(sidebar).getByText('Kontakte')).toBeInTheDocument();
expect(within(sidebar).getByText('Einstellungen')).toBeInTheDocument();
});
@@ -12,7 +12,6 @@ describe('Sidebar', () => {
</MemoryRouter>
);
expect(screen.getByLabelText('Dashboard')).toBeInTheDocument();
expect(screen.getByLabelText('Firmen')).toBeInTheDocument();
expect(screen.getByLabelText('Kontakte')).toBeInTheDocument();
expect(screen.getByLabelText('Einstellungen')).toBeInTheDocument();
});
+265 -1
View File
@@ -1,5 +1,5 @@
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { apiPost, apiGet, apiPatch, apiDelete, apiClient, setCsrfToken } from './client';
import { apiPost, apiGet, apiPatch, apiPut, apiDelete, apiClient, setCsrfToken } from './client';
import { useAuthStore } from '@/store/authStore';
export interface LoginPayload {
@@ -567,6 +567,270 @@ export function useDeleteRole() {
});
}
// ════════════════════════════════════════════════════════════════════════════
// ════════════════════════════════════════════════════════════════════════════
// Unified Contact (Rentman-style) hooks
// ════════════════════════════════════════════════════════════════════════════
export interface ContactPerson {
id: string;
contact_id: string;
displayname: string;
firstname?: string | null;
middle_name?: string | null;
lastname?: string | null;
function?: string | null;
phone?: string | null;
mobilephone?: string | null;
email?: string | null;
street?: string | null;
number?: string | null;
postalcode?: string | null;
city?: string | null;
state?: string | null;
country?: string | null;
tags?: string | null;
custom?: Record<string, any> | null;
created_at?: string;
updated_at?: string;
}
export interface UnifiedContact {
id: string;
type: 'company' | 'person';
displayname: string;
name?: string | null;
firstname?: string | null;
surname?: string | null;
surfix?: string | null;
ext_name_line?: string | null;
gender?: string | null;
code?: string | null;
accounting_code?: string | null;
vendor_accounting_code?: string | null;
// Mailing address
mailing_street?: string | null;
mailing_number?: string | null;
mailing_unit_number?: string | null;
mailing_district?: string | null;
mailing_extra_address_line?: string | null;
mailing_postalcode?: string | null;
mailing_city?: string | null;
mailing_state?: string | null;
mailing_country?: string | null;
// Visit address
visit_street?: string | null;
visit_number?: string | null;
visit_unit_number?: string | null;
visit_district?: string | null;
visit_extra_address_line?: string | null;
visit_postalcode?: string | null;
visit_city?: string | null;
visit_state?: string | null;
// Invoice address
invoice_street?: string | null;
invoice_number?: string | null;
invoice_unit_number?: string | null;
invoice_district?: string | null;
invoice_extra_address_line?: string | null;
invoice_postalcode?: string | null;
invoice_city?: string | null;
invoice_state?: string | null;
invoice_country?: string | null;
country?: string | null;
// Communication
phone_1?: string | null;
phone_2?: string | null;
email_1?: string | null;
email_2?: string | null;
website?: string | null;
// Financial
vat_code?: string | null;
fiscal_code?: string | null;
commerce_code?: string | null;
purchase_number?: string | null;
bic?: string | null;
bank_account?: string | null;
// Discounts
discount_crew: number;
discount_transport: number;
discount_rental: number;
discount_sale: number;
discount_subrent: number;
discount_total: number;
// Geo
latitude?: number | null;
longitude?: number | null;
// Notes
projectnote?: string | null;
projectnote_title?: string | null;
contact_warning?: string | null;
tags?: string | null;
image?: string | null;
custom?: Record<string, any> | null;
default_person_id?: string | null;
admin_contactperson_id?: string | null;
contact_persons?: ContactPerson[];
created_at?: string;
updated_at?: string;
}
export function useUnifiedContacts(
page = 1,
pageSize = 25,
search?: string,
contactType?: string,
sortBy?: string,
sortOrder?: string,
) {
const params = new URLSearchParams({ page: String(page), page_size: String(pageSize) });
if (search) params.set('search', search);
if (contactType) params.set('type', contactType);
if (sortBy) params.set('sort_by', sortBy);
if (sortOrder) params.set('sort_order', sortOrder);
return useQuery({
queryKey: ['unifiedContacts', page, pageSize, search, contactType, sortBy, sortOrder],
queryFn: () =>
apiGet<PaginatedResponse<UnifiedContact>>(`/contacts?${params.toString()}`),
});
}
export function useUnifiedContact(id?: string) {
return useQuery({
queryKey: ['unifiedContacts', id],
queryFn: () => apiGet<UnifiedContact & { contact_persons: ContactPerson[] }>(`/contacts/${id}`),
enabled: !!id,
});
}
export function useCreateUnifiedContact() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (data: Partial<UnifiedContact>) => apiPost('/contacts', data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['unifiedContacts'] });
},
});
}
export function useUpdateUnifiedContact() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ id, data }: { id: string; data: Partial<UnifiedContact> }) =>
apiPut(`/contacts/${id}`, data),
onSuccess: (_data, variables) => {
queryClient.invalidateQueries({ queryKey: ['unifiedContacts'] });
queryClient.invalidateQueries({ queryKey: ['unifiedContacts', variables.id] });
},
});
}
export function useDeleteUnifiedContact() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ id, hard }: { id: string; hard?: boolean }) =>
apiDelete(`/contacts/${id}${hard ? '?hard=true' : ''}`),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['unifiedContacts'] });
},
});
}
export function useContactPersons(contactId?: string) {
return useQuery({
queryKey: ['unifiedContacts', contactId, 'persons'],
queryFn: () => apiGet<{ items: ContactPerson[] }>(`/contacts/${contactId}/persons`),
enabled: !!contactId,
});
}
export function useCreateContactPerson() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ contactId, data }: { contactId: string; data: Partial<ContactPerson> }) =>
apiPost(`/contacts/${contactId}/persons`, data),
onSuccess: (_data, variables) => {
queryClient.invalidateQueries({ queryKey: ['unifiedContacts'] });
queryClient.invalidateQueries({ queryKey: ['unifiedContacts', variables.contactId] });
queryClient.invalidateQueries({ queryKey: ['unifiedContacts', variables.contactId, 'persons'] });
},
});
}
export function useUpdateContactPerson() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ contactId, personId, data }: { contactId: string; personId: string; data: Partial<ContactPerson> }) =>
apiPut(`/contacts/${contactId}/persons/${personId}`, data),
onSuccess: (_data, variables) => {
queryClient.invalidateQueries({ queryKey: ['unifiedContacts'] });
queryClient.invalidateQueries({ queryKey: ['unifiedContacts', variables.contactId] });
queryClient.invalidateQueries({ queryKey: ['unifiedContacts', variables.contactId, 'persons'] });
},
});
}
export function useDeleteContactPerson() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ contactId, personId }: { contactId: string; personId: string }) =>
apiDelete(`/contacts/${contactId}/persons/${personId}`),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['unifiedContacts'] });
},
});
}
// Standalone API functions (for non-hook usage)
export async function fetchContacts(
page = 1,
pageSize = 25,
search?: string,
contactType?: string,
sortBy?: string,
sortOrder?: string,
): Promise<PaginatedResponse<UnifiedContact>> {
const params = new URLSearchParams({ page: String(page), page_size: String(pageSize) });
if (search) params.set('search', search);
if (contactType) params.set('type', contactType);
if (sortBy) params.set('sort_by', sortBy);
if (sortOrder) params.set('sort_order', sortOrder);
return apiGet<PaginatedResponse<UnifiedContact>>(`/contacts?${params.toString()}`);
}
export async function fetchContact(id: string): Promise<UnifiedContact & { contact_persons: ContactPerson[] }> {
return apiGet<UnifiedContact & { contact_persons: ContactPerson[] }>(`/contacts/${id}`);
}
export async function createContact(data: Partial<UnifiedContact>): Promise<UnifiedContact> {
return apiPost<UnifiedContact>('/contacts', data);
}
export async function updateContact(id: string, data: Partial<UnifiedContact>): Promise<UnifiedContact> {
return apiPut<UnifiedContact>(`/contacts/${id}`, data);
}
export async function deleteContact(id: string, hard?: boolean): Promise<void> {
await apiDelete(`/contacts/${id}${hard ? '?hard=true' : ''}`);
}
export async function fetchContactPersons(contactId: string): Promise<{ items: ContactPerson[] }> {
return apiGet<{ items: ContactPerson[] }>(`/contacts/${contactId}/persons`);
}
export async function createContactPerson(contactId: string, data: Partial<ContactPerson>): Promise<ContactPerson> {
return apiPost<ContactPerson>(`/contacts/${contactId}/persons`, data);
}
export async function updateContactPerson(contactId: string, personId: string, data: Partial<ContactPerson>): Promise<ContactPerson> {
return apiPut<ContactPerson>(`/contacts/${contactId}/persons/${personId}`, data);
}
export async function deleteContactPerson(contactId: string, personId: string): Promise<void> {
await apiDelete(`/contacts/${contactId}/persons/${personId}`);
}
// ════════════════════════════════════════════════════════════════════════════
// System Settings, Currency, Tax, Sequence hooks
// ════════════════════════════════════════════════════════════════════════════
@@ -0,0 +1,372 @@
import React, { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Button } from '@/components/ui/Button';
import { Badge } from '@/components/ui/Badge';
import { EmptyState } from '@/components/ui/EmptyState';
import { Modal } from '@/components/ui/Modal';
import { Input } from '@/components/ui/Input';
import { useToast } from '@/components/ui/Toast';
import {
type UnifiedContact,
type ContactPerson,
useDeleteUnifiedContact,
useCreateContactPerson,
useUpdateContactPerson,
useDeleteContactPerson,
} from '@/api/hooks';
export interface ContactDetailProps {
contact: UnifiedContact | null;
loading?: boolean;
onEdit: () => void;
onDeleted: () => void;
}
function Field({ label, value }: { label: string; value?: string | null }) {
return (
<div>
<dt className="text-xs font-medium text-secondary-500">{label}</dt>
<dd className="text-sm text-secondary-900">{value || '—'}</dd>
</div>
);
}
function Section({ title, children, action }: { title: string; children: React.ReactNode; action?: React.ReactNode }) {
return (
<div className="border border-secondary-200 rounded-lg p-4 mb-3">
<div className="flex items-center justify-between mb-3">
<h3 className="text-sm font-semibold text-secondary-700">{title}</h3>
{action}
</div>
{children}
</div>
);
}
function AddressBlock({ prefix, contact, t }: { prefix: string; contact: UnifiedContact; t: (k: string) => string }) {
const street = (contact as any)[`${prefix}_street`] as string | undefined;
const number = (contact as any)[`${prefix}_number`] as string | undefined;
const postalcode = (contact as any)[`${prefix}_postalcode`] as string | undefined;
const city = (contact as any)[`${prefix}_city`] as string | undefined;
const state = (contact as any)[`${prefix}_state`] as string | undefined;
const country = (contact as any)[`${prefix}_country`] as string | undefined;
const district = (contact as any)[`${prefix}_district`] as string | undefined;
const extra = (contact as any)[`${prefix}_extra_address_line`] as string | undefined;
const hasData = street || city || postalcode;
if (!hasData) return null;
return (
<div className="text-sm text-secondary-900 space-y-0.5">
{extra && <div>{extra}</div>}
<div>{street} {number}</div>
{district && <div>{district}</div>}
<div>{postalcode} {city}</div>
{state && <div>{state}</div>}
{country && <div>{country}</div>}
</div>
);
}
function ContactPersonModal({
open,
onClose,
onSave,
person,
}: {
open: boolean;
onClose: () => void;
onSave: (data: Partial<ContactPerson>) => void;
person?: ContactPerson | null;
}) {
const { t } = useTranslation();
const [firstname, setFirstname] = useState(person?.firstname || '');
const [lastname, setLastname] = useState(person?.lastname || '');
const [func, setFunc] = useState(person?.function || '');
const [email, setEmail] = useState(person?.email || '');
const [phone, setPhone] = useState(person?.phone || '');
const [mobilephone, setMobilephone] = useState(person?.mobilephone || '');
React.useEffect(() => {
if (open) {
setFirstname(person?.firstname || '');
setLastname(person?.lastname || '');
setFunc(person?.function || '');
setEmail(person?.email || '');
setPhone(person?.phone || '');
setMobilephone(person?.mobilephone || '');
}
}, [open, person]);
const handleSave = () => {
onSave({
firstname: firstname || null,
lastname: lastname || null,
function: func || null,
email: email || null,
phone: phone || null,
mobilephone: mobilephone || null,
displayname: [firstname, lastname].filter(Boolean).join(' ') || firstname || lastname || '',
});
};
return (
<Modal open={open} onClose={onClose} title={person ? t('contacts.editPerson') : t('contacts.addPerson')} size="md">
<div className="space-y-3">
<div className="grid grid-cols-2 gap-3">
<Input label={t('contacts.firstName')} value={firstname} onChange={(e) => setFirstname(e.target.value)} />
<Input label={t('contacts.lastName')} value={lastname} onChange={(e) => setLastname(e.target.value)} />
</div>
<Input label={t('contacts.position')} value={func} onChange={(e) => setFunc(e.target.value)} />
<Input label={t('contacts.email')} type="email" value={email} onChange={(e) => setEmail(e.target.value)} />
<div className="grid grid-cols-2 gap-3">
<Input label={t('contacts.phone')} value={phone} onChange={(e) => setPhone(e.target.value)} />
<Input label={t('contacts.mobile')} value={mobilephone} onChange={(e) => setMobilephone(e.target.value)} />
</div>
<div className="flex justify-end gap-2 pt-2">
<Button variant="secondary" onClick={onClose}>{t('common.cancel')}</Button>
<Button onClick={handleSave} data-testid="person-save-btn">{t('common.save')}</Button>
</div>
</div>
</Modal>
);
}
export function ContactDetail({ contact, loading, onEdit, onDeleted }: ContactDetailProps) {
const { t } = useTranslation();
const toast = useToast();
const deleteMutation = useDeleteUnifiedContact();
const createPersonMutation = useCreateContactPerson();
const updatePersonMutation = useUpdateContactPerson();
const deletePersonMutation = useDeleteContactPerson();
const [personModalOpen, setPersonModalOpen] = useState(false);
const [editingPerson, setEditingPerson] = useState<ContactPerson | null>(null);
if (loading) {
return (
<div className="flex items-center justify-center py-12" data-testid="contact-detail-loading">
<svg className="animate-spin h-5 w-5 text-secondary-400" fill="none" viewBox="0 0 24 24" aria-hidden="true">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
</svg>
<span className="ml-2 text-sm text-secondary-500">{t('common.loading')}</span>
</div>
);
}
if (!contact) {
return (
<div data-testid="contact-detail-empty">
<EmptyState title={t('contacts.selectContact')} description={t('contacts.selectContactDesc')} />
</div>
);
}
const displayName = contact.displayname || contact.name || [contact.firstname, contact.surname].filter(Boolean).join(' ') || contact.id;
const persons = contact.contact_persons || [];
const tags = contact.tags ? contact.tags.split(',').map((t) => t.trim()).filter(Boolean) : [];
const handleDelete = async () => {
if (!window.confirm(t('contacts.deleteConfirm'))) return;
try {
await deleteMutation.mutateAsync({ id: contact.id });
toast.success(t('contacts.deleted'));
onDeleted();
} catch (err: any) {
toast.error(err.message || t('common.error'));
}
};
const handleSavePerson = async (data: Partial<ContactPerson>) => {
try {
if (editingPerson) {
await updatePersonMutation.mutateAsync({ contactId: contact.id, personId: editingPerson.id, data });
toast.success(t('contacts.personUpdated'));
} else {
await createPersonMutation.mutateAsync({ contactId: contact.id, data });
toast.success(t('contacts.personCreated'));
}
setPersonModalOpen(false);
setEditingPerson(null);
} catch (err: any) {
toast.error(err.message || t('common.error'));
}
};
const handleDeletePerson = async (person: ContactPerson) => {
if (!window.confirm(t('contacts.deletePersonConfirm'))) return;
try {
await deletePersonMutation.mutateAsync({ contactId: contact.id, personId: person.id });
toast.success(t('contacts.personDeleted'));
} catch (err: any) {
toast.error(err.message || t('common.error'));
}
};
return (
<div className="overflow-y-auto h-full" data-testid="contact-detail">
{/* Header */}
<div className="flex items-center justify-between px-4 py-3 border-b border-secondary-200 sticky top-0 bg-white z-10">
<div className="flex items-center gap-3 min-w-0">
<div className="w-10 h-10 rounded-full bg-primary-100 flex items-center justify-center text-primary-700 font-semibold flex-shrink-0">
{displayName.charAt(0).toUpperCase()}
</div>
<div className="min-w-0">
<h2 className="text-lg font-semibold text-secondary-900 truncate">{displayName}</h2>
<Badge variant={contact.type === 'company' ? 'primary' : 'info'}>
{contact.type === 'company' ? t('contacts.companies') : t('contacts.persons')}
</Badge>
</div>
</div>
<div className="flex items-center gap-2 flex-shrink-0">
<Button variant="ghost" size="sm" onClick={onEdit}>{t('common.edit')}</Button>
<Button variant="ghost" size="sm" onClick={handleDelete} isLoading={deleteMutation.isPending}>{t('common.delete')}</Button>
</div>
</div>
<div className="p-4">
{/* Contact Persons */}
<Section
title={t('contacts.contactPersons')}
action={
<Button size="sm" variant="ghost" onClick={() => { setEditingPerson(null); setPersonModalOpen(true); }}>
{t('contacts.addPerson')}
</Button>
}
>
{persons.length === 0 ? (
<p className="text-sm text-secondary-500">{t('contacts.noPersons')}</p>
) : (
<ul className="space-y-2" role="list">
{persons.map((person) => (
<li key={person.id} className="flex items-center gap-3 p-2 rounded-md hover:bg-secondary-50">
<div className="flex-1 min-w-0">
<div className="text-sm font-medium text-secondary-900">
{[person.firstname, person.lastname].filter(Boolean).join(' ') || person.displayname}
</div>
{person.function && <div className="text-xs text-secondary-500">{person.function}</div>}
{person.email && <div className="text-xs text-secondary-500">{person.email}</div>}
{person.phone && <div className="text-xs text-secondary-500">{person.phone}</div>}
</div>
<button
onClick={() => { setEditingPerson(person); setPersonModalOpen(true); }}
className="text-xs text-primary-600 hover:text-primary-700 px-2 py-1 min-h-touch"
>
{t('common.edit')}
</button>
<button
onClick={() => handleDeletePerson(person)}
className="text-xs text-danger-600 hover:text-danger-700 px-2 py-1 min-h-touch"
>
{t('common.delete')}
</button>
</li>
))}
</ul>
)}
</Section>
{/* Addresses */}
<Section title={t('address.title')}>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div>
<h4 className="text-xs font-semibold text-secondary-500 mb-1">{t('contacts.mailingAddress')}</h4>
<AddressBlock prefix="mailing" contact={contact} t={t} />
{!contact.mailing_street && !contact.mailing_city && <p className="text-sm text-secondary-400"></p>}
</div>
<div>
<h4 className="text-xs font-semibold text-secondary-500 mb-1">{t('contacts.visitAddress')}</h4>
<AddressBlock prefix="visit" contact={contact} t={t} />
{!contact.visit_street && !contact.visit_city && <p className="text-sm text-secondary-400"></p>}
</div>
<div>
<h4 className="text-xs font-semibold text-secondary-500 mb-1">{t('contacts.invoiceAddress')}</h4>
<AddressBlock prefix="invoice" contact={contact} t={t} />
{!contact.invoice_street && !contact.invoice_city && <p className="text-sm text-secondary-400"></p>}
</div>
</div>
</Section>
{/* Communication */}
<Section title={t('contacts.communication')}>
<dl className="grid grid-cols-2 gap-3">
<Field label={t('contacts.phone') + ' 1'} value={contact.phone_1} />
<Field label={t('contacts.phone') + ' 2'} value={contact.phone_2} />
<Field label={t('contacts.email') + ' 1'} value={contact.email_1} />
<Field label={t('contacts.email') + ' 2'} value={contact.email_2} />
<Field label={t('contacts.website')} value={contact.website} />
</dl>
</Section>
{/* Financial */}
<Section title={t('contacts.financial')}>
<dl className="grid grid-cols-2 gap-3">
<Field label={t('contacts.vatCode')} value={contact.vat_code} />
<Field label={t('contacts.fiscalCode')} value={contact.fiscal_code} />
<Field label={t('contacts.commerceCode')} value={contact.commerce_code} />
<Field label={t('contacts.purchaseNumber')} value={contact.purchase_number} />
<Field label={t('contacts.bic')} value={contact.bic} />
<Field label={t('contacts.bankAccount')} value={contact.bank_account} />
</dl>
</Section>
{/* Discounts */}
<Section title={t('contacts.discounts')}>
<dl className="grid grid-cols-3 gap-3">
<Field label={t('contacts.discountCrew')} value={contact.discount_crew ? String(contact.discount_crew) : '0'} />
<Field label={t('contacts.discountTransport')} value={contact.discount_transport ? String(contact.discount_transport) : '0'} />
<Field label={t('contacts.discountRental')} value={contact.discount_rental ? String(contact.discount_rental) : '0'} />
<Field label={t('contacts.discountSale')} value={contact.discount_sale ? String(contact.discount_sale) : '0'} />
<Field label={t('contacts.discountSubrent')} value={contact.discount_subrent ? String(contact.discount_subrent) : '0'} />
<Field label={t('contacts.discountTotal')} value={contact.discount_total ? String(contact.discount_total) : '0'} />
</dl>
</Section>
{/* Geo */}
<Section title={t('contacts.geo')}>
<dl className="grid grid-cols-2 gap-3">
<Field label={t('contacts.latitude')} value={contact.latitude != null ? String(contact.latitude) : null} />
<Field label={t('contacts.longitude')} value={contact.longitude != null ? String(contact.longitude) : null} />
</dl>
</Section>
{/* Notes & Tags */}
<Section title={t('contacts.notes')}>
<dl className="space-y-3">
<Field label={t('contacts.projectnote')} value={contact.projectnote} />
<Field label={t('contacts.projectnoteTitle')} value={contact.projectnote_title} />
<Field label={t('contacts.contactWarning')} value={contact.contact_warning} />
<div>
<dt className="text-xs font-medium text-secondary-500">{t('tags.title')}</dt>
<dd>
{tags.length > 0 ? (
<div className="flex flex-wrap gap-1 mt-1">
{tags.map((tag) => <Badge key={tag} variant="secondary">{tag}</Badge>)}
</div>
) : (
<span className="text-sm text-secondary-400"></span>
)}
</dd>
</div>
</dl>
</Section>
{/* Custom Fields */}
{contact.custom && Object.keys(contact.custom).length > 0 && (
<Section title={t('contacts.customFields')}>
<pre className="text-xs text-secondary-700 bg-secondary-50 rounded p-2 overflow-x-auto">
{JSON.stringify(contact.custom, null, 2)}
</pre>
</Section>
)}
</div>
<ContactPersonModal
open={personModalOpen}
onClose={() => { setPersonModalOpen(false); setEditingPerson(null); }}
onSave={handleSavePerson}
person={editingPerson}
/>
</div>
);
}
@@ -0,0 +1,221 @@
import React, { useState, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
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';
export interface ContactEditModalProps {
open: boolean;
onClose: () => void;
contact?: UnifiedContact | null;
onSaved?: (id: string) => void;
}
export function ContactEditModal({ open, onClose, contact, onSaved }: ContactEditModalProps) {
const { t } = useTranslation();
const toast = useToast();
const createMutation = useCreateUnifiedContact();
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('');
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 || '');
}
}, [open, contact]);
const handleSave = async () => {
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,
};
try {
if (isEdit && contact) {
await updateMutation.mutateAsync({ id: contact.id, data });
toast.success(t('contacts.updated'));
onSaved?.(contact.id);
} else {
const result = await createMutation.mutateAsync(data) as { id: string };
toast.success(t('contacts.created'));
onSaved?.(result.id);
}
onClose();
} catch (err: any) {
toast.error(err.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>
<div className="space-y-4">
{/* Type */}
<Select
label={t('contacts.type')}
value={type}
onChange={(e) => setType(e.target.value as 'company' | 'person')}
options={[
{ value: 'company', label: t('contacts.companies') },
{ value: 'person', label: t('contacts.persons') },
]}
data-testid="contact-type-select"
/>
{/* 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" />
) : (
<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" />
</div>
)}
{/* Code */}
<Input label={t('contacts.code')} value={code} onChange={(e) => setCode(e.target.value)} 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)} />
</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')} 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)} />
</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')} 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)} />
</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')} value={tags} onChange={(e) => setTags(e.target.value)} 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)}
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)}
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>
{/* 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">
{isEdit ? t('common.save') : t('common.create')}
</Button>
</div>
</div>
</Modal>
);
}
@@ -0,0 +1,102 @@
import React from 'react';
import clsx from 'clsx';
import { useTranslation } from 'react-i18next';
export type ContactFilter = 'all' | 'company' | 'person' | `tag:${string}`;
export interface ContactFolderTreeProps {
selectedFilter: ContactFilter;
onSelect: (filter: ContactFilter) => void;
tags: string[];
loading?: boolean;
}
const folderIcon = (path: string) => (
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d={path} />
</svg>
);
export function ContactFolderTree({
selectedFilter,
onSelect,
tags,
loading,
}: ContactFolderTreeProps) {
const { t } = useTranslation();
const folders: { key: ContactFilter; label: string; icon: React.ReactNode }[] = [
{
key: 'all',
label: t('contacts.allContacts'),
icon: folderIcon('M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6-3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z'),
},
{
key: 'company',
label: t('contacts.companies'),
icon: folderIcon('M19 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16m14 0h2m-2 0h-5m-9 0H3m2 0h5M9 7h1m-1 4h1m4-4h1m-1 4h1m-5 10v-5a1 1 0 011-1h2a1 1 0 011 1v5m-4 0h4'),
},
{
key: 'person',
label: t('contacts.persons'),
icon: folderIcon('M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z'),
},
];
return (
<nav className="space-y-1" aria-label={t('contacts.title')} data-testid="contact-folder-tree">
{folders.map((folder) => (
<button
key={folder.key}
onClick={() => onSelect(folder.key)}
className={clsx(
'flex items-center gap-2 w-full px-2 py-1.5 rounded-md text-sm font-medium min-h-touch text-left',
'transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-primary-500',
selectedFilter === folder.key
? 'bg-primary-50 text-primary-700'
: 'text-secondary-700 hover:bg-secondary-100',
)}
aria-current={selectedFilter === folder.key ? 'true' : undefined}
>
{folder.icon}
<span>{folder.label}</span>
</button>
))}
{tags.length > 0 && (
<>
<div className="my-2 border-t border-secondary-200" />
<div className="px-2 py-1 text-xs font-semibold text-secondary-500 uppercase tracking-wide">
{t('tags.title')}
</div>
{tags.map((tag) => {
const key = `tag:${tag}` as ContactFilter;
return (
<button
key={key}
onClick={() => onSelect(key)}
className={clsx(
'flex items-center gap-2 w-full px-2 py-1.5 rounded-md text-sm font-medium min-h-touch text-left',
'transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-primary-500',
selectedFilter === key
? 'bg-primary-50 text-primary-700'
: 'text-secondary-700 hover:bg-secondary-100',
)}
aria-current={selectedFilter === key ? 'true' : undefined}
>
<svg className="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M7 7h.01M7 3h5a1.99 1.99 0 01.832.184l4 2A2 2 0 0118 7v10a2 2 0 01-2 2H7a2 2 0 01-2-2V5a2 2 0 012-2z" />
</svg>
<span className="truncate">{tag}</span>
</button>
);
})}
</>
)}
{loading && (
<div className="px-2 py-1 text-xs text-secondary-400">{t('common.loading')}</div>
)}
</nav>
);
}
@@ -0,0 +1,290 @@
import React from 'react';
import clsx from 'clsx';
import { useTranslation } from 'react-i18next';
import { Badge } from '@/components/ui/Badge';
import { Pagination } from '@/components/ui/Pagination';
import { EmptyState } from '@/components/ui/EmptyState';
import type { UnifiedContact } from '@/api/hooks';
export type ContactViewMode = 'list' | 'table' | 'cards';
export interface ContactListProps {
contacts: UnifiedContact[];
selectedContactId: string | null;
onSelectContact: (contact: UnifiedContact) => void;
loading?: boolean;
currentPage: number;
total: number;
pageSize: number;
onPageChange: (page: number) => void;
viewMode: ContactViewMode;
sortBy: string;
sortOrder: 'asc' | 'desc';
onSortChange: (sortBy: string, sortOrder: 'asc' | 'desc') => void;
}
function TypeBadge({ type }: { type: string }) {
const { t } = useTranslation();
return (
<Badge variant={type === 'company' ? 'primary' : 'info'}>
{type === 'company' ? t('contacts.companies') : t('contacts.persons')}
</Badge>
);
}
function getDisplayName(c: UnifiedContact): string {
return c.displayname || c.name || [c.firstname, c.surname].filter(Boolean).join(' ') || c.email_1 || c.id;
}
function getInitials(c: UnifiedContact): string {
const name = getDisplayName(c);
return name.charAt(0).toUpperCase();
}
function getCity(c: UnifiedContact): string {
return c.mailing_city || c.visit_city || c.invoice_city || '—';
}
function getEmail(c: UnifiedContact): string {
return c.email_1 || c.email_2 || '—';
}
function getPhone(c: UnifiedContact): string {
return c.phone_1 || c.phone_2 || '—';
}
function getTags(c: UnifiedContact): string[] {
if (!c.tags) return [];
return c.tags.split(',').map((t) => t.trim()).filter(Boolean);
}
export function ContactList({
contacts,
selectedContactId,
onSelectContact,
loading,
currentPage,
total,
pageSize,
onPageChange,
viewMode,
sortBy,
sortOrder,
onSortChange,
}: ContactListProps) {
const { t } = useTranslation();
if (loading) {
return (
<div className="flex items-center justify-center py-12" data-testid="contact-list-loading">
<svg className="animate-spin h-5 w-5 text-secondary-400" fill="none" viewBox="0 0 24 24" aria-hidden="true">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
</svg>
<span className="ml-2 text-sm text-secondary-500">{t('common.loading')}</span>
</div>
);
}
if (contacts.length === 0) {
return (
<div data-testid="contact-list-empty">
<EmptyState title={t('contacts.emptyTitle')} description={t('contacts.emptyDescription')} />
</div>
);
}
const totalPages = Math.ceil(total / pageSize);
const handleSort = (field: string) => {
if (sortBy === field) {
onSortChange(field, sortOrder === 'asc' ? 'desc' : 'asc');
} else {
onSortChange(field, 'asc');
}
};
// ── List View ──
if (viewMode === 'list') {
return (
<div className="flex flex-col h-full" data-testid="contact-list-view">
<div className="flex-1 overflow-y-auto">
<ul className="divide-y divide-secondary-100" role="list">
{contacts.map((contact) => (
<li key={contact.id}>
<button
onClick={() => onSelectContact(contact)}
className={clsx(
'flex items-center gap-3 w-full px-3 py-2.5 text-left min-h-touch transition-colors',
selectedContactId === contact.id
? 'bg-primary-50'
: 'hover:bg-secondary-50',
)}
aria-current={selectedContactId === contact.id ? 'true' : undefined}
>
<div className="w-9 h-9 rounded-full bg-primary-100 flex items-center justify-center text-primary-700 font-semibold text-sm flex-shrink-0">
{getInitials(contact)}
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="font-medium text-sm text-secondary-900 truncate">{getDisplayName(contact)}</span>
<TypeBadge type={contact.type} />
</div>
<div className="text-xs text-secondary-500 truncate">
{getEmail(contact)} · {getCity(contact)}
</div>
</div>
</button>
</li>
))}
</ul>
</div>
<Pagination
currentPage={currentPage}
totalPages={totalPages}
total={total}
pageSize={pageSize}
onPageChange={onPageChange}
/>
</div>
);
}
// ── Table View ──
if (viewMode === 'table') {
const sortIcon = (field: string) => {
if (sortBy !== field) return null;
return sortOrder === 'asc' ? ' ▲' : ' ▼';
};
return (
<div className="flex flex-col h-full" data-testid="contact-table-view">
<div className="flex-1 overflow-auto">
<table className="w-full text-sm">
<thead className="sticky top-0 bg-white border-b border-secondary-200">
<tr>
<th className="px-3 py-2 text-left font-medium text-secondary-600 w-10">
<input type="checkbox" className="rounded border-secondary-300" aria-label={t('common.all')} />
</th>
<th
className="px-3 py-2 text-left font-medium text-secondary-600 cursor-pointer select-none"
onClick={() => handleSort('type')}
aria-sort={sortBy === 'type' ? (sortOrder === 'asc' ? 'ascending' : 'descending') : 'none'}
>
{t('contacts.type')}{sortIcon('type')}
</th>
<th
className="px-3 py-2 text-left font-medium text-secondary-600 cursor-pointer select-none"
onClick={() => handleSort('displayname')}
aria-sort={sortBy === 'displayname' ? (sortOrder === 'asc' ? 'ascending' : 'descending') : 'none'}
>
{t('contacts.fullName')}{sortIcon('displayname')}
</th>
<th
className="px-3 py-2 text-left font-medium text-secondary-600 cursor-pointer select-none"
onClick={() => handleSort('email_1')}
aria-sort={sortBy === 'email_1' ? (sortOrder === 'asc' ? 'ascending' : 'descending') : 'none'}
>
{t('contacts.email')}{sortIcon('email_1')}
</th>
<th className="px-3 py-2 text-left font-medium text-secondary-600">{t('contacts.phone')}</th>
<th
className="px-3 py-2 text-left font-medium text-secondary-600 cursor-pointer select-none"
onClick={() => handleSort('mailing_city')}
aria-sort={sortBy === 'mailing_city' ? (sortOrder === 'asc' ? 'ascending' : 'descending') : 'none'}
>
{t('address.city')}{sortIcon('mailing_city')}
</th>
<th className="px-3 py-2 text-left font-medium text-secondary-600">{t('tags.title')}</th>
</tr>
</thead>
<tbody className="divide-y divide-secondary-100">
{contacts.map((contact) => (
<tr
key={contact.id}
onClick={() => onSelectContact(contact)}
className={clsx(
'cursor-pointer transition-colors',
selectedContactId === contact.id ? 'bg-primary-50' : 'hover:bg-secondary-50',
)}
aria-current={selectedContactId === contact.id ? 'true' : undefined}
>
<td className="px-3 py-2" onClick={(e) => e.stopPropagation()}>
<input type="checkbox" className="rounded border-secondary-300" aria-label={getDisplayName(contact)} />
</td>
<td className="px-3 py-2"><TypeBadge type={contact.type} /></td>
<td className="px-3 py-2 font-medium text-secondary-900">{getDisplayName(contact)}</td>
<td className="px-3 py-2 text-secondary-600">{getEmail(contact)}</td>
<td className="px-3 py-2 text-secondary-600">{getPhone(contact)}</td>
<td className="px-3 py-2 text-secondary-600">{getCity(contact)}</td>
<td className="px-3 py-2">
<div className="flex flex-wrap gap-1">
{getTags(contact).slice(0, 3).map((tag) => (
<Badge key={tag} variant="secondary">{tag}</Badge>
))}
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
<Pagination
currentPage={currentPage}
totalPages={totalPages}
total={total}
pageSize={pageSize}
onPageChange={onPageChange}
/>
</div>
);
}
// ── Cards View ──
return (
<div className="flex flex-col h-full" data-testid="contact-cards-view">
<div className="flex-1 overflow-y-auto p-3">
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
{contacts.map((contact) => (
<div
key={contact.id}
onClick={() => onSelectContact(contact)}
className={clsx(
'p-3 rounded-lg border cursor-pointer transition-colors min-h-touch',
selectedContactId === contact.id
? 'border-primary-300 bg-primary-50'
: 'border-secondary-200 bg-white hover:bg-secondary-50',
)}
role="button"
tabIndex={0}
aria-current={selectedContactId === contact.id ? 'true' : undefined}
>
<div className="flex items-center gap-3 mb-2">
<div className="w-10 h-10 rounded-full bg-primary-100 flex items-center justify-center text-primary-700 font-semibold flex-shrink-0">
{getInitials(contact)}
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="font-medium text-sm text-secondary-900 truncate">{getDisplayName(contact)}</span>
</div>
<TypeBadge type={contact.type} />
</div>
</div>
<div className="text-xs text-secondary-500 space-y-0.5">
<div className="truncate">{getEmail(contact)}</div>
<div className="truncate">{getPhone(contact)}</div>
<div className="truncate">{getCity(contact)}</div>
</div>
</div>
))}
</div>
</div>
<Pagination
currentPage={currentPage}
totalPages={totalPages}
total={total}
pageSize={pageSize}
onPageChange={onPageChange}
/>
</div>
);
}
@@ -18,7 +18,6 @@ const navIcon = (path: string) => (
const navItems: NavItem[] = [
{ to: '/dashboard', labelKey: 'nav.dashboard', icon: navIcon('M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6') },
{ to: '/companies', labelKey: 'nav.companies', icon: navIcon('M19 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16m14 0h2m-2 0h-5m-9 0H3m2 0h5M9 7h1m-1 4h1m4-4h1m-1 4h1m-5 10v-5a1 1 0 011-1h2a1 1 0 011 1v5m-4 0h4') },
{ to: '/contacts', labelKey: 'nav.contacts', icon: navIcon('M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6-3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z') },
{ to: '/calendar', labelKey: 'nav.calendar', icon: navIcon('M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z') },
{ to: '/dms', labelKey: 'nav.files', icon: navIcon('M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z') },
+49 -1
View File
@@ -147,7 +147,55 @@
"noCompanies": "Dieser Kontakt ist keiner Firma zugeordnet.",
"noFiles": "Keine Dateien vorhanden.",
"noActivity": "Keine Aktivität vorhanden.",
"fullName": "Name"
"fullName": "Name",
"allContacts": "Alle Kontakte",
"persons": "Privatpersonen",
"type": "Typ",
"name": "Name",
"code": "Kundennummer",
"mobile": "Mobil",
"website": "Website",
"searchPlaceholder": "Kontakte durchsuchen...",
"viewMode": "Ansicht",
"viewList": "Liste",
"viewTable": "Tabelle",
"viewCards": "Karten",
"selectContact": "Kein Kontakt ausgewählt",
"selectContactDesc": "Wählen Sie einen Kontakt aus der Liste, um Details anzuzeigen.",
"contactPersons": "Ansprechpartner",
"addPerson": "Ansprechpartner hinzufügen",
"editPerson": "Ansprechpartner bearbeiten",
"noPersons": "Keine Ansprechpartner vorhanden.",
"deletePersonConfirm": "Möchten Sie diesen Ansprechpartner wirklich löschen?",
"personCreated": "Ansprechpartner erfolgreich erstellt.",
"personUpdated": "Ansprechpartner erfolgreich aktualisiert.",
"personDeleted": "Ansprechpartner erfolgreich gelöscht.",
"mailingAddress": "Postadresse",
"visitAddress": "Besuchsadresse",
"invoiceAddress": "Rechnungsadresse",
"communication": "Kommunikation",
"financial": "Finanzen & Steuern",
"vatCode": "USt-IdNr.",
"fiscalCode": "Steuernummer",
"commerceCode": "Handelsregister",
"purchaseNumber": "Bestellnummer",
"bic": "BIC",
"bankAccount": "IBAN",
"discounts": "Rabatte",
"discountCrew": "Personalrabatt",
"discountTransport": "Transportrabatt",
"discountRental": "Mietrabatt",
"discountSale": "Verkaufsrabatt",
"discountSubrent": "Fremdverleihrabatt",
"discountTotal": "Gesamtrabatt",
"geo": "Geografie",
"latitude": "Breitengrad",
"longitude": "Längengrad",
"notes": "Notizen",
"projectnote": "Projektnotiz",
"projectnoteTitle": "Notiztitel",
"contactWarning": "Kontaktwarnung",
"customFields": "Benutzerdefinierte Felder"
},
"settings": {
"title": "Einstellungen",
+49 -1
View File
@@ -147,7 +147,55 @@
"noCompanies": "This contact is not assigned to any company.",
"noFiles": "No files available.",
"noActivity": "No activity available.",
"fullName": "Name"
"fullName": "Name",
"allContacts": "All Contacts",
"persons": "Persons",
"type": "Type",
"name": "Name",
"code": "Customer Code",
"mobile": "Mobile",
"website": "Website",
"searchPlaceholder": "Search contacts...",
"viewMode": "View",
"viewList": "List",
"viewTable": "Table",
"viewCards": "Cards",
"selectContact": "No contact selected",
"selectContactDesc": "Select a contact from the list to view details.",
"contactPersons": "Contact Persons",
"addPerson": "Add contact person",
"editPerson": "Edit contact person",
"noPersons": "No contact persons.",
"deletePersonConfirm": "Delete this contact person?",
"personCreated": "Contact person created.",
"personUpdated": "Contact person updated.",
"personDeleted": "Contact person deleted.",
"mailingAddress": "Mailing Address",
"visitAddress": "Visit Address",
"invoiceAddress": "Invoice Address",
"communication": "Communication",
"financial": "Financial & Tax",
"vatCode": "VAT ID",
"fiscalCode": "Tax Number",
"commerceCode": "Commercial Register",
"purchaseNumber": "Purchase Number",
"bic": "BIC",
"bankAccount": "IBAN",
"discounts": "Discounts",
"discountCrew": "Crew Discount",
"discountTransport": "Transport Discount",
"discountRental": "Rental Discount",
"discountSale": "Sale Discount",
"discountSubrent": "Subrent Discount",
"discountTotal": "Total Discount",
"geo": "Geography",
"latitude": "Latitude",
"longitude": "Longitude",
"notes": "Notes",
"projectnote": "Project Note",
"projectnoteTitle": "Note Title",
"contactWarning": "Contact Warning",
"customFields": "Custom Fields"
},
"settings": {
"title": "Settings",
-194
View File
@@ -1,194 +0,0 @@
import React, { useState, useMemo } from 'react';
import { useNavigate } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { ColumnDef } from '@tanstack/react-table';
import { useCompanies, useCompanyExport, useDeleteCompany, Company } from '@/api/hooks';
import { DataGrid } from '@/components/shared/DataGrid';
import { CsvImportDialog } from '@/components/shared/CsvImportDialog';
import { Button } from '@/components/ui/Button';
import { Input } from '@/components/ui/Input';
import { EmptyState } from '@/components/ui/EmptyState';
import { ConfirmDialog } from '@/components/ui/ConfirmDialog';
import { useToast } from '@/components/ui/Toast';
import { Badge } from '@/components/ui/Badge';
export function CompaniesListPage() {
const { t } = useTranslation();
const navigate = useNavigate();
const toast = useToast();
const [page, setPage] = useState(1);
const [pageSize] = useState(25);
const [search, setSearch] = useState('');
const [debouncedSearch, setDebouncedSearch] = useState('');
const [importOpen, setImportOpen] = useState(false);
const [deleteTarget, setDeleteTarget] = useState<Company | null>(null);
const { data, isLoading } = useCompanies(page, pageSize, debouncedSearch);
const exportMutation = useCompanyExport(debouncedSearch);
const deleteMutation = useDeleteCompany();
React.useEffect(() => {
const timer = setTimeout(() => setDebouncedSearch(search), 300);
return () => clearTimeout(timer);
}, [search]);
const columns = useMemo<ColumnDef<Company, any>[]>(
() => [
{
accessorKey: 'name',
header: t('companies.name'),
cell: (info) => (
<span className="font-medium text-primary-700 hover:text-primary-800">
{info.getValue()}
</span>
),
},
{
accessorKey: 'account_number',
header: t('companies.accountNumber'),
cell: (info) => info.getValue() || '—',
},
{
accessorKey: 'industry',
header: t('companies.industry'),
cell: (info) =>
info.getValue() ? (
<Badge variant="primary">{info.getValue()}</Badge>
) : (
'—'
),
},
{
accessorKey: 'phone',
header: t('companies.phone'),
cell: (info) => info.getValue() || '—',
},
{
accessorKey: 'email',
header: t('companies.email'),
cell: (info) =>
info.getValue() ? (
<a href={`mailto:${info.getValue()}`} className="text-primary-600 hover:text-primary-700">
{info.getValue()}
</a>
) : (
'—'
),
},
],
[t]
);
const handleExport = async () => {
try {
const blob = await exportMutation.mutateAsync();
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `companies_${new Date().toISOString().split('T')[0]}.csv`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
window.URL.revokeObjectURL(url);
toast.success(t('companies.export') + ' — OK');
} catch (err: any) {
toast.error(err.message || t('common.error'));
}
};
const handleDelete = async () => {
if (!deleteTarget) return;
try {
await deleteMutation.mutateAsync(deleteTarget.id);
toast.success(t('companies.deleted'));
setDeleteTarget(null);
} catch (err: any) {
toast.error(err.message || t('common.error'));
}
};
const companies = data?.items ?? [];
const total = data?.total ?? 0;
const isEmpty = !isLoading && companies.length === 0 && !debouncedSearch;
if (isEmpty) {
return (
<div className="p-6 max-w-7xl mx-auto" data-testid="companies-list-page">
<div className="flex items-center justify-between mb-6">
<h1 className="text-2xl font-bold text-secondary-900">{t('companies.title')}</h1>
</div>
<EmptyState
title={t('companies.emptyTitle')}
description={t('companies.emptyDescription')}
action={
<Button onClick={() => navigate('/companies/new')}>
{t('companies.create')}
</Button>
}
/>
</div>
);
}
return (
<div className="p-6 max-w-7xl mx-auto" data-testid="companies-list-page">
<div className="flex items-center justify-between mb-6">
<h1 className="text-2xl font-bold text-secondary-900">{t('companies.title')}</h1>
<div className="flex gap-2">
<Button variant="secondary" onClick={() => setImportOpen(true)}>
{t('companies.import')}
</Button>
<Button
variant="secondary"
onClick={handleExport}
isLoading={exportMutation.isPending}
>
{t('companies.export')}
</Button>
<Button onClick={() => navigate('/companies/new')}>
{t('companies.create')}
</Button>
</div>
</div>
<div className="mb-4">
<Input
type="search"
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder={t('common.search')}
aria-label={t('common.search')}
/>
</div>
<DataGrid
columns={columns}
data={companies}
total={total}
page={page}
pageSize={pageSize}
onPageChange={setPage}
onRowClick={(row) => navigate(`/companies/${row.id}`)}
loading={isLoading}
emptyMessage={t('common.noResults')}
testId="companies-grid"
/>
<CsvImportDialog
open={importOpen}
onClose={() => setImportOpen(false)}
onSuccess={() => setPage(1)}
/>
<ConfirmDialog
open={!!deleteTarget}
title={t('companies.deleteConfirmTitle')}
message={t('companies.deleteConfirm')}
variant="danger"
onConfirm={handleDelete}
onCancel={() => setDeleteTarget(null)}
/>
</div>
);
}
-152
View File
@@ -1,152 +0,0 @@
import React from 'react';
import { useParams, useNavigate } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { useCompany } from '@/api/hooks';
import { Tabs, TabItem } from '@/components/shared/Tabs';
import { Card } from '@/components/ui/Card';
import { Button } from '@/components/ui/Button';
import { Badge } from '@/components/ui/Badge';
import { EmptyState } from '@/components/ui/EmptyState';
import { Skeleton } from '@/components/ui/Skeleton';
import { Avatar } from '@/components/ui/Avatar';
import { TagPicker } from '@/components/tags/TagPicker';
import { AddressList } from '@/components/AddressList';
export function CompanyDetailPage() {
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
const { t } = useTranslation();
const { data: company, isLoading, isError } = useCompany(id);
if (isLoading) {
return (
<div className="p-6 max-w-7xl mx-auto" data-testid="company-detail-page">
<Skeleton className="h-8 w-64 mb-6" />
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<Skeleton className="h-32" />
<Skeleton className="h-32" />
</div>
</div>
);
}
if (isError || !company) {
return (
<div className="p-6 max-w-7xl mx-auto" data-testid="company-detail-page">
<EmptyState
title={t('companies.notFound')}
action={<Button onClick={() => navigate('/companies')}>{t('common.back')}</Button>}
/>
</div>
);
}
const contacts = company.contacts ?? [];
const overviewTab: TabItem = {
key: 'overview',
label: t('companies.overview'),
content: (
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<Card title={t('companies.name')}>
<p className="text-secondary-900">{company.name}</p>
</Card>
<Card title={t('companies.accountNumber')}>
<p className="text-secondary-900">{company.account_number || '—'}</p>
</Card>
<Card title={t('companies.industry')}>
{company.industry ? <Badge variant="primary">{company.industry}</Badge> : <p className="text-secondary-500"></p>}
</Card>
<Card title={t('companies.phone')}>
<p className="text-secondary-900">{company.phone || '—'}</p>
</Card>
<Card title={t('companies.email')}>
{company.email ? (
<a href={`mailto:${company.email}`} className="text-primary-600 hover:text-primary-700">{company.email}</a>
) : <p className="text-secondary-500"></p>}
</Card>
<Card title={t('companies.website')}>
{company.website ? (
<a href={company.website} target="_blank" rel="noopener noreferrer" className="text-primary-600 hover:text-primary-700">{company.website}</a>
) : <p className="text-secondary-500"></p>}
</Card>
{company.description && (
<Card title={t('companies.description')}>
<p className="text-secondary-700 text-sm whitespace-pre-wrap">{company.description}</p>
</Card>
)}
</div>
),
};
const contactsTab: TabItem = {
key: 'contacts',
label: t('companies.contacts'),
badge: contacts.length,
content: contacts.length === 0 ? (
<EmptyState title={t('companies.noContacts')} />
) : (
<ul className="space-y-2" role="list">
{contacts.map((contact) => (
<li key={contact.id}>
<button
onClick={() => navigate(`/contacts/${contact.id}`)}
className="w-full flex items-center gap-3 p-3 rounded-lg hover:bg-secondary-50 text-left min-h-touch"
>
<Avatar name={`${contact.first_name} ${contact.last_name}`} size="sm" />
<div className="flex-1 min-w-0">
<p className="font-medium text-secondary-900">{contact.first_name} {contact.last_name}</p>
<p className="text-sm text-secondary-500 truncate">{contact.email}{contact.position ? ` · ${contact.position}` : ''}</p>
</div>
</button>
</li>
))}
</ul>
),
};
const addressesTab: TabItem = {
key: 'addresses',
label: t('address.title'),
content: (
<AddressList entityType="company" entityId={company.id} />
),
};
const tagsTab: TabItem = {
key: 'tags',
label: t('tags.title'),
content: (
<TagPicker entityType="company" entityId={company.id} />
),
};
const filesTab: TabItem = {
key: 'files',
label: t('companies.files'),
content: <EmptyState title={t('companies.noFiles')} />,
};
const activityTab: TabItem = {
key: 'activity',
label: t('companies.activity'),
content: <EmptyState title={t('companies.noActivity')} />,
};
return (
<div className="p-6 max-w-7xl mx-auto" data-testid="company-detail-page">
<div className="flex items-center justify-between mb-6">
<div className="flex items-center gap-4">
<Button variant="ghost" onClick={() => navigate('/companies')} aria-label={t('common.back')}>
{t('common.back')}
</Button>
<h1 className="text-2xl font-bold text-secondary-900">{company.name}</h1>
</div>
<Button onClick={() => navigate(`/companies/${company.id}/edit`)}>
{t('common.edit')}
</Button>
</div>
<Tabs tabs={[overviewTab, contactsTab, addressesTab, tagsTab, filesTab, activityTab]} defaultKey="overview" />
</div>
);
}
-189
View File
@@ -1,189 +0,0 @@
import React, { useEffect } from 'react';
import { useParams, useNavigate } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
import { useCompany, useCreateCompany, useUpdateCompany } from '@/api/hooks';
import { Input } from '@/components/ui/Input';
import { Button } from '@/components/ui/Button';
import { Card } from '@/components/ui/Card';
import { Skeleton } from '@/components/ui/Skeleton';
import { useToast } from '@/components/ui/Toast';
import { UnsavedChangesGuard } from '@/components/shared/UnsavedChangesGuard';
const companySchema = z.object({
name: z.string().min(1, 'Name ist erforderlich').max(100, 'Maximal 100 Zeichen'),
account_number: z.string().max(40, 'Maximal 40 Zeichen').optional().or(z.literal('')),
industry: z.string().max(50, 'Maximal 50 Zeichen').optional().or(z.literal('')),
phone: z.string().max(30, 'Maximal 30 Zeichen').optional().or(z.literal('')),
email: z.string().email('Ungültige E-Mail-Adresse').max(255).optional().or(z.literal('')),
website: z.string().max(500, 'Maximal 500 Zeichen').optional().or(z.literal('')),
description: z.string().optional().or(z.literal('')),
});
type CompanyFormValues = z.infer<typeof companySchema>;
export function CompanyFormPage() {
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
const { t } = useTranslation();
const toast = useToast();
const isEdit = !!id;
const { data: company, isLoading } = useCompany(isEdit ? id : undefined);
const createMutation = useCreateCompany();
const updateMutation = useUpdateCompany();
const {
register,
handleSubmit,
reset,
formState: { errors, isDirty, isSubmitting },
} = useForm<CompanyFormValues>({
resolver: zodResolver(companySchema),
defaultValues: {
name: '',
account_number: '',
industry: '',
phone: '',
email: '',
website: '',
description: '',
},
});
useEffect(() => {
if (isEdit && company) {
reset({
name: company.name || '',
account_number: company.account_number || '',
industry: company.industry || '',
phone: company.phone || '',
email: company.email || '',
website: company.website || '',
description: company.description || '',
});
}
}, [company, isEdit, reset]);
const onSubmit = async (values: CompanyFormValues) => {
try {
if (isEdit && id) {
await updateMutation.mutateAsync({ id, data: values });
toast.success(t('companies.updated'));
navigate(`/companies/${id}`);
} else {
const result = await createMutation.mutateAsync(values) as { id: string };
toast.success(t('companies.created'));
navigate(`/companies/${result.id}`);
}
} catch (err: any) {
toast.error(err.message || (isEdit ? t('companies.updateFailed') : t('companies.createFailed')));
}
};
if (isEdit && isLoading) {
return (
<div className="p-6 max-w-4xl mx-auto" data-testid="company-form-page">
<Skeleton className="h-8 w-48 mb-6" />
<Skeleton className="h-96" />
</div>
);
}
return (
<div className="p-6 max-w-4xl mx-auto" data-testid="company-form-page">
<UnsavedChangesGuard isDirty={isDirty} />
<div className="flex items-center justify-between mb-6">
<div className="flex items-center gap-4">
<Button variant="ghost" onClick={() => navigate(-1)}>
{t('common.back')}
</Button>
<h1 className="text-2xl font-bold text-secondary-900">
{isEdit ? t('companies.edit') : t('companies.create')}
</h1>
</div>
</div>
<form onSubmit={handleSubmit(onSubmit)} noValidate className="space-y-4">
<Card title={t('companies.name')}>
<Input
{...register('name')}
label={t('companies.name')}
required
error={errors.name?.message}
placeholder="TechCorp GmbH"
data-testid="company-name-input"
/>
</Card>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<Card title={t('companies.accountNumber')}>
<Input
{...register('account_number')}
label={t('companies.accountNumber')}
error={errors.account_number?.message}
placeholder="K-00123"
/>
</Card>
<Card title={t('companies.industry')}>
<Input
{...register('industry')}
label={t('companies.industry')}
error={errors.industry?.message}
placeholder="IT & Software"
/>
</Card>
<Card title={t('companies.phone')}>
<Input
{...register('phone')}
label={t('companies.phone')}
error={errors.phone?.message}
placeholder="+49 30 1234567"
/>
</Card>
<Card title={t('companies.email')}>
<Input
{...register('email')}
label={t('companies.email')}
type="email"
error={errors.email?.message}
placeholder="info@techcorp.de"
/>
</Card>
</div>
<Card title={t('companies.website')}>
<Input
{...register('website')}
label={t('companies.website')}
error={errors.website?.message}
placeholder="https://www.techcorp.de"
/>
</Card>
<Card title={t('companies.description')}>
<textarea
{...register('description')}
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-24"
placeholder="Firmenbeschreibung..."
aria-label={t('companies.description')}
/>
{errors.description && (
<p className="mt-1 text-sm text-danger-600">{errors.description.message}</p>
)}
</Card>
<div className="flex justify-end gap-3">
<Button variant="secondary" onClick={() => navigate(-1)}>
{t('common.cancel')}
</Button>
<Button type="submit" isLoading={isSubmitting} data-testid="company-submit-btn">
{isEdit ? t('common.save') : t('common.create')}
</Button>
</div>
</form>
</div>
);
}
-150
View File
@@ -1,150 +0,0 @@
import React from 'react';
import { useParams, useNavigate } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { useContact } from '@/api/hooks';
import { Tabs, TabItem } from '@/components/shared/Tabs';
import { Card } from '@/components/ui/Card';
import { Button } from '@/components/ui/Button';
import { Badge } from '@/components/ui/Badge';
import { EmptyState } from '@/components/ui/EmptyState';
import { Skeleton } from '@/components/ui/Skeleton';
import { Avatar } from '@/components/ui/Avatar';
import { TagPicker } from '@/components/tags/TagPicker';
import { AddressList } from '@/components/AddressList';
export function ContactDetailPage() {
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
const { t } = useTranslation();
const { data: contact, isLoading, isError } = useContact(id);
if (isLoading) {
return (
<div className="p-6 max-w-7xl mx-auto" data-testid="contact-detail-page">
<Skeleton className="h-8 w-64 mb-6" />
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<Skeleton className="h-32" />
<Skeleton className="h-32" />
</div>
</div>
);
}
if (isError || !contact) {
return (
<div className="p-6 max-w-7xl mx-auto" data-testid="contact-detail-page">
<EmptyState
title={t('contacts.notFound')}
action={<Button onClick={() => navigate('/contacts')}>{t('common.back')}</Button>}
/>
</div>
);
}
const companies = contact.companies ?? [];
const overviewTab: TabItem = {
key: 'overview',
label: t('contacts.overview'),
content: (
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<Card title={t('contacts.firstName')}>
<p className="text-secondary-900">{contact.first_name}</p>
</Card>
<Card title={t('contacts.lastName')}>
<p className="text-secondary-900">{contact.last_name}</p>
</Card>
<Card title={t('contacts.email')}>
{contact.email ? (
<a href={`mailto:${contact.email}`} className="text-primary-600 hover:text-primary-700">{contact.email}</a>
) : <p className="text-secondary-500"></p>}
</Card>
<Card title={t('contacts.phone')}>
<p className="text-secondary-900">{contact.phone || '—'}</p>
</Card>
<Card title={t('contacts.position')}>
<p className="text-secondary-900">{contact.position || '—'}</p>
</Card>
</div>
),
};
const companiesTab: TabItem = {
key: 'companies',
label: t('contacts.companies'),
badge: companies.length,
content: companies.length === 0 ? (
<EmptyState title={t('contacts.noCompanies')} />
) : (
<ul className="space-y-2" role="list">
{companies.map((company) => (
<li key={company.id}>
<button
onClick={() => navigate(`/companies/${company.id}`)}
className="w-full flex items-center gap-3 p-3 rounded-lg hover:bg-secondary-50 text-left min-h-touch"
>
<div className="w-10 h-10 rounded-lg bg-primary-50 flex items-center justify-center text-primary-600 font-semibold flex-shrink-0">
{company.name.charAt(0).toUpperCase()}
</div>
<div className="flex-1 min-w-0">
<p className="font-medium text-secondary-900">{company.name}</p>
{company.industry && (
<p className="text-sm text-secondary-500">{company.industry}</p>
)}
</div>
{company.email && (
<Badge variant="info">{company.email}</Badge>
)}
</button>
</li>
))}
</ul>
),
};
const addressesTab: TabItem = {
key: 'addresses',
label: t('address.title'),
content: (
<AddressList entityType="contact" entityId={contact.id} />
),
};
const tagsTab: TabItem = {
key: 'tags',
label: t('tags.title'),
content: (
<TagPicker entityType="contact" entityId={contact.id} />
),
};
const filesTab: TabItem = {
key: 'files',
label: t('contacts.files'),
content: <EmptyState title={t('contacts.noFiles')} />,
};
const activityTab: TabItem = {
key: 'activity',
label: t('contacts.activity'),
content: <EmptyState title={t('contacts.noActivity')} />,
};
return (
<div className="p-6 max-w-7xl mx-auto" data-testid="contact-detail-page">
<div className="flex items-center justify-between mb-6">
<div className="flex items-center gap-4">
<Button variant="ghost" onClick={() => navigate('/contacts')} aria-label={t('common.back')}>
{t('common.back')}
</Button>
<Avatar name={`${contact.first_name} ${contact.last_name}`} size="md" />
<h1 className="text-2xl font-bold text-secondary-900">{contact.first_name} {contact.last_name}</h1>
</div>
<Button onClick={() => navigate(`/contacts/${contact.id}/edit`)}>
{t('common.edit')}
</Button>
</div>
<Tabs tabs={[overviewTab, companiesTab, addressesTab, tagsTab, filesTab, activityTab]} defaultKey="overview" />
</div>
);
}
-211
View File
@@ -1,211 +0,0 @@
import React, { useEffect, useState } from 'react';
import { useParams, useNavigate } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
import { useContact, useCreateContact, useUpdateContact, useCompanies, Company } from '@/api/hooks';
import { Input } from '@/components/ui/Input';
import { Button } from '@/components/ui/Button';
import { Card } from '@/components/ui/Card';
import { Skeleton } from '@/components/ui/Skeleton';
import { Badge } from '@/components/ui/Badge';
import { useToast } from '@/components/ui/Toast';
import { UnsavedChangesGuard } from '@/components/shared/UnsavedChangesGuard';
const contactSchema = z.object({
first_name: z.string().min(1, 'Vorname ist erforderlich').max(100),
last_name: z.string().min(1, 'Nachname ist erforderlich').max(100),
email: z.string().email('Ungültige E-Mail-Adresse').max(255).optional().or(z.literal('')),
phone: z.string().max(30).optional().or(z.literal('')),
position: z.string().max(100).optional().or(z.literal('')),
});
type ContactFormValues = z.infer<typeof contactSchema>;
export function ContactFormPage() {
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
const { t } = useTranslation();
const toast = useToast();
const isEdit = !!id;
const { data: contact, isLoading } = useContact(isEdit ? id : undefined);
const createMutation = useCreateContact();
const updateMutation = useUpdateContact();
const { data: companiesData } = useCompanies(1, 100);
const [selectedCompanyIds, setSelectedCompanyIds] = useState<string[]>([]);
const {
register,
handleSubmit,
reset,
formState: { errors, isDirty, isSubmitting },
} = useForm<ContactFormValues>({
resolver: zodResolver(contactSchema),
defaultValues: {
first_name: '',
last_name: '',
email: '',
phone: '',
position: '',
},
});
useEffect(() => {
if (isEdit && contact) {
reset({
first_name: contact.first_name || '',
last_name: contact.last_name || '',
email: contact.email || '',
phone: contact.phone || '',
position: contact.position || '',
});
setSelectedCompanyIds(contact.company_ids || (contact.companies?.map((c) => c.id) || []));
}
}, [contact, isEdit, reset]);
const toggleCompany = (companyId: string) => {
setSelectedCompanyIds((prev) =>
prev.includes(companyId)
? prev.filter((cid) => cid !== companyId)
: [...prev, companyId]
);
};
const onSubmit = async (values: ContactFormValues) => {
const payload = { ...values, company_ids: selectedCompanyIds };
try {
if (isEdit && id) {
await updateMutation.mutateAsync({ id, data: payload });
toast.success(t('contacts.updated'));
navigate(`/contacts/${id}`);
} else {
const result = await createMutation.mutateAsync(payload) as { id: string };
toast.success(t('contacts.created'));
navigate(`/contacts/${result.id}`);
}
} catch (err: any) {
toast.error(err.message || (isEdit ? t('contacts.updateFailed') : t('contacts.createFailed')));
}
};
if (isEdit && isLoading) {
return (
<div className="p-6 max-w-4xl mx-auto" data-testid="contact-form-page">
<Skeleton className="h-8 w-48 mb-6" />
<Skeleton className="h-96" />
</div>
);
}
const availableCompanies: Company[] = companiesData?.items ?? [];
return (
<div className="p-6 max-w-4xl mx-auto" data-testid="contact-form-page">
<UnsavedChangesGuard isDirty={isDirty || selectedCompanyIds.length > 0} />
<div className="flex items-center justify-between mb-6">
<div className="flex items-center gap-4">
<Button variant="ghost" onClick={() => navigate(-1)}>
{t('common.back')}
</Button>
<h1 className="text-2xl font-bold text-secondary-900">
{isEdit ? t('contacts.edit') : t('contacts.create')}
</h1>
</div>
</div>
<form onSubmit={handleSubmit(onSubmit)} noValidate className="space-y-4">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<Card title={t('contacts.firstName')}>
<Input
{...register('first_name')}
label={t('contacts.firstName')}
required
error={errors.first_name?.message}
placeholder="Max"
data-testid="contact-first-name-input"
/>
</Card>
<Card title={t('contacts.lastName')}>
<Input
{...register('last_name')}
label={t('contacts.lastName')}
required
error={errors.last_name?.message}
placeholder="Mustermann"
data-testid="contact-last-name-input"
/>
</Card>
<Card title={t('contacts.email')}>
<Input
{...register('email')}
label={t('contacts.email')}
type="email"
error={errors.email?.message}
placeholder="max@mustermann.de"
/>
</Card>
<Card title={t('contacts.phone')}>
<Input
{...register('phone')}
label={t('contacts.phone')}
error={errors.phone?.message}
placeholder="+49 170 1234567"
/>
</Card>
</div>
<Card title={t('contacts.position')}>
<Input
{...register('position')}
label={t('contacts.position')}
error={errors.position?.message}
placeholder="Geschäftsführer"
/>
</Card>
<Card title={t('contacts.assignCompanies')}>
{availableCompanies.length === 0 ? (
<p className="text-sm text-secondary-500">{t('companies.emptyTitle')}</p>
) : (
<div className="space-y-2" data-testid="company-assignment-list">
{availableCompanies.map((company) => (
<label
key={company.id}
className="flex items-center gap-3 p-2 rounded-md hover:bg-secondary-50 cursor-pointer min-h-touch"
>
<input
type="checkbox"
checked={selectedCompanyIds.includes(company.id)}
onChange={() => toggleCompany(company.id)}
className="w-4 h-4 rounded border-secondary-300 text-primary-600 focus:ring-primary-500"
aria-label={company.name}
/>
<span className="text-sm text-secondary-900">{company.name}</span>
{company.industry && (
<Badge variant="primary">{company.industry}</Badge>
)}
</label>
))}
</div>
)}
{selectedCompanyIds.length > 0 && (
<p className="mt-2 text-xs text-secondary-500">
{selectedCompanyIds.length} {selectedCompanyIds.length === 1 ? 'Firma zugewiesen' : 'Firmen zugewiesen'}
</p>
)}
</Card>
<div className="flex justify-end gap-3">
<Button variant="secondary" onClick={() => navigate(-1)}>
{t('common.cancel')}
</Button>
<Button type="submit" isLoading={isSubmitting} data-testid="contact-submit-btn">
{isEdit ? t('common.save') : t('common.create')}
</Button>
</div>
</form>
</div>
);
}
+358 -117
View File
@@ -1,148 +1,389 @@
import React, { useState, useMemo } from 'react';
import { useNavigate } from 'react-router-dom';
/**
* Contacts page — unified 3-column layout (folder tree + list + detail).
* Replaces old separate Contacts and Company pages.
* Uses ResizablePanel for drag-to-resize columns, like Mail.tsx.
*/
import React, { useState, useEffect, useCallback, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { ColumnDef } from '@tanstack/react-table';
import { useContacts, useDeleteContact, Contact } from '@/api/hooks';
import { DataGrid } from '@/components/shared/DataGrid';
import { ResizablePanel } from '@/components/ui/ResizablePanel';
import { Button } from '@/components/ui/Button';
import { Input } from '@/components/ui/Input';
import { EmptyState } from '@/components/ui/EmptyState';
import { ConfirmDialog } from '@/components/ui/ConfirmDialog';
import { Select } from '@/components/ui/Select';
import { useToast } from '@/components/ui/Toast';
import { Avatar } from '@/components/ui/Avatar';
import { EmptyState } from '@/components/ui/EmptyState';
import { usePluginToolbarStore } from '@/store/pluginToolbarStore';
import { ContactFolderTree, type ContactFilter } from '@/components/contacts/ContactFolderTree';
import { ContactList, type ContactViewMode } from '@/components/contacts/ContactList';
import { ContactDetail } from '@/components/contacts/ContactDetail';
import { ContactEditModal } from '@/components/contacts/ContactEditModal';
import {
useUnifiedContacts,
useUnifiedContact,
type UnifiedContact,
} from '@/api/hooks';
const PAGE_SIZE = 25;
export function ContactsListPage() {
const { t } = useTranslation();
const navigate = useNavigate();
const toast = useToast();
const [page, setPage] = useState(1);
const [pageSize] = useState(25);
const [selectedFilter, setSelectedFilter] = useState<ContactFilter>('all');
const [search, setSearch] = useState('');
const [debouncedSearch, setDebouncedSearch] = useState('');
const [deleteTarget, setDeleteTarget] = useState<Contact | null>(null);
const [page, setPage] = useState(1);
const [sortBy, setSortBy] = useState('displayname');
const [sortOrder, setSortOrder] = useState<'asc' | 'desc'>('asc');
const [viewMode, setViewMode] = useState<ContactViewMode>('list');
const [selectedContactId, setSelectedContactId] = useState<string | null>(null);
const [activeView, setActiveView] = useState<'folders' | 'list' | 'detail'>('folders');
const [editModalOpen, setEditModalOpen] = useState(false);
const [editingContact, setEditingContact] = useState<UnifiedContact | null>(null);
const { data, isLoading } = useContacts(page, pageSize, debouncedSearch);
const deleteMutation = useDeleteContact();
React.useEffect(() => {
// Debounce search
useEffect(() => {
const timer = setTimeout(() => setDebouncedSearch(search), 300);
return () => clearTimeout(timer);
}, [search]);
const columns = useMemo<ColumnDef<Contact, any>[]>(
() => [
{
id: 'name',
header: t('contacts.fullName'),
cell: ({ row }) => (
<div className="flex items-center gap-2">
<Avatar name={`${row.original.first_name} ${row.original.last_name}`} size="sm" />
<span className="font-medium text-primary-700">
{row.original.first_name} {row.original.last_name}
</span>
</div>
),
},
{
accessorKey: 'email',
header: t('contacts.email'),
cell: (info) =>
info.getValue() ? (
<a href={`mailto:${info.getValue()}`} className="text-primary-600 hover:text-primary-700">
{info.getValue()}
</a>
) : (
'—'
),
},
{
accessorKey: 'phone',
header: t('contacts.phone'),
cell: (info) => info.getValue() || '—',
},
{
accessorKey: 'position',
header: t('contacts.position'),
cell: (info) => info.getValue() || '—',
},
],
[t]
// Derive type filter from selectedFilter
const contactType = useMemo(() => {
if (selectedFilter === 'company') return 'company';
if (selectedFilter === 'person') return 'person';
return undefined;
}, [selectedFilter]);
// Derive tag filter
const tagFilter = useMemo(() => {
if (selectedFilter.startsWith('tag:')) return selectedFilter.slice(4);
return undefined;
}, [selectedFilter]);
// Fetch contacts list
const { data, isLoading } = useUnifiedContacts(
page,
PAGE_SIZE,
debouncedSearch || undefined,
contactType,
sortBy,
sortOrder,
);
const handleDelete = async () => {
if (!deleteTarget) return;
try {
await deleteMutation.mutateAsync(deleteTarget.id);
toast.success(t('contacts.deleted'));
setDeleteTarget(null);
} catch (err: any) {
toast.error(err.message || t('common.error'));
}
};
// Fetch selected contact detail (with contact_persons)
const { data: selectedContact, isLoading: loadingDetail } = useUnifiedContact(selectedContactId || undefined);
const contacts = data?.items ?? [];
const total = data?.total ?? 0;
const isEmpty = !isLoading && contacts.length === 0 && !debouncedSearch;
if (isEmpty) {
return (
<div className="p-6 max-w-7xl mx-auto" data-testid="contacts-list-page">
<div className="flex items-center justify-between mb-6">
<h1 className="text-2xl font-bold text-secondary-900">{t('contacts.title')}</h1>
</div>
<EmptyState
title={t('contacts.emptyTitle')}
description={t('contacts.emptyDescription')}
action={
<Button onClick={() => navigate('/contacts/new')}>
{t('contacts.create')}
</Button>
}
/>
</div>
);
}
// Derive tags from all loaded contacts (for folder tree)
const allTags = useMemo(() => {
const tagSet = new Set<string>();
contacts.forEach((c) => {
if (c.tags) {
c.tags.split(',').forEach((tag) => {
const trimmed = tag.trim();
if (trimmed) tagSet.add(trimmed);
});
}
});
return Array.from(tagSet).sort();
}, [contacts]);
// Filter by tag client-side if tag filter is active
const filteredContacts = useMemo(() => {
if (!tagFilter) return contacts;
return contacts.filter((c) => {
if (!c.tags) return false;
return c.tags.split(',').map((t) => t.trim()).includes(tagFilter);
});
}, [contacts, tagFilter]);
// Handle folder selection
const handleSelectFilter = useCallback((filter: ContactFilter) => {
setSelectedFilter(filter);
setPage(1);
setSelectedContactId(null);
setActiveView('list');
}, []);
// Handle contact selection
const handleSelectContact = useCallback((contact: UnifiedContact) => {
setSelectedContactId(contact.id);
setActiveView('detail');
}, []);
// Handle sort change
const handleSortChange = useCallback((newSortBy: string, newSortOrder: 'asc' | 'desc') => {
setSortBy(newSortBy);
setSortOrder(newSortOrder);
setPage(1);
}, []);
// Handle search
const handleSearch = useCallback((query: string) => {
setSearch(query);
setPage(1);
}, []);
// Handle create
const handleCreate = useCallback(() => {
setEditingContact(null);
setEditModalOpen(true);
}, []);
// Handle edit
const handleEdit = useCallback(() => {
if (selectedContact) {
setEditingContact(selectedContact);
setEditModalOpen(true);
}
}, [selectedContact]);
// Handle delete (from detail)
const handleDeleted = useCallback(() => {
setSelectedContactId(null);
setActiveView('list');
}, []);
// Handle edit modal saved
const handleSaved = useCallback((id: string) => {
setSelectedContactId(id);
setActiveView('detail');
}, []);
// Register toolbar items
const registerItems = usePluginToolbarStore((s) => s.registerItems);
const unregisterPlugin = usePluginToolbarStore((s) => s.unregisterPlugin);
useEffect(() => {
const items = [
{
id: 'search',
plugin: 'contacts',
label: t('common.search'),
type: 'search' as const,
group: 'search',
searchPlaceholder: t('contacts.searchPlaceholder'),
onSearch: handleSearch,
onClick: () => {},
},
{
id: 'new',
plugin: 'contacts',
label: t('contacts.create'),
group: 'actions',
icon: (
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
</svg>
),
onClick: handleCreate,
},
];
registerItems('contacts', items);
return () => unregisterPlugin('contacts');
}, [handleSearch, handleCreate, t, registerItems, unregisterPlugin]);
// Sort options
const sortOptions = [
{ value: 'displayname', label: t('contacts.fullName') },
{ value: 'name', label: t('contacts.name') },
{ value: 'email_1', label: t('contacts.email') },
{ value: 'mailing_city', label: t('address.city') },
{ value: 'code', label: t('contacts.code') },
];
const viewModeOptions = [
{ value: 'list', label: t('contacts.viewList') },
{ value: 'table', label: t('contacts.viewTable') },
{ value: 'cards', label: t('contacts.viewCards') },
];
return (
<div className="p-6 max-w-7xl mx-auto" data-testid="contacts-list-page">
<div className="flex items-center justify-between mb-6">
<h1 className="text-2xl font-bold text-secondary-900">{t('contacts.title')}</h1>
<Button onClick={() => navigate('/contacts/new')}>
{t('contacts.create')}
</Button>
<div className="flex flex-col h-full" data-testid="contacts-list-page">
{/* Error display */}
{/* (errors handled by react-query + toast) */}
{/* Desktop: three-pane layout with resizable panels */}
<div className="hidden md:flex flex-1 overflow-hidden">
{/* Left: Folder tree — resizable */}
<ResizablePanel
initialWidth={240}
minWidth={150}
maxWidth={400}
className="border-r border-secondary-200 bg-white"
data-testid="contact-folder-pane"
>
<div className="p-3">
<ContactFolderTree
selectedFilter={selectedFilter}
onSelect={handleSelectFilter}
tags={allTags}
/>
</div>
</ResizablePanel>
{/* Middle: Contact list — resizable */}
<ResizablePanel
initialWidth={400}
minWidth={250}
maxWidth={600}
className="border-r border-secondary-200 bg-white"
data-testid="contact-list-pane"
>
{/* Toolbar */}
<div className="flex items-center gap-2 px-3 py-2 border-b border-secondary-200 bg-white flex-shrink-0">
<Input
type="search"
value={search}
onChange={(e) => handleSearch(e.target.value)}
placeholder={t('contacts.searchPlaceholder')}
aria-label={t('common.search')}
className="flex-1"
/>
<Select
value={viewMode}
onChange={(e) => setViewMode(e.target.value as ContactViewMode)}
options={viewModeOptions}
aria-label={t('contacts.viewMode')}
className="w-auto"
/>
<Select
value={sortBy}
onChange={(e) => { setSortBy(e.target.value); setPage(1); }}
options={sortOptions}
aria-label={t('common.filter')}
className="w-auto"
/>
<Button size="sm" onClick={handleCreate} data-testid="contacts-new-btn">
{t('contacts.create')}
</Button>
</div>
{/* List */}
<div className="flex-1 min-h-0">
<ContactList
contacts={filteredContacts}
selectedContactId={selectedContactId}
onSelectContact={handleSelectContact}
loading={isLoading}
currentPage={page}
total={total}
pageSize={PAGE_SIZE}
onPageChange={setPage}
viewMode={viewMode}
sortBy={sortBy}
sortOrder={sortOrder}
onSortChange={handleSortChange}
/>
</div>
</ResizablePanel>
{/* Right: Detail panel — flex-1, not resizable */}
<ResizablePanel
resizable={false}
className="bg-white"
data-testid="contact-detail-pane"
>
<ContactDetail
contact={selectedContact ?? null}
loading={loadingDetail}
onEdit={handleEdit}
onDeleted={handleDeleted}
/>
</ResizablePanel>
</div>
<div className="mb-4">
<Input
type="search"
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder={t('common.search')}
aria-label={t('common.search')}
/>
{/* Mobile: single-pane view switching */}
<div className="flex md:hidden flex-1 overflow-hidden flex-col">
{/* View 1: Folder tree */}
{activeView === 'folders' && (
<div className="flex-1 overflow-y-auto bg-white" data-testid="mobile-folder-pane">
<div className="p-3">
<ContactFolderTree
selectedFilter={selectedFilter}
onSelect={handleSelectFilter}
tags={allTags}
/>
</div>
</div>
)}
{/* View 2: Contact list */}
{activeView === 'list' && (
<div className="flex-1 overflow-y-auto bg-white" data-testid="mobile-list-pane">
<div className="flex items-center gap-2 px-3 py-2 border-b border-secondary-200 sticky top-0 bg-white z-10">
<button
onClick={() => setActiveView('folders')}
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')}
data-testid="mobile-back-to-folders"
>
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
</svg>
<span className="text-sm font-medium">{t('contacts.allContacts')}</span>
</button>
</div>
<div className="px-3 py-2 border-b border-secondary-200">
<Input
type="search"
value={search}
onChange={(e) => handleSearch(e.target.value)}
placeholder={t('contacts.searchPlaceholder')}
aria-label={t('common.search')}
/>
</div>
<ContactList
contacts={filteredContacts}
selectedContactId={selectedContactId}
onSelectContact={handleSelectContact}
loading={isLoading}
currentPage={page}
total={total}
pageSize={PAGE_SIZE}
onPageChange={setPage}
viewMode={viewMode}
sortBy={sortBy}
sortOrder={sortOrder}
onSortChange={handleSortChange}
/>
</div>
)}
{/* View 3: Contact detail */}
{activeView === 'detail' && (
<div className="flex-1 overflow-y-auto bg-white" data-testid="mobile-detail-pane">
<div className="flex items-center gap-2 px-3 py-2 border-b border-secondary-200 sticky top-0 bg-white z-10">
<button
onClick={() => setActiveView('list')}
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')}
data-testid="mobile-back-to-list"
>
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
</svg>
<span className="text-sm font-medium">{t('common.back')}</span>
</button>
</div>
<ContactDetail
contact={selectedContact ?? null}
loading={loadingDetail}
onEdit={handleEdit}
onDeleted={handleDeleted}
/>
</div>
)}
</div>
<DataGrid
columns={columns}
data={contacts}
total={total}
page={page}
pageSize={pageSize}
onPageChange={setPage}
onRowClick={(row) => navigate(`/contacts/${row.id}`)}
loading={isLoading}
emptyMessage={t('common.noResults')}
testId="contacts-grid"
/>
<ConfirmDialog
open={!!deleteTarget}
title={t('contacts.deleteConfirmTitle')}
message={t('contacts.deleteConfirm')}
variant="danger"
onConfirm={handleDelete}
onCancel={() => setDeleteTarget(null)}
{/* Edit/Create Modal */}
<ContactEditModal
open={editModalOpen}
onClose={() => setEditModalOpen(false)}
contact={editingContact}
onSaved={handleSaved}
/>
</div>
);
+3 -3
View File
@@ -2,12 +2,12 @@ import React from 'react';
import { useTranslation } from 'react-i18next';
import { StatCard } from '@/components/shared/StatCard';
import { ActivityFeed, ActivityItem } from '@/components/shared/ActivityFeed';
import { useCompanies, useContacts, useAuditLog } from '@/api/hooks';
import { useUnifiedContacts, useAuditLog } from '@/api/hooks';
export function DashboardPage() {
const { t } = useTranslation();
const { data: companiesData } = useCompanies(1, 1);
const { data: contactsData } = useContacts(1, 1);
const { data: companiesData } = useUnifiedContacts(1, 1, undefined, 'company');
const { data: contactsData } = useUnifiedContacts(1, 1, undefined, 'person');
const { data: auditData, isError: auditError } = useAuditLog(1, 5);
const totalCompanies = companiesData?.total ?? 0;
-12
View File
@@ -6,12 +6,7 @@ import { LoginPage } from '@/pages/Login';
import { PasswordResetRequestPage } from '@/pages/PasswordResetRequest';
import { PasswordResetConfirmPage } from '@/pages/PasswordResetConfirm';
import { DashboardPage } from '@/pages/Dashboard';
import { CompaniesListPage } from '@/pages/CompaniesList';
import { CompanyDetailPage } from '@/pages/CompanyDetail';
import { CompanyFormPage } from '@/pages/CompanyForm';
import { ContactsListPage } from '@/pages/ContactsList';
import { ContactDetailPage } from '@/pages/ContactDetail';
import { ContactFormPage } from '@/pages/ContactForm';
import { AuditLogPage } from '@/pages/AuditLog';
import { GlobalSearchResultsPage } from '@/pages/GlobalSearchResults';
import { SettingsPage } from '@/pages/Settings';
@@ -57,14 +52,7 @@ const router = createBrowserRouter([
children: [
{ path: '/', element: <DashboardPage /> },
{ path: '/dashboard', element: <DashboardPage /> },
{ path: '/companies', element: <CompaniesListPage /> },
{ path: '/companies/new', element: <CompanyFormPage /> },
{ path: '/companies/:id', element: <CompanyDetailPage /> },
{ path: '/companies/:id/edit', element: <CompanyFormPage /> },
{ path: '/contacts', element: <ContactsListPage /> },
{ path: '/contacts/new', element: <ContactFormPage /> },
{ path: '/contacts/:id', element: <ContactDetailPage /> },
{ path: '/contacts/:id/edit', element: <ContactFormPage /> },
{ path: '/audit-log', element: <AuditLogPage /> },
{ path: '/search', element: <GlobalSearchResultsPage /> },
{ path: '/calendar', element: <CalendarPage /> },