feat(companies): UI fuer Firmen-Verwaltung mit Ansprechpartner-Links — Modul 12/16 des UI-Backlogs
Check Cross-Plugin Imports / check (push) Has been cancelled
Check Cross-Plugin Imports / check (push) Has been cancelled
This commit is contained in:
@@ -96,11 +96,13 @@ class ContactsPlugin(BasePlugin):
|
|||||||
],
|
],
|
||||||
menu_items=[
|
menu_items=[
|
||||||
FrontendMenuItem(label_key='nav.contacts', label='Kontakte', path='/contacts', icon='Users', order=10, permission='contacts:read'),
|
FrontendMenuItem(label_key='nav.contacts', label='Kontakte', path='/contacts', icon='Users', order=10, permission='contacts:read'),
|
||||||
|
FrontendMenuItem(label_key='nav.companies', label='Firmen', path='/companies', icon='Building2', order=11, permission='contacts:read'),
|
||||||
],
|
],
|
||||||
page_routes=[
|
page_routes=[
|
||||||
FrontendPageRoute(path='/contacts', component='@/pages/ContactsList', protected=True, permission='contacts:read'),
|
FrontendPageRoute(path='/contacts', component='@/pages/ContactsList', protected=True, permission='contacts:read'),
|
||||||
FrontendPageRoute(path='/contacts/:id', component='@/pages/ContactDetailPage', protected=True, permission='contacts:read'),
|
FrontendPageRoute(path='/contacts/:id', component='@/pages/ContactDetailPage', protected=True, permission='contacts:read'),
|
||||||
FrontendPageRoute(path='/contacts/dedup', component='@/pages/DedupMerge', protected=True, permission='contacts:read'),
|
FrontendPageRoute(path='/contacts/dedup', component='@/pages/DedupMerge', protected=True, permission='contacts:read'),
|
||||||
|
FrontendPageRoute(path='/companies', component='@/pages/Companies', protected=True, permission='contacts:read'),
|
||||||
],
|
],
|
||||||
permissions=[
|
permissions=[
|
||||||
"contacts:read",
|
"contacts:read",
|
||||||
|
|||||||
@@ -0,0 +1,271 @@
|
|||||||
|
/**
|
||||||
|
* Companies page tests — firmen management UI (UI-Backlog module 12/16).
|
||||||
|
*
|
||||||
|
* Covers: rendering, permission gating, list with cards, search/industry
|
||||||
|
* filters, create form validation + payload, edit prefill, delete flow,
|
||||||
|
* detail modal with contact persons link/unlink.
|
||||||
|
*/
|
||||||
|
import React from 'react';
|
||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||||
|
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
|
||||||
|
import { MemoryRouter } from 'react-router-dom';
|
||||||
|
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||||
|
import { CompaniesPage } from '@/pages/Companies';
|
||||||
|
import type { Company } from '@/api/companies';
|
||||||
|
|
||||||
|
const { createMut, updateMut, deleteMut, linkMut, unlinkMut } = vi.hoisted(() => ({
|
||||||
|
createMut: vi.fn().mockResolvedValue({}),
|
||||||
|
updateMut: vi.fn().mockResolvedValue({}),
|
||||||
|
deleteMut: vi.fn().mockResolvedValue({}),
|
||||||
|
linkMut: vi.fn().mockResolvedValue({}),
|
||||||
|
unlinkMut: vi.fn().mockResolvedValue({}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const makeCompany = (overrides: Partial<Company> = {}): Company => ({
|
||||||
|
id: '11111111-1111-1111-1111-111111111111',
|
||||||
|
name: 'TechCorp GmbH',
|
||||||
|
displayname: 'TechCorp GmbH',
|
||||||
|
status: 'lead',
|
||||||
|
industry: 'IT',
|
||||||
|
description: null,
|
||||||
|
email_1: 'info@techcorp.de',
|
||||||
|
email_2: null,
|
||||||
|
phone_1: '+49 30 123456',
|
||||||
|
phone_2: null,
|
||||||
|
website: 'https://techcorp.de',
|
||||||
|
mailing_city: 'Berlin',
|
||||||
|
mailing_postalcode: null,
|
||||||
|
mailing_country: 'Deutschland',
|
||||||
|
tags: null,
|
||||||
|
custom: null,
|
||||||
|
...overrides,
|
||||||
|
});
|
||||||
|
|
||||||
|
let mockItems: Company[] = [];
|
||||||
|
let mockTotal = 0;
|
||||||
|
let mockDetail: Company | null = null;
|
||||||
|
let mockCanRead = true;
|
||||||
|
let mockCanWrite = true;
|
||||||
|
let mockCanDelete = true;
|
||||||
|
|
||||||
|
vi.mock('@/api/companies', async (importOriginal) => {
|
||||||
|
const actual = await importOriginal<typeof import('@/api/companies')>();
|
||||||
|
return {
|
||||||
|
...actual,
|
||||||
|
useCompanies: () => ({
|
||||||
|
data: { items: mockItems, total: mockTotal, page: 1, page_size: 20 },
|
||||||
|
isLoading: false,
|
||||||
|
isError: false,
|
||||||
|
}),
|
||||||
|
useCompany: () => ({
|
||||||
|
data: mockDetail,
|
||||||
|
isLoading: false,
|
||||||
|
isError: false,
|
||||||
|
}),
|
||||||
|
useCompanyEmails: () => ({ data: [], isLoading: false }),
|
||||||
|
useCreateCompany: () => ({ mutate: createMut, isPending: false }),
|
||||||
|
useUpdateCompany: () => ({ mutate: updateMut, isPending: false }),
|
||||||
|
useDeleteCompany: () => ({ mutate: deleteMut, isPending: false }),
|
||||||
|
useLinkContact: () => ({ mutate: linkMut, isPending: false }),
|
||||||
|
useUnlinkContact: () => ({ mutate: unlinkMut, isPending: false }),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.mock('@/hooks/usePermission', () => ({
|
||||||
|
usePermission: () => ({
|
||||||
|
hasPermission: (perm: string) =>
|
||||||
|
(mockCanRead || perm !== 'contacts:read') &&
|
||||||
|
(mockCanWrite || perm !== 'contacts:write') &&
|
||||||
|
(mockCanDelete || perm !== 'contacts:delete'),
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
function renderPage() {
|
||||||
|
const client = new QueryClient({
|
||||||
|
defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
|
||||||
|
});
|
||||||
|
return render(
|
||||||
|
<QueryClientProvider client={client}>
|
||||||
|
<MemoryRouter>
|
||||||
|
<CompaniesPage />
|
||||||
|
</MemoryRouter>
|
||||||
|
</QueryClientProvider>,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
createMut.mockClear();
|
||||||
|
updateMut.mockClear();
|
||||||
|
deleteMut.mockClear();
|
||||||
|
linkMut.mockClear();
|
||||||
|
unlinkMut.mockClear();
|
||||||
|
mockItems = [];
|
||||||
|
mockTotal = 0;
|
||||||
|
mockDetail = null;
|
||||||
|
mockCanRead = true;
|
||||||
|
mockCanWrite = true;
|
||||||
|
mockCanDelete = true;
|
||||||
|
window.confirm = () => true;
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('CompaniesPage', () => {
|
||||||
|
it('renders page title, export buttons and create button', () => {
|
||||||
|
renderPage();
|
||||||
|
expect(screen.getByTestId('companies-page')).toBeInTheDocument();
|
||||||
|
expect(screen.getByTestId('company-export-csv')).toBeInTheDocument();
|
||||||
|
expect(screen.getByTestId('company-export-xlsx')).toBeInTheDocument();
|
||||||
|
expect(screen.getByTestId('company-create-btn')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows empty state when no companies exist', async () => {
|
||||||
|
renderPage();
|
||||||
|
expect(await screen.findByTestId('companies-empty')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows no-permission card without contacts:read', () => {
|
||||||
|
mockCanRead = false;
|
||||||
|
renderPage();
|
||||||
|
expect(screen.getByTestId('companies-no-permission')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders company cards with name, status badge and industry', () => {
|
||||||
|
mockItems = [makeCompany()];
|
||||||
|
mockTotal = 1;
|
||||||
|
renderPage();
|
||||||
|
const card = screen.getByTestId('company-card-11111111-1111-1111-1111-111111111111');
|
||||||
|
expect(card).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('TechCorp GmbH')).toBeInTheDocument();
|
||||||
|
expect(screen.getByTestId('company-status-11111111-1111-1111-1111-111111111111')).toHaveTextContent('lead');
|
||||||
|
expect(screen.getByText('IT')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('hides create/edit/delete buttons without write/delete permissions', () => {
|
||||||
|
mockItems = [makeCompany()];
|
||||||
|
mockTotal = 1;
|
||||||
|
mockCanWrite = false;
|
||||||
|
mockCanDelete = false;
|
||||||
|
renderPage();
|
||||||
|
expect(screen.queryByTestId('company-create-btn')).not.toBeInTheDocument();
|
||||||
|
expect(
|
||||||
|
screen.queryByTestId('company-edit-11111111-1111-1111-1111-111111111111'),
|
||||||
|
).not.toBeInTheDocument();
|
||||||
|
expect(
|
||||||
|
screen.queryByTestId('company-delete-11111111-1111-1111-1111-111111111111'),
|
||||||
|
).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('opens the create form and submits a trimmed payload', async () => {
|
||||||
|
renderPage();
|
||||||
|
fireEvent.click(screen.getByTestId('company-create-btn'));
|
||||||
|
|
||||||
|
fireEvent.change(screen.getByTestId('company-form-name'), {
|
||||||
|
target: { value: ' Neue Firma AG ' },
|
||||||
|
});
|
||||||
|
fireEvent.change(screen.getByTestId('company-form-industry'), {
|
||||||
|
target: { value: 'Handel' },
|
||||||
|
});
|
||||||
|
fireEvent.change(screen.getByTestId('company-form-email'), {
|
||||||
|
target: { value: 'info@neuefirma.de' },
|
||||||
|
});
|
||||||
|
fireEvent.click(screen.getByTestId('company-form-submit'));
|
||||||
|
|
||||||
|
await waitFor(() => expect(createMut).toHaveBeenCalled());
|
||||||
|
const payload = createMut.mock.calls[0][0];
|
||||||
|
expect(payload.name).toBe('Neue Firma AG');
|
||||||
|
expect(payload.status).toBe('lead');
|
||||||
|
expect(payload.industry).toBe('Handel');
|
||||||
|
expect(payload.email_1).toBe('info@neuefirma.de');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('create submit disabled without a name', () => {
|
||||||
|
renderPage();
|
||||||
|
fireEvent.click(screen.getByTestId('company-create-btn'));
|
||||||
|
expect(screen.getByTestId('company-form-submit')).toBeDisabled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('opens the edit form pre-filled and submits only changed fields', async () => {
|
||||||
|
mockItems = [makeCompany()];
|
||||||
|
mockTotal = 1;
|
||||||
|
renderPage();
|
||||||
|
fireEvent.click(screen.getByTestId('company-edit-11111111-1111-1111-1111-111111111111'));
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByTestId('company-form-name')).toHaveValue('TechCorp GmbH');
|
||||||
|
});
|
||||||
|
expect(screen.getByTestId('company-form-industry')).toHaveValue('IT');
|
||||||
|
|
||||||
|
fireEvent.change(screen.getByTestId('company-form-name'), {
|
||||||
|
target: { value: 'TechCorp AG' },
|
||||||
|
});
|
||||||
|
fireEvent.click(screen.getByTestId('company-form-submit'));
|
||||||
|
|
||||||
|
await waitFor(() => expect(updateMut).toHaveBeenCalled());
|
||||||
|
const call = updateMut.mock.calls[0][0];
|
||||||
|
expect(call.id).toBe('11111111-1111-1111-1111-111111111111');
|
||||||
|
expect(call.data.name).toBe('TechCorp AG');
|
||||||
|
// unchanged fields must not be part of the update payload
|
||||||
|
expect(call.data.industry).toBeUndefined();
|
||||||
|
expect(call.data.email_1).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('deletes a company after confirm', async () => {
|
||||||
|
mockItems = [makeCompany()];
|
||||||
|
mockTotal = 1;
|
||||||
|
renderPage();
|
||||||
|
fireEvent.click(screen.getByTestId('company-delete-11111111-1111-1111-1111-111111111111'));
|
||||||
|
await waitFor(() => expect(deleteMut).toHaveBeenCalled());
|
||||||
|
expect(deleteMut.mock.calls[0][0].id).toBe('11111111-1111-1111-1111-111111111111');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('opens the detail modal with contact persons and unlinks one', async () => {
|
||||||
|
mockItems = [makeCompany()];
|
||||||
|
mockTotal = 1;
|
||||||
|
mockDetail = makeCompany({
|
||||||
|
contacts: [
|
||||||
|
{ id: '22222222-2222-2222-2222-222222222222', firstname: 'Max', lastname: 'Mustermann', email: 'max@techcorp.de', phone: null },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
renderPage();
|
||||||
|
fireEvent.click(screen.getByTestId('company-detail-trigger-11111111-1111-1111-1111-111111111111'));
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByTestId('company-person-22222222-2222-2222-2222-222222222222')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
expect(screen.getByText('Max Mustermann')).toBeInTheDocument();
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByTestId('company-unlink-22222222-2222-2222-2222-222222222222'));
|
||||||
|
await waitFor(() => expect(unlinkMut).toHaveBeenCalled());
|
||||||
|
expect(unlinkMut.mock.calls[0][0].companyId).toBe('11111111-1111-1111-1111-111111111111');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('links a contact person via UUID input', async () => {
|
||||||
|
mockItems = [makeCompany()];
|
||||||
|
mockTotal = 1;
|
||||||
|
mockDetail = makeCompany({ contacts: [] });
|
||||||
|
renderPage();
|
||||||
|
fireEvent.click(screen.getByTestId('company-detail-trigger-11111111-1111-1111-1111-111111111111'));
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByTestId('company-link-contact-id')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
fireEvent.change(screen.getByTestId('company-link-contact-id'), {
|
||||||
|
target: { value: '33333333-3333-3333-3333-333333333333' },
|
||||||
|
});
|
||||||
|
fireEvent.click(screen.getByTestId('company-link-submit'));
|
||||||
|
|
||||||
|
await waitFor(() => expect(linkMut).toHaveBeenCalled());
|
||||||
|
expect(linkMut.mock.calls[0][0]).toEqual({
|
||||||
|
companyId: '11111111-1111-1111-1111-111111111111',
|
||||||
|
contactId: '33333333-3333-3333-3333-333333333333',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('search input submits on Enter', async () => {
|
||||||
|
renderPage();
|
||||||
|
const input = screen.getByTestId('company-search-input');
|
||||||
|
fireEvent.change(input, { target: { value: 'Tech' } });
|
||||||
|
fireEvent.keyDown(input, { key: 'Enter' });
|
||||||
|
// no crash + the search button is still there
|
||||||
|
expect(screen.getByTestId('company-search-submit')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,173 @@
|
|||||||
|
/**
|
||||||
|
* Companies API client — CRUD, search, export and contact links for
|
||||||
|
* companies (UI-Backlog module 12/16).
|
||||||
|
*
|
||||||
|
* Backend: /api/v1/companies (contacts plugin). Companies are Contact
|
||||||
|
* entities with type='company'; industry and description live in the
|
||||||
|
* custom JSONB field.
|
||||||
|
* - GET / → paginated list (search, industry, sort)
|
||||||
|
* - POST / → create (201)
|
||||||
|
* - GET /export → CSV/XLSX download
|
||||||
|
* - GET /{id} → detail incl. linked contact persons
|
||||||
|
* - PUT /{id} → update
|
||||||
|
* - DELETE /{id} → soft-delete (?cascade=true: persons too)
|
||||||
|
* - POST /{id}/contacts/{cid} → link a contact person
|
||||||
|
* - DELETE /{id}/contacts/{cid} → unlink (removes all persons links)
|
||||||
|
* - GET /{id}/emails → emails mentioning the company
|
||||||
|
* Permissions: contacts:read (list/get/export) / contacts:write
|
||||||
|
* (create/update/link/unlink) / contacts:delete (delete).
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { apiGet, apiPost, apiPut, apiDelete } from '@/api/client';
|
||||||
|
|
||||||
|
export interface Company {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
displayname: string | null;
|
||||||
|
status: string | null;
|
||||||
|
industry: string | null;
|
||||||
|
description: string | null;
|
||||||
|
email_1: string | null;
|
||||||
|
email_2: string | null;
|
||||||
|
phone_1: string | null;
|
||||||
|
phone_2: string | null;
|
||||||
|
website: string | null;
|
||||||
|
mailing_city: string | null;
|
||||||
|
mailing_postalcode: string | null;
|
||||||
|
mailing_country: string | null;
|
||||||
|
tags: string | null;
|
||||||
|
custom: Record<string, unknown> | null;
|
||||||
|
/** Only present in the detail response: */
|
||||||
|
contacts?: CompanyContactPerson[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CompanyContactPerson {
|
||||||
|
id: string;
|
||||||
|
firstname: string | null;
|
||||||
|
lastname: string | null;
|
||||||
|
email: string | null;
|
||||||
|
phone: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CompanyListResult {
|
||||||
|
items: Company[];
|
||||||
|
total: number;
|
||||||
|
page: number;
|
||||||
|
page_size: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CompanyListParams {
|
||||||
|
page?: number;
|
||||||
|
page_size?: number;
|
||||||
|
search?: string | null;
|
||||||
|
industry?: string | null;
|
||||||
|
sort_by?: string;
|
||||||
|
sort_order?: 'asc' | 'desc';
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CompanyCreatePayload {
|
||||||
|
name: string;
|
||||||
|
status?: string;
|
||||||
|
industry?: string;
|
||||||
|
description?: string;
|
||||||
|
email_1?: string;
|
||||||
|
phone_1?: string;
|
||||||
|
website?: string;
|
||||||
|
mailing_city?: string;
|
||||||
|
mailing_country?: string;
|
||||||
|
[key: string]: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type CompanyUpdatePayload = Partial<CompanyCreatePayload>;
|
||||||
|
|
||||||
|
// ─── Query hooks ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
export function useCompanies(params: CompanyListParams = {}) {
|
||||||
|
const searchParams = new URLSearchParams();
|
||||||
|
if (params.page) searchParams.set('page', String(params.page));
|
||||||
|
if (params.page_size) searchParams.set('page_size', String(params.page_size));
|
||||||
|
if (params.search) searchParams.set('search', params.search);
|
||||||
|
if (params.industry) searchParams.set('industry', params.industry);
|
||||||
|
if (params.sort_by) searchParams.set('sort_by', params.sort_by);
|
||||||
|
if (params.sort_order) searchParams.set('sort_order', params.sort_order);
|
||||||
|
const qs = searchParams.toString();
|
||||||
|
return useQuery<CompanyListResult>({
|
||||||
|
queryKey: ['companies', params.page, params.page_size, params.search, params.industry, params.sort_by, params.sort_order],
|
||||||
|
queryFn: () => apiGet<CompanyListResult>(`/companies${qs ? `?${qs}` : ''}`),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useCompany(companyId: string | null) {
|
||||||
|
return useQuery<Company>({
|
||||||
|
queryKey: ['companies', 'detail', companyId],
|
||||||
|
queryFn: () => apiGet<Company>(`/companies/${companyId}`),
|
||||||
|
enabled: !!companyId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useCompanyEmails(companyId: string | null) {
|
||||||
|
return useQuery<unknown[]>({
|
||||||
|
queryKey: ['companies', 'emails', companyId],
|
||||||
|
queryFn: () => apiGet<unknown[]>(`/companies/${companyId}/emails`),
|
||||||
|
enabled: !!companyId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Mutation hooks ──────────────────────────────────────────
|
||||||
|
|
||||||
|
function invalidateCompanies(qc: ReturnType<typeof useQueryClient>) {
|
||||||
|
qc.invalidateQueries({ queryKey: ['companies'] });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useCreateCompany() {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
return useMutation<Company, Error, CompanyCreatePayload>({
|
||||||
|
mutationFn: (data) => apiPost<Company>('/companies', data),
|
||||||
|
onSuccess: () => invalidateCompanies(qc),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useUpdateCompany() {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
return useMutation<Company, Error, { id: string; data: CompanyUpdatePayload }>({
|
||||||
|
mutationFn: ({ id, data }) => apiPut<Company>(`/companies/${id}`, data),
|
||||||
|
onSuccess: (company) => {
|
||||||
|
invalidateCompanies(qc);
|
||||||
|
qc.invalidateQueries({ queryKey: ['companies', 'detail', company.id] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useDeleteCompany() {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
return useMutation<void, Error, { id: string; cascade?: boolean }>({
|
||||||
|
mutationFn: ({ id, cascade }) =>
|
||||||
|
apiDelete(`/companies/${id}${cascade ? '?cascade=true' : ''}`),
|
||||||
|
onSuccess: () => invalidateCompanies(qc),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useLinkContact() {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
return useMutation<{ company_id: string; contact_id: string }, Error, { companyId: string; contactId: string }>({
|
||||||
|
mutationFn: ({ companyId, contactId }) =>
|
||||||
|
apiPost(`/companies/${companyId}/contacts/${contactId}`),
|
||||||
|
onSuccess: (r) => {
|
||||||
|
invalidateCompanies(qc);
|
||||||
|
qc.invalidateQueries({ queryKey: ['companies', 'detail', r.company_id] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useUnlinkContact() {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
return useMutation<void, Error, { companyId: string; contactId: string }>({
|
||||||
|
mutationFn: ({ companyId, contactId }) =>
|
||||||
|
apiDelete(`/companies/${companyId}/contacts/${contactId}`),
|
||||||
|
onSuccess: (_, vars) => {
|
||||||
|
invalidateCompanies(qc);
|
||||||
|
qc.invalidateQueries({ queryKey: ['companies', 'detail', vars.companyId] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -14,7 +14,7 @@ import {
|
|||||||
Reply, Forward, Paperclip, MoreVertical, RefreshCw, Download,
|
Reply, Forward, Paperclip, MoreVertical, RefreshCw, Download,
|
||||||
Upload, Eye, EyeOff, Lock, Unlock, Clock, AlertCircle,
|
Upload, Eye, EyeOff, Lock, Unlock, Clock, AlertCircle,
|
||||||
CheckCircle, XCircle, Info, Loader2, Inbox, Send,
|
CheckCircle, XCircle, Info, Loader2, Inbox, Send,
|
||||||
BookOpen, Lightbulb, Share2,
|
BookOpen, Lightbulb, Share2, Building2,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
|
|
||||||
const ICON_MAP: Record<string, React.ComponentType<{ className?: string }>> = {
|
const ICON_MAP: Record<string, React.ComponentType<{ className?: string }>> = {
|
||||||
@@ -26,7 +26,7 @@ const ICON_MAP: Record<string, React.ComponentType<{ className?: string }>> = {
|
|||||||
MoreVertical, RefreshCw, Download, Upload, Eye, EyeOff, Lock,
|
MoreVertical, RefreshCw, Download, Upload, Eye, EyeOff, Lock,
|
||||||
Unlock, Clock, AlertCircle, CheckCircle, XCircle, Info, Loader2,
|
Unlock, Clock, AlertCircle, CheckCircle, XCircle, Info, Loader2,
|
||||||
Inbox, Send, ChevronRight, BookOpen, Lightbulb,
|
Inbox, Send, ChevronRight, BookOpen, Lightbulb,
|
||||||
Brain, Store, Tags, ArrowRightLeft, Share2,
|
Brain, Store, Tags, ArrowRightLeft, Share2, Building2,
|
||||||
};
|
};
|
||||||
import { useMenuOrder } from '@/api/users';
|
import { useMenuOrder } from '@/api/users';
|
||||||
import { usePermission } from '@/hooks/usePermission';
|
import { usePermission } from '@/hooks/usePermission';
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ export const PLUGIN_COMPONENT_MAP: Record<string, LazyComponentFactory> = {
|
|||||||
'@/pages/Calendar': () => import('@/pages/Calendar').then(normalizeModule),
|
'@/pages/Calendar': () => import('@/pages/Calendar').then(normalizeModule),
|
||||||
'@/pages/CalendarKanban': () => import('@/pages/CalendarKanban').then(normalizeModule),
|
'@/pages/CalendarKanban': () => import('@/pages/CalendarKanban').then(normalizeModule),
|
||||||
'@/pages/Communication': () => import('@/pages/Communication').then(normalizeModule),
|
'@/pages/Communication': () => import('@/pages/Communication').then(normalizeModule),
|
||||||
|
'@/pages/Companies': () => import('@/pages/Companies').then((m) => ({ default: m.CompaniesPage })),
|
||||||
'@/pages/ContactDetailPage': () => import('@/pages/ContactDetailPage').then((m) => ({ default: m.ContactDetailPage })),
|
'@/pages/ContactDetailPage': () => import('@/pages/ContactDetailPage').then((m) => ({ default: m.ContactDetailPage })),
|
||||||
'@/pages/ContactsList': () => import('@/pages/ContactsList').then((m) => ({ default: m.ContactsListPage })),
|
'@/pages/ContactsList': () => import('@/pages/ContactsList').then((m) => ({ default: m.ContactsListPage })),
|
||||||
'@/pages/DedupMerge': () => import('@/pages/DedupMerge').then((m) => ({ default: m.DedupMergePage })),
|
'@/pages/DedupMerge': () => import('@/pages/DedupMerge').then((m) => ({ default: m.DedupMergePage })),
|
||||||
|
|||||||
@@ -202,7 +202,31 @@
|
|||||||
"notFound": "Firma nicht gefunden.",
|
"notFound": "Firma nicht gefunden.",
|
||||||
"noContacts": "Diese Firma hat noch keine Kontakte.",
|
"noContacts": "Diese Firma hat noch keine Kontakte.",
|
||||||
"noFiles": "Keine Dateien vorhanden.",
|
"noFiles": "Keine Dateien vorhanden.",
|
||||||
"noActivity": "Keine Aktivität vorhanden."
|
"noActivity": "Keine Aktivität vorhanden.",
|
||||||
|
"createTitle": "Neue Firma",
|
||||||
|
"editTitle": "Firma bearbeiten: {{name}}",
|
||||||
|
"status": "Status",
|
||||||
|
"statusLead": "Lead",
|
||||||
|
"statusCustomer": "Kunde",
|
||||||
|
"statusInactive": "Inaktiv",
|
||||||
|
"city": "Stadt",
|
||||||
|
"country": "Land",
|
||||||
|
"save": "Speichern",
|
||||||
|
"createSubmit": "Erstellen",
|
||||||
|
"search": "Suchen",
|
||||||
|
"searchPlaceholder": "Firma suchen…",
|
||||||
|
"industryFilter": "Branche-Filter",
|
||||||
|
"sort": "Sortierung",
|
||||||
|
"contactPersons": "Ansprechpartner",
|
||||||
|
"noPersons": "Keine Ansprechpartner verknüpft.",
|
||||||
|
"linkContactLabel": "Kontakt verknüpfen (UUID)",
|
||||||
|
"link": "Verknüpfen",
|
||||||
|
"unlink": "Verknüpfung lösen",
|
||||||
|
"prev": "Zurück",
|
||||||
|
"next": "Weiter",
|
||||||
|
"empty": "Keine Firmen vorhanden.",
|
||||||
|
"noPermission": "Keine Berechtigung (contacts:read erforderlich).",
|
||||||
|
"loadError": "Firmen konnten nicht geladen werden."
|
||||||
},
|
},
|
||||||
"contacts": {
|
"contacts": {
|
||||||
"title": "Kontakte",
|
"title": "Kontakte",
|
||||||
|
|||||||
@@ -202,7 +202,31 @@
|
|||||||
"notFound": "Company not found.",
|
"notFound": "Company not found.",
|
||||||
"noContacts": "This company has no contacts yet.",
|
"noContacts": "This company has no contacts yet.",
|
||||||
"noFiles": "No files available.",
|
"noFiles": "No files available.",
|
||||||
"noActivity": "No activity available."
|
"noActivity": "No activity available.",
|
||||||
|
"createTitle": "New company",
|
||||||
|
"editTitle": "Edit company: {{name}}",
|
||||||
|
"status": "Status",
|
||||||
|
"statusLead": "Lead",
|
||||||
|
"statusCustomer": "Customer",
|
||||||
|
"statusInactive": "Inactive",
|
||||||
|
"city": "City",
|
||||||
|
"country": "Country",
|
||||||
|
"save": "Save",
|
||||||
|
"createSubmit": "Create",
|
||||||
|
"search": "Search",
|
||||||
|
"searchPlaceholder": "Search companies…",
|
||||||
|
"industryFilter": "Industry filter",
|
||||||
|
"sort": "Sort",
|
||||||
|
"contactPersons": "Contact persons",
|
||||||
|
"noPersons": "No contact persons linked.",
|
||||||
|
"linkContactLabel": "Link contact (UUID)",
|
||||||
|
"link": "Link",
|
||||||
|
"unlink": "Unlink",
|
||||||
|
"prev": "Previous",
|
||||||
|
"next": "Next",
|
||||||
|
"empty": "No companies yet.",
|
||||||
|
"noPermission": "No permission (contacts:read required).",
|
||||||
|
"loadError": "Failed to load companies."
|
||||||
},
|
},
|
||||||
"contacts": {
|
"contacts": {
|
||||||
"title": "Contacts",
|
"title": "Contacts",
|
||||||
|
|||||||
@@ -0,0 +1,600 @@
|
|||||||
|
/**
|
||||||
|
* Companies page — firmen management (UI-Backlog module 12/16).
|
||||||
|
*
|
||||||
|
* Backend: /api/v1/companies (contacts plugin). Companies are Contact
|
||||||
|
* entities with type='company'; industry/description live in custom JSONB.
|
||||||
|
* Ops: list (search/industry/sort), create, export (csv/xlsx), get (with
|
||||||
|
* linked persons), update, delete (?cascade), link/unlink contacts.
|
||||||
|
* Permissions: contacts:read / contacts:write / contacts:delete.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React, { useMemo, useState } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import clsx from 'clsx';
|
||||||
|
import {
|
||||||
|
Building2,
|
||||||
|
Plus,
|
||||||
|
Pencil,
|
||||||
|
Trash2,
|
||||||
|
Download,
|
||||||
|
Inbox,
|
||||||
|
AlertTriangle,
|
||||||
|
Search,
|
||||||
|
Users,
|
||||||
|
UserPlus,
|
||||||
|
X,
|
||||||
|
Globe,
|
||||||
|
Mail,
|
||||||
|
Phone,
|
||||||
|
MapPin,
|
||||||
|
} from 'lucide-react';
|
||||||
|
import {
|
||||||
|
useCompanies,
|
||||||
|
useCompany,
|
||||||
|
useCreateCompany,
|
||||||
|
useUpdateCompany,
|
||||||
|
useDeleteCompany,
|
||||||
|
useLinkContact,
|
||||||
|
useUnlinkContact,
|
||||||
|
type Company,
|
||||||
|
} from '@/api/companies';
|
||||||
|
import { usePermission } from '@/hooks/usePermission';
|
||||||
|
import { Card } from '@/components/ui/Card';
|
||||||
|
import { Button } from '@/components/ui/Button';
|
||||||
|
import { Modal } from '@/components/ui/Modal';
|
||||||
|
import { Input } from '@/components/ui/Input';
|
||||||
|
|
||||||
|
const PAGE_SIZE = 20;
|
||||||
|
|
||||||
|
const STATUS_BADGE: Record<string, string> = {
|
||||||
|
lead: 'bg-warning-100 text-warning-800 dark:bg-warning-900/30 dark:text-warning-300',
|
||||||
|
customer: 'bg-success-100 text-success-800 dark:bg-success-900/30 dark:text-success-300',
|
||||||
|
inactive: 'bg-secondary-200 text-secondary-700 dark:bg-secondary-800 dark:text-secondary-300',
|
||||||
|
};
|
||||||
|
|
||||||
|
function statusBadgeClass(status: string | null): string {
|
||||||
|
return STATUS_BADGE[status ?? ''] ?? 'bg-primary-100 text-primary-800 dark:bg-primary-900/30 dark:text-primary-300';
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CompanyFormState {
|
||||||
|
name: string;
|
||||||
|
status: string;
|
||||||
|
industry: string;
|
||||||
|
description: string;
|
||||||
|
email_1: string;
|
||||||
|
phone_1: string;
|
||||||
|
website: string;
|
||||||
|
mailing_city: string;
|
||||||
|
mailing_country: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const EMPTY_FORM: CompanyFormState = {
|
||||||
|
name: '',
|
||||||
|
status: 'lead',
|
||||||
|
industry: '',
|
||||||
|
description: '',
|
||||||
|
email_1: '',
|
||||||
|
phone_1: '',
|
||||||
|
website: '',
|
||||||
|
mailing_city: '',
|
||||||
|
mailing_country: '',
|
||||||
|
};
|
||||||
|
|
||||||
|
function formFromCompany(c: Company): CompanyFormState {
|
||||||
|
return {
|
||||||
|
name: c.name ?? '',
|
||||||
|
status: c.status ?? 'lead',
|
||||||
|
industry: c.industry ?? '',
|
||||||
|
description: c.description ?? '',
|
||||||
|
email_1: c.email_1 ?? '',
|
||||||
|
phone_1: c.phone_1 ?? '',
|
||||||
|
website: c.website ?? '',
|
||||||
|
mailing_city: c.mailing_city ?? '',
|
||||||
|
mailing_country: c.mailing_country ?? '',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function CompanyCard({
|
||||||
|
company,
|
||||||
|
canWrite,
|
||||||
|
canDelete,
|
||||||
|
isMutating,
|
||||||
|
onEdit,
|
||||||
|
onDelete,
|
||||||
|
onDetail,
|
||||||
|
}: {
|
||||||
|
company: Company;
|
||||||
|
canWrite: boolean;
|
||||||
|
canDelete: boolean;
|
||||||
|
isMutating: boolean;
|
||||||
|
onEdit: (c: Company) => void;
|
||||||
|
onDelete: (c: Company) => void;
|
||||||
|
onDetail: (c: Company) => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<Card className="p-4 space-y-2" data-testid={`company-card-${company.id}`}>
|
||||||
|
<div className="flex items-start justify-between gap-3">
|
||||||
|
<div className="min-w-0 flex-1 cursor-pointer" onClick={() => onDetail(company)} data-testid={`company-detail-trigger-${company.id}`}>
|
||||||
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
|
<Building2 className="w-4 h-4 text-primary-500 flex-shrink-0" aria-hidden="true" />
|
||||||
|
<span className="text-sm font-medium text-secondary-900 dark:text-secondary-100 break-words">
|
||||||
|
{company.name}
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
className={clsx('inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium', statusBadgeClass(company.status))}
|
||||||
|
data-testid={`company-status-${company.id}`}
|
||||||
|
>
|
||||||
|
{company.status ?? '—'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="mt-1 flex flex-wrap gap-x-3 gap-y-0.5 text-xs text-secondary-500">
|
||||||
|
{company.industry && <span className="font-medium">{company.industry}</span>}
|
||||||
|
{company.mailing_city && (
|
||||||
|
<span className="inline-flex items-center gap-0.5">
|
||||||
|
<MapPin className="w-3 h-3" aria-hidden="true" />{company.mailing_city}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{company.email_1 && (
|
||||||
|
<span className="inline-flex items-center gap-0.5">
|
||||||
|
<Mail className="w-3 h-3" aria-hidden="true" />{company.email_1}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{company.phone_1 && (
|
||||||
|
<span className="inline-flex items-center gap-0.5">
|
||||||
|
<Phone className="w-3 h-3" aria-hidden="true" />{company.phone_1}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{company.website && (
|
||||||
|
<span className="inline-flex items-center gap-0.5">
|
||||||
|
<Globe className="w-3 h-3" aria-hidden="true" />{company.website}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1 flex-shrink-0">
|
||||||
|
{canWrite && (
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => onEdit(company)}
|
||||||
|
disabled={isMutating}
|
||||||
|
aria-label="Edit"
|
||||||
|
data-testid={`company-edit-${company.id}`}
|
||||||
|
>
|
||||||
|
<Pencil className="w-4 h-4" aria-hidden="true" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{canDelete && (
|
||||||
|
<Button
|
||||||
|
variant="danger"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => onDelete(company)}
|
||||||
|
disabled={isMutating}
|
||||||
|
aria-label="Delete"
|
||||||
|
data-testid={`company-delete-${company.id}`}
|
||||||
|
>
|
||||||
|
<Trash2 className="w-4 h-4" aria-hidden="true" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function CompanyFormModal({
|
||||||
|
open,
|
||||||
|
company,
|
||||||
|
onClose,
|
||||||
|
onSubmit,
|
||||||
|
isSubmitting,
|
||||||
|
}: {
|
||||||
|
open: boolean;
|
||||||
|
company: Company | null;
|
||||||
|
onClose: () => void;
|
||||||
|
onSubmit: (data: CompanyFormState) => void;
|
||||||
|
isSubmitting: boolean;
|
||||||
|
}) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const [form, setForm] = useState<CompanyFormState>(EMPTY_FORM);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (open) {
|
||||||
|
setForm(company ? formFromCompany(company) : EMPTY_FORM);
|
||||||
|
}
|
||||||
|
}, [open, company]);
|
||||||
|
|
||||||
|
const set = (key: keyof CompanyFormState) => (e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement>) =>
|
||||||
|
setForm((f) => ({ ...f, [key]: e.target.value }));
|
||||||
|
|
||||||
|
const valid = form.name.trim().length > 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
open={open}
|
||||||
|
onClose={onClose}
|
||||||
|
title={company ? t('companies.editTitle', { name: company.name }) : t('companies.createTitle')}
|
||||||
|
size="lg"
|
||||||
|
>
|
||||||
|
<div className="space-y-3">
|
||||||
|
<Input label={t('companies.name')} value={form.name} onChange={set('name')} required maxLength={255} data-testid="company-form-name" />
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||||
|
<div>
|
||||||
|
<label htmlFor="company-form-status" className="block text-sm font-medium text-secondary-700 mb-1">{t('companies.status')}</label>
|
||||||
|
<select
|
||||||
|
id="company-form-status"
|
||||||
|
value={form.status}
|
||||||
|
onChange={set('status')}
|
||||||
|
className="w-full rounded-md border border-secondary-300 dark:border-secondary-600 bg-white dark:bg-secondary-800 px-3 py-2 text-sm min-h-touch"
|
||||||
|
data-testid="company-form-status"
|
||||||
|
>
|
||||||
|
<option value="lead">{t('companies.statusLead')}</option>
|
||||||
|
<option value="customer">{t('companies.statusCustomer')}</option>
|
||||||
|
<option value="inactive">{t('companies.statusInactive')}</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<Input label={t('companies.industry')} value={form.industry} onChange={set('industry')} maxLength={100} data-testid="company-form-industry" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label htmlFor="company-form-description" className="block text-sm font-medium text-secondary-700 mb-1">{t('companies.description')}</label>
|
||||||
|
<textarea
|
||||||
|
id="company-form-description"
|
||||||
|
value={form.description}
|
||||||
|
onChange={set('description')}
|
||||||
|
rows={2}
|
||||||
|
maxLength={1000}
|
||||||
|
className="w-full rounded-md border border-secondary-300 dark:border-secondary-600 bg-white dark:bg-secondary-800 px-3 py-2 text-sm"
|
||||||
|
data-testid="company-form-description"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||||
|
<Input label={t('companies.email')} type="email" value={form.email_1} onChange={set('email_1')} data-testid="company-form-email" />
|
||||||
|
<Input label={t('companies.phone')} value={form.phone_1} onChange={set('phone_1')} data-testid="company-form-phone" />
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3">
|
||||||
|
<Input label={t('companies.website')} value={form.website} onChange={set('website')} placeholder="https://…" data-testid="company-form-website" />
|
||||||
|
<Input label={t('companies.city')} value={form.mailing_city} onChange={set('mailing_city')} data-testid="company-form-city" />
|
||||||
|
<Input label={t('companies.country')} value={form.mailing_country} onChange={set('mailing_country')} data-testid="company-form-country" />
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-end gap-2 pt-2">
|
||||||
|
<Button variant="ghost" onClick={onClose}>{t('common.cancel')}</Button>
|
||||||
|
<Button onClick={() => valid && onSubmit(form)} disabled={!valid || isSubmitting} data-testid="company-form-submit">
|
||||||
|
{company ? t('companies.save') : t('companies.createSubmit')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function CompanyDetailModal({
|
||||||
|
open,
|
||||||
|
company,
|
||||||
|
canWrite,
|
||||||
|
onClose,
|
||||||
|
}: {
|
||||||
|
open: boolean;
|
||||||
|
company: Company | null;
|
||||||
|
canWrite: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
}) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const [linkContactId, setLinkContactId] = useState('');
|
||||||
|
const detailQuery = useCompany(open && company ? company.id : null);
|
||||||
|
const linkMut = useLinkContact();
|
||||||
|
const unlinkMut = useUnlinkContact();
|
||||||
|
|
||||||
|
const detail = detailQuery.data;
|
||||||
|
const persons = detail?.contacts ?? [];
|
||||||
|
|
||||||
|
const submitLink = () => {
|
||||||
|
if (!company || !linkContactId.trim()) return;
|
||||||
|
linkMut.mutate(
|
||||||
|
{ companyId: company.id, contactId: linkContactId.trim() },
|
||||||
|
{ onSuccess: () => setLinkContactId('') },
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal open={open} onClose={onClose} title={company?.name ?? ''} size="lg">
|
||||||
|
<div className="space-y-4">
|
||||||
|
{detailQuery.isLoading ? (
|
||||||
|
<div className="flex items-center justify-center py-8" role="status">
|
||||||
|
<span className="animate-spin h-6 w-6 border-2 border-primary-500 border-t-transparent rounded-full" aria-hidden="true" />
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="grid grid-cols-2 sm:grid-cols-4 gap-2 text-sm">
|
||||||
|
<div><span className="text-xs text-secondary-500 block">{t('companies.industry')}</span><span className="font-medium">{detail?.industry || '—'}</span></div>
|
||||||
|
<div><span className="text-xs text-secondary-500 block">{t('companies.status')}</span><span className="font-medium">{detail?.status || '—'}</span></div>
|
||||||
|
<div><span className="text-xs text-secondary-500 block">{t('companies.email')}</span><span className="font-medium break-all">{detail?.email_1 || '—'}</span></div>
|
||||||
|
<div><span className="text-xs text-secondary-500 block">{t('companies.phone')}</span><span className="font-medium">{detail?.phone_1 || '—'}</span></div>
|
||||||
|
</div>
|
||||||
|
{detail?.description && (
|
||||||
|
<p className="text-sm text-secondary-600 dark:text-secondary-400 bg-secondary-50 dark:bg-secondary-800/50 rounded-md px-3 py-2">
|
||||||
|
{detail.description}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="border-t border-secondary-200 dark:border-secondary-700 pt-3">
|
||||||
|
<h3 className="text-sm font-semibold text-secondary-900 dark:text-secondary-100 mb-2 flex items-center gap-1.5">
|
||||||
|
<Users className="w-4 h-4" aria-hidden="true" />
|
||||||
|
{t('companies.contactPersons')} ({persons.length})
|
||||||
|
</h3>
|
||||||
|
<div className="space-y-1.5" data-testid="company-persons-list">
|
||||||
|
{persons.length === 0 && (
|
||||||
|
<p className="text-xs text-secondary-500">{t('companies.noPersons')}</p>
|
||||||
|
)}
|
||||||
|
{persons.map((p) => (
|
||||||
|
<div key={p.id} className="flex items-center justify-between gap-2 text-sm bg-secondary-50 dark:bg-secondary-800/50 rounded-md px-3 py-1.5" data-testid={`company-person-${p.id}`}>
|
||||||
|
<span className="min-w-0 truncate">
|
||||||
|
<span className="font-medium">{p.firstname} {p.lastname}</span>
|
||||||
|
{p.email && <span className="text-secondary-500 text-xs ml-2">{p.email}</span>}
|
||||||
|
</span>
|
||||||
|
{canWrite && (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => company && unlinkMut.mutate({ companyId: company.id, contactId: p.id })}
|
||||||
|
disabled={unlinkMut.isPending}
|
||||||
|
aria-label={t('companies.unlink')}
|
||||||
|
data-testid={`company-unlink-${p.id}`}
|
||||||
|
>
|
||||||
|
<X className="w-3.5 h-3.5" aria-hidden="true" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{canWrite && (
|
||||||
|
<div className="flex items-end gap-2 mt-3">
|
||||||
|
<div className="flex-1">
|
||||||
|
<Input
|
||||||
|
label={t('companies.linkContactLabel')}
|
||||||
|
value={linkContactId}
|
||||||
|
onChange={(e) => setLinkContactId(e.target.value)}
|
||||||
|
placeholder="00000000-0000-0000-0000-000000000000"
|
||||||
|
data-testid="company-link-contact-id"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
onClick={submitLink}
|
||||||
|
disabled={!linkContactId.trim() || linkMut.isPending}
|
||||||
|
data-testid="company-link-submit"
|
||||||
|
>
|
||||||
|
<UserPlus className="w-4 h-4" aria-hidden="true" />
|
||||||
|
{t('companies.link')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<div className="flex justify-end pt-1">
|
||||||
|
<Button variant="ghost" onClick={onClose}>{t('common.close')}</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CompaniesPage() {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const [page, setPage] = useState(1);
|
||||||
|
const [search, setSearch] = useState('');
|
||||||
|
const [searchInput, setSearchInput] = useState('');
|
||||||
|
const [industry, setIndustry] = useState('');
|
||||||
|
const [sortOrder, setSortOrder] = useState<'asc' | 'desc'>('asc');
|
||||||
|
const [showCreate, setShowCreate] = useState(false);
|
||||||
|
const [editCompany, setEditCompany] = useState<Company | null>(null);
|
||||||
|
const [detailCompany, setDetailCompany] = useState<Company | null>(null);
|
||||||
|
|
||||||
|
const { hasPermission } = usePermission();
|
||||||
|
const canRead = hasPermission('contacts:read');
|
||||||
|
const canWrite = hasPermission('contacts:write');
|
||||||
|
const canDelete = hasPermission('contacts:delete');
|
||||||
|
|
||||||
|
const listQuery = useCompanies({ page, page_size: PAGE_SIZE, search: search || null, industry: industry || null, sort_order: sortOrder });
|
||||||
|
const createMut = useCreateCompany();
|
||||||
|
const updateMut = useUpdateCompany();
|
||||||
|
const deleteMut = useDeleteCompany();
|
||||||
|
|
||||||
|
const isMutating = createMut.isPending || updateMut.isPending || deleteMut.isPending;
|
||||||
|
|
||||||
|
const items = listQuery.data?.items ?? [];
|
||||||
|
const total = listQuery.data?.total ?? 0;
|
||||||
|
const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE));
|
||||||
|
|
||||||
|
const handleCreate = (form: CompanyFormState) => {
|
||||||
|
const payload: Record<string, unknown> = { name: form.name.trim(), status: form.status };
|
||||||
|
if (form.industry.trim()) payload.industry = form.industry.trim();
|
||||||
|
if (form.description.trim()) payload.description = form.description.trim();
|
||||||
|
if (form.email_1.trim()) payload.email_1 = form.email_1.trim();
|
||||||
|
if (form.phone_1.trim()) payload.phone_1 = form.phone_1.trim();
|
||||||
|
if (form.website.trim()) payload.website = form.website.trim();
|
||||||
|
if (form.mailing_city.trim()) payload.mailing_city = form.mailing_city.trim();
|
||||||
|
if (form.mailing_country.trim()) payload.mailing_country = form.mailing_country.trim();
|
||||||
|
createMut.mutate(payload as never, { onSuccess: () => setShowCreate(false) });
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleUpdate = (form: CompanyFormState) => {
|
||||||
|
if (!editCompany) return;
|
||||||
|
const data: Record<string, unknown> = {};
|
||||||
|
if (form.name.trim() !== (editCompany.name ?? '')) data.name = form.name.trim();
|
||||||
|
if (form.status !== (editCompany.status ?? '')) data.status = form.status;
|
||||||
|
if (form.industry.trim() !== (editCompany.industry ?? '')) data.industry = form.industry.trim() || null;
|
||||||
|
if (form.description.trim() !== (editCompany.description ?? '')) data.description = form.description.trim() || null;
|
||||||
|
if (form.email_1.trim() !== (editCompany.email_1 ?? '')) data.email_1 = form.email_1.trim() || null;
|
||||||
|
if (form.phone_1.trim() !== (editCompany.phone_1 ?? '')) data.phone_1 = form.phone_1.trim() || null;
|
||||||
|
if (form.website.trim() !== (editCompany.website ?? '')) data.website = form.website.trim() || null;
|
||||||
|
if (form.mailing_city.trim() !== (editCompany.mailing_city ?? '')) data.mailing_city = form.mailing_city.trim() || null;
|
||||||
|
if (form.mailing_country.trim() !== (editCompany.mailing_country ?? '')) data.mailing_country = form.mailing_country.trim() || null;
|
||||||
|
updateMut.mutate({ id: editCompany.id, data: data as never }, { onSuccess: () => setEditCompany(null) });
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDelete = (c: Company) => {
|
||||||
|
if (window.confirm(t('companies.deleteConfirm', { name: c.name }))) {
|
||||||
|
deleteMut.mutate({ id: c.id });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const submitSearch = () => {
|
||||||
|
setSearch(searchInput);
|
||||||
|
setPage(1);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="max-w-4xl mx-auto p-4 sm:p-6 space-y-4" data-testid="companies-page">
|
||||||
|
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Building2 className="w-6 h-6 text-primary-600" aria-hidden="true" />
|
||||||
|
<h1 className="text-2xl font-bold text-secondary-900 dark:text-secondary-100">
|
||||||
|
{t('companies.title')}
|
||||||
|
</h1>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{canRead && (
|
||||||
|
<>
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => window.open('/api/v1/companies/export?format=csv', '_blank')}
|
||||||
|
aria-label="CSV Export"
|
||||||
|
data-testid="company-export-csv"
|
||||||
|
>
|
||||||
|
<Download className="w-4 h-4" aria-hidden="true" />CSV
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => window.open('/api/v1/companies/export?format=xlsx', '_blank')}
|
||||||
|
aria-label="XLSX Export"
|
||||||
|
data-testid="company-export-xlsx"
|
||||||
|
>
|
||||||
|
<Download className="w-4 h-4" aria-hidden="true" />XLSX
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{canWrite && (
|
||||||
|
<Button onClick={() => setShowCreate(true)} data-testid="company-create-btn">
|
||||||
|
<Plus className="w-4 h-4" aria-hidden="true" />
|
||||||
|
{t('companies.create')}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Filter bar */}
|
||||||
|
<div className="flex flex-wrap items-end gap-2">
|
||||||
|
<div className="flex items-end gap-1 flex-1 min-w-48">
|
||||||
|
<div className="flex-1">
|
||||||
|
<Input
|
||||||
|
value={searchInput}
|
||||||
|
onChange={(e) => setSearchInput(e.target.value)}
|
||||||
|
onKeyDown={(e) => e.key === 'Enter' && submitSearch()}
|
||||||
|
placeholder={t('companies.searchPlaceholder')}
|
||||||
|
aria-label={t('companies.searchPlaceholder')}
|
||||||
|
data-testid="company-search-input"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Button variant="secondary" onClick={submitSearch} aria-label={t('companies.search')} data-testid="company-search-submit">
|
||||||
|
<Search className="w-4 h-4" aria-hidden="true" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<div className="w-40">
|
||||||
|
<Input
|
||||||
|
value={industry}
|
||||||
|
onChange={(e) => { setIndustry(e.target.value); setPage(1); }}
|
||||||
|
placeholder={t('companies.industryFilter')}
|
||||||
|
aria-label={t('companies.industryFilter')}
|
||||||
|
data-testid="company-industry-filter"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="w-36">
|
||||||
|
<label htmlFor="company-sort" className="block text-xs font-medium text-secondary-500 mb-1">{t('companies.sort')}</label>
|
||||||
|
<select
|
||||||
|
id="company-sort"
|
||||||
|
value={sortOrder}
|
||||||
|
onChange={(e) => setSortOrder(e.target.value as 'asc' | 'desc')}
|
||||||
|
className="w-full rounded-md border border-secondary-300 dark:border-secondary-600 bg-white dark:bg-secondary-800 px-2 py-2 text-sm min-h-touch"
|
||||||
|
data-testid="company-sort-order"
|
||||||
|
>
|
||||||
|
<option value="asc">A → Z</option>
|
||||||
|
<option value="desc">Z → A</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!canRead ? (
|
||||||
|
<Card className="p-8 text-center" data-testid="companies-no-permission">
|
||||||
|
<AlertTriangle className="w-8 h-8 mx-auto text-warning-500" aria-hidden="true" />
|
||||||
|
<p className="mt-2 text-sm text-secondary-600">{t('companies.noPermission')}</p>
|
||||||
|
</Card>
|
||||||
|
) : listQuery.isLoading ? (
|
||||||
|
<div className="flex items-center justify-center py-12" role="status">
|
||||||
|
<span className="animate-spin h-6 w-6 border-2 border-primary-500 border-t-transparent rounded-full" aria-hidden="true" />
|
||||||
|
</div>
|
||||||
|
) : listQuery.isError ? (
|
||||||
|
<Card className="p-8 text-center">
|
||||||
|
<AlertTriangle className="w-8 h-8 mx-auto text-danger-500" aria-hidden="true" />
|
||||||
|
<p className="mt-2 text-sm text-secondary-600">{t('companies.loadError')}</p>
|
||||||
|
</Card>
|
||||||
|
) : items.length === 0 ? (
|
||||||
|
<Card className="p-12 text-center">
|
||||||
|
<Inbox className="w-10 h-10 mx-auto text-secondary-300" aria-hidden="true" />
|
||||||
|
<p className="mt-3 text-sm text-secondary-500" data-testid="companies-empty">
|
||||||
|
{t('companies.empty')}
|
||||||
|
</p>
|
||||||
|
</Card>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{items.map((c) => (
|
||||||
|
<CompanyCard
|
||||||
|
key={c.id}
|
||||||
|
company={c}
|
||||||
|
canWrite={canWrite}
|
||||||
|
canDelete={canDelete}
|
||||||
|
isMutating={isMutating}
|
||||||
|
onEdit={setEditCompany}
|
||||||
|
onDelete={handleDelete}
|
||||||
|
onDetail={setDetailCompany}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
{totalPages > 1 && (
|
||||||
|
<div className="flex justify-center items-center gap-2 pt-2">
|
||||||
|
<Button variant="ghost" size="sm" onClick={() => setPage((p) => Math.max(1, p - 1))} disabled={page <= 1}>
|
||||||
|
{t('companies.prev')}
|
||||||
|
</Button>
|
||||||
|
<span className="text-sm text-secondary-500">{page} / {totalPages}</span>
|
||||||
|
<Button variant="ghost" size="sm" onClick={() => setPage((p) => Math.min(totalPages, p + 1))} disabled={page >= totalPages}>
|
||||||
|
{t('companies.next')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<CompanyFormModal
|
||||||
|
open={showCreate}
|
||||||
|
company={null}
|
||||||
|
onClose={() => setShowCreate(false)}
|
||||||
|
onSubmit={handleCreate}
|
||||||
|
isSubmitting={createMut.isPending}
|
||||||
|
/>
|
||||||
|
<CompanyFormModal
|
||||||
|
open={!!editCompany}
|
||||||
|
company={editCompany}
|
||||||
|
onClose={() => setEditCompany(null)}
|
||||||
|
onSubmit={handleUpdate}
|
||||||
|
isSubmitting={updateMut.isPending}
|
||||||
|
/>
|
||||||
|
<CompanyDetailModal
|
||||||
|
open={!!detailCompany}
|
||||||
|
company={detailCompany}
|
||||||
|
canWrite={canWrite}
|
||||||
|
onClose={() => setDetailCompany(null)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user