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
+9
View File
@@ -0,0 +1,9 @@
node_modules/
dist/
*.local
.env
.env.*
!.env.example
coverage/
.vitest/
*.log
+16
View File
@@ -0,0 +1,16 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>leocrm — Mini-CRM</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet" />
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+4754
View File
File diff suppressed because it is too large Load Diff
+46
View File
@@ -0,0 +1,46 @@
{
"name": "leocrm-frontend",
"private": true,
"version": "1.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc --noEmit && vite build",
"preview": "vite preview",
"test": "vitest run src/__tests__/ --reporter=verbose",
"test:coverage": "vitest run src/__tests__/ --coverage",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-router-dom": "^6.26.0",
"@tanstack/react-query": "^5.56.0",
"zustand": "^4.5.5",
"react-i18next": "^15.0.0",
"i18next": "^23.14.0",
"i18next-browser-languagedetector": "^8.0.0",
"react-hook-form": "^7.53.0",
"zod": "^3.23.8",
"@hookform/resolvers": "^3.9.0",
"axios": "^1.7.7",
"clsx": "^2.1.1"
},
"devDependencies": {
"@types/react": "^18.3.8",
"@types/react-dom": "^18.3.0",
"@vitejs/plugin-react": "^4.3.1",
"typescript": "^5.6.0",
"vite": "^5.4.0",
"vitest": "^2.1.0",
"@vitest/coverage-v8": "^2.1.0",
"jsdom": "^25.0.0",
"@testing-library/react": "^16.0.1",
"@testing-library/jest-dom": "^6.5.0",
"@testing-library/user-event": "^14.5.2",
"tailwindcss": "^3.4.13",
"postcss": "^8.4.47",
"autoprefixer": "^10.4.20",
"identity-obj-proxy": "^3.0.0"
}
}
+6
View File
@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};
+32
View File
@@ -0,0 +1,32 @@
import React from 'react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { AppRouter } from '@/routes';
import { setUnauthorizedHandler } from '@/api/client';
import { useAuthStore } from '@/store/authStore';
const queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: 1,
staleTime: 5 * 60 * 1000,
refetchOnWindowFocus: false,
},
},
});
export default function App() {
const { logout } = useAuthStore();
React.useEffect(() => {
setUnauthorizedHandler(() => {
logout();
window.location.href = '/login';
});
}, [logout]);
return (
<QueryClientProvider client={queryClient}>
<AppRouter />
</QueryClientProvider>
);
}
@@ -0,0 +1,70 @@
import React from 'react';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { MemoryRouter } from 'react-router-dom';
import { LoginPage } from '@/pages/Login';
vi.mock('@/api/hooks', () => ({
useLogin: () => ({
mutateAsync: vi.fn().mockResolvedValue({ user: { id: '1', email: 'test@test.de' } }),
isPending: false,
}),
}));
vi.mock('@/store/uiStore', () => ({
useUIStore: () => ({
addToast: vi.fn(),
removeToast: vi.fn(),
}),
}));
function renderLogin() {
return render(
<MemoryRouter initialEntries={['/login']}>
<LoginPage />
</MemoryRouter>
);
}
describe('LoginPage', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('renders email and password form', () => {
renderLogin();
expect(screen.getByLabelText(/E-Mail-Adresse/)).toBeInTheDocument();
expect(screen.getByLabelText(/Passwort/)).toBeInTheDocument();
expect(screen.getByRole('button', { name: /Anmelden/ })).toBeInTheDocument();
});
it('has forgot password link', () => {
renderLogin();
expect(screen.getByText(/Passwort vergessen/)).toBeInTheDocument();
});
it('form has noValidate attribute', () => {
renderLogin();
const form = screen.getByRole('form');
expect(form).toHaveAttribute('noValidate');
});
it('email input has type=email', () => {
renderLogin();
const emailInput = screen.getByLabelText(/E-Mail-Adresse/);
expect(emailInput).toHaveAttribute('type', 'email');
});
it('password input has type=password', () => {
renderLogin();
const passwordInput = screen.getByLabelText(/Passwort/);
expect(passwordInput).toHaveAttribute('type', 'password');
});
it('submit button has min-h-touch for 44px target', () => {
renderLogin();
const btn = screen.getByRole('button', { name: /Anmelden/ });
expect(btn.className).toContain('min-h-touch');
});
});
@@ -0,0 +1,79 @@
import React from 'react';
import { describe, it, expect, vi } from 'vitest';
import { render, screen } from '@testing-library/react';
import { MemoryRouter } from 'react-router-dom';
import { PasswordResetRequestPage } from '@/pages/PasswordResetRequest';
import { PasswordResetConfirmPage } from '@/pages/PasswordResetConfirm';
vi.mock('@/api/hooks', () => ({
usePasswordResetRequest: () => ({
mutateAsync: vi.fn().mockResolvedValue({}),
isPending: false,
}),
usePasswordResetConfirm: () => ({
mutateAsync: vi.fn().mockResolvedValue({}),
isPending: false,
}),
}));
describe('PasswordResetRequestPage', () => {
it('renders form with email input and submit button', () => {
render(
<MemoryRouter initialEntries={['/password-reset']}>
<PasswordResetRequestPage />
</MemoryRouter>
);
expect(screen.getByLabelText(/E-Mail-Adresse/)).toBeInTheDocument();
expect(screen.getByRole('button', { name: /Link senden/ })).toBeInTheDocument();
});
it('renders description text', () => {
render(
<MemoryRouter initialEntries={['/password-reset']}>
<PasswordResetRequestPage />
</MemoryRouter>
);
expect(screen.getByText(/Geben Sie Ihre E-Mail-Adresse ein/)).toBeInTheDocument();
});
it('has back to login link', () => {
render(
<MemoryRouter initialEntries={['/password-reset']}>
<PasswordResetRequestPage />
</MemoryRouter>
);
expect(screen.getByText(/Zurück zur Anmeldung/)).toBeInTheDocument();
});
});
describe('PasswordResetConfirmPage', () => {
it('renders new password and confirm password inputs', () => {
render(
<MemoryRouter initialEntries={['/password-reset/confirm?token=abc123']}>
<PasswordResetConfirmPage />
</MemoryRouter>
);
expect(screen.getByLabelText(/Neues Passwort/)).toBeInTheDocument();
expect(screen.getByLabelText(/Passwort bestätigen/)).toBeInTheDocument();
expect(screen.getByRole('button', { name: /Passwort ändern/ })).toBeInTheDocument();
});
it('renders description text', () => {
render(
<MemoryRouter initialEntries={['/password-reset/confirm?token=abc123']}>
<PasswordResetConfirmPage />
</MemoryRouter>
);
expect(screen.getByText(/Geben Sie Ihr neues Passwort ein/)).toBeInTheDocument();
});
it('reads token from URL search params', () => {
render(
<MemoryRouter initialEntries={['/password-reset/confirm?token=test-token-123']}>
<PasswordResetConfirmPage />
</MemoryRouter>
);
// Token is used internally for the API call
expect(screen.getByTestId('reset-confirm-page')).toBeInTheDocument();
});
});
@@ -0,0 +1,53 @@
import React from 'react';
import { describe, it, expect, vi } from 'vitest';
import { render, screen, within } from '@testing-library/react';
import { MemoryRouter, Route, Routes } from 'react-router-dom';
import { AppShell } from '@/components/layout/AppShell';
vi.mock('@/api/hooks', () => ({
useLogout: () => ({ mutateAsync: vi.fn(), isPending: false }),
useSwitchTenant: () => ({ mutateAsync: vi.fn(), isPending: false }),
}));
function renderWithRouter(initialPath = '/dashboard') {
return render(
<MemoryRouter initialEntries={[initialPath]}>
<Routes>
<Route path="*" element={<AppShell />} />
</Routes>
</MemoryRouter>
);
}
describe('AppShell', () => {
it('renders sidebar, topbar, and content area', () => {
renderWithRouter();
expect(screen.getByTestId('app-shell')).toBeInTheDocument();
expect(screen.getByTestId('sidebar')).toBeInTheDocument();
expect(screen.getByTestId('topbar')).toBeInTheDocument();
expect(screen.getByTestId('content-area')).toBeInTheDocument();
});
it('sidebar contains navigation items', () => {
renderWithRouter();
const sidebar = screen.getByTestId('sidebar');
expect(within(sidebar).getByText('Dashboard')).toBeInTheDocument();
expect(within(sidebar).getByText('Firmen')).toBeInTheDocument();
expect(within(sidebar).getByText('Kontakte')).toBeInTheDocument();
expect(within(sidebar).getByText('Einstellungen')).toBeInTheDocument();
});
it('topbar contains tenant switcher, search, notifications, and user menu', () => {
renderWithRouter();
const topbar = screen.getByTestId('topbar');
expect(within(topbar).getByLabelText('Mandant wechseln')).toBeInTheDocument();
expect(within(topbar).getByLabelText('Suchen...')).toBeInTheDocument();
expect(within(topbar).getByLabelText('Benachrichtigungen')).toBeInTheDocument();
expect(within(topbar).getByLabelText('Benutzermenü')).toBeInTheDocument();
});
it('content area has main role for screen readers', () => {
renderWithRouter();
expect(screen.getByRole('main')).toBeInTheDocument();
});
});
@@ -0,0 +1,42 @@
import React from 'react';
import { describe, it, expect, vi } from 'vitest';
import { render, screen, act } from '@testing-library/react';
import { MemoryRouter, Route, Routes, Navigate } from 'react-router-dom';
import { ProtectedRoute } from '@/routes/ProtectedRoute';
import { useAuthStore } from '@/store/authStore';
function ProtectedTest() {
return (
<MemoryRouter initialEntries={['/dashboard']}>
<Routes>
<Route path="/login" element={<div data-testid="login-page">Login</div>} />
<Route path="/dashboard" element={
<ProtectedRoute>
<div data-testid="protected-content">Protected</div>
</ProtectedRoute>
} />
</Routes>
</MemoryRouter>
);
}
describe('Router & ProtectedRoute', () => {
it('redirects to /login when not authenticated', () => {
useAuthStore.setState({ isAuthenticated: false, user: null });
render(<ProtectedTest />);
expect(screen.getByTestId('login-page')).toBeInTheDocument();
expect(screen.queryByTestId('protected-content')).not.toBeInTheDocument();
});
it('shows protected content when authenticated', () => {
useAuthStore.setState({
isAuthenticated: true,
user: { id: '1', email: 'test@test.de', first_name: 'Test', last_name: 'User', role: 'admin', avatar_url: null, tenants: [{ id: 't1', name: 'Test Tenant', slug: 'test' }] },
currentTenant: { id: 't1', name: 'Test Tenant', slug: 'test' },
});
render(<ProtectedTest />);
expect(screen.getByTestId('protected-content')).toBeInTheDocument();
expect(screen.queryByTestId('login-page')).not.toBeInTheDocument();
useAuthStore.setState({ isAuthenticated: false, user: null });
});
});
@@ -0,0 +1,40 @@
import React from 'react';
import { describe, it, expect } from 'vitest';
import { render, screen, within } from '@testing-library/react';
import { MemoryRouter } from 'react-router-dom';
import { Sidebar } from '@/components/layout/Sidebar';
describe('Sidebar', () => {
it('renders navigation links with ARIA labels', () => {
render(
<MemoryRouter initialEntries={['/dashboard']}>
<Sidebar />
</MemoryRouter>
);
expect(screen.getByLabelText('Dashboard')).toBeInTheDocument();
expect(screen.getByLabelText('Firmen')).toBeInTheDocument();
expect(screen.getByLabelText('Kontakte')).toBeInTheDocument();
expect(screen.getByLabelText('Einstellungen')).toBeInTheDocument();
});
it('has navigation landmark with aria-label', () => {
render(
<MemoryRouter initialEntries={['/dashboard']}>
<Sidebar />
</MemoryRouter>
);
expect(screen.getByLabelText('Seitenleiste Navigation')).toBeInTheDocument();
});
it('all nav links have min 44px touch targets', () => {
render(
<MemoryRouter initialEntries={['/dashboard']}>
<Sidebar />
</MemoryRouter>
);
const navLinks = screen.getAllByRole('link');
navLinks.forEach((link) => {
expect(link.className).toContain('min-h-touch');
});
});
});
@@ -0,0 +1,68 @@
import React from 'react';
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { render, screen, within } from '@testing-library/react';
import { MemoryRouter } from 'react-router-dom';
import { TopBar } from '@/components/layout/TopBar';
import { useAuthStore } from '@/store/authStore';
vi.mock('@/api/hooks', () => ({
useLogout: () => ({ mutateAsync: vi.fn(), isPending: false }),
useSwitchTenant: () => ({ mutateAsync: vi.fn(), isPending: false }),
}));
function renderTopBar() {
return render(
<MemoryRouter initialEntries={['/dashboard']}>
<TopBar />
</MemoryRouter>
);
}
describe('TopBar', () => {
beforeEach(() => {
useAuthStore.setState({
isAuthenticated: true,
user: {
id: '1',
email: 'max@test.de',
first_name: 'Max',
last_name: 'Mustermann',
role: 'admin',
avatar_url: null,
tenants: [
{ id: 't1', name: 'Firma Alpha', slug: 'alpha' },
{ id: 't2', name: 'Firma Beta', slug: 'beta' },
],
},
currentTenant: { id: 't1', name: 'Firma Alpha', slug: 'alpha' },
});
});
it('renders tenant switcher with current tenant name', () => {
renderTopBar();
expect(screen.getByText('Firma Alpha')).toBeInTheDocument();
});
it('renders search input with aria-label', () => {
renderTopBar();
expect(screen.getByLabelText('Suchen...')).toBeInTheDocument();
});
it('renders notifications button with aria-label', () => {
renderTopBar();
expect(screen.getByLabelText('Benachrichtigungen')).toBeInTheDocument();
});
it('renders user menu button with aria-label', () => {
renderTopBar();
expect(screen.getByLabelText('Benutzermenü')).toBeInTheDocument();
});
it('all interactive elements have min-h-touch for 44px targets', () => {
renderTopBar();
const buttons = screen.getAllByRole('button');
buttons.forEach((btn) => {
expect(btn.className).toContain('min-h-touch');
});
});
});
+34
View File
@@ -0,0 +1,34 @@
import React from 'react';
import { describe, it, expect } from 'vitest';
import { render, screen, fireEvent } from '@testing-library/react';
import { Avatar } from '@/components/ui/Avatar';
describe('Avatar', () => {
it('renders initials when no src provided', () => {
render(<Avatar name="Max Mustermann" />);
expect(screen.getByText('MM')).toBeInTheDocument();
});
it('renders single initial for single name', () => {
render(<Avatar name="Anna" />);
expect(screen.getByText('A')).toBeInTheDocument();
});
it('has role=img and aria-label', () => {
render(<Avatar name="Max Mustermann" />);
expect(screen.getByRole('img')).toHaveAttribute('aria-label', 'Avatar von Max Mustermann');
});
it('renders image when src provided', () => {
render(<Avatar name="Test" src="https://example.com/avatar.png" />);
const img = screen.getByRole('img').querySelector('img')!;
expect(img).not.toBeNull();
});
it('falls back to initials on image error', () => {
render(<Avatar name="Max Mustermann" src="https://example.com/broken.png" />);
const img = screen.getByRole('img').querySelector('img')!;
fireEvent.error(img);
expect(screen.getByText('MM')).toBeInTheDocument();
});
});
+33
View File
@@ -0,0 +1,33 @@
import React from 'react';
import { describe, it, expect } from 'vitest';
import { render, screen } from '@testing-library/react';
import { Badge } from '@/components/ui/Badge';
describe('Badge', () => {
it('renders children text', () => {
render(<Badge>aktiv</Badge>);
expect(screen.getByText('aktiv')).toBeInTheDocument();
});
it('renders with default variant', () => {
render(<Badge>Standard</Badge>);
const badge = screen.getByText('Standard');
expect(badge.className).toContain('bg-secondary-100');
});
it('renders with success variant', () => {
render(<Badge variant="success">OK</Badge>);
expect(screen.getByText('OK').className).toContain('bg-success-100');
});
it('renders with danger variant', () => {
render(<Badge variant="danger">Fehler</Badge>);
expect(screen.getByText('Fehler').className).toContain('bg-danger-100');
});
it('renders dot indicator when dot=true', () => {
const { container } = render(<Badge dot>aktiv</Badge>);
const dot = container.querySelector('span > span');
expect(dot).toBeInTheDocument();
});
});
+61
View File
@@ -0,0 +1,61 @@
import React from 'react';
import { describe, it, expect } from 'vitest';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { Button } from '@/components/ui/Button';
describe('Button', () => {
it('renders primary variant by default', () => {
render(<Button>Klick mich</Button>);
const btn = screen.getByRole('button', { name: 'Klick mich' });
expect(btn).toBeInTheDocument();
expect(btn.className).toContain('bg-primary-600');
});
it('renders secondary variant', () => {
render(<Button variant="secondary">Sekundär</Button>);
const btn = screen.getByRole('button', { name: 'Sekundär' });
expect(btn.className).toContain('bg-secondary-200');
});
it('renders danger variant', () => {
render(<Button variant="danger">Löschen</Button>);
const btn = screen.getByRole('button', { name: 'Löschen' });
expect(btn.className).toContain('bg-danger-600');
});
it('renders ghost variant', () => {
render(<Button variant="ghost">Geist</Button>);
const btn = screen.getByRole('button', { name: 'Geist' });
expect(btn.className).toContain('bg-transparent');
});
it('calls onClick when clicked', async () => {
const onClick = vi.fn();
render(<Button onClick={onClick}>Klick</Button>);
await userEvent.click(screen.getByRole('button'));
expect(onClick).toHaveBeenCalledTimes(1);
});
it('shows loading spinner when isLoading', () => {
render(<Button isLoading>Laden</Button>);
const btn = screen.getByRole('button');
expect(btn).toBeDisabled();
expect(btn).toHaveAttribute('aria-busy', 'true');
});
it('has min-h-touch for 44px touch target', () => {
render(<Button>Test</Button>);
expect(screen.getByRole('button').className).toContain('min-h-touch');
});
it('is disabled when disabled prop is true', () => {
render(<Button disabled>Disabled</Button>);
expect(screen.getByRole('button')).toBeDisabled();
});
it('renders with full width when fullWidth', () => {
render(<Button fullWidth>Voll</Button>);
expect(screen.getByRole('button').className).toContain('w-full');
});
});
+26
View File
@@ -0,0 +1,26 @@
import React from 'react';
import { describe, it, expect } from 'vitest';
import { render, screen } from '@testing-library/react';
import { Card } from '@/components/ui/Card';
describe('Card', () => {
it('renders children content', () => {
render(<Card>Inhalt der Karte</Card>);
expect(screen.getByText('Inhalt der Karte')).toBeInTheDocument();
});
it('renders title when provided', () => {
render(<Card title="Firmendetails">Inhalt</Card>);
expect(screen.getByText('Firmendetails')).toBeInTheDocument();
});
it('renders description when provided', () => {
render(<Card title="Test" description="Eine Beschreibung">Inhalt</Card>);
expect(screen.getByText('Eine Beschreibung')).toBeInTheDocument();
});
it('renders footer when provided', () => {
render(<Card footer={<button>Aktion</button>}>Inhalt</Card>);
expect(screen.getByRole('button', { name: 'Aktion' })).toBeInTheDocument();
});
});
@@ -0,0 +1,46 @@
import React from 'react';
import { describe, it, expect, vi } from 'vitest';
import { render, screen, fireEvent } from '@testing-library/react';
import { ConfirmDialog } from '@/components/ui/ConfirmDialog';
describe('ConfirmDialog', () => {
it('renders nothing when open=false', () => {
render(<ConfirmDialog open={false} message="Wirklich löschen?" onConfirm={() => {}} onCancel={() => {}} />);
expect(screen.queryByText('Wirklich löschen?')).not.toBeInTheDocument();
});
it('renders message when open=true', () => {
render(<ConfirmDialog open={true} message="Wirklich löschen?" onConfirm={() => {}} onCancel={() => {}} />);
expect(screen.getByText('Wirklich löschen?')).toBeInTheDocument();
});
it('calls onConfirm when confirm button clicked', () => {
const onConfirm = vi.fn();
render(<ConfirmDialog open={true} message="Löschen?" onConfirm={onConfirm} onCancel={() => {}} />);
fireEvent.click(screen.getByRole('button', { name: 'Bestätigen' }));
expect(onConfirm).toHaveBeenCalledTimes(1);
});
it('calls onCancel when cancel button clicked', () => {
const onCancel = vi.fn();
render(<ConfirmDialog open={true} message="Löschen?" onConfirm={() => {}} onCancel={onCancel} />);
fireEvent.click(screen.getByRole('button', { name: 'Abbrechen' }));
expect(onCancel).toHaveBeenCalledTimes(1);
});
it('renders default title', () => {
render(<ConfirmDialog open={true} message="Test" onConfirm={() => {}} onCancel={() => {}} />);
expect(screen.getByText('Bestätigung erforderlich')).toBeInTheDocument();
});
it('renders custom title', () => {
render(<ConfirmDialog open={true} title="Löschen bestätigen" message="Test" onConfirm={() => {}} onCancel={() => {}} />);
expect(screen.getByText('Löschen bestätigen')).toBeInTheDocument();
});
it('uses danger variant button when variant=danger', () => {
render(<ConfirmDialog open={true} variant="danger" message="Test" onConfirm={() => {}} onCancel={() => {}} />);
const confirmBtn = screen.getByRole('button', { name: 'Bestätigen' });
expect(confirmBtn.className).toContain('bg-danger-600');
});
});
@@ -0,0 +1,28 @@
import React from 'react';
import { describe, it, expect } from 'vitest';
import { render, screen } from '@testing-library/react';
import { EmptyState } from '@/components/ui/EmptyState';
describe('EmptyState', () => {
it('renders default title and description', () => {
render(<EmptyState />);
expect(screen.getByText('Nichts gefunden')).toBeInTheDocument();
expect(screen.getByText('Es sind noch keine Daten vorhanden.')).toBeInTheDocument();
});
it('renders custom title and description', () => {
render(<EmptyState title="Keine Firmen" description="Erstellen Sie Ihre erste Firma." />);
expect(screen.getByText('Keine Firmen')).toBeInTheDocument();
expect(screen.getByText('Erstellen Sie Ihre erste Firma.')).toBeInTheDocument();
});
it('renders action when provided', () => {
render(<EmptyState action={<button>Firma erstellen</button>} />);
expect(screen.getByRole('button', { name: 'Firma erstellen' })).toBeInTheDocument();
});
it('has role=status', () => {
render(<EmptyState />);
expect(screen.getByRole('status')).toBeInTheDocument();
});
});
+49
View File
@@ -0,0 +1,49 @@
import React from 'react';
import { describe, it, expect } from 'vitest';
import { render, screen } from '@testing-library/react';
import { Input } from '@/components/ui/Input';
describe('Input', () => {
it('renders with label', () => {
render(<Input label="Name" />);
expect(screen.getByText('Name')).toBeInTheDocument();
});
it('renders error message with role=alert', () => {
render(<Input label="Email" error="Ungültige E-Mail" />);
expect(screen.getByRole('alert')).toHaveTextContent('Ungültige E-Mail');
});
it('renders helper text', () => {
render(<Input label="Passwort" helperText="Mindestens 8 Zeichen" />);
expect(screen.getByText('Mindestens 8 Zeichen')).toBeInTheDocument();
});
it('does not render helper text when error is present', () => {
render(<Input label="Passwort" error="Fehler" helperText="Hilfetext" />);
expect(screen.queryByText('Hilfetext')).not.toBeInTheDocument();
});
it('marks input as required with aria-required', () => {
render(<Input label="Name" required />);
expect(screen.getByLabelText(/Name/)).toHaveAttribute('aria-required', 'true');
});
it('sets aria-invalid when error present', () => {
render(<Input label="Email" error="Fehler" />);
expect(screen.getByLabelText(/Email/)).toHaveAttribute('aria-invalid', 'true');
});
it('has min-h-touch for 44px target', () => {
render(<Input label="Test" />);
expect(screen.getByLabelText(/Test/).className).toContain('min-h-touch');
});
it('links error via aria-describedby', () => {
render(<Input label="Email" error="Fehler" />);
const input = screen.getByLabelText(/Email/);
const errorId = input.getAttribute('aria-describedby');
expect(errorId).toBeTruthy();
expect(document.getElementById(errorId!)).toHaveTextContent('Fehler');
});
});
+54
View File
@@ -0,0 +1,54 @@
import React from 'react';
import { describe, it, expect } from 'vitest';
import { render, screen, fireEvent } from '@testing-library/react';
import { Modal } from '@/components/ui/Modal';
describe('Modal', () => {
it('renders nothing when open=false', () => {
render(<Modal open={false} onClose={() => {}}>Content</Modal>);
expect(screen.queryByText('Content')).not.toBeInTheDocument();
});
it('renders content when open=true', () => {
render(<Modal open={true} onClose={() => {}}>Modal Content</Modal>);
expect(screen.getByText('Modal Content')).toBeInTheDocument();
});
it('has role=dialog and aria-modal=true', () => {
render(<Modal open={true} onClose={() => {}}>Content</Modal>);
expect(screen.getByRole('dialog')).toHaveAttribute('aria-modal', 'true');
});
it('calls onClose when backdrop clicked', () => {
const onClose = vi.fn();
render(<Modal open={true} onClose={onClose}>Content</Modal>);
fireEvent.click(screen.getByTestId('modal-backdrop'));
expect(onClose).toHaveBeenCalledTimes(1);
});
it('calls onClose when ESC pressed', () => {
const onClose = vi.fn();
render(<Modal open={true} onClose={onClose}>Content</Modal>);
fireEvent.keyDown(document, { key: 'Escape' });
expect(onClose).toHaveBeenCalledTimes(1);
});
it('does not call onClose on backdrop when closeOnBackdrop=false', () => {
const onClose = vi.fn();
render(<Modal open={true} onClose={onClose} closeOnBackdrop={false}>Content</Modal>);
fireEvent.click(screen.getByTestId('modal-backdrop'));
expect(onClose).not.toHaveBeenCalled();
});
it('renders title with aria-labelledby', () => {
render(<Modal open={true} onClose={() => {}} title="Test Title">Content</Modal>);
const dialog = screen.getByRole('dialog');
expect(dialog).toHaveAttribute('aria-labelledby', 'modal-title');
expect(screen.getByText('Test Title')).toBeInTheDocument();
});
it('renders close button with aria-label', () => {
render(<Modal open={true} onClose={() => {}}>Content</Modal>);
expect(screen.getByLabelText('Close dialog')).toBeInTheDocument();
});
});
@@ -0,0 +1,61 @@
import React from 'react';
import { describe, it, expect, vi } from 'vitest';
import { render, screen, fireEvent } from '@testing-library/react';
import { Pagination } from '@/components/ui/Pagination';
describe('Pagination', () => {
it('renders page info and navigation', () => {
render(
<Pagination currentPage={2} totalPages={5} total={100} pageSize={20} onPageChange={() => {}} />
);
expect(screen.getByText('2140')).toBeInTheDocument();
expect(screen.getByText('100')).toBeInTheDocument();
});
it('calls onPageChange with next page', () => {
const onPageChange = vi.fn();
render(
<Pagination currentPage={1} totalPages={3} total={60} pageSize={20} onPageChange={onPageChange} />
);
fireEvent.click(screen.getByLabelText('Weiter'));
expect(onPageChange).toHaveBeenCalledWith(2);
});
it('calls onPageChange with previous page', () => {
const onPageChange = vi.fn();
render(
<Pagination currentPage={2} totalPages={3} total={60} pageSize={20} onPageChange={onPageChange} />
);
fireEvent.click(screen.getByLabelText('Zurück'));
expect(onPageChange).toHaveBeenCalledWith(1);
});
it('disables previous on first page', () => {
render(
<Pagination currentPage={1} totalPages={3} total={60} pageSize={20} onPageChange={() => {}} />
);
expect(screen.getByLabelText('Zurück')).toBeDisabled();
});
it('disables next on last page', () => {
render(
<Pagination currentPage={3} totalPages={3} total={60} pageSize={20} onPageChange={() => {}} />
);
expect(screen.getByLabelText('Weiter')).toBeDisabled();
});
it('returns null when totalPages <= 1', () => {
const { container } = render(
<Pagination currentPage={1} totalPages={1} total={5} pageSize={20} onPageChange={() => {}} />
);
expect(container.firstChild).toBeNull();
});
it('has aria-current on current page button', () => {
render(
<Pagination currentPage={2} totalPages={5} total={100} pageSize={20} onPageChange={() => {}} />
);
const currentBtn = screen.getByLabelText('Seite 2');
expect(currentBtn).toHaveAttribute('aria-current', 'page');
});
});
+45
View File
@@ -0,0 +1,45 @@
import React from 'react';
import { describe, it, expect } from 'vitest';
import { render, screen, fireEvent } from '@testing-library/react';
import { Select } from '@/components/ui/Select';
const options = [
{ value: 'de', label: 'Deutsch' },
{ value: 'en', label: 'English' },
];
describe('Select', () => {
it('renders label and options', () => {
render(<Select label="Sprache" options={options} />);
expect(screen.getByText('Sprache')).toBeInTheDocument();
expect(screen.getByText('Deutsch')).toBeInTheDocument();
expect(screen.getByText('English')).toBeInTheDocument();
});
it('renders error with role=alert', () => {
render(<Select label="Sprache" options={options} error="Bitte auswählen" />);
expect(screen.getByRole('alert')).toHaveTextContent('Bitte auswählen');
});
it('renders helper text', () => {
render(<Select label="Sprache" options={options} helperText="Wählen Sie eine Sprache" />);
expect(screen.getByText('Wählen Sie eine Sprache')).toBeInTheDocument();
});
it('calls onChange when value changes', () => {
const onChange = vi.fn();
render(<Select label="Sprache" options={options} onChange={onChange} />);
fireEvent.change(screen.getByLabelText(/Sprache/), { target: { value: 'en' } });
expect(onChange).toHaveBeenCalled();
});
it('has min-h-touch for 44px target', () => {
render(<Select label="Sprache" options={options} />);
expect(screen.getByLabelText(/Sprache/).className).toContain('min-h-touch');
});
it('sets aria-required when required', () => {
render(<Select label="Sprache" options={options} required />);
expect(screen.getByLabelText(/Sprache/)).toHaveAttribute('aria-required', 'true');
});
});
@@ -0,0 +1,41 @@
import React from 'react';
import { describe, it, expect } from 'vitest';
import { render, screen } from '@testing-library/react';
import { Skeleton, SkeletonText } from '@/components/ui/Skeleton';
describe('Skeleton', () => {
it('renders with role=status', () => {
render(<Skeleton />);
expect(screen.getByRole('status')).toBeInTheDocument();
});
it('renders with aria-label', () => {
render(<Skeleton />);
expect(screen.getByRole('status')).toHaveAttribute('aria-label', 'Wird geladen');
});
it('applies variant classes correctly', () => {
const { container } = render(<Skeleton variant="circle" />);
expect(container.firstChild).toHaveClass('rounded-full');
});
it('applies width and height styles', () => {
const { container } = render(<Skeleton width="200px" height="50px" />);
const el = container.firstChild as HTMLElement;
expect(el.style.width).toBe('200px');
expect(el.style.height).toBe('50px');
});
});
describe('SkeletonText', () => {
it('renders specified number of lines', () => {
const { container } = render(<SkeletonText lines={3} />);
const skeletons = container.querySelectorAll('[role="status"]');
expect(skeletons.length).toBeGreaterThanOrEqual(1);
});
it('has role=status for accessibility', () => {
render(<SkeletonText lines={2} />);
expect(screen.getAllByRole('status').length).toBeGreaterThan(0);
});
});
+72
View File
@@ -0,0 +1,72 @@
import React from 'react';
import { describe, it, expect } from 'vitest';
import { render, screen, fireEvent } from '@testing-library/react';
import { Table, TableColumn } from '@/components/ui/Table';
interface TestRow {
id: string;
name: string;
email: string;
}
const columns: TableColumn<TestRow>[] = [
{ key: 'name', header: 'Name', sortable: true },
{ key: 'email', header: 'E-Mail', sortable: true },
{ key: 'id', header: 'ID', sortable: false },
];
const data: TestRow[] = [
{ id: '1', name: 'Anna Schmidt', email: 'anna@test.de' },
{ id: '2', name: 'Max Müller', email: 'max@test.de' },
{ id: '3', name: 'Lisa Weber', email: 'lisa@test.de' },
];
describe('Table', () => {
it('renders all rows and headers', () => {
render(<Table columns={columns} data={data} rowKey={(r) => r.id} />);
expect(screen.getByText('Anna Schmidt')).toBeInTheDocument();
expect(screen.getByText('Max Müller')).toBeInTheDocument();
expect(screen.getByText('Lisa Weber')).toBeInTheDocument();
expect(screen.getByText('Name')).toBeInTheDocument();
expect(screen.getByText('E-Mail')).toBeInTheDocument();
});
it('sortable headers have role=button and aria-label', () => {
render(<Table columns={columns} data={data} rowKey={(r) => r.id} />);
const nameHeader = screen.getByRole('button', { name: /Name/ });
expect(nameHeader).toBeInTheDocument();
});
it('clicking sortable header sorts ascending then descending', () => {
render(<Table columns={columns} data={data} rowKey={(r) => r.id} />);
const nameHeader = screen.getByRole('button', { name: /Name/ });
fireEvent.click(nameHeader);
// ascending: Anna, Lisa, Max
const rows = screen.getAllByRole('row');
expect(rows[1]).toHaveTextContent('Anna Schmidt');
expect(rows[3]).toHaveTextContent('Max Müller');
fireEvent.click(nameHeader);
// descending: Max, Lisa, Anna
const rowsDesc = screen.getAllByRole('row');
expect(rowsDesc[1]).toHaveTextContent('Max Müller');
expect(rowsDesc[3]).toHaveTextContent('Anna Schmidt');
});
it('aria-sort updates on sort', () => {
render(<Table columns={columns} data={data} rowKey={(r) => r.id} />);
const nameHeader = screen.getByRole('button', { name: /Name/ });
expect(nameHeader.closest('th')).toHaveAttribute('aria-sort', 'none');
fireEvent.click(nameHeader);
expect(nameHeader.closest('th')).toHaveAttribute('aria-sort', 'ascending');
});
it('shows empty message when no data', () => {
render(<Table columns={columns} data={[]} rowKey={(r) => r.id} emptyMessage="Keine Daten" />);
expect(screen.getByText('Keine Daten')).toBeInTheDocument();
});
it('shows loading state', () => {
render(<Table columns={columns} data={[]} rowKey={(r) => r.id} loading={true} />);
expect(screen.getByText('Wird geladen...')).toBeInTheDocument();
});
});
+56
View File
@@ -0,0 +1,56 @@
import React from 'react';
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { render, screen, act, fireEvent } from '@testing-library/react';
import { ToastContainer, useToast } from '@/components/ui/Toast';
import { useUIStore } from '@/store/uiStore';
describe('Toast', () => {
beforeEach(() => {
useUIStore.setState({ toasts: [] });
});
it('renders toast container', () => {
render(<ToastContainer />);
expect(screen.getByTestId('toast-container')).toBeInTheDocument();
});
it('displays toast when added to store', () => {
useUIStore.setState({
toasts: [{ id: '1', type: 'success', message: 'Erfolgreich gespeichert' }],
});
render(<ToastContainer />);
expect(screen.getByText('Erfolgreich gespeichert')).toBeInTheDocument();
});
it('toast has role=alert for accessibility', () => {
useUIStore.setState({
toasts: [{ id: '1', type: 'error', message: 'Fehler aufgetreten' }],
});
render(<ToastContainer />);
expect(screen.getByRole('alert')).toHaveTextContent('Fehler aufgetreten');
});
it('auto-dismisses after duration', () => {
vi.useFakeTimers();
useUIStore.setState({
toasts: [{ id: '1', type: 'info', message: 'Info', duration: 1000 }],
});
render(<ToastContainer />);
expect(screen.getByText('Info')).toBeInTheDocument();
act(() => {
vi.advanceTimersByTime(1000);
});
expect(screen.queryByText('Info')).not.toBeInTheDocument();
vi.useRealTimers();
});
it('can be manually dismissed', () => {
useUIStore.setState({
toasts: [{ id: '1', type: 'warning', message: 'Warnung' }],
});
render(<ToastContainer />);
const closeBtn = screen.getByLabelText('Close notification');
fireEvent.click(closeBtn);
expect(screen.queryByText('Warnung')).not.toBeInTheDocument();
});
});
+35
View File
@@ -0,0 +1,35 @@
import { describe, it, expect, beforeAll } from 'vitest';
import i18n from '@/i18n';
describe('i18n', () => {
beforeAll(async () => {
await i18n.changeLanguage('de');
});
it('loads German locale by default', () => {
expect(i18n.language).toBe('de');
});
it('translates key in German', () => {
expect(i18n.t('nav.dashboard')).toBe('Dashboard');
expect(i18n.t('nav.companies')).toBe('Firmen');
expect(i18n.t('auth.login')).toBe('Anmelden');
});
it('can switch to English', async () => {
await i18n.changeLanguage('en');
expect(i18n.language).toBe('en');
expect(i18n.t('auth.login')).toBe('Sign In');
expect(i18n.t('nav.companies')).toBe('Companies');
});
it('falls back to German for missing English keys', async () => {
await i18n.changeLanguage('en');
expect(i18n.t('app.name')).toBe('leocrm');
});
it('switches back to German', async () => {
await i18n.changeLanguage('de');
expect(i18n.t('auth.login')).toBe('Anmelden');
});
});
+117
View File
@@ -0,0 +1,117 @@
import axios, { AxiosError, AxiosRequestConfig, InternalAxiosRequestConfig } from 'axios';
export interface ApiError {
status: number;
message: string;
detail?: string;
validationErrors?: Record<string, string[]>;
}
export const apiClient = axios.create({
baseURL: '/api/v1',
withCredentials: true,
headers: {
'Content-Type': 'application/json',
},
});
let onUnauthorized: (() => void) | null = null;
let onValidationError: ((errors: Record<string, string[]>) => void) | null = null;
export function setUnauthorizedHandler(handler: () => void) {
onUnauthorized = handler;
}
export function setValidationErrorHandler(handler: (errors: Record<string, string[]>) => void) {
onValidationError = handler;
}
apiClient.interceptors.request.use(
(config: InternalAxiosRequestConfig) => {
return config;
},
(error) => Promise.reject(error)
);
apiClient.interceptors.response.use(
(response) => response,
(error: AxiosError) => {
const status = error.response?.status || 0;
const data = error.response?.data as any;
if (status === 401) {
if (onUnauthorized) {
onUnauthorized();
}
}
if (status === 422 && data?.detail) {
const validationErrors: Record<string, string[]> = {};
if (Array.isArray(data.detail)) {
for (const item of data.detail) {
if (item.loc && item.loc.length > 1) {
const field = item.loc[item.loc.length - 1];
if (!validationErrors[field]) {
validationErrors[field] = [];
}
validationErrors[field].push(item.msg || 'Invalid value');
}
}
}
if (onValidationError) {
onValidationError(validationErrors);
}
}
const apiError: ApiError = {
status,
message: data?.detail?.message || data?.message || error.message || 'An error occurred',
detail: typeof data?.detail === 'string' ? data.detail : undefined,
validationErrors: status === 422 ? extractValidationErrors(data) : undefined,
};
return Promise.reject(apiError);
}
);
function extractValidationErrors(data: any): Record<string, string[]> | undefined {
if (!data?.detail || !Array.isArray(data.detail)) return undefined;
const errors: Record<string, string[]> = {};
for (const item of data.detail) {
if (item.loc && item.loc.length > 1) {
const field = item.loc[item.loc.length - 1];
if (!errors[field]) {
errors[field] = [];
}
errors[field].push(item.msg || 'Invalid value');
}
}
return Object.keys(errors).length > 0 ? errors : undefined;
}
export async function apiGet<T>(url: string, config?: AxiosRequestConfig): Promise<T> {
const response = await apiClient.get<T>(url, config);
return response.data;
}
export async function apiPost<T>(url: string, data?: any, config?: AxiosRequestConfig): Promise<T> {
const response = await apiClient.post<T>(url, data, config);
return response.data;
}
export async function apiPut<T>(url: string, data?: any, config?: AxiosRequestConfig): Promise<T> {
const response = await apiClient.put<T>(url, data, config);
return response.data;
}
export async function apiPatch<T>(url: string, data?: any, config?: AxiosRequestConfig): Promise<T> {
const response = await apiClient.patch<T>(url, data, config);
return response.data;
}
export async function apiDelete<T>(url: string, config?: AxiosRequestConfig): Promise<T> {
const response = await apiClient.delete<T>(url, config);
return response.data;
}
export default apiClient;
+162
View File
@@ -0,0 +1,162 @@
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { apiPost, apiGet, apiPatch, apiDelete } from './client';
import { useAuthStore } from '@/store/authStore';
export interface LoginPayload {
email: string;
password: string;
}
export interface PasswordResetRequestPayload {
email: string;
}
export interface PasswordResetConfirmPayload {
token: string;
password: string;
}
export interface PaginatedResponse<T> {
items: T[];
total: number;
page: number;
page_size: number;
}
export function useLogin() {
const { setUser, setError } = useAuthStore();
return useMutation({
mutationFn: (payload: LoginPayload) =>
apiPost('/auth/login', payload),
onSuccess: (data: any) => {
setUser(data.user || data);
setError(null);
},
onError: (error: any) => {
setError(error.message || 'Login failed');
},
});
}
export function useLogout() {
const { logout } = useAuthStore();
const queryClient = useQueryClient();
return useMutation({
mutationFn: () => apiPost('/auth/logout'),
onSettled: () => {
logout();
queryClient.clear();
},
});
}
export function useCurrentUser() {
const { setUser, setAuthenticated } = useAuthStore();
return useQuery({
queryKey: ['currentUser'],
queryFn: async () => {
const data = await apiGet<any>('/auth/me');
setUser(data.user || data);
setAuthenticated(true);
return data;
},
retry: false,
staleTime: 5 * 60 * 1000,
});
}
export function usePasswordResetRequest() {
return useMutation({
mutationFn: (payload: PasswordResetRequestPayload) =>
apiPost('/auth/password-reset/request', payload),
});
}
export function usePasswordResetConfirm() {
return useMutation({
mutationFn: (payload: PasswordResetConfirmPayload) =>
apiPost('/auth/password-reset/confirm', payload),
});
}
export function useSwitchTenant() {
const { setTenant } = useAuthStore();
const queryClient = useQueryClient();
return useMutation({
mutationFn: (tenantId: string) =>
apiPost('/auth/switch-tenant', { tenant_id: tenantId }),
onSuccess: (data: any) => {
setTenant(data.tenant || data);
queryClient.invalidateQueries();
},
});
}
export function useUsers(page = 1, pageSize = 25) {
return useQuery({
queryKey: ['users', page, pageSize],
queryFn: () =>
apiGet<PaginatedResponse<any>>(`/users?page=${page}&page_size=${pageSize}`),
});
}
export function useCompanies(page = 1, pageSize = 25, search?: string) {
const params = new URLSearchParams({ page: String(page), page_size: String(pageSize) });
if (search) params.set('search', search);
return useQuery({
queryKey: ['companies', page, pageSize, search],
queryFn: () =>
apiGet<PaginatedResponse<any>>(`/companies?${params.toString()}`),
});
}
export function useContacts(page = 1, pageSize = 25, search?: string) {
const params = new URLSearchParams({ page: String(page), page_size: String(pageSize) });
if (search) params.set('search', search);
return useQuery({
queryKey: ['contacts', page, pageSize, search],
queryFn: () =>
apiGet<PaginatedResponse<any>>(`/contacts?${params.toString()}`),
});
}
export function useNotifications() {
return useQuery({
queryKey: ['notifications'],
queryFn: () => apiGet<PaginatedResponse<any>>('/notifications'),
});
}
export function useUnreadNotificationCount() {
return useQuery({
queryKey: ['notifications', 'unread-count'],
queryFn: () => apiGet<number>('/notifications/unread-count'),
});
}
export function useMarkNotificationRead() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (id: string) => apiPatch(`/notifications/${id}/read`),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['notifications'] });
},
});
}
export function useDeleteNotification() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (id: string) => apiDelete(`/notifications/${id}`),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['notifications'] });
},
});
}
export function usePlugins() {
return useQuery({
queryKey: ['plugins'],
queryFn: () => apiGet<any[]>('/plugins'),
});
}
@@ -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,
};
}
+22
View File
@@ -0,0 +1,22 @@
import { useEffect } from 'react';
import { useAuthStore } from '@/store/authStore';
import { useCurrentUser } from '@/api/hooks';
export function useAuth() {
const store = useAuthStore();
const { data, isLoading, isError } = useCurrentUser();
useEffect(() => {
if (isError) {
store.setAuthenticated(false);
store.setUser(null);
}
}, [isError, store]);
return {
user: store.user,
isAuthenticated: store.isAuthenticated,
isLoading: isLoading && !store.user,
currentTenant: store.currentTenant,
};
}
+24
View File
@@ -0,0 +1,24 @@
import { useAuthStore } from '@/store/authStore';
import { useSwitchTenant } from '@/api/hooks';
export function useTenant() {
const { user, currentTenant, setTenant } = useAuthStore();
const switchTenantMutation = useSwitchTenant();
const availableTenants = user?.tenants ?? [];
const switchTenant = async (tenantId: string) => {
try {
await switchTenantMutation.mutateAsync(tenantId);
} catch (error) {
console.error('Failed to switch tenant:', error);
}
};
return {
currentTenant,
availableTenants,
switchTenant,
isSwitching: switchTenantMutation.isPending,
};
}
+27
View File
@@ -0,0 +1,27 @@
import i18n from 'i18next';
import { initReactI18next } from 'react-i18next';
import LanguageDetector from 'i18next-browser-languagedetector';
import de from './locales/de.json';
import en from './locales/en.json';
i18n
.use(LanguageDetector)
.use(initReactI18next)
.init({
resources: {
de: { translation: de },
en: { translation: en },
},
fallbackLng: 'de',
supportedLngs: ['de', 'en'],
interpolation: {
escapeValue: false,
},
detection: {
order: ['localStorage', 'navigator'],
lookupLocalStorage: 'leocrm_lang',
caches: ['localStorage'],
},
});
export default i18n;
+125
View File
@@ -0,0 +1,125 @@
{
"app": {
"name": "leocrm",
"tagline": "Mini-CRM für kleine Unternehmen"
},
"nav": {
"dashboard": "Dashboard",
"companies": "Firmen",
"contacts": "Kontakte",
"calendar": "Kalender",
"files": "Dateien",
"email": "E-Mail",
"users": "Benutzer",
"auditLog": "Audit-Log",
"settings": "Einstellungen"
},
"auth": {
"login": "Anmelden",
"logout": "Abmelden",
"email": "E-Mail-Adresse",
"password": "Passwort",
"loginButton": "Anmelden",
"loginFailed": "Anmeldung fehlgeschlagen. Bitte überprüfen Sie Ihre Eingaben.",
"forgotPassword": "Passwort vergessen?",
"resetPassword": "Passwort zurücksetzen",
"resetRequestTitle": "Passwort zurücksetzen",
"resetRequestDesc": "Geben Sie Ihre E-Mail-Adresse ein. Sie erhalten einen Link zum Zurücksetzen Ihres Passworts.",
"resetRequestButton": "Link senden",
"resetRequestSuccess": "Wenn die E-Mail-Adresse existiert, wurde ein Reset-Link gesendet.",
"resetConfirmTitle": "Neues Passwort festlegen",
"resetConfirmDesc": "Geben Sie Ihr neues Passwort ein.",
"newPassword": "Neues Passwort",
"confirmPassword": "Passwort bestätigen",
"resetConfirmButton": "Passwort ändern",
"resetConfirmSuccess": "Passwort erfolgreich geändert. Sie können sich jetzt anmelden.",
"resetConfirmFailed": "Passwort konnte nicht geändert werden. Der Link ist möglicherweise abgelaufen.",
"backToLogin": "Zurück zur Anmeldung",
"passwordMismatch": "Passwörter stimmen nicht überein.",
"passwordTooShort": "Passwort muss mindestens 8 Zeichen lang sein."
},
"common": {
"save": "Speichern",
"cancel": "Abbrechen",
"delete": "Löschen",
"edit": "Bearbeiten",
"create": "Erstellen",
"search": "Suchen",
"loading": "Wird geladen...",
"noResults": "Keine Ergebnisse gefunden",
"confirm": "Bestätigen",
"close": "Schließen",
"yes": "Ja",
"no": "Nein",
"actions": "Aktionen",
"all": "Alle",
"none": "Keine",
"error": "Fehler",
"success": "Erfolg",
"warning": "Warnung",
"info": "Information",
"required": "Pflichtfeld",
"optional": "Optional"
},
"settings": {
"title": "Einstellungen",
"language": "Sprache",
"theme": "Design",
"light": "Hell",
"dark": "Dunkel",
"system": "System",
"profile": "Profil",
"notifications": "Benachrichtigungen",
"security": "Sicherheit"
},
"topbar": {
"tenant": "Mandant",
"switchTenant": "Mandant wechseln",
"search": "Suchen...",
"notifications": "Benachrichtigungen",
"userMenu": "Benutzermenü",
"profile": "Profil",
"settings": "Einstellungen",
"logout": "Abmelden"
},
"toast": {
"success": "Erfolg",
"error": "Fehler",
"warning": "Warnung",
"info": "Information"
},
"table": {
"page": "Seite",
"of": "von",
"results": "Ergebnisse",
"perPage": "pro Seite",
"previous": "Zurück",
"next": "Weiter",
"sortAscending": "Aufsteigend sortieren",
"sortDescending": "Absteigend sortieren",
"sortedBy": "Sortiert nach",
"empty": "Keine Daten vorhanden"
},
"confirmDialog": {
"title": "Bestätigung erforderlich",
"confirm": "Bestätigen",
"cancel": "Abbrechen"
},
"pagination": {
"previous": "Zurück",
"next": "Weiter",
"page": "Seite {{page}}",
"first": "Erste Seite",
"last": "Letzte Seite"
},
"emptyState": {
"title": "Nichts gefunden",
"description": "Es sind noch keine Daten vorhanden."
},
"validation": {
"required": "Dieses Feld ist erforderlich.",
"email": "Bitte geben Sie eine gültige E-Mail-Adresse ein.",
"minLength": "Mindestens {{count}} Zeichen erforderlich.",
"maxLength": "Maximal {{count}} Zeichen erlaubt."
}
}
+125
View File
@@ -0,0 +1,125 @@
{
"app": {
"name": "leocrm",
"tagline": "Mini-CRM for small businesses"
},
"nav": {
"dashboard": "Dashboard",
"companies": "Companies",
"contacts": "Contacts",
"calendar": "Calendar",
"files": "Files",
"email": "Email",
"users": "Users",
"auditLog": "Audit Log",
"settings": "Settings"
},
"auth": {
"login": "Sign In",
"logout": "Sign Out",
"email": "Email Address",
"password": "Password",
"loginButton": "Sign In",
"loginFailed": "Sign in failed. Please check your credentials.",
"forgotPassword": "Forgot password?",
"resetPassword": "Reset Password",
"resetRequestTitle": "Reset Password",
"resetRequestDesc": "Enter your email address. You will receive a link to reset your password.",
"resetRequestButton": "Send Link",
"resetRequestSuccess": "If the email address exists, a reset link has been sent.",
"resetConfirmTitle": "Set New Password",
"resetConfirmDesc": "Enter your new password.",
"newPassword": "New Password",
"confirmPassword": "Confirm Password",
"resetConfirmButton": "Change Password",
"resetConfirmSuccess": "Password changed successfully. You can now sign in.",
"resetConfirmFailed": "Password could not be changed. The link may have expired.",
"backToLogin": "Back to Sign In",
"passwordMismatch": "Passwords do not match.",
"passwordTooShort": "Password must be at least 8 characters long."
},
"common": {
"save": "Save",
"cancel": "Cancel",
"delete": "Delete",
"edit": "Edit",
"create": "Create",
"search": "Search",
"loading": "Loading...",
"noResults": "No results found",
"confirm": "Confirm",
"close": "Close",
"yes": "Yes",
"no": "No",
"actions": "Actions",
"all": "All",
"none": "None",
"error": "Error",
"success": "Success",
"warning": "Warning",
"info": "Information",
"required": "Required",
"optional": "Optional"
},
"settings": {
"title": "Settings",
"language": "Language",
"theme": "Theme",
"light": "Light",
"dark": "Dark",
"system": "System",
"profile": "Profile",
"notifications": "Notifications",
"security": "Security"
},
"topbar": {
"tenant": "Tenant",
"switchTenant": "Switch Tenant",
"search": "Search...",
"notifications": "Notifications",
"userMenu": "User Menu",
"profile": "Profile",
"settings": "Settings",
"logout": "Sign Out"
},
"toast": {
"success": "Success",
"error": "Error",
"warning": "Warning",
"info": "Information"
},
"table": {
"page": "Page",
"of": "of",
"results": "results",
"perPage": "per page",
"previous": "Previous",
"next": "Next",
"sortAscending": "Sort ascending",
"sortDescending": "Sort descending",
"sortedBy": "Sorted by",
"empty": "No data available"
},
"confirmDialog": {
"title": "Confirmation Required",
"confirm": "Confirm",
"cancel": "Cancel"
},
"pagination": {
"previous": "Previous",
"next": "Next",
"page": "Page {{page}}",
"first": "First Page",
"last": "Last Page"
},
"emptyState": {
"title": "Nothing found",
"description": "No data available yet."
},
"validation": {
"required": "This field is required.",
"email": "Please enter a valid email address.",
"minLength": "At least {{count}} characters required.",
"maxLength": "At most {{count}} characters allowed."
}
}
+93
View File
@@ -0,0 +1,93 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
:root {
/* Design tokens from prototype */
--color-primary: #2563eb;
--color-primary-light: #3b82f6;
--color-primary-dark: #1d4ed8;
--color-secondary: #64748b;
--color-accent: #d946ef;
--color-danger: #dc2626;
--color-warning: #f59e0b;
--color-success: #16a34a;
--color-bg: #ffffff;
--color-bg-secondary: #f8fafc;
--color-bg-tertiary: #f1f5f9;
--color-border: #e2e8f0;
--color-text: #0f172a;
--color-text-muted: #64748b;
--color-text-light: #94a3b8;
--radius-sm: 0.375rem;
--radius-md: 0.5rem;
--radius-lg: 0.75rem;
--shadow-sm: 0 1px 2px 0 rgba(0,0,0,0.05);
--shadow-md: 0 4px 6px -1px rgba(0,0,0,0.1), 0 2px 4px -2px rgba(0,0,0,0.1);
--shadow-lg: 0 10px 15px -3px rgba(0,0,0,0.1), 0 4px 6px -4px rgba(0,0,0,0.1);
--font-sans: 'Inter', system-ui, -apple-system, sans-serif;
--font-mono: 'JetBrains Mono', monospace;
--touch-target: 44px;
}
.dark {
--color-bg: #0f172a;
--color-bg-secondary: #1e293b;
--color-bg-tertiary: #334155;
--color-border: #334155;
--color-text: #f1f5f9;
--color-text-muted: #94a3b8;
--color-text-light: #64748b;
}
@layer base {
* {
@apply border-secondary-200;
}
body {
@apply bg-secondary-50 text-secondary-900 font-sans antialiased;
margin: 0;
min-height: 100vh;
}
#root {
min-height: 100vh;
}
}
@layer components {
.btn-touch {
@apply min-h-touch min-w-touch;
}
.focus-ring {
@apply focus:outline-none focus-visible:ring-2 focus-visible:ring-primary-500 focus-visible:ring-offset-2;
}
.sr-only-focusable:not(:focus):not(:focus-within) {
@apply sr-only;
}
}
/* Reduced motion support */
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
scroll-behavior: auto !important;
}
}
/* Custom scrollbar */
::-webkit-scrollbar {
width: 8px;
height: 8px;
}
::-webkit-scrollbar-track {
background: transparent;
}
::-webkit-scrollbar-thumb {
background: #cbd5e1;
border-radius: 4px;
}
::-webkit-scrollbar-thumb:hover {
background: #94a3b8;
}
+11
View File
@@ -0,0 +1,11 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
import App from './App';
import './index.css';
import './i18n';
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<App />
</React.StrictMode>
);
+55
View File
@@ -0,0 +1,55 @@
import React from 'react';
import { useTranslation } from 'react-i18next';
import { Card } from '@/components/ui/Card';
import { Badge } from '@/components/ui/Badge';
export function DashboardPage() {
const { t } = useTranslation();
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 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' },
];
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>
<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>
))}
</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>
</div>
);
}
+92
View File
@@ -0,0 +1,92 @@
import React, { useState } from 'react';
import { useNavigate, Link } 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 { useLogin } from '@/api/hooks';
import { useUIStore } from '@/store/uiStore';
import { Button } from '@/components/ui/Button';
import { Input } from '@/components/ui/Input';
const loginSchema = z.object({
email: z.string().email(),
password: z.string().min(1),
});
type LoginFormData = z.infer<typeof loginSchema>;
export function LoginPage() {
const { t } = useTranslation();
const navigate = useNavigate();
const loginMutation = useLogin();
const { addToast } = useUIStore();
const [submitError, setSubmitError] = useState<string | null>(null);
const {
register,
handleSubmit,
formState: { errors },
} = useForm<LoginFormData>({
resolver: zodResolver(loginSchema),
defaultValues: { email: '', password: '' },
});
const onSubmit = async (data: LoginFormData) => {
setSubmitError(null);
try {
await loginMutation.mutateAsync(data);
addToast({ type: 'success', message: t('auth.login') + ' erfolgreich' });
navigate('/dashboard');
} catch (error: any) {
const msg = error.message || t('auth.loginFailed');
setSubmitError(msg);
addToast({ type: 'error', message: msg });
}
};
return (
<div className="min-h-screen flex items-center justify-center bg-secondary-50 px-4" data-testid="login-page">
<div className="w-full max-w-md">
<div className="text-center mb-8">
<h1 className="text-3xl font-bold text-primary-600">leocrm</h1>
<p className="text-secondary-500 mt-2">{t('app.tagline')}</p>
</div>
<div className="bg-white rounded-lg shadow-md p-8">
<h2 className="text-xl font-semibold text-secondary-900 mb-6">{t('auth.login')}</h2>
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4" noValidate aria-label={t('auth.login')}>
<Input
label={t('auth.email')}
type="email"
autoComplete="email"
required
error={errors.email?.message}
{...register('email')}
/>
<Input
label={t('auth.password')}
type="password"
autoComplete="current-password"
required
error={errors.password?.message}
{...register('password')}
/>
{submitError && (
<div role="alert" className="text-sm text-danger-600 bg-danger-50 rounded-md p-3">
{submitError}
</div>
)}
<Button type="submit" fullWidth isLoading={loginMutation.isPending}>
{t('auth.loginButton')}
</Button>
</form>
<div className="mt-4 text-center">
<Link to="/password-reset" className="text-sm text-primary-600 hover:text-primary-700 focus:outline-none focus-visible:ring-2 focus-visible:ring-primary-500 rounded">
{t('auth.forgotPassword')}
</Link>
</div>
</div>
</div>
</div>
);
}
+106
View File
@@ -0,0 +1,106 @@
import React, { useState } from 'react';
import { Link, useSearchParams } 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 { usePasswordResetConfirm } from '@/api/hooks';
import { Button } from '@/components/ui/Button';
import { Input } from '@/components/ui/Input';
const resetConfirmSchema = z.object({
password: z.string().min(8),
confirmPassword: z.string().min(8),
}).refine((data) => data.password === data.confirmPassword, {
message: 'Passwords do not match',
path: ['confirmPassword'],
});
type ResetConfirmFormData = z.infer<typeof resetConfirmSchema>;
export function PasswordResetConfirmPage() {
const { t } = useTranslation();
const [searchParams] = useSearchParams();
const token = searchParams.get('token') || '';
const resetMutation = usePasswordResetConfirm();
const [result, setResult] = useState<'success' | 'error' | null>(null);
const {
register,
handleSubmit,
formState: { errors },
} = useForm<ResetConfirmFormData>({
resolver: zodResolver(resetConfirmSchema),
defaultValues: { password: '', confirmPassword: '' },
});
const onSubmit = async (data: ResetConfirmFormData) => {
try {
await resetMutation.mutateAsync({ token, password: data.password });
setResult('success');
} catch {
setResult('error');
}
};
if (result === 'success') {
return (
<div className="min-h-screen flex items-center justify-center bg-secondary-50 px-4" data-testid="reset-confirm-page">
<div className="w-full max-w-md">
<div className="bg-white rounded-lg shadow-md p-8 text-center">
<p className="text-secondary-700 mb-6">{t('auth.resetConfirmSuccess')}</p>
<Link to="/login" className="text-sm text-primary-600 hover:text-primary-700">
{t('auth.backToLogin')}
</Link>
</div>
</div>
</div>
);
}
return (
<div className="min-h-screen flex items-center justify-center bg-secondary-50 px-4" data-testid="reset-confirm-page">
<div className="w-full max-w-md">
<div className="text-center mb-8">
<h1 className="text-3xl font-bold text-primary-600">leocrm</h1>
</div>
<div className="bg-white rounded-lg shadow-md p-8">
<h2 className="text-xl font-semibold text-secondary-900 mb-2">{t('auth.resetConfirmTitle')}</h2>
<p className="text-sm text-secondary-500 mb-6">{t('auth.resetConfirmDesc')}</p>
{result === 'error' && (
<div role="alert" className="text-sm text-danger-600 bg-danger-50 rounded-md p-3 mb-4">
{t('auth.resetConfirmFailed')}
</div>
)}
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4" noValidate>
<Input
label={t('auth.newPassword')}
type="password"
autoComplete="new-password"
required
error={errors.password?.message}
helperText={t('auth.passwordTooShort')}
{...register('password')}
/>
<Input
label={t('auth.confirmPassword')}
type="password"
autoComplete="new-password"
required
error={errors.confirmPassword?.message}
{...register('confirmPassword')}
/>
<Button type="submit" fullWidth isLoading={resetMutation.isPending}>
{t('auth.resetConfirmButton')}
</Button>
</form>
<div className="mt-4 text-center">
<Link to="/login" className="text-sm text-primary-600 hover:text-primary-700">
{t('auth.backToLogin')}
</Link>
</div>
</div>
</div>
</div>
);
}
@@ -0,0 +1,91 @@
import React, { useState } from 'react';
import { Link } 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 { usePasswordResetRequest } from '@/api/hooks';
import { Button } from '@/components/ui/Button';
import { Input } from '@/components/ui/Input';
const resetRequestSchema = z.object({
email: z.string().email(),
});
type ResetRequestFormData = z.infer<typeof resetRequestSchema>;
export function PasswordResetRequestPage() {
const { t } = useTranslation();
const resetMutation = usePasswordResetRequest();
const [submitted, setSubmitted] = useState(false);
const {
register,
handleSubmit,
formState: { errors },
} = useForm<ResetRequestFormData>({
resolver: zodResolver(resetRequestSchema),
defaultValues: { email: '' },
});
const onSubmit = async (data: ResetRequestFormData) => {
try {
await resetMutation.mutateAsync(data);
setSubmitted(true);
} catch {
setSubmitted(true);
}
};
if (submitted) {
return (
<div className="min-h-screen flex items-center justify-center bg-secondary-50 px-4" data-testid="reset-request-page">
<div className="w-full max-w-md">
<div className="bg-white rounded-lg shadow-md p-8 text-center">
<div className="mb-4 w-12 h-12 mx-auto rounded-full bg-success-100 flex items-center justify-center" aria-hidden="true">
<svg className="w-6 h-6 text-success-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
</div>
<p className="text-secondary-700 mb-6">{t('auth.resetRequestSuccess')}</p>
<Link to="/login" className="text-sm text-primary-600 hover:text-primary-700">
{t('auth.backToLogin')}
</Link>
</div>
</div>
</div>
);
}
return (
<div className="min-h-screen flex items-center justify-center bg-secondary-50 px-4" data-testid="reset-request-page">
<div className="w-full max-w-md">
<div className="text-center mb-8">
<h1 className="text-3xl font-bold text-primary-600">leocrm</h1>
</div>
<div className="bg-white rounded-lg shadow-md p-8">
<h2 className="text-xl font-semibold text-secondary-900 mb-2">{t('auth.resetRequestTitle')}</h2>
<p className="text-sm text-secondary-500 mb-6">{t('auth.resetRequestDesc')}</p>
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4" noValidate>
<Input
label={t('auth.email')}
type="email"
autoComplete="email"
required
error={errors.email?.message}
{...register('email')}
/>
<Button type="submit" fullWidth isLoading={resetMutation.isPending}>
{t('auth.resetRequestButton')}
</Button>
</form>
<div className="mt-4 text-center">
<Link to="/login" className="text-sm text-primary-600 hover:text-primary-700">
{t('auth.backToLogin')}
</Link>
</div>
</div>
</div>
</div>
);
}
+55
View File
@@ -0,0 +1,55 @@
import React from 'react';
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 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');
}
};
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>
);
}
+14
View File
@@ -0,0 +1,14 @@
import React from 'react';
import { Navigate, useLocation } from 'react-router-dom';
import { useAuthStore } from '@/store/authStore';
export function ProtectedRoute({ children }: { children: React.ReactNode }) {
const { isAuthenticated } = useAuthStore();
const location = useLocation();
if (!isAuthenticated) {
return <Navigate to="/login" state={{ from: location }} replace />;
}
return <>{children}</>;
}
+40
View File
@@ -0,0 +1,40 @@
import React from 'react';
import { createBrowserRouter, RouterProvider } from 'react-router-dom';
import { AppShell } from '@/components/layout/AppShell';
import { ProtectedRoute } from './ProtectedRoute';
import { LoginPage } from '@/pages/Login';
import { PasswordResetRequestPage } from '@/pages/PasswordResetRequest';
import { PasswordResetConfirmPage } from '@/pages/PasswordResetConfirm';
import { DashboardPage } from '@/pages/Dashboard';
import { SettingsPage } from '@/pages/Settings';
const router = createBrowserRouter([
{
path: '/login',
element: <LoginPage />,
},
{
path: '/password-reset',
element: <PasswordResetRequestPage />,
},
{
path: '/password-reset/confirm',
element: <PasswordResetConfirmPage />,
},
{
element: (
<ProtectedRoute>
<AppShell />
</ProtectedRoute>
),
children: [
{ path: '/dashboard', element: <DashboardPage /> },
{ path: '/settings', element: <SettingsPage /> },
{ path: '/', element: <DashboardPage /> },
],
},
]);
export function AppRouter() {
return <RouterProvider router={router} />;
}
+55
View File
@@ -0,0 +1,55 @@
import { create } from 'zustand';
export interface Tenant {
id: string;
name: string;
slug: string;
}
export interface User {
id: string;
email: string;
first_name: string;
last_name: string;
role: string;
avatar_url: string | null;
tenants: Tenant[];
}
export interface AuthState {
user: User | null;
currentTenant: Tenant | null; isAuthenticated: boolean;
isLoading: boolean;
error: string | null;
setUser: (user: User | null) => void;
setTenant: (tenant: Tenant | null) => void;
setAuthenticated: (authed: boolean) => void;
setLoading: (loading: boolean) => void;
setError: (error: string | null) => void;
logout: () => void;
}
export const useAuthStore = create<AuthState>((set) => ({
user: null,
currentTenant: null,
isAuthenticated: false,
isLoading: false,
error: null,
setUser: (user) =>
set({
user,
isAuthenticated: !!user,
currentTenant: user?.tenants?.[0] ?? null,
}),
setTenant: (tenant) => set({ currentTenant: tenant }),
setAuthenticated: (authed) => set({ isAuthenticated: authed }),
setLoading: (loading) => set({ isLoading: loading }),
setError: (error) => set({ error }),
logout: () =>
set({
user: null,
currentTenant: null,
isAuthenticated: false,
error: null,
}),
}));
+51
View File
@@ -0,0 +1,51 @@
import { create } from 'zustand';
export type Theme = 'light' | 'dark' | 'system';
export type Locale = 'de' | 'en';
export interface Toast {
id: string;
type: 'success' | 'error' | 'warning' | 'info';
message: string;
duration?: number;
}
export interface UIState {
theme: Theme;
locale: Locale;
sidebarOpen: boolean;
toasts: Toast[];
setTheme: (theme: Theme) => void;
setLocale: (locale: Locale) => void;
toggleSidebar: () => void;
setSidebarOpen: (open: boolean) => void;
addToast: (toast: Omit<Toast, 'id'>) => void;
removeToast: (id: string) => void;
clearToasts: () => void;
}
let toastIdCounter = 0;
export const useUIStore = create<UIState>((set) => ({
theme: (localStorage.getItem('leocrm_theme') as Theme) || 'light',
locale: (localStorage.getItem('leocrm_lang') as Locale) || 'de',
sidebarOpen: true,
toasts: [],
setTheme: (theme) => {
localStorage.setItem('leocrm_theme', theme);
set({ theme });
},
setLocale: (locale) => {
localStorage.setItem('leocrm_lang', locale);
set({ locale });
},
toggleSidebar: () => set((s) => ({ sidebarOpen: !s.sidebarOpen })),
setSidebarOpen: (open) => set({ sidebarOpen: open }),
addToast: (toast) => {
const id = `toast-${++toastIdCounter}`;
set((s) => ({ toasts: [...s.toasts, { ...toast, id }] }));
},
removeToast: (id) =>
set((s) => ({ toasts: s.toasts.filter((t) => t.id !== id) })),
clearToasts: () => set({ toasts: [] }),
}));
+66
View File
@@ -0,0 +1,66 @@
import '@testing-library/jest-dom';
import { vi, beforeEach, afterEach, beforeAll } from 'vitest';
import i18n from '@/i18n';
// Mock matchMedia for jsdom
Object.defineProperty(window, 'matchMedia', {
writable: true,
value: vi.fn().mockImplementation((query: string) => ({
matches: false,
media: query,
onchange: null,
addListener: vi.fn(),
removeListener: vi.fn(),
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
dispatchEvent: vi.fn(),
})),
});
// Mock IntersectionObserver
class MockIntersectionObserver {
observe = vi.fn();
unobserve = vi.fn();
disconnect = vi.fn();
takeRecords = vi.fn();
root = null;
rootMargin = '';
thresholds = [];
}
Object.defineProperty(window, 'IntersectionObserver', {
writable: true,
configurable: true,
value: MockIntersectionObserver,
});
// Mock ResizeObserver
class MockResizeObserver {
observe = vi.fn();
unobserve = vi.fn();
disconnect = vi.fn();
}
Object.defineProperty(window, 'ResizeObserver', {
writable: true,
configurable: true,
value: MockResizeObserver,
});
// Mock scrollTo
window.scrollTo = vi.fn() as any;
// Initialize i18n before all tests
beforeAll(async () => {
await i18n.changeLanguage('de');
});
// Clean up after each test
afterEach(() => {
vi.clearAllMocks();
localStorage.clear();
});
beforeEach(() => {
localStorage.clear();
});
+1
View File
@@ -0,0 +1 @@
/// <reference types="vite/client" />
+129
View File
@@ -0,0 +1,129 @@
/** @type {import('tailwindcss').Config} */
export default {
content: ['./index.html', './src/**/*.{ts,tsx}'],
darkMode: 'class',
theme: {
extend: {
colors: {
primary: {
50: '#eff6ff',
100: '#dbeafe',
200: '#bfdbfe',
300: '#93c5fd',
400: '#60a5fa',
500: '#3b82f6',
600: '#2563eb',
700: '#1d4ed8',
800: '#1e40af',
900: '#1e3a8a',
DEFAULT: '#2563eb',
},
secondary: {
50: '#f8fafc',
100: '#f1f5f9',
200: '#e2e8f0',
300: '#cbd5e1',
400: '#94a3b8',
500: '#64748b',
600: '#475569',
700: '#334155',
800: '#1e293b',
900: '#0f172a',
DEFAULT: '#64748b',
},
accent: {
50: '#fdf4ff',
100: '#fae8ff',
200: '#f5d0fe',
300: '#f0abfc',
400: '#e879f9',
500: '#d946ef',
600: '#c026d3',
700: '#a21caf',
800: '#86198f',
900: '#701a75',
DEFAULT: '#d946ef',
},
danger: {
50: '#fef2f2',
100: '#fee2e2',
200: '#fecaca',
300: '#fca5a5',
400: '#f87171',
500: '#ef4444',
600: '#dc2626',
700: '#b91c1c',
800: '#991b1b',
900: '#7f1d1d',
DEFAULT: '#dc2626',
},
warning: {
50: '#fffbeb',
100: '#fef3c7',
200: '#fde68a',
300: '#fcd34d',
400: '#fbbf24',
500: '#f59e0b',
600: '#d97706',
700: '#b45309',
800: '#92400e',
900: '#78350f',
DEFAULT: '#f59e0b',
},
success: {
50: '#f0fdf4',
100: '#dcfce7',
200: '#bbf7d0',
300: '#86efac',
400: '#4ade80',
500: '#22c55e',
600: '#16a34a',
700: '#15803d',
800: '#166534',
900: '#14532d',
DEFAULT: '#16a34a',
},
},
fontFamily: {
sans: ['Inter', 'system-ui', '-apple-system', 'sans-serif'],
mono: ['JetBrains Mono', 'monospace'],
},
fontSize: {
xs: ['0.75rem', { lineHeight: '1rem' }],
sm: ['0.875rem', { lineHeight: '1.25rem' }],
base: ['1rem', { lineHeight: '1.5rem' }],
lg: ['1.125rem', { lineHeight: '1.75rem' }],
xl: ['1.25rem', { lineHeight: '1.75rem' }],
'2xl': ['1.5rem', { lineHeight: '2rem' }],
'3xl': ['1.875rem', { lineHeight: '2.25rem' }],
'4xl': ['2.25rem', { lineHeight: '2.5rem' }],
},
borderRadius: {
sm: '0.375rem',
md: '0.5rem',
lg: '0.75rem',
xl: '1rem',
},
boxShadow: {
sm: '0 1px 2px 0 rgba(0,0,0,0.05)',
md: '0 4px 6px -1px rgba(0,0,0,0.1), 0 2px 4px -2px rgba(0,0,0,0.1)',
lg: '0 10px 15px -3px rgba(0,0,0,0.1), 0 4px 6px -4px rgba(0,0,0,0.1)',
xl: '0 20px 25px -5px rgba(0,0,0,0.1), 0 8px 10px -6px rgba(0,0,0,0.1)',
},
spacing: {
18: '4.5rem',
88: '22rem',
},
minWidth: {
touch: '44px',
},
minHeight: {
touch: '44px',
},
transitionProperty: {
'reduce': 'none',
},
},
},
plugins: [],
};
+27
View File
@@ -0,0 +1,27 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
"strict": true,
"noUnusedLocals": false,
"noUnusedParameters": false,
"noFallthroughCasesInSwitch": true,
"forceConsistentCasingInFileNames": true,
"types": ["vitest/globals", "@testing-library/jest-dom"],
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"]
}
},
"include": ["src"]
}
+15
View File
@@ -0,0 +1,15 @@
{
"compilerOptions": {
"target": "ES2022",
"lib": ["ES2023"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowSyntheticDefaultImports": true,
"isolatedModules": true,
"moduleDetection": "force",
"noEmit": true,
"strict": true
},
"include": ["vite.config.ts"]
}
+33
View File
@@ -0,0 +1,33 @@
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import { resolve } from 'path';
export default defineConfig({
plugins: [react()],
resolve: {
alias: {
'@': resolve(__dirname, 'src'),
},
},
server: {
port: 5173,
proxy: {
'/api': {
target: 'http://localhost:8000',
changeOrigin: true,
},
},
},
test: {
globals: true,
environment: 'jsdom',
setupFiles: 'src/test/setup.ts',
css: true,
coverage: {
provider: 'v8',
reporter: ['text', 'json-summary'],
include: ['src/**/*.{ts,tsx}'],
exclude: ['src/test/**', 'src/**/*.d.ts', 'src/main.tsx'],
},
},
});