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
+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>
);
}