feat(companies): UI fuer Firmen-Verwaltung mit Ansprechpartner-Links — Modul 12/16 des UI-Backlogs
Check Cross-Plugin Imports / check (push) Has been cancelled

This commit is contained in:
Agent Zero
2026-09-15 22:56:38 +02:00
parent 71ffee021e
commit 06b72843da
8 changed files with 1099 additions and 4 deletions
@@ -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();
});
});
+173
View File
@@ -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] });
},
});
}
+2 -2
View File
@@ -14,7 +14,7 @@ import {
Reply, Forward, Paperclip, MoreVertical, RefreshCw, Download,
Upload, Eye, EyeOff, Lock, Unlock, Clock, AlertCircle,
CheckCircle, XCircle, Info, Loader2, Inbox, Send,
BookOpen, Lightbulb, Share2,
BookOpen, Lightbulb, Share2, Building2,
} from 'lucide-react';
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,
Unlock, Clock, AlertCircle, CheckCircle, XCircle, Info, Loader2,
Inbox, Send, ChevronRight, BookOpen, Lightbulb,
Brain, Store, Tags, ArrowRightLeft, Share2,
Brain, Store, Tags, ArrowRightLeft, Share2, Building2,
};
import { useMenuOrder } from '@/api/users';
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/CalendarKanban': () => import('@/pages/CalendarKanban').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/ContactsList': () => import('@/pages/ContactsList').then((m) => ({ default: m.ContactsListPage })),
'@/pages/DedupMerge': () => import('@/pages/DedupMerge').then((m) => ({ default: m.DedupMergePage })),
+25 -1
View File
@@ -202,7 +202,31 @@
"notFound": "Firma nicht gefunden.",
"noContacts": "Diese Firma hat noch keine Kontakte.",
"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": {
"title": "Kontakte",
+25 -1
View File
@@ -202,7 +202,31 @@
"notFound": "Company not found.",
"noContacts": "This company has no contacts yet.",
"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": {
"title": "Contacts",
+600
View File
@@ -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>
);
}