T07a: frontend core SPA — shell + auth + routing + i18n + UI library + a11y

- React 18 + Vite + TypeScript + Tailwind CSS setup
- AppShell with Sidebar (plugin menu) + TopBar (tenant switcher, search, notifications, user menu)
- Auth pages: Login, PasswordResetRequest, PasswordResetConfirm
- Protected routes with auth guard
- API client (axios with interceptors: session cookie, 401 redirect, 422 validation)
- TanStack Query hooks for auth, users, companies, contacts, notifications
- Zustand stores: authStore, uiStore
- i18n setup (de/en locales) with react-i18next
- UI component library: Button, Input, Select, Modal, Toast, Table, Card, Badge, Avatar, Pagination, EmptyState, Skeleton, ConfirmDialog
- Accessibility: ARIA labels, 44px touch targets, keyboard nav, reduced-motion, sr-only
- Design tokens from prototype as CSS custom properties
- 111 tests passing across 20 test files
- tsc --noEmit: 0 errors
- npm run build: success (471KB JS, 24KB CSS)
This commit is contained in:
leocrm-bot
2026-06-29 07:55:47 +02:00
parent f8193a6ab5
commit 22976abe92
66 changed files with 8598 additions and 0 deletions
@@ -0,0 +1,26 @@
import React from 'react';
import { Outlet } from 'react-router-dom';
import { Sidebar } from './Sidebar';
import { TopBar } from './TopBar';
import { ToastContainer } from '@/components/ui/Toast';
export function AppShell() {
return (
<div className="flex h-screen overflow-hidden bg-secondary-50" data-testid="app-shell">
<Sidebar />
<div className="flex-1 flex flex-col overflow-hidden">
<TopBar />
<main
className="flex-1 overflow-y-auto focus:outline-none"
tabIndex={-1}
role="main"
id="main-content"
data-testid="content-area"
>
<Outlet />
</main>
</div>
<ToastContainer />
</div>
);
}
@@ -0,0 +1,84 @@
import React from 'react';
import clsx from 'clsx';
import { NavLink } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { useUIStore } from '@/store/uiStore';
interface NavItem {
to: string;
labelKey: string;
icon: React.ReactNode;
}
const navIcon = (path: string) => (
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d={path} />
</svg>
);
const navItems: NavItem[] = [
{ to: '/dashboard', labelKey: 'nav.dashboard', icon: navIcon('M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6') },
{ to: '/companies', labelKey: 'nav.companies', icon: navIcon('M19 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16m14 0h2m-2 0h-5m-9 0H3m2 0h5M9 7h1m-1 4h1m4-4h1m-1 4h1m-5 10v-5a1 1 0 011-1h2a1 1 0 011 1v5m-4 0h4') },
{ to: '/contacts', labelKey: 'nav.contacts', icon: navIcon('M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6-3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z') },
{ to: '/calendar', labelKey: 'nav.calendar', icon: navIcon('M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z') },
{ to: '/files', labelKey: 'nav.files', icon: navIcon('M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z') },
{ to: '/email', labelKey: 'nav.email', icon: navIcon('M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z') },
{ to: '/users', labelKey: 'nav.users', icon: navIcon('M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197M13 7a4 4 0 11-8 0 4 4 0 018 0z') },
{ to: '/audit-log', labelKey: 'nav.auditLog', icon: navIcon('M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z') },
{ to: '/settings', labelKey: 'nav.settings', icon: navIcon('M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z') },
];
export function Sidebar() {
const { t } = useTranslation();
const { sidebarOpen, setSidebarOpen } = useUIStore();
return (
<>
{sidebarOpen && (
<div
className="fixed inset-0 bg-secondary-900/50 z-30 md:hidden"
onClick={() => setSidebarOpen(false)}
aria-hidden="true"
data-testid="sidebar-overlay"
/>
)}
<aside
className={clsx(
'fixed md:static inset-y-0 left-0 z-40 w-64 bg-secondary-900 text-secondary-300',
'flex flex-col transition-transform motion-safe:duration-300',
sidebarOpen ? 'translate-x-0' : '-translate-x-full md:hidden'
)}
aria-label="Seitenleiste Navigation"
data-testid="sidebar"
>
<div className="h-16 flex items-center px-6 border-b border-secondary-700">
<span className="text-xl font-bold text-white">leocrm</span>
</div>
<nav className="flex-1 overflow-y-auto py-4" aria-label="Hauptnavigation">
<ul className="space-y-1 px-3">
{navItems.map((item) => (
<li key={item.to}>
<NavLink
to={item.to}
className={({ isActive }) =>
clsx(
'flex items-center gap-3 px-3 py-2.5 rounded-md text-sm font-medium min-h-touch',
'motion-safe:transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-primary-500',
isActive
? 'bg-primary-600 text-white'
: 'hover:bg-secondary-800 hover:text-white'
)
}
aria-label={t(item.labelKey)}
>
{item.icon}
<span>{t(item.labelKey)}</span>
</NavLink>
</li>
))}
</ul>
</nav>
</aside>
</>
);
}
+216
View File
@@ -0,0 +1,216 @@
import React, { useState, useRef, useEffect } from 'react';
import clsx from 'clsx';
import { useNavigate } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { useAuthStore } from '@/store/authStore';
import { useUIStore } from '@/store/uiStore';
import { useTenant } from '@/hooks/useTenant';
import { useLogout } from '@/api/hooks';
import { Avatar } from '@/components/ui/Avatar';
import { Badge } from '@/components/ui/Badge';
export function TopBar() {
const { t } = useTranslation();
const navigate = useNavigate();
const { user } = useAuthStore();
const { toggleSidebar } = useUIStore();
const { currentTenant, availableTenants, switchTenant, isSwitching } = useTenant();
const logoutMutation = useLogout();
const [tenantMenuOpen, setTenantMenuOpen] = useState(false);
const [userMenuOpen, setUserMenuOpen] = useState(false);
const [notifOpen, setNotifOpen] = useState(false);
const tenantRef = useRef<HTMLDivElement>(null);
const userRef = useRef<HTMLDivElement>(null);
const notifRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const handleClickOutside = (e: MouseEvent) => {
if (tenantRef.current && !tenantRef.current.contains(e.target as Node)) setTenantMenuOpen(false);
if (userRef.current && !userRef.current.contains(e.target as Node)) setUserMenuOpen(false);
if (notifRef.current && !notifRef.current.contains(e.target as Node)) setNotifOpen(false);
};
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}, []);
const handleLogout = async () => {
try {
await logoutMutation.mutateAsync();
} catch {
// ignore
}
navigate('/login');
};
const handleTenantSwitch = async (tenantId: string) => {
await switchTenant(tenantId);
setTenantMenuOpen(false);
};
const fullName = user ? `${user.first_name} ${user.last_name}` : '';
return (
<header
className="h-16 bg-white border-b border-secondary-200 flex items-center justify-between px-4 sticky top-0 z-20"
role="banner"
data-testid="topbar"
>
<div className="flex items-center gap-3">
<button
onClick={toggleSidebar}
className="p-2 rounded-md hover:bg-secondary-100 min-h-touch min-w-touch flex items-center justify-center focus:outline-none focus-visible:ring-2 focus-visible:ring-primary-500"
aria-label="Seitenleiste ein-/ausklappen"
aria-expanded={true}
>
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 6h16M4 12h16M4 18h16" />
</svg>
</button>
{/* Tenant switcher */}
<div ref={tenantRef} className="relative">
<button
onClick={() => setTenantMenuOpen(!tenantMenuOpen)}
className="flex items-center gap-2 px-3 py-2 rounded-md hover:bg-secondary-100 min-h-touch focus:outline-none focus-visible:ring-2 focus-visible:ring-primary-500"
aria-label={t('topbar.switchTenant')}
aria-expanded={tenantMenuOpen}
aria-haspopup="listbox"
>
<svg className="w-4 h-4 text-secondary-500" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16m14 0h2m-2 0h-5m-9 0H3m2 0h5M9 7h1m-1 4h1m4-4h1m-1 4h1m-5 10v-5a1 1 0 011-1h2a1 1 0 011 1v5m-4 0h4" />
</svg>
<span className="text-sm font-medium text-secondary-700 max-w-32 truncate">
{currentTenant?.name || t('topbar.tenant')}
</span>
<svg className="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="M19 9l-7 7-7-7" />
</svg>
</button>
{tenantMenuOpen && (
<ul
className="absolute top-full left-0 mt-1 w-56 bg-white rounded-md shadow-lg border border-secondary-200 py-1 z-50"
role="listbox"
aria-label={t('topbar.switchTenant')}
>
{availableTenants.map((tenant) => (
<li key={tenant.id}>
<button
onClick={() => handleTenantSwitch(tenant.id)}
className={clsx(
'w-full text-left px-3 py-2 text-sm hover:bg-secondary-50 min-h-touch flex items-center justify-between focus:outline-none focus-visible:ring-2 focus-visible:ring-primary-500',
tenant.id === currentTenant?.id && 'bg-primary-50 text-primary-700'
)}
role="option"
aria-selected={tenant.id === currentTenant?.id}
disabled={isSwitching}
>
{tenant.name}
{tenant.id === currentTenant?.id && (
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
)}
</button>
</li>
))}
</ul>
)}
</div>
{/* Search */}
<div className="hidden md:block relative">
<input
type="search"
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')}
/>
<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>
</div>
</div>
<div className="flex items-center gap-2">
{/* Notifications */}
<div ref={notifRef} className="relative">
<button
onClick={() => setNotifOpen(!notifOpen)}
className="p-2 rounded-md hover:bg-secondary-100 min-h-touch min-w-touch flex items-center justify-center focus:outline-none focus-visible:ring-2 focus-visible:ring-primary-500"
aria-label={t('topbar.notifications')}
aria-expanded={notifOpen}
>
<svg className="w-5 h-5 text-secondary-600" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9" />
</svg>
<Badge variant="danger" className="absolute -top-1 -right-1 px-1.5 py-0">3</Badge>
</button>
{notifOpen && (
<div className="absolute top-full right-0 mt-1 w-80 bg-white rounded-md shadow-lg border border-secondary-200 z-50">
<div className="px-4 py-3 border-b border-secondary-200">
<h3 className="text-sm font-semibold text-secondary-900">{t('topbar.notifications')}</h3>
</div>
<ul className="max-h-64 overflow-y-auto py-1" role="list">
<li className="px-4 py-2 hover:bg-secondary-50 cursor-pointer text-sm text-secondary-700">
Neuer Kontakt hinzugefügt: Max Mustermann
</li>
<li className="px-4 py-2 hover:bg-secondary-50 cursor-pointer text-sm text-secondary-700">
Firma aktualisiert: TechCorp GmbH
</li>
<li className="px-4 py-2 hover:bg-secondary-50 cursor-pointer text-sm text-secondary-700">
Meeting um 14:00 Uhr mit Müller AG
</li>
</ul>
</div>
)}
</div>
{/* User menu */}
<div ref={userRef} className="relative">
<button
onClick={() => setUserMenuOpen(!userMenuOpen)}
className="flex items-center gap-2 p-1 rounded-md hover:bg-secondary-100 min-h-touch focus:outline-none focus-visible:ring-2 focus-visible:ring-primary-500"
aria-label={t('topbar.userMenu')}
aria-expanded={userMenuOpen}
aria-haspopup="menu"
>
<Avatar name={fullName || 'User'} src={user?.avatar_url} size="sm" />
<span className="hidden md:block text-sm font-medium text-secondary-700 max-w-24 truncate">
{user?.first_name || ''}
</span>
<svg className="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="M19 9l-7 7-7-7" />
</svg>
</button>
{userMenuOpen && (
<div
className="absolute top-full right-0 mt-1 w-56 bg-white rounded-md shadow-lg border border-secondary-200 py-1 z-50"
role="menu"
aria-label={t('topbar.userMenu')}
>
<div className="px-3 py-2 border-b border-secondary-100">
<p className="text-sm font-medium text-secondary-900 truncate">{fullName}</p>
<p className="text-xs text-secondary-500 truncate">{user?.email}</p>
</div>
<button
onClick={() => { setUserMenuOpen(false); navigate('/settings'); }}
className="w-full text-left px-3 py-2 text-sm hover:bg-secondary-50 min-h-touch focus:outline-none focus-visible:ring-2 focus-visible:ring-primary-500"
role="menuitem"
>
{t('topbar.settings')}
</button>
<button
onClick={handleLogout}
className="w-full text-left px-3 py-2 text-sm text-danger-600 hover:bg-danger-50 min-h-touch focus:outline-none focus-visible:ring-2 focus-visible:ring-danger-500"
role="menuitem"
>
{t('topbar.logout')}
</button>
</div>
)}
</div>
</div>
</header>
);
}
+51
View File
@@ -0,0 +1,51 @@
import React from 'react';
import clsx from 'clsx';
export interface AvatarProps {
name: string;
src?: string | null;
size?: 'xs' | 'sm' | 'md' | 'lg';
className?: string;
}
const sizeClasses = {
xs: 'w-6 h-6 text-xs',
sm: 'w-8 h-8 text-sm',
md: 'w-10 h-10 text-base',
lg: 'w-14 h-14 text-lg',
};
function getInitials(name: string): string {
const parts = name.trim().split(/\s+/);
if (parts.length === 0) return '?';
if (parts.length === 1) return parts[0].charAt(0).toUpperCase();
return (parts[0].charAt(0) + parts[parts.length - 1].charAt(0)).toUpperCase();
}
export function Avatar({ name, src, size = 'md', className }: AvatarProps) {
const [imgError, setImgError] = React.useState(false);
const showImage = src && !imgError;
return (
<div
className={clsx(
'inline-flex items-center justify-center rounded-full bg-primary-100 text-primary-700 font-semibold flex-shrink-0 overflow-hidden',
sizeClasses[size],
className
)}
role="img"
aria-label={`Avatar von ${name}`}
>
{showImage ? (
<img
src={src}
alt=""
className="w-full h-full object-cover"
onError={() => setImgError(true)}
/>
) : (
<span aria-hidden="true">{getInitials(name)}</span>
)}
</div>
);
}
+44
View File
@@ -0,0 +1,44 @@
import React from 'react';
import clsx from 'clsx';
export type BadgeVariant = 'default' | 'primary' | 'success' | 'warning' | 'danger' | 'info';
export interface BadgeProps {
variant?: BadgeVariant;
children: React.ReactNode;
className?: string;
dot?: boolean;
}
const variantClasses: Record<BadgeVariant, string> = {
default: 'bg-secondary-100 text-secondary-700',
primary: 'bg-primary-100 text-primary-700',
success: 'bg-success-100 text-success-700',
warning: 'bg-warning-100 text-warning-700',
danger: 'bg-danger-100 text-danger-700',
info: 'bg-accent-100 text-accent-700',
};
const dotColors: Record<BadgeVariant, string> = {
default: 'bg-secondary-400',
primary: 'bg-primary-500',
success: 'bg-success-500',
warning: 'bg-warning-500',
danger: 'bg-danger-500',
info: 'bg-accent-500',
};
export function Badge({ variant = 'default', children, className, dot = false }: BadgeProps) {
return (
<span
className={clsx(
'inline-flex items-center gap-1.5 px-2.5 py-0.5 rounded-full text-xs font-medium',
variantClasses[variant],
className
)}
>
{dot && <span className={clsx('w-1.5 h-1.5 rounded-full', dotColors[variant])} aria-hidden="true" />}
{children}
</span>
);
}
+56
View File
@@ -0,0 +1,56 @@
import React from 'react';
import clsx from 'clsx';
export type ButtonVariant = 'primary' | 'secondary' | 'danger' | 'ghost';
export type ButtonSize = 'sm' | 'md' | 'lg';
export interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
variant?: ButtonVariant;
size?: ButtonSize;
isLoading?: boolean;
icon?: React.ReactNode;
fullWidth?: boolean;
}
const variantClasses: Record<ButtonVariant, string> = {
primary: 'bg-primary-600 text-white hover:bg-primary-700 focus-visible:ring-primary-500',
secondary: 'bg-secondary-200 text-secondary-800 hover:bg-secondary-300 focus-visible:ring-secondary-500',
danger: 'bg-danger-600 text-white hover:bg-danger-700 focus-visible:ring-danger-500',
ghost: 'bg-transparent text-secondary-700 hover:bg-secondary-100 focus-visible:ring-secondary-500',
};
const sizeClasses: Record<ButtonSize, string> = {
sm: 'text-sm px-3 py-1.5 rounded-md min-h-touch',
md: 'text-base px-4 py-2 rounded-md min-h-touch',
lg: 'text-lg px-6 py-3 rounded-lg min-h-touch',
};
export const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ variant = 'primary', size = 'md', isLoading = false, icon, fullWidth = false, className, children, disabled, ...props }, ref) => {
return (
<button
ref={ref}
className={clsx(
'inline-flex items-center justify-center font-medium transition-colors motion-safe:duration-200 focus:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 disabled:opacity-50 disabled:cursor-not-allowed',
variantClasses[variant],
sizeClasses[size],
fullWidth && 'w-full',
className
)}
disabled={disabled || isLoading}
aria-busy={isLoading}
{...props}
>
{isLoading && (
<svg className="animate-spin motion-reduce:animate-none -ml-1 mr-2 h-4 w-4" 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>
)}
{icon && !isLoading && <span className="mr-2" aria-hidden="true">{icon}</span>}
{children}
</button>
);
}
);
Button.displayName = 'Button';
+36
View File
@@ -0,0 +1,36 @@
import React from 'react';
import clsx from 'clsx';
export interface CardProps {
title?: string;
description?: string;
children: React.ReactNode;
footer?: React.ReactNode;
className?: string;
actions?: React.ReactNode;
}
export function Card({ title, description, children, footer, className, actions }: CardProps) {
return (
<div
className={clsx(
'bg-white rounded-lg shadow-sm border border-secondary-200 overflow-hidden',
className
)}
>
{(title || actions) && (
<div className="px-6 py-4 border-b border-secondary-200 flex items-center justify-between">
<div>
{title && <h3 className="text-lg font-semibold text-secondary-900">{title}</h3>}
{description && <p className="text-sm text-secondary-500 mt-1">{description}</p>}
</div>
{actions && <div className="flex items-center gap-2">{actions}</div>}
</div>
)}
<div className="px-6 py-4">{children}</div>
{footer && (
<div className="px-6 py-3 bg-secondary-50 border-t border-secondary-200">{footer}</div>
)}
</div>
);
}
@@ -0,0 +1,42 @@
import React from 'react';
import { Modal } from './Modal';
import { Button } from './Button';
import { useTranslation } from 'react-i18next';
export interface ConfirmDialogProps {
open: boolean;
title?: string;
message: string;
confirmLabel?: string;
cancelLabel?: string;
variant?: 'primary' | 'danger';
onConfirm: () => void;
onCancel: () => void;
}
export function ConfirmDialog({
open,
title,
message,
confirmLabel,
cancelLabel,
variant = 'primary',
onConfirm,
onCancel,
}: ConfirmDialogProps) {
const { t } = useTranslation();
return (
<Modal open={open} onClose={onCancel} title={title || t('confirmDialog.title')} size="sm">
<p className="text-sm text-secondary-700">{message}</p>
<div className="mt-6 flex justify-end gap-3">
<Button variant="secondary" onClick={onCancel}>
{cancelLabel || t('confirmDialog.cancel')}
</Button>
<Button variant={variant === 'danger' ? 'danger' : 'primary'} onClick={onConfirm}>
{confirmLabel || t('confirmDialog.confirm')}
</Button>
</div>
</Modal>
);
}
+51
View File
@@ -0,0 +1,51 @@
import React from 'react';
import clsx from 'clsx';
import { useTranslation } from 'react-i18next';
export interface EmptyStateProps {
title?: string;
description?: string;
icon?: React.ReactNode;
action?: React.ReactNode;
className?: string;
}
export function EmptyState({
title,
description,
icon,
action,
className,
}: EmptyStateProps) {
const { t } = useTranslation();
return (
<div
className={clsx(
'flex flex-col items-center justify-center py-12 px-6 text-center',
className
)}
role="status"
>
{icon && (
<div className="mb-4 text-secondary-300" aria-hidden="true">
{icon}
</div>
)}
{!icon && (
<div className="mb-4 w-16 h-16 rounded-full bg-secondary-100 flex items-center justify-center" aria-hidden="true">
<svg className="w-8 h-8 text-secondary-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M9 13h6m-3-3v6m-9 1V7a2 2 0 012-2h6l2 2h6a2 2 0 012 2v8a2 2 0 01-2 2H5a2 2 0 01-2-2z" />
</svg>
</div>
)}
<h3 className="text-lg font-semibold text-secondary-900">
{title || t('emptyState.title')}
</h3>
<p className="mt-1 text-sm text-secondary-500 max-w-sm">
{description || t('emptyState.description')}
</p>
{action && <div className="mt-6">{action}</div>}
</div>
);
}
+57
View File
@@ -0,0 +1,57 @@
import React, { useId } from 'react';
import clsx from 'clsx';
export interface InputProps extends React.InputHTMLAttributes<HTMLInputElement> {
label?: string;
error?: string;
helperText?: string;
required?: boolean;
}
export const Input = React.forwardRef<HTMLInputElement, InputProps>(
({ label, error, helperText, required, className, id, ...props }, ref) => {
const generatedId = useId();
const inputId = id || generatedId;
const errorId = `${inputId}-error`;
const helperId = `${inputId}-helper`;
return (
<div className="w-full">
{label && (
<label htmlFor={inputId} className="block text-sm font-medium text-secondary-700 mb-1">
{label}
{required && <span className="text-danger-500 ml-1" aria-label="required">*</span>}
</label>
)}
<input
ref={ref}
id={inputId}
className={clsx(
'block w-full rounded-md border px-3 py-2 text-base min-h-touch',
'focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-primary-500',
'motion-safe:transition-colors',
error
? 'border-danger-500 text-danger-900 placeholder-danger-300'
: 'border-secondary-300 text-secondary-900 placeholder-secondary-400',
className
)}
aria-invalid={!!error}
aria-describedby={clsx(error && errorId, helperText && helperId) || undefined}
aria-required={required}
{...props}
/>
{error && (
<p id={errorId} className="mt-1 text-sm text-danger-600" role="alert">
{error}
</p>
)}
{helperText && !error && (
<p id={helperId} className="mt-1 text-sm text-secondary-500">
{helperText}
</p>
)}
</div>
);
}
);
Input.displayName = 'Input';
+106
View File
@@ -0,0 +1,106 @@
import React, { useEffect, useRef } from 'react';
import clsx from 'clsx';
export interface ModalProps {
open: boolean;
onClose: () => void;
title?: string;
children: React.ReactNode;
size?: 'sm' | 'md' | 'lg' | 'xl';
closeOnBackdrop?: boolean;
closeOnEscape?: boolean;
showCloseButton?: boolean;
}
const sizeClasses = {
sm: 'max-w-md',
md: 'max-w-lg',
lg: 'max-w-2xl',
xl: 'max-w-4xl',
};
export function Modal({
open,
onClose,
title,
children,
size = 'md',
closeOnBackdrop = true,
closeOnEscape = true,
showCloseButton = true,
}: ModalProps) {
const dialogRef = useRef<HTMLDivElement>(null);
const previouslyFocused = useRef<HTMLElement | null>(null);
useEffect(() => {
if (open) {
previouslyFocused.current = document.activeElement as HTMLElement;
const focusable = dialogRef.current?.querySelector<HTMLElement>(
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
);
focusable?.focus();
} else {
previouslyFocused.current?.focus();
}
}, [open]);
useEffect(() => {
if (!open || !closeOnEscape) return;
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
e.stopPropagation();
onClose();
}
};
document.addEventListener('keydown', handleKeyDown);
return () => document.removeEventListener('keydown', handleKeyDown);
}, [open, closeOnEscape, onClose]);
if (!open) return null;
return (
<div
className="fixed inset-0 z-50 flex items-center justify-center p-4"
role="dialog"
aria-modal="true"
aria-labelledby={title ? 'modal-title' : undefined}
>
<div
className="absolute inset-0 bg-secondary-900/50 motion-safe:animate-opacity"
onClick={closeOnBackdrop ? onClose : undefined}
aria-hidden="true"
data-testid="modal-backdrop"
/>
<div
ref={dialogRef}
className={clsx(
'relative bg-white rounded-lg shadow-xl w-full',
'motion-safe:animate-scale',
sizeClasses[size]
)}
>
{title && (
<div className="px-6 py-4 border-b border-secondary-200">
<h2 id="modal-title" className="text-lg font-semibold text-secondary-900">
{title}
</h2>
</div>
)}
{showCloseButton && (
<button
onClick={onClose}
className="absolute top-4 right-4 text-secondary-400 hover:text-secondary-600 min-h-touch min-w-touch flex items-center justify-center rounded-md focus:outline-none focus-visible:ring-2 focus-visible:ring-primary-500"
aria-label="Close dialog"
>
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
)}
<div className="px-6 py-4 max-h-[70vh] overflow-y-auto">
{children}
</div>
</div>
</div>
);
}
+101
View File
@@ -0,0 +1,101 @@
import React from 'react';
import clsx from 'clsx';
import { useTranslation } from 'react-i18next';
export interface PaginationProps {
currentPage: number;
totalPages: number;
total: number;
pageSize: number;
onPageChange: (page: number) => void;
}
export function Pagination({ currentPage, totalPages, total, pageSize, onPageChange }: PaginationProps) {
const { t } = useTranslation();
const start = total === 0 ? 0 : (currentPage - 1) * pageSize + 1;
const end = Math.min(currentPage * pageSize, total);
const pages: number[] = [];
const maxVisible = 5;
const halfVisible = Math.floor(maxVisible / 2);
let startPage = Math.max(1, currentPage - halfVisible);
let endPage = Math.min(totalPages, startPage + maxVisible - 1);
if (endPage - startPage < maxVisible - 1) {
startPage = Math.max(1, endPage - maxVisible + 1);
}
for (let i = startPage; i <= endPage; i++) {
pages.push(i);
}
if (totalPages <= 1) return null;
return (
<nav className="flex items-center justify-between px-6 py-3 border-t border-secondary-200" aria-label="Pagination">
<div className="text-sm text-secondary-600">
<span>{start}{end}</span> <span>{t('table.of')}</span> <span>{total}</span> <span>{t('table.results')}</span>
</div>
<div className="flex items-center gap-1">
<button
onClick={() => onPageChange(currentPage - 1)}
disabled={currentPage <= 1}
className="px-3 py-1.5 text-sm rounded-md border border-secondary-300 hover:bg-secondary-50 disabled:opacity-50 disabled:cursor-not-allowed min-h-touch min-w-touch flex items-center justify-center focus:outline-none focus-visible:ring-2 focus-visible:ring-primary-500"
aria-label={t('pagination.previous')}
>
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
</svg>
</button>
{startPage > 1 && (
<>
<button
onClick={() => onPageChange(1)}
className="px-3 py-1.5 text-sm rounded-md border border-secondary-300 hover:bg-secondary-50 min-h-touch min-w-touch focus:outline-none focus-visible:ring-2 focus-visible:ring-primary-500"
aria-label={t('pagination.first')}
>
1
</button>
{startPage > 2 && <span className="px-1 text-secondary-400" aria-hidden="true"></span>}
</>
)}
{pages.map((page) => (
<button
key={page}
onClick={() => onPageChange(page)}
className={clsx(
'px-3 py-1.5 text-sm rounded-md border min-h-touch min-w-touch focus:outline-none focus-visible:ring-2 focus-visible:ring-primary-500',
page === currentPage
? 'border-primary-500 bg-primary-50 text-primary-700 font-semibold'
: 'border-secondary-300 hover:bg-secondary-50'
)}
aria-label={t('pagination.page', { page })}
aria-current={page === currentPage ? 'page' : undefined}
>
{page}
</button>
))}
{endPage < totalPages && (
<>
{endPage < totalPages - 1 && <span className="px-1 text-secondary-400" aria-hidden="true"></span>}
<button
onClick={() => onPageChange(totalPages)}
className="px-3 py-1.5 text-sm rounded-md border border-secondary-300 hover:bg-secondary-50 min-h-touch min-w-touch focus:outline-none focus-visible:ring-2 focus-visible:ring-primary-500"
aria-label={t('pagination.last')}
>
{totalPages}
</button>
</>
)}
<button
onClick={() => onPageChange(currentPage + 1)}
disabled={currentPage >= totalPages}
className="px-3 py-1.5 text-sm rounded-md border border-secondary-300 hover:bg-secondary-50 disabled:opacity-50 disabled:cursor-not-allowed min-h-touch min-w-touch flex items-center justify-center focus:outline-none focus-visible:ring-2 focus-visible:ring-primary-500"
aria-label={t('pagination.next')}
>
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
</svg>
</button>
</div>
</nav>
);
}
+69
View File
@@ -0,0 +1,69 @@
import React, { useId } from 'react';
import clsx from 'clsx';
export interface SelectOption {
value: string;
label: string;
}
export interface SelectProps extends React.SelectHTMLAttributes<HTMLSelectElement> {
label?: string;
error?: string;
helperText?: string;
options: SelectOption[];
required?: boolean;
placeholder?: string;
}
export const Select = React.forwardRef<HTMLSelectElement, SelectProps>(
({ label, error, helperText, options, required, placeholder, className, id, ...props }, ref) => {
const generatedId = useId();
const selectId = id || generatedId;
const errorId = `${selectId}-error`;
const helperId = `${selectId}-helper`;
return (
<div className="w-full">
{label && (
<label htmlFor={selectId} className="block text-sm font-medium text-secondary-700 mb-1">
{label}
{required && <span className="text-danger-500 ml-1" aria-label="required">*</span>}
</label>
)}
<select
ref={ref}
id={selectId}
className={clsx(
'block w-full rounded-md border px-3 py-2 text-base min-h-touch',
'focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-primary-500',
'motion-safe:transition-colors',
error
? 'border-danger-500 text-danger-900'
: 'border-secondary-300 text-secondary-900',
className
)}
aria-invalid={!!error}
aria-describedby={clsx(error && errorId, helperText && helperId) || undefined}
aria-required={required}
{...props}
>
{placeholder && <option value="" disabled>{placeholder}</option>}
{options.map((opt) => (
<option key={opt.value} value={opt.value}>{opt.label}</option>
))}
</select>
{error && (
<p id={errorId} className="mt-1 text-sm text-danger-600" role="alert">
{error}
</p>
)}
{helperText && !error && (
<p id={helperId} className="mt-1 text-sm text-secondary-500">
{helperText}
</p>
)}
</div>
);
}
);
Select.displayName = 'Select';
+44
View File
@@ -0,0 +1,44 @@
import React from 'react';
import clsx from 'clsx';
export interface SkeletonProps {
className?: string;
variant?: 'text' | 'rect' | 'circle';
width?: string;
height?: string;
}
export function Skeleton({ className, variant = 'rect', width, height }: SkeletonProps) {
const variantClass = {
text: 'rounded',
rect: 'rounded-md',
circle: 'rounded-full',
};
return (
<div
className={clsx(
'animate-pulse motion-reduce:animate-none bg-secondary-200',
variantClass[variant],
className
)}
style={{ width, height }}
role="status"
aria-label="Wird geladen"
/>
);
}
export function SkeletonText({ lines = 3, className }: { lines?: number; className?: string }) {
return (
<div className={clsx('space-y-2', className)} role="status" aria-label="Wird geladen">
{Array.from({ length: lines }).map((_, i) => (
<Skeleton
key={i}
variant="text"
className={clsx('h-4', i === lines - 1 && 'w-2/3')}
/>
))}
</div>
);
}
+143
View File
@@ -0,0 +1,143 @@
import React, { useState, useMemo } from 'react';
import clsx from 'clsx';
export interface TableColumn<T> {
key: string;
header: string;
sortable?: boolean;
render?: (row: T) => React.ReactNode;
accessor?: (row: T) => string | number;
width?: string;
}
export interface TableProps<T> {
columns: TableColumn<T>[];
data: T[];
rowKey: (row: T) => string;
onRowClick?: (row: T) => void;
emptyMessage?: string;
loading?: boolean;
}
type SortDirection = 'asc' | 'desc';
export function Table<T extends Record<string, any>>({
columns,
data,
rowKey,
onRowClick,
emptyMessage = 'Keine Daten vorhanden',
loading = false,
}: TableProps<T>) {
const [sortColumn, setSortColumn] = useState<string | null>(null);
const [sortDirection, setSortDirection] = useState<SortDirection>('asc');
const sortedData = useMemo(() => {
if (!sortColumn) return data;
const column = columns.find((c) => c.key === sortColumn);
if (!column?.sortable) return data;
const accessor = column.accessor || ((row: T) => row[sortColumn]);
return [...data].sort((a, b) => {
const aVal = accessor(a);
const bVal = accessor(b);
if (aVal < bVal) return sortDirection === 'asc' ? -1 : 1;
if (aVal > bVal) return sortDirection === 'asc' ? 1 : -1;
return 0;
});
}, [data, sortColumn, sortDirection, columns]);
const handleSort = (column: TableColumn<T>) => {
if (!column.sortable) return;
if (sortColumn === column.key) {
setSortDirection((prev) => (prev === 'asc' ? 'desc' : 'asc'));
} else {
setSortColumn(column.key);
setSortDirection('asc');
}
};
return (
<div className="overflow-x-auto" role="region" aria-label="Data table">
<table className="min-w-full divide-y divide-secondary-200">
<thead>
<tr>
{columns.map((col) => (
<th
key={col.key}
scope="col"
className={clsx(
'px-6 py-3 text-left text-xs font-semibold text-secondary-600 uppercase tracking-wider',
col.sortable && 'cursor-pointer select-none hover:bg-secondary-50 min-h-touch'
)}
style={col.width ? { width: col.width } : undefined}
aria-sort={sortColumn === col.key ? (sortDirection === 'asc' ? 'ascending' : 'descending') : 'none'}
onClick={() => handleSort(col)}
onKeyDown={(e) => {
if (col.sortable && (e.key === 'Enter' || e.key === ' ')) {
e.preventDefault();
handleSort(col);
}
}}
tabIndex={col.sortable ? 0 : undefined}
role={col.sortable ? 'button' : undefined}
aria-label={col.sortable ? `${col.header}, sortierbar` : undefined}
>
<div className="flex items-center gap-1">
{col.header}
{col.sortable && (
<span aria-hidden="true">
{sortColumn === col.key ? (sortDirection === 'asc' ? '▲' : '▼') : '↕'}
</span>
)}
</div>
</th>
))}
</tr>
</thead>
<tbody className="divide-y divide-secondary-100">
{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>
Wird geladen...
</span>
</td>
</tr>
) : sortedData.length === 0 ? (
<tr>
<td colSpan={columns.length} className="px-6 py-8 text-center text-secondary-500">
{emptyMessage}
</td>
</tr>
) : (
sortedData.map((row) => (
<tr
key={rowKey(row)}
className={clsx(
'hover:bg-secondary-50 motion-safe:transition-colors',
onRowClick && 'cursor-pointer'
)}
onClick={onRowClick ? () => onRowClick(row) : undefined}
tabIndex={onRowClick ? 0 : undefined}
onKeyDown={onRowClick ? (e) => {
if (e.key === 'Enter') onRowClick(row);
} : undefined}
>
{columns.map((col) => (
<td key={col.key} className="px-6 py-4 text-sm text-secondary-900">
{col.render ? col.render(row) : (row[col.key] ?? '—')}
</td>
))}
</tr>
))
)}
</tbody>
</table>
</div>
);
}
+80
View File
@@ -0,0 +1,80 @@
import React, { useEffect, useCallback } from 'react';
import clsx from 'clsx';
import { useUIStore, Toast as ToastType } from '@/store/uiStore';
const toastStyles: Record<ToastType['type'], string> = {
success: 'bg-success-50 border-success-500 text-success-800',
error: 'bg-danger-50 border-danger-500 text-danger-800',
warning: 'bg-warning-50 border-warning-500 text-warning-800',
info: 'bg-primary-50 border-primary-500 text-primary-800',
};
const toastIcons: Record<ToastType['type'], string> = {
success: 'M5 13l4 4L19 7',
error: 'M6 18L18 6M6 6l12 12',
warning: 'M12 9v2m0 4h.01M5 19h14a2 2 0 001.684-3.048l-7-12a2 2 0 00-3.368 0l-7 12A2 2 0 005 19z',
info: 'M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z',
};
function ToastItem({ toast, onRemove }: { toast: ToastType; onRemove: (id: string) => void }) {
useEffect(() => {
const duration = toast.duration ?? 5000;
const timer = setTimeout(() => onRemove(toast.id), duration);
return () => clearTimeout(timer);
}, [toast.id, toast.duration, onRemove]);
return (
<div
className={clsx(
'flex items-start gap-3 p-4 rounded-lg border-l-4 shadow-md min-w-72 max-w-md',
'motion-safe:animate-slide-in',
toastStyles[toast.type]
)}
role="alert"
aria-live="polite"
>
<svg className="h-5 w-5 flex-shrink-0 mt-0.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d={toastIcons[toast.type]} />
</svg>
<p className="flex-1 text-sm font-medium">{toast.message}</p>
<button
onClick={() => onRemove(toast.id)}
className="flex-shrink-0 text-current opacity-60 hover:opacity-100 min-h-touch min-w-touch flex items-center justify-center rounded focus:outline-none focus-visible:ring-2 focus-visible:ring-current"
aria-label="Close notification"
>
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
);
}
export function ToastContainer() {
const { toasts, removeToast } = useUIStore();
const handleRemove = useCallback((id: string) => removeToast(id), [removeToast]);
return (
<div
className="fixed top-4 right-4 z-[100] flex flex-col gap-2"
aria-live="polite"
aria-atomic="false"
data-testid="toast-container"
>
{toasts.map((toast) => (
<ToastItem key={toast.id} toast={toast} onRemove={handleRemove} />
))}
</div>
);
}
export function useToast() {
const { addToast, removeToast } = useUIStore();
return {
success: (message: string, duration?: number) => addToast({ type: 'success', message, duration }),
error: (message: string, duration?: number) => addToast({ type: 'error', message, duration }),
warning: (message: string, duration?: number) => addToast({ type: 'warning', message, duration }),
info: (message: string, duration?: number) => addToast({ type: 'info', message, duration }),
remove: removeToast,
};
}