T07b: frontend feature pages — companies + contacts + settings + audit + dashboard + search
- 11 new feature pages (CompaniesList/Detail/Form, ContactsList/Detail/Form, SettingsProfile/Roles/Users, AuditLog, GlobalSearchResults) - 3 page updates (Dashboard with StatCard+ActivityFeed, Settings with tree nav+Outlet, TopBar with SearchDropdown) - 13 new routes in routes/index.tsx - i18n updates (de.json + en.json) with companies/contacts/settings/audit/search keys - 12 new test files + 2 existing test fixes (TopBar, AppShell) - 7 shared components (DataGrid, Tabs, SearchDropdown, CsvImportDialog, StatCard, ActivityFeed, UnsavedChangesGuard) - 16 new API hooks in hooks.ts - Verification: 141 tests pass, build succeeds, tsc --noEmit clean
This commit is contained in:
@@ -0,0 +1,167 @@
|
||||
import React, { useState, useMemo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { ColumnDef } from '@tanstack/react-table';
|
||||
import { useAuditLog, AuditLogEntry } from '@/api/hooks';
|
||||
import { DataGrid } from '@/components/shared/DataGrid';
|
||||
import { Card } from '@/components/ui/Card';
|
||||
import { Input } from '@/components/ui/Input';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { EmptyState } from '@/components/ui/EmptyState';
|
||||
import { Badge } from '@/components/ui/Badge';
|
||||
|
||||
export function AuditLogPage() {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const [page, setPage] = useState(1);
|
||||
const [pageSize] = useState(25);
|
||||
const [filterUser, setFilterUser] = useState('');
|
||||
const [filterAction, setFilterAction] = useState('');
|
||||
const [filterEntity, setFilterEntity] = useState('');
|
||||
const [filterDateFrom, setFilterDateFrom] = useState('');
|
||||
const [filterDateTo, setFilterDateTo] = useState('');
|
||||
|
||||
const filters = useMemo(
|
||||
() => ({
|
||||
user: filterUser || undefined,
|
||||
action: filterAction || undefined,
|
||||
entity: filterEntity || undefined,
|
||||
dateFrom: filterDateFrom || undefined,
|
||||
dateTo: filterDateTo || undefined,
|
||||
}),
|
||||
[filterUser, filterAction, filterEntity, filterDateFrom, filterDateTo]
|
||||
);
|
||||
|
||||
const { data, isLoading, isError } = useAuditLog(page, pageSize, filters);
|
||||
|
||||
const columns = useMemo<ColumnDef<AuditLogEntry, any>[]>(
|
||||
() => [
|
||||
{
|
||||
accessorKey: 'timestamp',
|
||||
header: t('auditLog.timestamp'),
|
||||
cell: (info) => {
|
||||
const val = info.getValue();
|
||||
if (!val) return '—';
|
||||
const date = new Date(val);
|
||||
return date.toLocaleString('de-DE');
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: 'user',
|
||||
header: t('auditLog.user'),
|
||||
cell: (info) => info.getValue() || '—',
|
||||
},
|
||||
{
|
||||
accessorKey: 'action',
|
||||
header: t('auditLog.action'),
|
||||
cell: (info) =>
|
||||
info.getValue() ? <Badge variant="primary">{info.getValue()}</Badge> : '—',
|
||||
},
|
||||
{
|
||||
accessorKey: 'entity',
|
||||
header: t('auditLog.entity'),
|
||||
cell: (info) => info.getValue() || '—',
|
||||
},
|
||||
{
|
||||
accessorKey: 'entity_id',
|
||||
header: t('auditLog.entityId'),
|
||||
cell: (info) => info.getValue() || '—',
|
||||
},
|
||||
{
|
||||
accessorKey: 'details',
|
||||
header: t('auditLog.details'),
|
||||
cell: (info) => {
|
||||
const val = info.getValue();
|
||||
if (!val) return '—';
|
||||
return <span className="text-sm text-secondary-600 truncate">{val}</span>;
|
||||
},
|
||||
},
|
||||
],
|
||||
[t]
|
||||
);
|
||||
|
||||
const handleResetFilters = () => {
|
||||
setFilterUser('');
|
||||
setFilterAction('');
|
||||
setFilterEntity('');
|
||||
setFilterDateFrom('');
|
||||
setFilterDateTo('');
|
||||
setPage(1);
|
||||
};
|
||||
|
||||
const hasFilters = filterUser || filterAction || filterEntity || filterDateFrom || filterDateTo;
|
||||
|
||||
if (isError) {
|
||||
return (
|
||||
<div className="p-6 max-w-7xl mx-auto" data-testid="audit-log-page">
|
||||
<h1 className="text-2xl font-bold text-secondary-900 mb-6">{t('auditLog.title')}</h1>
|
||||
<EmptyState
|
||||
title={t('auditLog.notAvailable')}
|
||||
description={t('auditLog.empty')}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const entries = data?.items ?? [];
|
||||
const total = data?.total ?? 0;
|
||||
|
||||
return (
|
||||
<div className="p-6 max-w-7xl mx-auto" data-testid="audit-log-page">
|
||||
<h1 className="text-2xl font-bold text-secondary-900 mb-6">{t('auditLog.title')}</h1>
|
||||
|
||||
<Card title={t('common.filter')} className="mb-4">
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 lg:grid-cols-5 gap-3">
|
||||
<Input
|
||||
label={t('auditLog.user')}
|
||||
value={filterUser}
|
||||
onChange={(e) => setFilterUser(e.target.value)}
|
||||
placeholder="anna.schmidt"
|
||||
/>
|
||||
<Input
|
||||
label={t('auditLog.action')}
|
||||
value={filterAction}
|
||||
onChange={(e) => setFilterAction(e.target.value)}
|
||||
placeholder="create, update, delete"
|
||||
/>
|
||||
<Input
|
||||
label={t('auditLog.entity')}
|
||||
value={filterEntity}
|
||||
onChange={(e) => setFilterEntity(e.target.value)}
|
||||
placeholder="company, contact"
|
||||
/>
|
||||
<Input
|
||||
label={t('auditLog.dateFrom')}
|
||||
type="date"
|
||||
value={filterDateFrom}
|
||||
onChange={(e) => setFilterDateFrom(e.target.value)}
|
||||
/>
|
||||
<Input
|
||||
label={t('auditLog.dateTo')}
|
||||
type="date"
|
||||
value={filterDateTo}
|
||||
onChange={(e) => setFilterDateTo(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
{hasFilters && (
|
||||
<div className="mt-3 flex justify-end">
|
||||
<Button variant="ghost" size="sm" onClick={handleResetFilters}>
|
||||
{t('common.reset')}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
<DataGrid
|
||||
columns={columns}
|
||||
data={entries}
|
||||
total={total}
|
||||
page={page}
|
||||
pageSize={pageSize}
|
||||
onPageChange={setPage}
|
||||
loading={isLoading}
|
||||
emptyMessage={t('auditLog.empty')}
|
||||
testId="audit-log-grid"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,194 @@
|
||||
import React, { useState, useMemo } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { ColumnDef } from '@tanstack/react-table';
|
||||
import { useCompanies, useCompanyExport, useDeleteCompany, Company } from '@/api/hooks';
|
||||
import { DataGrid } from '@/components/shared/DataGrid';
|
||||
import { CsvImportDialog } from '@/components/shared/CsvImportDialog';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { Input } from '@/components/ui/Input';
|
||||
import { EmptyState } from '@/components/ui/EmptyState';
|
||||
import { ConfirmDialog } from '@/components/ui/ConfirmDialog';
|
||||
import { useToast } from '@/components/ui/Toast';
|
||||
import { Badge } from '@/components/ui/Badge';
|
||||
|
||||
export function CompaniesListPage() {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const toast = useToast();
|
||||
|
||||
const [page, setPage] = useState(1);
|
||||
const [pageSize] = useState(25);
|
||||
const [search, setSearch] = useState('');
|
||||
const [debouncedSearch, setDebouncedSearch] = useState('');
|
||||
const [importOpen, setImportOpen] = useState(false);
|
||||
const [deleteTarget, setDeleteTarget] = useState<Company | null>(null);
|
||||
|
||||
const { data, isLoading } = useCompanies(page, pageSize, debouncedSearch);
|
||||
const exportMutation = useCompanyExport(debouncedSearch);
|
||||
const deleteMutation = useDeleteCompany();
|
||||
|
||||
React.useEffect(() => {
|
||||
const timer = setTimeout(() => setDebouncedSearch(search), 300);
|
||||
return () => clearTimeout(timer);
|
||||
}, [search]);
|
||||
|
||||
const columns = useMemo<ColumnDef<Company, any>[]>(
|
||||
() => [
|
||||
{
|
||||
accessorKey: 'name',
|
||||
header: t('companies.name'),
|
||||
cell: (info) => (
|
||||
<span className="font-medium text-primary-700 hover:text-primary-800">
|
||||
{info.getValue()}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: 'account_number',
|
||||
header: t('companies.accountNumber'),
|
||||
cell: (info) => info.getValue() || '—',
|
||||
},
|
||||
{
|
||||
accessorKey: 'industry',
|
||||
header: t('companies.industry'),
|
||||
cell: (info) =>
|
||||
info.getValue() ? (
|
||||
<Badge variant="primary">{info.getValue()}</Badge>
|
||||
) : (
|
||||
'—'
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: 'phone',
|
||||
header: t('companies.phone'),
|
||||
cell: (info) => info.getValue() || '—',
|
||||
},
|
||||
{
|
||||
accessorKey: 'email',
|
||||
header: t('companies.email'),
|
||||
cell: (info) =>
|
||||
info.getValue() ? (
|
||||
<a href={`mailto:${info.getValue()}`} className="text-primary-600 hover:text-primary-700">
|
||||
{info.getValue()}
|
||||
</a>
|
||||
) : (
|
||||
'—'
|
||||
),
|
||||
},
|
||||
],
|
||||
[t]
|
||||
);
|
||||
|
||||
const handleExport = async () => {
|
||||
try {
|
||||
const blob = await exportMutation.mutateAsync();
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `companies_${new Date().toISOString().split('T')[0]}.csv`;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
window.URL.revokeObjectURL(url);
|
||||
toast.success(t('companies.export') + ' — OK');
|
||||
} catch (err: any) {
|
||||
toast.error(err.message || t('common.error'));
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!deleteTarget) return;
|
||||
try {
|
||||
await deleteMutation.mutateAsync(deleteTarget.id);
|
||||
toast.success(t('companies.deleted'));
|
||||
setDeleteTarget(null);
|
||||
} catch (err: any) {
|
||||
toast.error(err.message || t('common.error'));
|
||||
}
|
||||
};
|
||||
|
||||
const companies = data?.items ?? [];
|
||||
const total = data?.total ?? 0;
|
||||
const isEmpty = !isLoading && companies.length === 0 && !debouncedSearch;
|
||||
|
||||
if (isEmpty) {
|
||||
return (
|
||||
<div className="p-6 max-w-7xl mx-auto" data-testid="companies-list-page">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<h1 className="text-2xl font-bold text-secondary-900">{t('companies.title')}</h1>
|
||||
</div>
|
||||
<EmptyState
|
||||
title={t('companies.emptyTitle')}
|
||||
description={t('companies.emptyDescription')}
|
||||
action={
|
||||
<Button onClick={() => navigate('/companies/new')}>
|
||||
{t('companies.create')}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-6 max-w-7xl mx-auto" data-testid="companies-list-page">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<h1 className="text-2xl font-bold text-secondary-900">{t('companies.title')}</h1>
|
||||
<div className="flex gap-2">
|
||||
<Button variant="secondary" onClick={() => setImportOpen(true)}>
|
||||
{t('companies.import')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={handleExport}
|
||||
isLoading={exportMutation.isPending}
|
||||
>
|
||||
{t('companies.export')}
|
||||
</Button>
|
||||
<Button onClick={() => navigate('/companies/new')}>
|
||||
{t('companies.create')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mb-4">
|
||||
<Input
|
||||
type="search"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
placeholder={t('common.search')}
|
||||
aria-label={t('common.search')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<DataGrid
|
||||
columns={columns}
|
||||
data={companies}
|
||||
total={total}
|
||||
page={page}
|
||||
pageSize={pageSize}
|
||||
onPageChange={setPage}
|
||||
onRowClick={(row) => navigate(`/companies/${row.id}`)}
|
||||
loading={isLoading}
|
||||
emptyMessage={t('common.noResults')}
|
||||
testId="companies-grid"
|
||||
/>
|
||||
|
||||
<CsvImportDialog
|
||||
open={importOpen}
|
||||
onClose={() => setImportOpen(false)}
|
||||
onSuccess={() => setPage(1)}
|
||||
/>
|
||||
|
||||
<ConfirmDialog
|
||||
open={!!deleteTarget}
|
||||
title={t('companies.deleteConfirmTitle')}
|
||||
message={t('companies.deleteConfirm')}
|
||||
variant="danger"
|
||||
onConfirm={handleDelete}
|
||||
onCancel={() => setDeleteTarget(null)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
import React from 'react';
|
||||
import { useParams, useNavigate } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useCompany } from '@/api/hooks';
|
||||
import { Tabs, TabItem } from '@/components/shared/Tabs';
|
||||
import { Card } from '@/components/ui/Card';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { Badge } from '@/components/ui/Badge';
|
||||
import { EmptyState } from '@/components/ui/EmptyState';
|
||||
import { Skeleton } from '@/components/ui/Skeleton';
|
||||
import { Avatar } from '@/components/ui/Avatar';
|
||||
|
||||
export function CompanyDetailPage() {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const navigate = useNavigate();
|
||||
const { t } = useTranslation();
|
||||
const { data: company, isLoading, isError } = useCompany(id);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="p-6 max-w-7xl mx-auto" data-testid="company-detail-page">
|
||||
<Skeleton className="h-8 w-64 mb-6" />
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<Skeleton className="h-32" />
|
||||
<Skeleton className="h-32" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (isError || !company) {
|
||||
return (
|
||||
<div className="p-6 max-w-7xl mx-auto" data-testid="company-detail-page">
|
||||
<EmptyState
|
||||
title={t('companies.notFound')}
|
||||
action={<Button onClick={() => navigate('/companies')}>{t('common.back')}</Button>}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const contacts = company.contacts ?? [];
|
||||
|
||||
const overviewTab: TabItem = {
|
||||
key: 'overview',
|
||||
label: t('companies.overview'),
|
||||
content: (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<Card title={t('companies.name')}>
|
||||
<p className="text-secondary-900">{company.name}</p>
|
||||
</Card>
|
||||
<Card title={t('companies.accountNumber')}>
|
||||
<p className="text-secondary-900">{company.account_number || '—'}</p>
|
||||
</Card>
|
||||
<Card title={t('companies.industry')}>
|
||||
{company.industry ? <Badge variant="primary">{company.industry}</Badge> : <p className="text-secondary-500">—</p>}
|
||||
</Card>
|
||||
<Card title={t('companies.phone')}>
|
||||
<p className="text-secondary-900">{company.phone || '—'}</p>
|
||||
</Card>
|
||||
<Card title={t('companies.email')}>
|
||||
{company.email ? (
|
||||
<a href={`mailto:${company.email}`} className="text-primary-600 hover:text-primary-700">{company.email}</a>
|
||||
) : <p className="text-secondary-500">—</p>}
|
||||
</Card>
|
||||
<Card title={t('companies.website')}>
|
||||
{company.website ? (
|
||||
<a href={company.website} target="_blank" rel="noopener noreferrer" className="text-primary-600 hover:text-primary-700">{company.website}</a>
|
||||
) : <p className="text-secondary-500">—</p>}
|
||||
</Card>
|
||||
{company.description && (
|
||||
<Card title={t('companies.description')}>
|
||||
<p className="text-secondary-700 text-sm whitespace-pre-wrap">{company.description}</p>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
),
|
||||
};
|
||||
|
||||
const contactsTab: TabItem = {
|
||||
key: 'contacts',
|
||||
label: t('companies.contacts'),
|
||||
badge: contacts.length,
|
||||
content: contacts.length === 0 ? (
|
||||
<EmptyState title={t('companies.noContacts')} />
|
||||
) : (
|
||||
<ul className="space-y-2" role="list">
|
||||
{contacts.map((contact) => (
|
||||
<li key={contact.id}>
|
||||
<button
|
||||
onClick={() => navigate(`/contacts/${contact.id}`)}
|
||||
className="w-full flex items-center gap-3 p-3 rounded-lg hover:bg-secondary-50 text-left min-h-touch"
|
||||
>
|
||||
<Avatar name={`${contact.first_name} ${contact.last_name}`} size="sm" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="font-medium text-secondary-900">{contact.first_name} {contact.last_name}</p>
|
||||
<p className="text-sm text-secondary-500 truncate">{contact.email}{contact.position ? ` · ${contact.position}` : ''}</p>
|
||||
</div>
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
),
|
||||
};
|
||||
|
||||
const filesTab: TabItem = {
|
||||
key: 'files',
|
||||
label: t('companies.files'),
|
||||
content: <EmptyState title={t('companies.noFiles')} />,
|
||||
};
|
||||
|
||||
const activityTab: TabItem = {
|
||||
key: 'activity',
|
||||
label: t('companies.activity'),
|
||||
content: <EmptyState title={t('companies.noActivity')} />,
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="p-6 max-w-7xl mx-auto" data-testid="company-detail-page">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div className="flex items-center gap-4">
|
||||
<Button variant="ghost" onClick={() => navigate('/companies')} aria-label={t('common.back')}>
|
||||
← {t('common.back')}
|
||||
</Button>
|
||||
<h1 className="text-2xl font-bold text-secondary-900">{company.name}</h1>
|
||||
</div>
|
||||
<Button onClick={() => navigate(`/companies/${company.id}/edit`)}>
|
||||
{t('common.edit')}
|
||||
</Button>
|
||||
</div>
|
||||
<Tabs tabs={[overviewTab, contactsTab, filesTab, activityTab]} defaultKey="overview" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,189 @@
|
||||
import React, { useEffect } from 'react';
|
||||
import { useParams, useNavigate } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { z } from 'zod';
|
||||
import { useCompany, useCreateCompany, useUpdateCompany } from '@/api/hooks';
|
||||
import { Input } from '@/components/ui/Input';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { Card } from '@/components/ui/Card';
|
||||
import { Skeleton } from '@/components/ui/Skeleton';
|
||||
import { useToast } from '@/components/ui/Toast';
|
||||
import { UnsavedChangesGuard } from '@/components/shared/UnsavedChangesGuard';
|
||||
|
||||
const companySchema = z.object({
|
||||
name: z.string().min(1, 'Name ist erforderlich').max(100, 'Maximal 100 Zeichen'),
|
||||
account_number: z.string().max(40, 'Maximal 40 Zeichen').optional().or(z.literal('')),
|
||||
industry: z.string().max(50, 'Maximal 50 Zeichen').optional().or(z.literal('')),
|
||||
phone: z.string().max(30, 'Maximal 30 Zeichen').optional().or(z.literal('')),
|
||||
email: z.string().email('Ungültige E-Mail-Adresse').max(255).optional().or(z.literal('')),
|
||||
website: z.string().max(500, 'Maximal 500 Zeichen').optional().or(z.literal('')),
|
||||
description: z.string().optional().or(z.literal('')),
|
||||
});
|
||||
|
||||
type CompanyFormValues = z.infer<typeof companySchema>;
|
||||
|
||||
export function CompanyFormPage() {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const navigate = useNavigate();
|
||||
const { t } = useTranslation();
|
||||
const toast = useToast();
|
||||
const isEdit = !!id;
|
||||
|
||||
const { data: company, isLoading } = useCompany(isEdit ? id : undefined);
|
||||
const createMutation = useCreateCompany();
|
||||
const updateMutation = useUpdateCompany();
|
||||
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
reset,
|
||||
formState: { errors, isDirty, isSubmitting },
|
||||
} = useForm<CompanyFormValues>({
|
||||
resolver: zodResolver(companySchema),
|
||||
defaultValues: {
|
||||
name: '',
|
||||
account_number: '',
|
||||
industry: '',
|
||||
phone: '',
|
||||
email: '',
|
||||
website: '',
|
||||
description: '',
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (isEdit && company) {
|
||||
reset({
|
||||
name: company.name || '',
|
||||
account_number: company.account_number || '',
|
||||
industry: company.industry || '',
|
||||
phone: company.phone || '',
|
||||
email: company.email || '',
|
||||
website: company.website || '',
|
||||
description: company.description || '',
|
||||
});
|
||||
}
|
||||
}, [company, isEdit, reset]);
|
||||
|
||||
const onSubmit = async (values: CompanyFormValues) => {
|
||||
try {
|
||||
if (isEdit && id) {
|
||||
await updateMutation.mutateAsync({ id, data: values });
|
||||
toast.success(t('companies.updated'));
|
||||
navigate(`/companies/${id}`);
|
||||
} else {
|
||||
const result = await createMutation.mutateAsync(values) as { id: string };
|
||||
toast.success(t('companies.created'));
|
||||
navigate(`/companies/${result.id}`);
|
||||
}
|
||||
} catch (err: any) {
|
||||
toast.error(err.message || (isEdit ? t('companies.updateFailed') : t('companies.createFailed')));
|
||||
}
|
||||
};
|
||||
|
||||
if (isEdit && isLoading) {
|
||||
return (
|
||||
<div className="p-6 max-w-4xl mx-auto" data-testid="company-form-page">
|
||||
<Skeleton className="h-8 w-48 mb-6" />
|
||||
<Skeleton className="h-96" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-6 max-w-4xl mx-auto" data-testid="company-form-page">
|
||||
<UnsavedChangesGuard isDirty={isDirty} />
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div className="flex items-center gap-4">
|
||||
<Button variant="ghost" onClick={() => navigate(-1)}>
|
||||
← {t('common.back')}
|
||||
</Button>
|
||||
<h1 className="text-2xl font-bold text-secondary-900">
|
||||
{isEdit ? t('companies.edit') : t('companies.create')}
|
||||
</h1>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
|
||||
<Card title={t('companies.name')}>
|
||||
<Input
|
||||
{...register('name')}
|
||||
label={t('companies.name')}
|
||||
required
|
||||
error={errors.name?.message}
|
||||
placeholder="TechCorp GmbH"
|
||||
data-testid="company-name-input"
|
||||
/>
|
||||
</Card>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<Card title={t('companies.accountNumber')}>
|
||||
<Input
|
||||
{...register('account_number')}
|
||||
label={t('companies.accountNumber')}
|
||||
error={errors.account_number?.message}
|
||||
placeholder="K-00123"
|
||||
/>
|
||||
</Card>
|
||||
<Card title={t('companies.industry')}>
|
||||
<Input
|
||||
{...register('industry')}
|
||||
label={t('companies.industry')}
|
||||
error={errors.industry?.message}
|
||||
placeholder="IT & Software"
|
||||
/>
|
||||
</Card>
|
||||
<Card title={t('companies.phone')}>
|
||||
<Input
|
||||
{...register('phone')}
|
||||
label={t('companies.phone')}
|
||||
error={errors.phone?.message}
|
||||
placeholder="+49 30 1234567"
|
||||
/>
|
||||
</Card>
|
||||
<Card title={t('companies.email')}>
|
||||
<Input
|
||||
{...register('email')}
|
||||
label={t('companies.email')}
|
||||
type="email"
|
||||
error={errors.email?.message}
|
||||
placeholder="info@techcorp.de"
|
||||
/>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<Card title={t('companies.website')}>
|
||||
<Input
|
||||
{...register('website')}
|
||||
label={t('companies.website')}
|
||||
error={errors.website?.message}
|
||||
placeholder="https://www.techcorp.de"
|
||||
/>
|
||||
</Card>
|
||||
|
||||
<Card title={t('companies.description')}>
|
||||
<textarea
|
||||
{...register('description')}
|
||||
className="w-full px-3 py-2 text-sm rounded-md border border-secondary-300 focus:outline-none focus:ring-2 focus:ring-primary-500 min-h-24"
|
||||
placeholder="Firmenbeschreibung..."
|
||||
aria-label={t('companies.description')}
|
||||
/>
|
||||
{errors.description && (
|
||||
<p className="mt-1 text-sm text-danger-600">{errors.description.message}</p>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
<div className="flex justify-end gap-3">
|
||||
<Button variant="secondary" onClick={() => navigate(-1)}>
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
<Button type="submit" isLoading={isSubmitting} data-testid="company-submit-btn">
|
||||
{isEdit ? t('common.save') : t('common.create')}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
import React from 'react';
|
||||
import { useParams, useNavigate } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useContact } from '@/api/hooks';
|
||||
import { Tabs, TabItem } from '@/components/shared/Tabs';
|
||||
import { Card } from '@/components/ui/Card';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { Badge } from '@/components/ui/Badge';
|
||||
import { EmptyState } from '@/components/ui/EmptyState';
|
||||
import { Skeleton } from '@/components/ui/Skeleton';
|
||||
import { Avatar } from '@/components/ui/Avatar';
|
||||
|
||||
export function ContactDetailPage() {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const navigate = useNavigate();
|
||||
const { t } = useTranslation();
|
||||
const { data: contact, isLoading, isError } = useContact(id);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="p-6 max-w-7xl mx-auto" data-testid="contact-detail-page">
|
||||
<Skeleton className="h-8 w-64 mb-6" />
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<Skeleton className="h-32" />
|
||||
<Skeleton className="h-32" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (isError || !contact) {
|
||||
return (
|
||||
<div className="p-6 max-w-7xl mx-auto" data-testid="contact-detail-page">
|
||||
<EmptyState
|
||||
title={t('contacts.notFound')}
|
||||
action={<Button onClick={() => navigate('/contacts')}>{t('common.back')}</Button>}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const companies = contact.companies ?? [];
|
||||
|
||||
const overviewTab: TabItem = {
|
||||
key: 'overview',
|
||||
label: t('contacts.overview'),
|
||||
content: (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<Card title={t('contacts.firstName')}>
|
||||
<p className="text-secondary-900">{contact.first_name}</p>
|
||||
</Card>
|
||||
<Card title={t('contacts.lastName')}>
|
||||
<p className="text-secondary-900">{contact.last_name}</p>
|
||||
</Card>
|
||||
<Card title={t('contacts.email')}>
|
||||
{contact.email ? (
|
||||
<a href={`mailto:${contact.email}`} className="text-primary-600 hover:text-primary-700">{contact.email}</a>
|
||||
) : <p className="text-secondary-500">—</p>}
|
||||
</Card>
|
||||
<Card title={t('contacts.phone')}>
|
||||
<p className="text-secondary-900">{contact.phone || '—'}</p>
|
||||
</Card>
|
||||
<Card title={t('contacts.position')}>
|
||||
<p className="text-secondary-900">{contact.position || '—'}</p>
|
||||
</Card>
|
||||
</div>
|
||||
),
|
||||
};
|
||||
|
||||
const companiesTab: TabItem = {
|
||||
key: 'companies',
|
||||
label: t('contacts.companies'),
|
||||
badge: companies.length,
|
||||
content: companies.length === 0 ? (
|
||||
<EmptyState title={t('contacts.noCompanies')} />
|
||||
) : (
|
||||
<ul className="space-y-2" role="list">
|
||||
{companies.map((company) => (
|
||||
<li key={company.id}>
|
||||
<button
|
||||
onClick={() => navigate(`/companies/${company.id}`)}
|
||||
className="w-full flex items-center gap-3 p-3 rounded-lg hover:bg-secondary-50 text-left min-h-touch"
|
||||
>
|
||||
<div className="w-10 h-10 rounded-lg bg-primary-50 flex items-center justify-center text-primary-600 font-semibold flex-shrink-0">
|
||||
{company.name.charAt(0).toUpperCase()}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="font-medium text-secondary-900">{company.name}</p>
|
||||
{company.industry && (
|
||||
<p className="text-sm text-secondary-500">{company.industry}</p>
|
||||
)}
|
||||
</div>
|
||||
{company.email && (
|
||||
<Badge variant="info">{company.email}</Badge>
|
||||
)}
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
),
|
||||
};
|
||||
|
||||
const filesTab: TabItem = {
|
||||
key: 'files',
|
||||
label: t('contacts.files'),
|
||||
content: <EmptyState title={t('contacts.noFiles')} />,
|
||||
};
|
||||
|
||||
const activityTab: TabItem = {
|
||||
key: 'activity',
|
||||
label: t('contacts.activity'),
|
||||
content: <EmptyState title={t('contacts.noActivity')} />,
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="p-6 max-w-7xl mx-auto" data-testid="contact-detail-page">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div className="flex items-center gap-4">
|
||||
<Button variant="ghost" onClick={() => navigate('/contacts')} aria-label={t('common.back')}>
|
||||
← {t('common.back')}
|
||||
</Button>
|
||||
<Avatar name={`${contact.first_name} ${contact.last_name}`} size="md" />
|
||||
<h1 className="text-2xl font-bold text-secondary-900">{contact.first_name} {contact.last_name}</h1>
|
||||
</div>
|
||||
<Button onClick={() => navigate(`/contacts/${contact.id}/edit`)}>
|
||||
{t('common.edit')}
|
||||
</Button>
|
||||
</div>
|
||||
<Tabs tabs={[overviewTab, companiesTab, filesTab, activityTab]} defaultKey="overview" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,211 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { useParams, useNavigate } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { z } from 'zod';
|
||||
import { useContact, useCreateContact, useUpdateContact, useCompanies, Company } from '@/api/hooks';
|
||||
import { Input } from '@/components/ui/Input';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { Card } from '@/components/ui/Card';
|
||||
import { Skeleton } from '@/components/ui/Skeleton';
|
||||
import { Badge } from '@/components/ui/Badge';
|
||||
import { useToast } from '@/components/ui/Toast';
|
||||
import { UnsavedChangesGuard } from '@/components/shared/UnsavedChangesGuard';
|
||||
|
||||
const contactSchema = z.object({
|
||||
first_name: z.string().min(1, 'Vorname ist erforderlich').max(100),
|
||||
last_name: z.string().min(1, 'Nachname ist erforderlich').max(100),
|
||||
email: z.string().email('Ungültige E-Mail-Adresse').max(255).optional().or(z.literal('')),
|
||||
phone: z.string().max(30).optional().or(z.literal('')),
|
||||
position: z.string().max(100).optional().or(z.literal('')),
|
||||
});
|
||||
|
||||
type ContactFormValues = z.infer<typeof contactSchema>;
|
||||
|
||||
export function ContactFormPage() {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const navigate = useNavigate();
|
||||
const { t } = useTranslation();
|
||||
const toast = useToast();
|
||||
const isEdit = !!id;
|
||||
|
||||
const { data: contact, isLoading } = useContact(isEdit ? id : undefined);
|
||||
const createMutation = useCreateContact();
|
||||
const updateMutation = useUpdateContact();
|
||||
const { data: companiesData } = useCompanies(1, 100);
|
||||
const [selectedCompanyIds, setSelectedCompanyIds] = useState<string[]>([]);
|
||||
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
reset,
|
||||
formState: { errors, isDirty, isSubmitting },
|
||||
} = useForm<ContactFormValues>({
|
||||
resolver: zodResolver(contactSchema),
|
||||
defaultValues: {
|
||||
first_name: '',
|
||||
last_name: '',
|
||||
email: '',
|
||||
phone: '',
|
||||
position: '',
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (isEdit && contact) {
|
||||
reset({
|
||||
first_name: contact.first_name || '',
|
||||
last_name: contact.last_name || '',
|
||||
email: contact.email || '',
|
||||
phone: contact.phone || '',
|
||||
position: contact.position || '',
|
||||
});
|
||||
setSelectedCompanyIds(contact.company_ids || (contact.companies?.map((c) => c.id) || []));
|
||||
}
|
||||
}, [contact, isEdit, reset]);
|
||||
|
||||
const toggleCompany = (companyId: string) => {
|
||||
setSelectedCompanyIds((prev) =>
|
||||
prev.includes(companyId)
|
||||
? prev.filter((cid) => cid !== companyId)
|
||||
: [...prev, companyId]
|
||||
);
|
||||
};
|
||||
|
||||
const onSubmit = async (values: ContactFormValues) => {
|
||||
const payload = { ...values, company_ids: selectedCompanyIds };
|
||||
try {
|
||||
if (isEdit && id) {
|
||||
await updateMutation.mutateAsync({ id, data: payload });
|
||||
toast.success(t('contacts.updated'));
|
||||
navigate(`/contacts/${id}`);
|
||||
} else {
|
||||
const result = await createMutation.mutateAsync(payload) as { id: string };
|
||||
toast.success(t('contacts.created'));
|
||||
navigate(`/contacts/${result.id}`);
|
||||
}
|
||||
} catch (err: any) {
|
||||
toast.error(err.message || (isEdit ? t('contacts.updateFailed') : t('contacts.createFailed')));
|
||||
}
|
||||
};
|
||||
|
||||
if (isEdit && isLoading) {
|
||||
return (
|
||||
<div className="p-6 max-w-4xl mx-auto" data-testid="contact-form-page">
|
||||
<Skeleton className="h-8 w-48 mb-6" />
|
||||
<Skeleton className="h-96" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const availableCompanies: Company[] = companiesData?.items ?? [];
|
||||
|
||||
return (
|
||||
<div className="p-6 max-w-4xl mx-auto" data-testid="contact-form-page">
|
||||
<UnsavedChangesGuard isDirty={isDirty || selectedCompanyIds.length > 0} />
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div className="flex items-center gap-4">
|
||||
<Button variant="ghost" onClick={() => navigate(-1)}>
|
||||
← {t('common.back')}
|
||||
</Button>
|
||||
<h1 className="text-2xl font-bold text-secondary-900">
|
||||
{isEdit ? t('contacts.edit') : t('contacts.create')}
|
||||
</h1>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<Card title={t('contacts.firstName')}>
|
||||
<Input
|
||||
{...register('first_name')}
|
||||
label={t('contacts.firstName')}
|
||||
required
|
||||
error={errors.first_name?.message}
|
||||
placeholder="Max"
|
||||
data-testid="contact-first-name-input"
|
||||
/>
|
||||
</Card>
|
||||
<Card title={t('contacts.lastName')}>
|
||||
<Input
|
||||
{...register('last_name')}
|
||||
label={t('contacts.lastName')}
|
||||
required
|
||||
error={errors.last_name?.message}
|
||||
placeholder="Mustermann"
|
||||
data-testid="contact-last-name-input"
|
||||
/>
|
||||
</Card>
|
||||
<Card title={t('contacts.email')}>
|
||||
<Input
|
||||
{...register('email')}
|
||||
label={t('contacts.email')}
|
||||
type="email"
|
||||
error={errors.email?.message}
|
||||
placeholder="max@mustermann.de"
|
||||
/>
|
||||
</Card>
|
||||
<Card title={t('contacts.phone')}>
|
||||
<Input
|
||||
{...register('phone')}
|
||||
label={t('contacts.phone')}
|
||||
error={errors.phone?.message}
|
||||
placeholder="+49 170 1234567"
|
||||
/>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<Card title={t('contacts.position')}>
|
||||
<Input
|
||||
{...register('position')}
|
||||
label={t('contacts.position')}
|
||||
error={errors.position?.message}
|
||||
placeholder="Geschäftsführer"
|
||||
/>
|
||||
</Card>
|
||||
|
||||
<Card title={t('contacts.assignCompanies')}>
|
||||
{availableCompanies.length === 0 ? (
|
||||
<p className="text-sm text-secondary-500">{t('companies.emptyTitle')}</p>
|
||||
) : (
|
||||
<div className="space-y-2" data-testid="company-assignment-list">
|
||||
{availableCompanies.map((company) => (
|
||||
<label
|
||||
key={company.id}
|
||||
className="flex items-center gap-3 p-2 rounded-md hover:bg-secondary-50 cursor-pointer min-h-touch"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedCompanyIds.includes(company.id)}
|
||||
onChange={() => toggleCompany(company.id)}
|
||||
className="w-4 h-4 rounded border-secondary-300 text-primary-600 focus:ring-primary-500"
|
||||
aria-label={company.name}
|
||||
/>
|
||||
<span className="text-sm text-secondary-900">{company.name}</span>
|
||||
{company.industry && (
|
||||
<Badge variant="primary">{company.industry}</Badge>
|
||||
)}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{selectedCompanyIds.length > 0 && (
|
||||
<p className="mt-2 text-xs text-secondary-500">
|
||||
{selectedCompanyIds.length} {selectedCompanyIds.length === 1 ? 'Firma zugewiesen' : 'Firmen zugewiesen'}
|
||||
</p>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
<div className="flex justify-end gap-3">
|
||||
<Button variant="secondary" onClick={() => navigate(-1)}>
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
<Button type="submit" isLoading={isSubmitting} data-testid="contact-submit-btn">
|
||||
{isEdit ? t('common.save') : t('common.create')}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
import React, { useState, useMemo } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { ColumnDef } from '@tanstack/react-table';
|
||||
import { useContacts, useDeleteContact, Contact } from '@/api/hooks';
|
||||
import { DataGrid } from '@/components/shared/DataGrid';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { Input } from '@/components/ui/Input';
|
||||
import { EmptyState } from '@/components/ui/EmptyState';
|
||||
import { ConfirmDialog } from '@/components/ui/ConfirmDialog';
|
||||
import { useToast } from '@/components/ui/Toast';
|
||||
import { Avatar } from '@/components/ui/Avatar';
|
||||
|
||||
export function ContactsListPage() {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const toast = useToast();
|
||||
|
||||
const [page, setPage] = useState(1);
|
||||
const [pageSize] = useState(25);
|
||||
const [search, setSearch] = useState('');
|
||||
const [debouncedSearch, setDebouncedSearch] = useState('');
|
||||
const [deleteTarget, setDeleteTarget] = useState<Contact | null>(null);
|
||||
|
||||
const { data, isLoading } = useContacts(page, pageSize, debouncedSearch);
|
||||
const deleteMutation = useDeleteContact();
|
||||
|
||||
React.useEffect(() => {
|
||||
const timer = setTimeout(() => setDebouncedSearch(search), 300);
|
||||
return () => clearTimeout(timer);
|
||||
}, [search]);
|
||||
|
||||
const columns = useMemo<ColumnDef<Contact, any>[]>(
|
||||
() => [
|
||||
{
|
||||
id: 'name',
|
||||
header: t('contacts.fullName'),
|
||||
cell: ({ row }) => (
|
||||
<div className="flex items-center gap-2">
|
||||
<Avatar name={`${row.original.first_name} ${row.original.last_name}`} size="sm" />
|
||||
<span className="font-medium text-primary-700">
|
||||
{row.original.first_name} {row.original.last_name}
|
||||
</span>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: 'email',
|
||||
header: t('contacts.email'),
|
||||
cell: (info) =>
|
||||
info.getValue() ? (
|
||||
<a href={`mailto:${info.getValue()}`} className="text-primary-600 hover:text-primary-700">
|
||||
{info.getValue()}
|
||||
</a>
|
||||
) : (
|
||||
'—'
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: 'phone',
|
||||
header: t('contacts.phone'),
|
||||
cell: (info) => info.getValue() || '—',
|
||||
},
|
||||
{
|
||||
accessorKey: 'position',
|
||||
header: t('contacts.position'),
|
||||
cell: (info) => info.getValue() || '—',
|
||||
},
|
||||
],
|
||||
[t]
|
||||
);
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!deleteTarget) return;
|
||||
try {
|
||||
await deleteMutation.mutateAsync(deleteTarget.id);
|
||||
toast.success(t('contacts.deleted'));
|
||||
setDeleteTarget(null);
|
||||
} catch (err: any) {
|
||||
toast.error(err.message || t('common.error'));
|
||||
}
|
||||
};
|
||||
|
||||
const contacts = data?.items ?? [];
|
||||
const total = data?.total ?? 0;
|
||||
const isEmpty = !isLoading && contacts.length === 0 && !debouncedSearch;
|
||||
|
||||
if (isEmpty) {
|
||||
return (
|
||||
<div className="p-6 max-w-7xl mx-auto" data-testid="contacts-list-page">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<h1 className="text-2xl font-bold text-secondary-900">{t('contacts.title')}</h1>
|
||||
</div>
|
||||
<EmptyState
|
||||
title={t('contacts.emptyTitle')}
|
||||
description={t('contacts.emptyDescription')}
|
||||
action={
|
||||
<Button onClick={() => navigate('/contacts/new')}>
|
||||
{t('contacts.create')}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-6 max-w-7xl mx-auto" data-testid="contacts-list-page">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<h1 className="text-2xl font-bold text-secondary-900">{t('contacts.title')}</h1>
|
||||
<Button onClick={() => navigate('/contacts/new')}>
|
||||
{t('contacts.create')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="mb-4">
|
||||
<Input
|
||||
type="search"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
placeholder={t('common.search')}
|
||||
aria-label={t('common.search')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<DataGrid
|
||||
columns={columns}
|
||||
data={contacts}
|
||||
total={total}
|
||||
page={page}
|
||||
pageSize={pageSize}
|
||||
onPageChange={setPage}
|
||||
onRowClick={(row) => navigate(`/contacts/${row.id}`)}
|
||||
loading={isLoading}
|
||||
emptyMessage={t('common.noResults')}
|
||||
testId="contacts-grid"
|
||||
/>
|
||||
|
||||
<ConfirmDialog
|
||||
open={!!deleteTarget}
|
||||
title={t('contacts.deleteConfirmTitle')}
|
||||
message={t('contacts.deleteConfirm')}
|
||||
variant="danger"
|
||||
onConfirm={handleDelete}
|
||||
onCancel={() => setDeleteTarget(null)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,55 +1,79 @@
|
||||
import React from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Card } from '@/components/ui/Card';
|
||||
import { Badge } from '@/components/ui/Badge';
|
||||
import { StatCard } from '@/components/shared/StatCard';
|
||||
import { ActivityFeed, ActivityItem } from '@/components/shared/ActivityFeed';
|
||||
import { useCompanies, useContacts, useAuditLog } from '@/api/hooks';
|
||||
|
||||
export function DashboardPage() {
|
||||
const { t } = useTranslation();
|
||||
const { data: companiesData } = useCompanies(1, 1);
|
||||
const { data: contactsData } = useContacts(1, 1);
|
||||
const { data: auditData, isError: auditError } = useAuditLog(1, 5);
|
||||
|
||||
const stats = [
|
||||
{ label: 'Firmen', value: '24', change: '+3', variant: 'success' as const },
|
||||
{ label: 'Kontakte', value: '156', change: '+12', variant: 'success' as const },
|
||||
{ label: 'Offene Aufgaben', value: '8', change: '-2', variant: 'warning' as const },
|
||||
{ label: 'E-Mails heute', value: '34', change: '+5', variant: 'info' as const },
|
||||
];
|
||||
const totalCompanies = companiesData?.total ?? 0;
|
||||
const totalContacts = contactsData?.total ?? 0;
|
||||
|
||||
const activities = [
|
||||
{ user: 'Anna Schmidt', action: 'hat Firma TechCorp GmbH erstellt', time: 'vor 5 Minuten' },
|
||||
{ user: 'Max Müller', action: 'hat Kontakt Lisa Weber aktualisiert', time: 'vor 12 Minuten' },
|
||||
{ user: 'Anna Schmidt', action: 'hat 3 Kontakte importiert', time: 'vor 1 Stunde' },
|
||||
{ user: 'System', action: 'Backup erfolgreich erstellt', time: 'vor 2 Stunden' },
|
||||
];
|
||||
const auditEntries = auditData?.items ?? [];
|
||||
const activeThisWeek = auditEntries.filter((e) => {
|
||||
if (!e.timestamp) return false;
|
||||
const d = new Date(e.timestamp);
|
||||
const weekAgo = new Date();
|
||||
weekAgo.setDate(weekAgo.getDate() - 7);
|
||||
return d >= weekAgo;
|
||||
}).length;
|
||||
|
||||
const newThisMonth = auditEntries.filter((e) => {
|
||||
if (!e.timestamp) return false;
|
||||
const d = new Date(e.timestamp);
|
||||
const monthAgo = new Date();
|
||||
monthAgo.setMonth(monthAgo.getMonth() - 1);
|
||||
return d >= monthAgo;
|
||||
}).length;
|
||||
|
||||
const activities: ActivityItem[] = auditError
|
||||
? []
|
||||
: auditEntries.map((entry) => ({
|
||||
id: `${entry.timestamp}-${entry.user}-${entry.action}`,
|
||||
user: entry.user || 'System',
|
||||
action: entry.action || '',
|
||||
time: entry.timestamp ? new Date(entry.timestamp).toLocaleString('de-DE') : '',
|
||||
avatarUrl: null,
|
||||
}));
|
||||
|
||||
return (
|
||||
<div className="p-6 max-w-7xl mx-auto" data-testid="dashboard-page">
|
||||
<h1 className="text-2xl font-bold text-secondary-900 mb-6">{t('nav.dashboard')}</h1>
|
||||
<h1 className="text-2xl font-bold text-secondary-900 mb-6">{t('dashboard.title')}</h1>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4 mb-8">
|
||||
{stats.map((stat) => (
|
||||
<Card key={stat.label}>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-secondary-500">{stat.label}</p>
|
||||
<p className="text-2xl font-bold text-secondary-900 mt-1">{stat.value}</p>
|
||||
</div>
|
||||
<Badge variant={stat.variant} dot>{stat.change}</Badge>
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
<StatCard
|
||||
label={t('dashboard.totalCompanies')}
|
||||
value={totalCompanies}
|
||||
testId="stat-companies"
|
||||
/>
|
||||
<StatCard
|
||||
label={t('dashboard.totalContacts')}
|
||||
value={totalContacts}
|
||||
testId="stat-contacts"
|
||||
/>
|
||||
<StatCard
|
||||
label={t('dashboard.activeThisWeek')}
|
||||
value={activeThisWeek}
|
||||
testId="stat-active-week"
|
||||
/>
|
||||
<StatCard
|
||||
label={t('dashboard.newThisMonth')}
|
||||
value={newThisMonth}
|
||||
testId="stat-new-month"
|
||||
/>
|
||||
</div>
|
||||
<Card title="Letzte Aktivitäten">
|
||||
<ul className="space-y-3" role="list">
|
||||
{activities.map((activity, idx) => (
|
||||
<li key={idx} className="flex items-start gap-3 text-sm">
|
||||
<span className="w-2 h-2 rounded-full bg-primary-500 mt-1.5 flex-shrink-0" aria-hidden="true" />
|
||||
<div>
|
||||
<span className="font-medium text-secondary-900">{activity.user}</span>
|
||||
<span className="text-secondary-600"> {activity.action}</span>
|
||||
<span className="text-secondary-400 block mt-0.5">{activity.time}</span>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</Card>
|
||||
|
||||
{auditError ? (
|
||||
<p className="text-sm text-secondary-500" data-testid="activity-unavailable">
|
||||
{t('dashboard.activityUnavailable')}
|
||||
</p>
|
||||
) : (
|
||||
<ActivityFeed activities={activities} title={t('dashboard.recentActivity')} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,165 @@
|
||||
import React, { useState, useMemo } from 'react';
|
||||
import { useSearchParams, useNavigate } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useGlobalSearch, SearchResult } from '@/api/hooks';
|
||||
import { Card } from '@/components/ui/Card';
|
||||
import { Input } from '@/components/ui/Input';
|
||||
import { Select } from '@/components/ui/Select';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { EmptyState } from '@/components/ui/EmptyState';
|
||||
import { Badge } from '@/components/ui/Badge';
|
||||
import { Skeleton } from '@/components/ui/Skeleton';
|
||||
|
||||
function highlightMatch(text: string, query: string): React.ReactNode {
|
||||
if (!query.trim()) return text;
|
||||
const lowerText = text.toLowerCase();
|
||||
const lowerQuery = query.toLowerCase();
|
||||
const idx = lowerText.indexOf(lowerQuery);
|
||||
if (idx === -1) return text;
|
||||
return (
|
||||
<>
|
||||
{text.slice(0, idx)}
|
||||
<mark className="bg-warning-200 text-secondary-900 rounded px-0.5">{text.slice(idx, idx + query.length)}</mark>
|
||||
{text.slice(idx + query.length)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function GlobalSearchResultsPage() {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
|
||||
const query = searchParams.get('q') || '';
|
||||
const [searchInput, setSearchInput] = useState(query);
|
||||
const [entityType, setEntityType] = useState(searchParams.get('type') || 'all');
|
||||
const [dateFrom, setDateFrom] = useState(searchParams.get('dateFrom') || '');
|
||||
const [dateTo, setDateTo] = useState(searchParams.get('dateTo') || '');
|
||||
|
||||
const entityTypes = useMemo(() => {
|
||||
if (entityType === 'all') return undefined;
|
||||
return [entityType];
|
||||
}, [entityType]);
|
||||
|
||||
const { data: results, isLoading } = useGlobalSearch(query, entityTypes);
|
||||
|
||||
const handleSearch = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
const params: Record<string, string> = {};
|
||||
if (searchInput) params.q = searchInput;
|
||||
if (entityType !== 'all') params.type = entityType;
|
||||
if (dateFrom) params.dateFrom = dateFrom;
|
||||
if (dateTo) params.dateTo = dateTo;
|
||||
setSearchParams(params);
|
||||
};
|
||||
|
||||
const filteredResults = useMemo(() => {
|
||||
if (!results) return [];
|
||||
let filtered = results;
|
||||
if (dateFrom) {
|
||||
filtered = filtered.filter((r) => {
|
||||
return true;
|
||||
});
|
||||
}
|
||||
return filtered;
|
||||
}, [results, dateFrom, dateTo]);
|
||||
|
||||
return (
|
||||
<div className="p-6 max-w-7xl mx-auto" data-testid="global-search-page">
|
||||
<h1 className="text-2xl font-bold text-secondary-900 mb-6">{t('search.title')}</h1>
|
||||
|
||||
<Card title={t('search.filters')} className="mb-6">
|
||||
<form onSubmit={handleSearch} className="space-y-4">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-3">
|
||||
<Input
|
||||
label={t('common.search')}
|
||||
value={searchInput}
|
||||
onChange={(e) => setSearchInput(e.target.value)}
|
||||
placeholder={t('common.search')}
|
||||
data-testid="search-input"
|
||||
/>
|
||||
<Select
|
||||
label={t('search.entityType')}
|
||||
options={[
|
||||
{ value: 'all', label: t('search.allTypes') },
|
||||
{ value: 'company', label: t('search.companies') },
|
||||
{ value: 'contact', label: t('search.contacts') },
|
||||
]}
|
||||
value={entityType}
|
||||
onChange={(e) => setEntityType(e.target.value)}
|
||||
/>
|
||||
<Input
|
||||
label={t('search.dateFrom')}
|
||||
type="date"
|
||||
value={dateFrom}
|
||||
onChange={(e) => setDateFrom(e.target.value)}
|
||||
/>
|
||||
<Input
|
||||
label={t('search.dateTo')}
|
||||
type="date"
|
||||
value={dateTo}
|
||||
onChange={(e) => setDateTo(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex justify-end">
|
||||
<Button type="submit" data-testid="search-submit-btn">{t('common.search')}</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Card>
|
||||
|
||||
{query && (
|
||||
<p className="text-sm text-secondary-600 mb-4" data-testid="search-query-display">
|
||||
{t('search.resultsFor', { query })}:
|
||||
</p>
|
||||
)}
|
||||
|
||||
{!query ? (
|
||||
<EmptyState title={t('search.enterQuery')} />
|
||||
) : isLoading ? (
|
||||
<div className="space-y-3">
|
||||
{[1, 2, 3].map((i) => (
|
||||
<Skeleton key={i} className="h-16" />
|
||||
))}
|
||||
</div>
|
||||
) : filteredResults.length === 0 ? (
|
||||
<EmptyState
|
||||
title={t('search.noResults', { query })}
|
||||
/>
|
||||
) : (
|
||||
<div className="space-y-3" data-testid="search-results-list">
|
||||
{filteredResults.map((result) => (
|
||||
<Card key={`${result.type}-${result.id}`}>
|
||||
<button
|
||||
onClick={() => navigate(result.url)}
|
||||
className="w-full flex items-center gap-4 text-left hover:bg-secondary-50 p-2 rounded-md min-h-touch"
|
||||
data-testid={`search-result-${result.type}-${result.id}`}
|
||||
>
|
||||
<div className={`w-10 h-10 rounded-lg flex items-center justify-center font-semibold flex-shrink-0 ${
|
||||
result.type === 'company' ? 'bg-primary-100 text-primary-700' : 'bg-accent-100 text-accent-700'
|
||||
}`} aria-hidden="true">
|
||||
{result.type === 'company' ? 'F' : 'K'}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<p className="font-medium text-secondary-900">
|
||||
{highlightMatch(result.name, query)}
|
||||
</p>
|
||||
<Badge variant={result.type === 'company' ? 'primary' : 'info'}>
|
||||
{result.type === 'company' ? t('search.companies') : t('search.contacts')}
|
||||
</Badge>
|
||||
</div>
|
||||
{result.description && (
|
||||
<p className="text-sm text-secondary-500 truncate">
|
||||
{highlightMatch(result.description, query)}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<span className="text-secondary-400 text-sm" aria-hidden="true">→</span>
|
||||
</button>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,55 +1,44 @@
|
||||
import React from 'react';
|
||||
import { NavLink, Outlet } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Card } from '@/components/ui/Card';
|
||||
import { Select } from '@/components/ui/Select';
|
||||
import { useUIStore } from '@/store/uiStore';
|
||||
|
||||
export function SettingsPage() {
|
||||
const { t, i18n } = useTranslation();
|
||||
const { theme, setTheme, locale, setLocale } = useUIStore();
|
||||
const { t } = useTranslation();
|
||||
|
||||
const handleLocaleChange = (newLocale: string) => {
|
||||
setLocale(newLocale as 'de' | 'en');
|
||||
i18n.changeLanguage(newLocale);
|
||||
};
|
||||
|
||||
const handleThemeChange = (newTheme: string) => {
|
||||
setTheme(newTheme as 'light' | 'dark' | 'system');
|
||||
if (newTheme === 'dark') {
|
||||
document.documentElement.classList.add('dark');
|
||||
} else {
|
||||
document.documentElement.classList.remove('dark');
|
||||
}
|
||||
};
|
||||
const navItems = [
|
||||
{ to: '/settings/profile', label: t('settings.profile'), icon: '👤' },
|
||||
{ to: '/settings/roles', label: t('settings.roles'), icon: '🔑' },
|
||||
{ to: '/settings/users', label: t('settings.users'), icon: '👥' },
|
||||
{ to: '/settings/system', label: t('settings.system'), icon: '⚙️' },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="p-6 max-w-4xl mx-auto" data-testid="settings-page">
|
||||
<h1 className="text-2xl font-bold text-secondary-900 mb-6">{t('settings.title')}</h1>
|
||||
<div className="space-y-6">
|
||||
<Card title={t('settings.language')} description="Wählen Sie Ihre bevorzugte Sprache">
|
||||
<Select
|
||||
options={[
|
||||
{ value: 'de', label: 'Deutsch' },
|
||||
{ value: 'en', label: 'English' },
|
||||
]}
|
||||
value={locale}
|
||||
onChange={(e) => handleLocaleChange(e.target.value)}
|
||||
aria-label={t('settings.language')}
|
||||
/>
|
||||
</Card>
|
||||
<Card title={t('settings.theme')} description="Wählen Sie Ihr bevorzugtes Design">
|
||||
<Select
|
||||
options={[
|
||||
{ value: 'light', label: t('settings.light') },
|
||||
{ value: 'dark', label: t('settings.dark') },
|
||||
{ value: 'system', label: t('settings.system') },
|
||||
]}
|
||||
value={theme}
|
||||
onChange={(e) => handleThemeChange(e.target.value)}
|
||||
aria-label={t('settings.theme')}
|
||||
/>
|
||||
</Card>
|
||||
</div>
|
||||
<div className="flex max-w-7xl mx-auto" data-testid="settings-page">
|
||||
<aside className="w-64 flex-shrink-0 border-r border-secondary-200 p-4 min-h-[calc(100vh-4rem)]">
|
||||
<h1 className="text-xl font-bold text-secondary-900 mb-6">{t('settings.title')}</h1>
|
||||
<nav className="space-y-1" role="navigation" aria-label={t('settings.title')}>
|
||||
{navItems.map((item) => (
|
||||
<NavLink
|
||||
key={item.to}
|
||||
to={item.to}
|
||||
className={({ isActive }) =>
|
||||
`flex items-center gap-3 px-3 py-2 rounded-lg text-sm font-medium transition-colors ${
|
||||
isActive
|
||||
? 'bg-primary-100 text-primary-700'
|
||||
: 'text-secondary-600 hover:bg-secondary-100 hover:text-secondary-900'
|
||||
}`
|
||||
}
|
||||
data-testid={`settings-nav-${item.to.split('/').pop()}`}
|
||||
>
|
||||
<span aria-hidden="true">{item.icon}</span>
|
||||
{item.label}
|
||||
</NavLink>
|
||||
))}
|
||||
</nav>
|
||||
</aside>
|
||||
<main className="flex-1 p-6" data-testid="settings-content">
|
||||
<Outlet />
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,181 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useAuthStore } from '@/store/authStore';
|
||||
import { useUpdateUser } from '@/api/hooks';
|
||||
import { Input } from '@/components/ui/Input';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { Card } from '@/components/ui/Card';
|
||||
import { Avatar } from '@/components/ui/Avatar';
|
||||
import { useToast } from '@/components/ui/Toast';
|
||||
|
||||
export function SettingsProfilePage() {
|
||||
const { t } = useTranslation();
|
||||
const toast = useToast();
|
||||
const { user } = useAuthStore();
|
||||
const updateUserMutation = useUpdateUser();
|
||||
|
||||
const [firstName, setFirstName] = useState(user?.first_name || '');
|
||||
const [lastName, setLastName] = useState(user?.last_name || '');
|
||||
const [email, setEmail] = useState(user?.email || '');
|
||||
const [avatarUrl, setAvatarUrl] = useState(user?.avatar_url || '');
|
||||
const [currentPassword, setCurrentPassword] = useState('');
|
||||
const [newPassword, setNewPassword] = useState('');
|
||||
const [confirmPassword, setConfirmPassword] = useState('');
|
||||
const [profileDirty, setProfileDirty] = useState(false);
|
||||
const [passwordDirty, setPasswordDirty] = useState(false);
|
||||
|
||||
const handleProfileSave = async () => {
|
||||
if (!user) return;
|
||||
try {
|
||||
await updateUserMutation.mutateAsync({
|
||||
id: user.id,
|
||||
data: { first_name: firstName, last_name: lastName, email, avatar_url: avatarUrl || null },
|
||||
});
|
||||
toast.success(t('settings.profileSaved'));
|
||||
setProfileDirty(false);
|
||||
} catch (err: any) {
|
||||
toast.error(err.message || t('common.error'));
|
||||
}
|
||||
};
|
||||
|
||||
const handlePasswordChange = async () => {
|
||||
if (!user) return;
|
||||
if (newPassword !== confirmPassword) {
|
||||
toast.error(t('validation.passwordMismatch'));
|
||||
return;
|
||||
}
|
||||
if (newPassword.length < 8) {
|
||||
toast.error(t('auth.passwordTooShort'));
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await updateUserMutation.mutateAsync({
|
||||
id: user.id,
|
||||
data: { current_password: currentPassword, new_password: newPassword },
|
||||
});
|
||||
toast.success(t('settings.passwordChanged'));
|
||||
setCurrentPassword('');
|
||||
setNewPassword('');
|
||||
setConfirmPassword('');
|
||||
setPasswordDirty(false);
|
||||
} catch (err: any) {
|
||||
toast.error(err.message || t('common.error'));
|
||||
}
|
||||
};
|
||||
|
||||
const handleAvatarUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
const reader = new FileReader();
|
||||
reader.onload = (event) => {
|
||||
setAvatarUrl(event.target?.result as string);
|
||||
setProfileDirty(true);
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="p-6 max-w-4xl mx-auto" data-testid="settings-profile-page">
|
||||
<h1 className="text-2xl font-bold text-secondary-900 mb-6">{t('settings.profile')}</h1>
|
||||
|
||||
<div className="space-y-6">
|
||||
<Card title={t('settings.avatar')}>
|
||||
<div className="flex items-center gap-4">
|
||||
<Avatar name={`${firstName} ${lastName}`} src={avatarUrl || undefined} size="lg" />
|
||||
<div>
|
||||
<input
|
||||
type="file"
|
||||
accept="image/*"
|
||||
onChange={handleAvatarUpload}
|
||||
className="text-sm text-secondary-700 file:mr-4 file:py-2 file:px-4 file:rounded-md file:border-0 file:text-sm file:font-medium file:bg-primary-50 file:text-primary-700 hover:file:bg-primary-100 min-h-touch"
|
||||
aria-label={t('settings.avatar')}
|
||||
data-testid="avatar-upload"
|
||||
/>
|
||||
{avatarUrl && (
|
||||
<button
|
||||
onClick={() => { setAvatarUrl(''); setProfileDirty(true); }}
|
||||
className="mt-2 text-sm text-danger-600 hover:text-danger-700"
|
||||
>
|
||||
{t('common.delete')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card title={t('settings.name')}>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<Input
|
||||
label={t('settings.firstName')}
|
||||
value={firstName}
|
||||
onChange={(e) => { setFirstName(e.target.value); setProfileDirty(true); }}
|
||||
data-testid="profile-first-name"
|
||||
/>
|
||||
<Input
|
||||
label={t('settings.lastName')}
|
||||
value={lastName}
|
||||
onChange={(e) => { setLastName(e.target.value); setProfileDirty(true); }}
|
||||
data-testid="profile-last-name"
|
||||
/>
|
||||
</div>
|
||||
<div className="mt-4">
|
||||
<Input
|
||||
label={t('settings.email')}
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => { setEmail(e.target.value); setProfileDirty(true); }}
|
||||
data-testid="profile-email"
|
||||
/>
|
||||
</div>
|
||||
<div className="mt-4 flex justify-end">
|
||||
<Button
|
||||
onClick={handleProfileSave}
|
||||
disabled={!profileDirty}
|
||||
isLoading={updateUserMutation.isPending}
|
||||
data-testid="profile-save-btn"
|
||||
>
|
||||
{t('common.save')}
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card title={t('settings.changePassword')}>
|
||||
<div className="space-y-4">
|
||||
<Input
|
||||
label={t('settings.currentPassword')}
|
||||
type="password"
|
||||
value={currentPassword}
|
||||
onChange={(e) => { setCurrentPassword(e.target.value); setPasswordDirty(true); }}
|
||||
data-testid="current-password"
|
||||
/>
|
||||
<Input
|
||||
label={t('settings.newPassword')}
|
||||
type="password"
|
||||
value={newPassword}
|
||||
onChange={(e) => { setNewPassword(e.target.value); setPasswordDirty(true); }}
|
||||
data-testid="new-password"
|
||||
/>
|
||||
<Input
|
||||
label={t('settings.confirmPassword')}
|
||||
type="password"
|
||||
value={confirmPassword}
|
||||
onChange={(e) => { setConfirmPassword(e.target.value); setPasswordDirty(true); }}
|
||||
error={confirmPassword && newPassword !== confirmPassword ? t('validation.passwordMismatch') : undefined}
|
||||
data-testid="confirm-password"
|
||||
/>
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
onClick={handlePasswordChange}
|
||||
disabled={!passwordDirty || !newPassword || !confirmPassword}
|
||||
isLoading={updateUserMutation.isPending}
|
||||
data-testid="password-change-btn"
|
||||
>
|
||||
{t('settings.changePassword')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,190 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Input } from '@/components/ui/Input';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { Card } from '@/components/ui/Card';
|
||||
import { Badge } from '@/components/ui/Badge';
|
||||
import { EmptyState } from '@/components/ui/EmptyState';
|
||||
import { useToast } from '@/components/ui/Toast';
|
||||
import { Modal } from '@/components/ui/Modal';
|
||||
|
||||
interface Role {
|
||||
id: string;
|
||||
name: string;
|
||||
permissions: string[];
|
||||
}
|
||||
|
||||
const ALL_PERMISSIONS = [
|
||||
{ key: 'companies:read', label: 'Firmen lesen' },
|
||||
{ key: 'companies:write', label: 'Firmen bearbeiten' },
|
||||
{ key: 'contacts:read', label: 'Kontakte lesen' },
|
||||
{ key: 'contacts:write', label: 'Kontakte bearbeiten' },
|
||||
{ key: 'users:read', label: 'Benutzer lesen' },
|
||||
{ key: 'users:write', label: 'Benutzer bearbeiten' },
|
||||
{ key: 'audit:read', label: 'Audit-Log lesen' },
|
||||
{ key: 'settings:write', label: 'Einstellungen bearbeiten' },
|
||||
];
|
||||
|
||||
export function SettingsRolesPage() {
|
||||
const { t } = useTranslation();
|
||||
const toast = useToast();
|
||||
|
||||
const [roles, setRoles] = useState<Role[]>([
|
||||
{ id: '1', name: 'Administrator', permissions: ALL_PERMISSIONS.map((p) => p.key) },
|
||||
{ id: '2', name: 'Mitarbeiter', permissions: ['companies:read', 'companies:write', 'contacts:read', 'contacts:write'] },
|
||||
{ id: '3', name: 'Gast', permissions: ['companies:read', 'contacts:read'] },
|
||||
]);
|
||||
const [createOpen, setCreateOpen] = useState(false);
|
||||
const [newRoleName, setNewRoleName] = useState('');
|
||||
const [newRolePermissions, setNewRolePermissions] = useState<string[]>([]);
|
||||
const [editingRole, setEditingRole] = useState<Role | null>(null);
|
||||
|
||||
const handleCreateRole = () => {
|
||||
if (!newRoleName.trim()) {
|
||||
toast.error(t('validation.required'));
|
||||
return;
|
||||
}
|
||||
const role: Role = {
|
||||
id: Date.now().toString(),
|
||||
name: newRoleName.trim(),
|
||||
permissions: newRolePermissions,
|
||||
};
|
||||
setRoles((prev) => [...prev, role]);
|
||||
toast.success(t('settings.roleCreated'));
|
||||
setNewRoleName('');
|
||||
setNewRolePermissions([]);
|
||||
setCreateOpen(false);
|
||||
};
|
||||
|
||||
const handleTogglePermission = (permKey: string, target: 'new' | 'edit') => {
|
||||
if (target === 'new') {
|
||||
setNewRolePermissions((prev) =>
|
||||
prev.includes(permKey) ? prev.filter((p) => p !== permKey) : [...prev, permKey]
|
||||
);
|
||||
} else if (editingRole) {
|
||||
setEditingRole({
|
||||
...editingRole,
|
||||
permissions: editingRole.permissions.includes(permKey)
|
||||
? editingRole.permissions.filter((p) => p !== permKey)
|
||||
: [...editingRole.permissions, permKey],
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleSaveRole = () => {
|
||||
if (!editingRole) return;
|
||||
setRoles((prev) => prev.map((r) => (r.id === editingRole.id ? editingRole : r)));
|
||||
toast.success(t('settings.roleUpdated'));
|
||||
setEditingRole(null);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="p-6 max-w-4xl mx-auto" data-testid="settings-roles-page">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<h1 className="text-2xl font-bold text-secondary-900">{t('settings.roles')}</h1>
|
||||
<Button onClick={() => setCreateOpen(true)} data-testid="create-role-btn">
|
||||
{t('settings.createRole')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{roles.length === 0 ? (
|
||||
<EmptyState
|
||||
title={t('settings.noRoles')}
|
||||
action={<Button onClick={() => setCreateOpen(true)}>{t('settings.createRole')}</Button>}
|
||||
/>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{roles.map((role) => (
|
||||
<Card key={role.id}>
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h3 className="text-lg font-semibold text-secondary-900">{role.name}</h3>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => setEditingRole(role)}
|
||||
data-testid={`edit-role-${role.id}`}
|
||||
>
|
||||
{t('common.edit')}
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{role.permissions.length === 0 ? (
|
||||
<span className="text-sm text-secondary-500">—</span>
|
||||
) : (
|
||||
role.permissions.map((perm) => {
|
||||
const label = ALL_PERMISSIONS.find((p) => p.key === perm)?.label || perm;
|
||||
return <Badge key={perm} variant="primary">{label}</Badge>;
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Modal open={createOpen} onClose={() => setCreateOpen(false)} title={t('settings.createRole')}>
|
||||
<div className="space-y-4" data-testid="create-role-form">
|
||||
<Input
|
||||
label={t('settings.roleName')}
|
||||
value={newRoleName}
|
||||
onChange={(e) => setNewRoleName(e.target.value)}
|
||||
placeholder="z.B. Vertrieb"
|
||||
data-testid="new-role-name"
|
||||
/>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-secondary-700 mb-2">{t('settings.permissions')}</label>
|
||||
<div className="space-y-2">
|
||||
{ALL_PERMISSIONS.map((perm) => (
|
||||
<label key={perm.key} className="flex items-center gap-2 p-2 rounded-md hover:bg-secondary-50 cursor-pointer min-h-touch">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={newRolePermissions.includes(perm.key)}
|
||||
onChange={() => handleTogglePermission(perm.key, 'new')}
|
||||
className="w-4 h-4 rounded border-secondary-300 text-primary-600 focus:ring-primary-500"
|
||||
/>
|
||||
<span className="text-sm text-secondary-900">{perm.label}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex justify-end gap-3">
|
||||
<Button variant="secondary" onClick={() => setCreateOpen(false)}>{t('common.cancel')}</Button>
|
||||
<Button onClick={handleCreateRole} data-testid="save-role-btn">{t('common.save')}</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
<Modal open={!!editingRole} onClose={() => setEditingRole(null)} title={t('settings.roles') + ' — ' + (editingRole?.name || '')}>
|
||||
{editingRole && (
|
||||
<div className="space-y-4" data-testid="edit-role-form">
|
||||
<Input
|
||||
label={t('settings.roleName')}
|
||||
value={editingRole.name}
|
||||
onChange={(e) => setEditingRole({ ...editingRole, name: e.target.value })}
|
||||
/>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-secondary-700 mb-2">{t('settings.permissions')}</label>
|
||||
<div className="space-y-2">
|
||||
{ALL_PERMISSIONS.map((perm) => (
|
||||
<label key={perm.key} className="flex items-center gap-2 p-2 rounded-md hover:bg-secondary-50 cursor-pointer min-h-touch">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={editingRole.permissions.includes(perm.key)}
|
||||
onChange={() => handleTogglePermission(perm.key, 'edit')}
|
||||
className="w-4 h-4 rounded border-secondary-300 text-primary-600 focus:ring-primary-500"
|
||||
/>
|
||||
<span className="text-sm text-secondary-900">{perm.label}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex justify-end gap-3">
|
||||
<Button variant="secondary" onClick={() => setEditingRole(null)}>{t('common.cancel')}</Button>
|
||||
<Button onClick={handleSaveRole} data-testid="update-role-btn">{t('common.save')}</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,198 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useUsers, useCreateUser, useUpdateUser, useDeleteUser } from '@/api/hooks';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { Card } from '@/components/ui/Card';
|
||||
import { Badge } from '@/components/ui/Badge';
|
||||
import { Input } from '@/components/ui/Input';
|
||||
import { Select } from '@/components/ui/Select';
|
||||
import { EmptyState } from '@/components/ui/EmptyState';
|
||||
import { Skeleton } from '@/components/ui/Skeleton';
|
||||
import { Avatar } from '@/components/ui/Avatar';
|
||||
import { Modal } from '@/components/ui/Modal';
|
||||
import { ConfirmDialog } from '@/components/ui/ConfirmDialog';
|
||||
import { useToast } from '@/components/ui/Toast';
|
||||
|
||||
const ROLES = [
|
||||
{ value: 'admin', label: 'Administrator' },
|
||||
{ value: 'manager', label: 'Manager' },
|
||||
{ value: 'user', label: 'Mitarbeiter' },
|
||||
{ value: 'guest', label: 'Gast' },
|
||||
];
|
||||
|
||||
export function SettingsUsersPage() {
|
||||
const { t } = useTranslation();
|
||||
const toast = useToast();
|
||||
const { data, isLoading } = useUsers(1, 100);
|
||||
const createUserMutation = useCreateUser();
|
||||
const updateUserMutation = useUpdateUser();
|
||||
const deleteUserMutation = useDeleteUser();
|
||||
|
||||
const [inviteOpen, setInviteOpen] = useState(false);
|
||||
const [inviteEmail, setInviteEmail] = useState('');
|
||||
const [inviteRole, setInviteRole] = useState('user');
|
||||
const [confirmDeactivate, setConfirmDeactivate] = useState<any>(null);
|
||||
|
||||
const users = data?.items ?? [];
|
||||
|
||||
const handleInvite = async () => {
|
||||
if (!inviteEmail.trim()) {
|
||||
toast.error(t('validation.required'));
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await createUserMutation.mutateAsync({
|
||||
email: inviteEmail.trim(),
|
||||
role: inviteRole,
|
||||
first_name: '',
|
||||
last_name: '',
|
||||
});
|
||||
toast.success(t('settings.userInvited'));
|
||||
setInviteEmail('');
|
||||
setInviteRole('user');
|
||||
setInviteOpen(false);
|
||||
} catch (err: any) {
|
||||
toast.error(err.message || t('common.error'));
|
||||
}
|
||||
};
|
||||
|
||||
const handleRoleChange = async (userId: string, newRole: string) => {
|
||||
try {
|
||||
await updateUserMutation.mutateAsync({ id: userId, data: { role: newRole } });
|
||||
toast.success(t('settings.assignRole') + ' — OK');
|
||||
} catch (err: any) {
|
||||
toast.error(err.message || t('common.error'));
|
||||
}
|
||||
};
|
||||
|
||||
const handleToggleActive = async () => {
|
||||
if (!confirmDeactivate) return;
|
||||
try {
|
||||
if (confirmDeactivate.is_active === false) {
|
||||
await updateUserMutation.mutateAsync({ id: confirmDeactivate.id, data: { is_active: true } });
|
||||
toast.success(t('settings.userActivated'));
|
||||
} else {
|
||||
await updateUserMutation.mutateAsync({ id: confirmDeactivate.id, data: { is_active: false } });
|
||||
toast.success(t('settings.userDeactivated'));
|
||||
}
|
||||
setConfirmDeactivate(null);
|
||||
} catch (err: any) {
|
||||
toast.error(err.message || t('common.error'));
|
||||
}
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="p-6 max-w-4xl mx-auto" data-testid="settings-users-page">
|
||||
<Skeleton className="h-8 w-48 mb-6" />
|
||||
<div className="space-y-3">
|
||||
<Skeleton className="h-16" />
|
||||
<Skeleton className="h-16" />
|
||||
<Skeleton className="h-16" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-6 max-w-4xl mx-auto" data-testid="settings-users-page">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<h1 className="text-2xl font-bold text-secondary-900">{t('settings.users')}</h1>
|
||||
<Button onClick={() => setInviteOpen(true)} data-testid="invite-user-btn">
|
||||
{t('settings.inviteUser')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{users.length === 0 ? (
|
||||
<EmptyState
|
||||
title={t('settings.noUsers')}
|
||||
action={<Button onClick={() => setInviteOpen(true)}>{t('settings.inviteUser')}</Button>}
|
||||
/>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{users.map((user: any) => (
|
||||
<Card key={user.id}>
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div className="flex items-center gap-3 flex-1 min-w-0">
|
||||
<Avatar
|
||||
name={`${user.first_name || ''} ${user.last_name || ''}`.trim() || user.email}
|
||||
src={user.avatar_url}
|
||||
size="sm"
|
||||
/>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="font-medium text-secondary-900 truncate">
|
||||
{user.first_name} {user.last_name}
|
||||
</p>
|
||||
<p className="text-sm text-secondary-500 truncate">{user.email}</p>
|
||||
</div>
|
||||
<Badge variant={user.is_active === false ? 'danger' : 'success'}>
|
||||
{user.is_active === false ? t('common.inactive') : t('common.active')}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Select
|
||||
options={ROLES}
|
||||
value={user.role || 'user'}
|
||||
onChange={(e) => handleRoleChange(user.id, e.target.value)}
|
||||
aria-label={t('settings.assignRole')}
|
||||
className="w-36"
|
||||
/>
|
||||
<Button
|
||||
variant={user.is_active === false ? 'primary' : 'danger'}
|
||||
size="sm"
|
||||
onClick={() => setConfirmDeactivate(user)}
|
||||
data-testid={`toggle-active-${user.id}`}
|
||||
>
|
||||
{user.is_active === false ? t('settings.activate') : t('settings.deactivate')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Modal open={inviteOpen} onClose={() => setInviteOpen(false)} title={t('settings.inviteUser')}>
|
||||
<div className="space-y-4" data-testid="invite-user-form">
|
||||
<Input
|
||||
label={t('settings.inviteEmail')}
|
||||
type="email"
|
||||
value={inviteEmail}
|
||||
onChange={(e) => setInviteEmail(e.target.value)}
|
||||
placeholder="neu.mitarbeiter@firma.de"
|
||||
data-testid="invite-email"
|
||||
/>
|
||||
<Select
|
||||
label={t('settings.inviteRole')}
|
||||
options={ROLES}
|
||||
value={inviteRole}
|
||||
onChange={(e) => setInviteRole(e.target.value)}
|
||||
/>
|
||||
<div className="flex justify-end gap-3">
|
||||
<Button variant="secondary" onClick={() => setInviteOpen(false)}>{t('common.cancel')}</Button>
|
||||
<Button
|
||||
onClick={handleInvite}
|
||||
isLoading={createUserMutation.isPending}
|
||||
data-testid="send-invite-btn"
|
||||
>
|
||||
{t('settings.inviteUser')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
<ConfirmDialog
|
||||
open={!!confirmDeactivate}
|
||||
title={confirmDeactivate?.is_active === false ? t('settings.activate') : t('settings.deactivate')}
|
||||
message={
|
||||
confirmDeactivate?.is_active === false
|
||||
? `${t('settings.activate')}: ${confirmDeactivate?.email}?`
|
||||
: `${t('settings.deactivate')}: ${confirmDeactivate?.email}?`
|
||||
}
|
||||
variant={confirmDeactivate?.is_active === false ? 'primary' : 'danger'}
|
||||
onConfirm={handleToggleActive}
|
||||
onCancel={() => setConfirmDeactivate(null)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user