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,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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user