diff --git a/frontend/src/__tests__/companies/CompaniesList.test.tsx b/frontend/src/__tests__/companies/CompaniesList.test.tsx deleted file mode 100644 index 3f91b23..0000000 --- a/frontend/src/__tests__/companies/CompaniesList.test.tsx +++ /dev/null @@ -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(); -} - -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(); - }); -}); diff --git a/frontend/src/__tests__/companies/CompanyDetail.test.tsx b/frontend/src/__tests__/companies/CompanyDetail.test.tsx deleted file mode 100644 index 0c38650..0000000 --- a/frontend/src/__tests__/companies/CompanyDetail.test.tsx +++ /dev/null @@ -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(); - expect(screen.getByTestId('company-detail-page')).toBeInTheDocument(); - }); - - it('renders company name as heading', () => { - render(); - expect(screen.getByRole('heading', { level: 1, name: 'TestCorp GmbH' })).toBeInTheDocument(); - }); - - it('renders tabs for detail sections', () => { - render(); - 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(); - 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(); - 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(); - expect(screen.getByText('Bearbeiten')).toBeInTheDocument(); - }); - - it('renders back button', () => { - render(); - expect(screen.getByText(/Zurück/)).toBeInTheDocument(); - }); - - it('shows error state when company not found', () => { - mockReturnData = { data: undefined, isLoading: false, isError: true }; - render(); - 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(); - expect(screen.getByTestId('company-detail-page')).toBeInTheDocument(); - mockReturnData = { data: mockCompany, isLoading: false, isError: false }; - }); -}); diff --git a/frontend/src/__tests__/companies/CompanyForm.test.tsx b/frontend/src/__tests__/companies/CompanyForm.test.tsx deleted file mode 100644 index d7d5d8a..0000000 --- a/frontend/src/__tests__/companies/CompanyForm.test.tsx +++ /dev/null @@ -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(); - expect(screen.getByLabelText(/Firmenname/)).toBeInTheDocument(); - }); - - it('renders email and phone fields', () => { - render(); - expect(screen.getByLabelText(/E-Mail/)).toBeInTheDocument(); - expect(screen.getByLabelText(/Telefon/)).toBeInTheDocument(); - }); - - it('renders website and description fields', () => { - render(); - expect(screen.getByLabelText(/Website/)).toBeInTheDocument(); - expect(screen.getByLabelText(/Beschreibung/)).toBeInTheDocument(); - }); - - it('renders account number and industry fields', () => { - render(); - expect(screen.getByLabelText(/Kontonummer/)).toBeInTheDocument(); - expect(screen.getByLabelText(/Branche/)).toBeInTheDocument(); - }); - - it('submit button is present', () => { - render(); - expect(screen.getByTestId('company-submit-btn')).toBeInTheDocument(); - }); - - it('renders cancel button', () => { - render(); - expect(screen.getByText('Abbrechen')).toBeInTheDocument(); - }); - - it('renders form page with correct test id', () => { - render(); - expect(screen.getByTestId('company-form-page')).toBeInTheDocument(); - }); - - it('renders create title for new company', () => { - render(); - expect(screen.getByText('Firma erstellen')).toBeInTheDocument(); - }); - - it('name field is marked as required', () => { - render(); - const nameInput = screen.getByTestId('company-name-input'); - expect(nameInput).toHaveAttribute('required'); - }); - - it('shows inline validation error when submitting empty form', async () => { - render(); - 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(); - 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' })); - }); - }); -}); diff --git a/frontend/src/__tests__/contacts/ContactDetail.test.tsx b/frontend/src/__tests__/contacts/ContactDetail.test.tsx deleted file mode 100644 index c4f6dc7..0000000 --- a/frontend/src/__tests__/contacts/ContactDetail.test.tsx +++ /dev/null @@ -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(); - expect(screen.getByTestId('contact-detail-page')).toBeInTheDocument(); - }); - - it('renders contact name as heading', () => { - render(); - expect(screen.getByText('Max Mustermann')).toBeInTheDocument(); - }); - - it('renders tabs for detail sections', () => { - render(); - 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(); - 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(); - 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(); - expect(screen.getByText('Bearbeiten')).toBeInTheDocument(); - }); - - it('renders back button', () => { - render(); - expect(screen.getByText(/Zurück/)).toBeInTheDocument(); - }); - - it('shows error state when contact not found', () => { - mockReturnData = { data: undefined, isLoading: false, isError: true }; - render(); - expect(screen.getByText('Kontakt nicht gefunden.')).toBeInTheDocument(); - mockReturnData = { data: mockContact, isLoading: false, isError: false }; - }); -}); diff --git a/frontend/src/__tests__/contacts/ContactForm.test.tsx b/frontend/src/__tests__/contacts/ContactForm.test.tsx deleted file mode 100644 index afbdbe8..0000000 --- a/frontend/src/__tests__/contacts/ContactForm.test.tsx +++ /dev/null @@ -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(); - expect(screen.getByLabelText(/Vorname/)).toBeInTheDocument(); - }); - - it('renders form with last name field', () => { - render(); - expect(screen.getByLabelText(/Nachname/)).toBeInTheDocument(); - }); - - it('renders email field', () => { - render(); - expect(screen.getByLabelText(/E-Mail/)).toBeInTheDocument(); - }); - - it('renders phone and position fields', () => { - render(); - expect(screen.getByLabelText(/Telefon/)).toBeInTheDocument(); - expect(screen.getByLabelText(/Position/)).toBeInTheDocument(); - }); - - it('renders submit button', () => { - render(); - expect(screen.getByTestId('contact-submit-btn')).toBeInTheDocument(); - }); - - it('renders cancel button', () => { - render(); - expect(screen.getByText('Abbrechen')).toBeInTheDocument(); - }); - - it('renders form page with correct test id', () => { - render(); - expect(screen.getByTestId('contact-form-page')).toBeInTheDocument(); - }); - - it('renders create title for new contact', () => { - render(); - expect(screen.getByText('Kontakt erstellen')).toBeInTheDocument(); - }); - - it('first name field is marked as required', () => { - render(); - const firstNameInput = screen.getByTestId('contact-first-name-input'); - expect(firstNameInput).toHaveAttribute('required'); - }); - - it('last name field is marked as required', () => { - render(); - const lastNameInput = screen.getByTestId('contact-last-name-input'); - expect(lastNameInput).toHaveAttribute('required'); - }); - - it('renders company assignment section with available companies', () => { - render(); - 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(); - 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(); - fireEvent.click(screen.getByTestId('contact-submit-btn')); - await waitFor(() => { - expect(screen.getByText(/Vorname ist erforderlich/)).toBeInTheDocument(); - expect(screen.getByText(/Nachname ist erforderlich/)).toBeInTheDocument(); - }); - }); -}); diff --git a/frontend/src/__tests__/contacts/ContactsList.test.tsx b/frontend/src/__tests__/contacts/ContactsList.test.tsx index c34819b..132081c 100644 --- a/frontend/src/__tests__/contacts/ContactsList.test.tsx +++ b/frontend/src/__tests__/contacts/ContactsList.test.tsx @@ -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(); +function renderWithProviders() { + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + return render( + + + + + , + ); } -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); }); }); diff --git a/frontend/src/__tests__/dashboard/Dashboard.test.tsx b/frontend/src/__tests__/dashboard/Dashboard.test.tsx index f8c50eb..df4a2a3 100644 --- a/frontend/src/__tests__/dashboard/Dashboard.test.tsx +++ b/frontend/src/__tests__/dashboard/Dashboard.test.tsx @@ -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: [ diff --git a/frontend/src/__tests__/shell/AppShell.test.tsx b/frontend/src/__tests__/shell/AppShell.test.tsx index ee29bc5..e04013e 100644 --- a/frontend/src/__tests__/shell/AppShell.test.tsx +++ b/frontend/src/__tests__/shell/AppShell.test.tsx @@ -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(); }); diff --git a/frontend/src/__tests__/shell/Sidebar.test.tsx b/frontend/src/__tests__/shell/Sidebar.test.tsx index 988cd96..16c182c 100644 --- a/frontend/src/__tests__/shell/Sidebar.test.tsx +++ b/frontend/src/__tests__/shell/Sidebar.test.tsx @@ -12,7 +12,6 @@ describe('Sidebar', () => { ); expect(screen.getByLabelText('Dashboard')).toBeInTheDocument(); - expect(screen.getByLabelText('Firmen')).toBeInTheDocument(); expect(screen.getByLabelText('Kontakte')).toBeInTheDocument(); expect(screen.getByLabelText('Einstellungen')).toBeInTheDocument(); }); diff --git a/frontend/src/api/hooks.ts b/frontend/src/api/hooks.ts index bf0c098..1d0de37 100644 --- a/frontend/src/api/hooks.ts +++ b/frontend/src/api/hooks.ts @@ -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 | 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 | 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>(`/contacts?${params.toString()}`), + }); +} + +export function useUnifiedContact(id?: string) { + return useQuery({ + queryKey: ['unifiedContacts', id], + queryFn: () => apiGet(`/contacts/${id}`), + enabled: !!id, + }); +} + +export function useCreateUnifiedContact() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (data: Partial) => apiPost('/contacts', data), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['unifiedContacts'] }); + }, + }); +} + +export function useUpdateUnifiedContact() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: ({ id, data }: { id: string; data: Partial }) => + 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 }) => + 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 }) => + 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> { + 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>(`/contacts?${params.toString()}`); +} + +export async function fetchContact(id: string): Promise { + return apiGet(`/contacts/${id}`); +} + +export async function createContact(data: Partial): Promise { + return apiPost('/contacts', data); +} + +export async function updateContact(id: string, data: Partial): Promise { + return apiPut(`/contacts/${id}`, data); +} + +export async function deleteContact(id: string, hard?: boolean): Promise { + 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): Promise { + return apiPost(`/contacts/${contactId}/persons`, data); +} + +export async function updateContactPerson(contactId: string, personId: string, data: Partial): Promise { + return apiPut(`/contacts/${contactId}/persons/${personId}`, data); +} + +export async function deleteContactPerson(contactId: string, personId: string): Promise { + await apiDelete(`/contacts/${contactId}/persons/${personId}`); +} + // ════════════════════════════════════════════════════════════════════════════ // System Settings, Currency, Tax, Sequence hooks // ════════════════════════════════════════════════════════════════════════════ diff --git a/frontend/src/components/contacts/ContactDetail.tsx b/frontend/src/components/contacts/ContactDetail.tsx new file mode 100644 index 0000000..bc199e7 --- /dev/null +++ b/frontend/src/components/contacts/ContactDetail.tsx @@ -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 ( +
+
{label}
+
{value || '—'}
+
+ ); +} + +function Section({ title, children, action }: { title: string; children: React.ReactNode; action?: React.ReactNode }) { + return ( +
+
+

{title}

+ {action} +
+ {children} +
+ ); +} + +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 ( +
+ {extra &&
{extra}
} +
{street} {number}
+ {district &&
{district}
} +
{postalcode} {city}
+ {state &&
{state}
} + {country &&
{country}
} +
+ ); +} + +function ContactPersonModal({ + open, + onClose, + onSave, + person, +}: { + open: boolean; + onClose: () => void; + onSave: (data: Partial) => 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 ( + +
+
+ setFirstname(e.target.value)} /> + setLastname(e.target.value)} /> +
+ setFunc(e.target.value)} /> + setEmail(e.target.value)} /> +
+ setPhone(e.target.value)} /> + setMobilephone(e.target.value)} /> +
+
+ + +
+
+
+ ); +} + +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(null); + + if (loading) { + return ( +
+ + {t('common.loading')} +
+ ); + } + + if (!contact) { + return ( +
+ +
+ ); + } + + 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) => { + 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 ( +
+ {/* Header */} +
+
+
+ {displayName.charAt(0).toUpperCase()} +
+
+

{displayName}

+ + {contact.type === 'company' ? t('contacts.companies') : t('contacts.persons')} + +
+
+
+ + +
+
+ +
+ {/* Contact Persons */} +
{ setEditingPerson(null); setPersonModalOpen(true); }}> + {t('contacts.addPerson')} + + } + > + {persons.length === 0 ? ( +

{t('contacts.noPersons')}

+ ) : ( +
    + {persons.map((person) => ( +
  • +
    +
    + {[person.firstname, person.lastname].filter(Boolean).join(' ') || person.displayname} +
    + {person.function &&
    {person.function}
    } + {person.email &&
    {person.email}
    } + {person.phone &&
    {person.phone}
    } +
    + + +
  • + ))} +
+ )} +
+ + {/* Addresses */} +
+
+
+

{t('contacts.mailingAddress')}

+ + {!contact.mailing_street && !contact.mailing_city &&

} +
+
+

{t('contacts.visitAddress')}

+ + {!contact.visit_street && !contact.visit_city &&

} +
+
+

{t('contacts.invoiceAddress')}

+ + {!contact.invoice_street && !contact.invoice_city &&

} +
+
+
+ + {/* Communication */} +
+
+ + + + + +
+
+ + {/* Financial */} +
+
+ + + + + + +
+
+ + {/* Discounts */} +
+
+ + + + + + +
+
+ + {/* Geo */} +
+
+ + +
+
+ + {/* Notes & Tags */} +
+
+ + + +
+
{t('tags.title')}
+
+ {tags.length > 0 ? ( +
+ {tags.map((tag) => {tag})} +
+ ) : ( + + )} +
+
+
+
+ + {/* Custom Fields */} + {contact.custom && Object.keys(contact.custom).length > 0 && ( +
+
+              {JSON.stringify(contact.custom, null, 2)}
+            
+
+ )} +
+ + { setPersonModalOpen(false); setEditingPerson(null); }} + onSave={handleSavePerson} + person={editingPerson} + /> +
+ ); +} diff --git a/frontend/src/components/contacts/ContactEditModal.tsx b/frontend/src/components/contacts/ContactEditModal.tsx new file mode 100644 index 0000000..f4bbb73 --- /dev/null +++ b/frontend/src/components/contacts/ContactEditModal.tsx @@ -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 = { + 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 ( + +
+ {/* Type */} + setName(e.target.value)} required data-testid="contact-name-input" placeholder="TechCorp GmbH" /> + ) : ( +
+ setFirstname(e.target.value)} data-testid="contact-first-name-input" /> + setSurname(e.target.value)} data-testid="contact-last-name-input" /> +
+ )} + + {/* Code */} + setCode(e.target.value)} placeholder="K-00123" /> + + {/* Communication */} +
+

{t('contacts.communication')}

+
+ setEmail1(e.target.value)} /> + setEmail2(e.target.value)} /> + setPhone1(e.target.value)} /> + setPhone2(e.target.value)} /> + setWebsite(e.target.value)} /> +
+
+ + {/* Mailing Address */} +
+

{t('contacts.mailingAddress')}

+
+ setMailingStreet(e.target.value)} /> + setMailingNumber(e.target.value)} /> + setMailingPostalcode(e.target.value)} /> + setMailingCity(e.target.value)} /> + setMailingCountry(e.target.value)} /> +
+
+ + {/* Financial */} +
+

{t('contacts.financial')}

+
+ setVatCode(e.target.value)} /> + setFiscalCode(e.target.value)} /> + setCommerceCode(e.target.value)} /> + setBic(e.target.value)} /> + setBankAccount(e.target.value)} /> +
+
+ + {/* Notes */} +
+

{t('contacts.notes')}

+
+ setTags(e.target.value)} placeholder="tag1, tag2" /> +
+ +