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=[
|
||||
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=[
|
||||
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/dedup', component='@/pages/DedupMerge', protected=True, permission='contacts:read'),
|
||||
FrontendPageRoute(path='/companies', component='@/pages/Companies', protected=True, permission='contacts:read'),
|
||||
],
|
||||
permissions=[
|
||||
"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,
|
||||
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 })),
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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