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:
leocrm-bot
2026-06-29 11:01:39 +02:00
parent 22976abe92
commit 700b7a71ad
47 changed files with 4089 additions and 157 deletions
@@ -0,0 +1,41 @@
import React from 'react';
import { Card } from '@/components/ui/Card';
import { Avatar } from '@/components/ui/Avatar';
export interface ActivityItem {
id: string;
user: string;
action: string;
time: string;
avatarUrl?: string | null;
}
export interface ActivityFeedProps {
activities: ActivityItem[];
title?: string;
maxItems?: number;
}
export function ActivityFeed({ activities, title = 'Letzte Aktivitäten', maxItems = 10 }: ActivityFeedProps) {
const visible = activities.slice(0, maxItems);
return (
<Card title={title} data-testid="activity-feed">
{visible.length === 0 ? (
<p className="text-sm text-secondary-500 py-4 text-center">Keine Aktivitäten vorhanden.</p>
) : (
<ul className="space-y-3" role="list">
{visible.map((activity) => (
<li key={activity.id} className="flex items-start gap-3 text-sm">
<Avatar name={activity.user} src={activity.avatarUrl} size="sm" />
<div className="flex-1 min-w-0">
<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>
);
}
@@ -0,0 +1,147 @@
import React, { useState, useRef, useCallback } from 'react';
import { Modal } from '@/components/ui/Modal';
import { Button } from '@/components/ui/Button';
import { useToast } from '@/components/ui/Toast';
import { useCompanyImport } from '@/api/hooks';
import { useTranslation } from 'react-i18next';
export interface CsvImportDialogProps {
open: boolean;
onClose: () => void;
onSuccess?: () => void;
}
interface ParsedRow {
[key: string]: string;
}
function parseCSV(text: string): { headers: string[]; rows: ParsedRow[] } {
const lines = text.trim().split(/\n/);
if (lines.length === 0) return { headers: [], rows: [] };
const headers = lines[0].split(',').map((h) => h.trim());
const rows: ParsedRow[] = [];
for (let i = 1; i < lines.length; i++) {
if (!lines[i].trim()) continue;
const values = lines[i].split(',').map((v) => v.trim());
const row: ParsedRow = {};
headers.forEach((header, idx) => {
row[header] = values[idx] || '';
});
rows.push(row);
}
return { headers, rows };
}
export function CsvImportDialog({ open, onClose, onSuccess }: CsvImportDialogProps) {
const { t } = useTranslation();
const toast = useToast();
const importMutation = useCompanyImport();
const fileInputRef = useRef<HTMLInputElement>(null);
const [selectedFile, setSelectedFile] = useState<File | null>(null);
const [previewData, setPreviewData] = useState<{ headers: string[]; rows: ParsedRow[] } | null>(null);
const [error, setError] = useState<string | null>(null);
const handleFileSelect = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
if (!file.name.endsWith('.csv')) {
setError('Bitte wählen Sie eine CSV-Datei aus.');
return;
}
setError(null);
setSelectedFile(file);
const reader = new FileReader();
reader.onload = (event) => {
const text = event.target?.result as string;
const parsed = parseCSV(text);
setPreviewData(parsed);
};
reader.readAsText(file);
}, []);
const handleImport = async () => {
if (!selectedFile) return;
try {
await importMutation.mutateAsync(selectedFile);
toast.success('Import erfolgreich abgeschlossen.');
setSelectedFile(null);
setPreviewData(null);
setError(null);
onSuccess?.();
onClose();
} catch (err: any) {
toast.error(err.message || 'Import fehlgeschlagen.');
}
};
const handleClose = () => {
setSelectedFile(null);
setPreviewData(null);
setError(null);
onClose();
};
return (
<Modal open={open} onClose={handleClose} title="CSV Import" size="lg" >
<div className="space-y-4" data-testid="csv-import-dialog">
<div>
<p className="text-sm text-secondary-600 mb-3">
Wählen Sie eine CSV-Datei mit Firmendaten. Erforderliche Spalte: name.
Optionale Spalten: account_number, industry, phone, email, website, description.
</p>
<input
ref={fileInputRef}
type="file"
accept=".csv"
onChange={handleFileSelect}
className="block w-full 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="CSV-Datei auswählen"
data-testid="csv-file-input"
/>
{error && <p className="mt-2 text-sm text-danger-600" role="alert">{error}</p>}
</div>
{previewData && previewData.rows.length > 0 && (
<div>
<h4 className="text-sm font-semibold text-secondary-900 mb-2">Vorschau ({previewData.rows.length} Datensätze)</h4>
<div className="overflow-x-auto border border-secondary-200 rounded-md max-h-60">
<table className="min-w-full text-sm">
<thead className="bg-secondary-50 sticky top-0">
<tr>
{previewData.headers.map((header) => (
<th key={header} className="px-3 py-2 text-left font-semibold text-secondary-600">{header}</th>
))}
</tr>
</thead>
<tbody className="divide-y divide-secondary-100">
{previewData.rows.slice(0, 10).map((row, idx) => (
<tr key={idx}>
{previewData.headers.map((header) => (
<td key={header} className="px-3 py-2 text-secondary-900">{row[header]}</td>
))}
</tr>
))}
</tbody>
</table>
</div>
{previewData.rows.length > 10 && (
<p className="text-xs text-secondary-500 mt-1">Zeige 10 von {previewData.rows.length} Datensätzen.</p>
)}
</div>
)}
<div className="flex justify-end gap-3 pt-2">
<Button variant="secondary" onClick={handleClose}>{t('common.cancel')}</Button>
<Button
onClick={handleImport}
disabled={!selectedFile || importMutation.isPending}
isLoading={importMutation.isPending}
data-testid="csv-import-button"
>
{t('common.save')}
</Button>
</div>
</div>
</Modal>
);
}
+160
View File
@@ -0,0 +1,160 @@
import React, { useState, useMemo } from 'react';
import {
useReactTable,
getCoreRowModel,
getSortedRowModel,
getFilteredRowModel,
flexRender,
ColumnDef,
SortingState,
ColumnFiltersState,
} from '@tanstack/react-table';
import clsx from 'clsx';
import { Pagination } from '@/components/ui/Pagination';
import { useTranslation } from 'react-i18next';
export interface DataGridProps<T> {
columns: ColumnDef<T, any>[];
data: T[];
total: number;
page: number;
pageSize: number;
onPageChange: (page: number) => void;
onRowClick?: (row: T) => void;
emptyMessage?: string;
loading?: boolean;
enableSorting?: boolean;
enableGlobalFilter?: boolean;
globalFilter?: string;
onGlobalFilterChange?: (value: string) => void;
testId?: string;
}
export function DataGrid<T extends Record<string, any>>({
columns,
data,
total,
page,
pageSize,
onPageChange,
onRowClick,
emptyMessage,
loading = false,
enableSorting = true,
testId,
}: DataGridProps<T>) {
const { t } = useTranslation();
const [sorting, setSorting] = useState<SortingState>([]);
const table = useReactTable({
data,
columns,
state: { sorting },
onSortingChange: setSorting,
getCoreRowModel: getCoreRowModel(),
getSortedRowModel: enableSorting ? getSortedRowModel() : undefined,
getFilteredRowModel: getFilteredRowModel(),
});
const totalPages = Math.ceil(total / pageSize);
return (
<div className="bg-white rounded-lg shadow-sm border border-secondary-200 overflow-hidden" data-testid={testId}>
<div className="overflow-x-auto" role="region" aria-label="Data grid">
<table className="min-w-full divide-y divide-secondary-200">
<thead className="bg-secondary-50">
{table.getHeaderGroups().map((headerGroup) => (
<tr key={headerGroup.id}>
{headerGroup.headers.map((header) => {
const canSort = header.column.getCanSort();
const sortDir = header.column.getIsSorted();
return (
<th
key={header.id}
scope="col"
className={clsx(
'px-6 py-3 text-left text-xs font-semibold text-secondary-600 uppercase tracking-wider',
canSort && 'cursor-pointer select-none hover:bg-secondary-100 min-h-touch'
)}
aria-sort={sortDir === 'asc' ? 'ascending' : sortDir === 'desc' ? 'descending' : 'none'}
onClick={canSort ? header.column.getToggleSortingHandler() : undefined}
onKeyDown={(e) => {
if (canSort && (e.key === 'Enter' || e.key === ' ')) {
e.preventDefault();
header.column.toggleSorting();
}
}}
tabIndex={canSort ? 0 : undefined}
role={canSort ? 'button' : undefined}
aria-label={canSort ? `${flexRender(header.column.columnDef.header, header.getContext())}, sortierbar` : undefined}
>
<div className="flex items-center gap-1">
{flexRender(header.column.columnDef.header, header.getContext())}
{canSort && (
<span aria-hidden="true">
{sortDir === 'asc' ? '▲' : sortDir === 'desc' ? '▼' : '↕'}
</span>
)}
</div>
</th>
);
})}
</tr>
))}
</thead>
<tbody className="divide-y divide-secondary-100 bg-white">
{loading ? (
<tr>
<td colSpan={columns.length} className="px-6 py-8 text-center text-secondary-500">
<span className="inline-flex items-center gap-2">
<svg className="animate-spin motion-reduce:animate-none h-5 w-5" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" aria-hidden="true">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
</svg>
{t('common.loading')}
</span>
</td>
</tr>
) : table.getRowModel().rows.length === 0 ? (
<tr>
<td colSpan={columns.length} className="px-6 py-8 text-center text-secondary-500">
{emptyMessage || t('table.empty')}
</td>
</tr>
) : (
table.getRowModel().rows.map((row) => (
<tr
key={row.id}
className={clsx(
'hover:bg-secondary-50 motion-safe:transition-colors',
onRowClick && 'cursor-pointer'
)}
onClick={onRowClick ? () => onRowClick(row.original) : undefined}
tabIndex={onRowClick ? 0 : undefined}
onKeyDown={onRowClick ? (e) => {
if (e.key === 'Enter') onRowClick(row.original);
} : undefined}
>
{row.getVisibleCells().map((cell) => (
<td key={cell.id} className="px-6 py-4 text-sm text-secondary-900">
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</td>
))}
</tr>
))
)}
</tbody>
</table>
</div>
{totalPages > 1 && (
<Pagination
currentPage={page}
totalPages={totalPages}
total={total}
pageSize={pageSize}
onPageChange={onPageChange}
/>
)}
</div>
);
}
@@ -0,0 +1,158 @@
import React, { useState, useRef, useEffect } from 'react';
import clsx from 'clsx';
import { useNavigate } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { useGlobalSearch, SearchResult } from '@/api/hooks';
export interface SearchDropdownProps {
placeholder?: string;
}
function highlightMatch(text: string, query: string): React.ReactNode {
if (!query.trim()) return text;
const idx = text.toLowerCase().indexOf(query.toLowerCase());
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 SearchDropdown({ placeholder }: SearchDropdownProps) {
const { t } = useTranslation();
const navigate = useNavigate();
const [query, setQuery] = useState('');
const [debouncedQuery, setDebouncedQuery] = useState('');
const [open, setOpen] = useState(false);
const [activeIndex, setActiveIndex] = useState(-1);
const containerRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLInputElement>(null);
const { data: results, isLoading } = useGlobalSearch(debouncedQuery);
useEffect(() => {
const timer = setTimeout(() => setDebouncedQuery(query), 300);
return () => clearTimeout(timer);
}, [query]);
useEffect(() => {
const handleClickOutside = (e: MouseEvent) => {
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
setOpen(false);
}
};
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}, []);
const handleKeyDown = (e: React.KeyboardEvent) => {
if (!results || results.length === 0) return;
if (e.key === 'ArrowDown') {
e.preventDefault();
setActiveIndex((prev) => Math.min(prev + 1, results.length - 1));
} else if (e.key === 'ArrowUp') {
e.preventDefault();
setActiveIndex((prev) => Math.max(prev - 1, 0));
} else if (e.key === 'Enter' && activeIndex >= 0) {
e.preventDefault();
const result = results[activeIndex];
navigate(result.url);
setOpen(false);
setQuery('');
} else if (e.key === 'Escape') {
setOpen(false);
inputRef.current?.blur();
}
};
const handleResultClick = (result: SearchResult) => {
navigate(result.url);
setOpen(false);
setQuery('');
};
const handleSeeAll = () => {
navigate(`/search?q=${encodeURIComponent(query)}`);
setOpen(false);
};
return (
<div ref={containerRef} className="relative w-full" data-testid="search-dropdown">
<input
ref={inputRef}
type="search"
value={query}
onChange={(e) => { setQuery(e.target.value); setOpen(true); setActiveIndex(-1); }}
onFocus={() => query && setOpen(true)}
onKeyDown={handleKeyDown}
placeholder={placeholder || t('topbar.search')}
className="w-64 pl-10 pr-3 py-2 text-sm rounded-md border border-secondary-300 focus:outline-none focus:ring-2 focus:ring-primary-500 min-h-touch"
aria-label={t('topbar.search')}
role="combobox"
aria-expanded={open}
aria-controls="search-results-list"
aria-autocomplete="list"
/>
<svg className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-secondary-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
</svg>
{open && query.trim() && (
<div
className="absolute top-full left-0 mt-1 w-96 bg-white rounded-md shadow-lg border border-secondary-200 z-50 max-h-96 overflow-y-auto"
role="listbox"
id="search-results-list"
data-testid="search-results"
>
{isLoading ? (
<div className="px-4 py-3 text-sm text-secondary-500">{t('common.loading')}</div>
) : results && results.length > 0 ? (
<>
<ul className="py-1">
{results.map((result, idx) => (
<li key={`${result.type}-${result.id}`}>
<button
onClick={() => handleResultClick(result)}
onMouseEnter={() => setActiveIndex(idx)}
className={clsx(
'w-full text-left px-4 py-2 text-sm hover:bg-secondary-50 min-h-touch flex items-center gap-3',
activeIndex === idx && 'bg-primary-50'
)}
role="option"
aria-selected={activeIndex === idx}
>
<span className={clsx(
'inline-flex items-center justify-center w-8 h-8 rounded-full text-xs 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'}
</span>
<div className="flex-1 min-w-0">
<p className="font-medium text-secondary-900 truncate">{highlightMatch(result.name, query)}</p>
{result.description && (
<p className="text-xs text-secondary-500 truncate">{highlightMatch(result.description, query)}</p>
)}
</div>
</button>
</li>
))}
</ul>
<div className="border-t border-secondary-200 px-4 py-2">
<button
onClick={handleSeeAll}
className="text-sm text-primary-600 hover:text-primary-700 font-medium min-h-touch"
>
Alle Ergebnisse anzeigen
</button>
</div>
</>
) : (
<div className="px-4 py-3 text-sm text-secondary-500">{t('common.noResults')}</div>
)}
</div>
)}
</div>
);
}
@@ -0,0 +1,36 @@
import React from 'react';
import clsx from 'clsx';
import { Card } from '@/components/ui/Card';
import { Badge, BadgeVariant } from '@/components/ui/Badge';
export interface StatCardProps {
label: string;
value: string | number;
change?: string;
changeVariant?: BadgeVariant;
icon?: React.ReactNode;
testId?: string;
}
export function StatCard({ label, value, change, changeVariant = 'success', icon, testId }: StatCardProps) {
return (
<Card>
<div className="flex items-center justify-between" data-testid={testId}>
<div>
<p className="text-sm text-secondary-500">{label}</p>
<p className="text-2xl font-bold text-secondary-900 mt-1">{value}</p>
{change && (
<p className="text-xs mt-1">
<Badge variant={changeVariant} dot>{change}</Badge>
</p>
)}
</div>
{icon && (
<div className="w-12 h-12 rounded-lg bg-primary-50 flex items-center justify-center text-primary-600" aria-hidden="true">
{icon}
</div>
)}
</div>
</Card>
);
}
+62
View File
@@ -0,0 +1,62 @@
import React, { useState } from 'react';
import clsx from 'clsx';
export interface TabItem {
key: string;
label: string;
content: React.ReactNode;
badge?: number;
}
export interface TabsProps {
tabs: TabItem[];
defaultKey?: string;
className?: string;
}
export function Tabs({ tabs, defaultKey, className }: TabsProps) {
const [activeKey, setActiveKey] = useState(defaultKey || tabs[0]?.key || '');
const activeTab = tabs.find((t) => t.key === activeKey);
return (
<div className={clsx('w-full', className)}>
<div className="border-b border-secondary-200" role="tablist">
<div className="flex gap-1 px-6 overflow-x-auto">
{tabs.map((tab) => (
<button
key={tab.key}
role="tab"
aria-selected={activeKey === tab.key}
aria-controls={`panel-${tab.key}`}
id={`tab-${tab.key}`}
tabIndex={activeKey === tab.key ? 0 : -1}
onClick={() => setActiveKey(tab.key)}
className={clsx(
'px-4 py-3 text-sm font-medium border-b-2 min-h-touch whitespace-nowrap',
'focus:outline-none focus-visible:ring-2 focus-visible:ring-primary-500 rounded-t-md',
activeKey === tab.key
? 'border-primary-600 text-primary-700'
: 'border-transparent text-secondary-600 hover:text-secondary-900 hover:border-secondary-300'
)}
>
{tab.label}
{tab.badge !== undefined && tab.badge > 0 && (
<span className="ml-2 inline-flex items-center justify-center px-2 py-0.5 rounded-full text-xs bg-primary-100 text-primary-700">
{tab.badge}
</span>
)}
</button>
))}
</div>
</div>
<div
id={`panel-${activeKey}`}
role="tabpanel"
aria-labelledby={`tab-${activeKey}`}
className="px-6 py-4"
>
{activeTab?.content}
</div>
</div>
);
}
@@ -0,0 +1,28 @@
import React, { useEffect, useRef } from 'react';
import { useLocation, useNavigate, useBlocker } from 'react-router-dom';
export interface UnsavedChangesGuardProps {
isDirty: boolean;
message?: string;
onConfirm?: () => void;
}
export function UnsavedChangesGuard({ isDirty, message = 'Sie haben ungespeicherte Änderungen. Möchten Sie die Seite wirklich verlassen?', onConfirm }: UnsavedChangesGuardProps) {
const blocker = useBlocker(isDirty);
const messageRef = useRef(message);
messageRef.current = message;
useEffect(() => {
if (blocker.state === 'blocked') {
const confirmed = window.confirm(messageRef.current);
if (confirmed) {
onConfirm?.();
blocker.proceed();
} else {
blocker.reset();
}
}
}, [blocker, onConfirm]);
return null;
}