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:
@@ -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
@@ -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') },
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 /> },
|
||||
|
||||
Reference in New Issue
Block a user