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:
@@ -0,0 +1,9 @@
|
||||
node_modules/
|
||||
dist/
|
||||
*.local
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
coverage/
|
||||
.vitest/
|
||||
*.log
|
||||
@@ -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>
|
||||
Generated
+4754
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
};
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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('21–40')).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');
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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';
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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';
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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';
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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}</>;
|
||||
}
|
||||
@@ -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} />;
|
||||
}
|
||||
@@ -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,
|
||||
}),
|
||||
}));
|
||||
@@ -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: [] }),
|
||||
}));
|
||||
@@ -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();
|
||||
});
|
||||
Vendored
+1
@@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
@@ -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: [],
|
||||
};
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -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'],
|
||||
},
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user