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

- React 18 + Vite + TypeScript + Tailwind CSS setup
- AppShell with Sidebar (plugin menu) + TopBar (tenant switcher, search, notifications, user menu)
- Auth pages: Login, PasswordResetRequest, PasswordResetConfirm
- Protected routes with auth guard
- API client (axios with interceptors: session cookie, 401 redirect, 422 validation)
- TanStack Query hooks for auth, users, companies, contacts, notifications
- Zustand stores: authStore, uiStore
- i18n setup (de/en locales) with react-i18next
- UI component library: Button, Input, Select, Modal, Toast, Table, Card, Badge, Avatar, Pagination, EmptyState, Skeleton, ConfirmDialog
- Accessibility: ARIA labels, 44px touch targets, keyboard nav, reduced-motion, sr-only
- Design tokens from prototype as CSS custom properties
- 111 tests passing across 20 test files
- tsc --noEmit: 0 errors
- npm run build: success (471KB JS, 24KB CSS)
This commit is contained in:
leocrm-bot
2026-06-29 07:55:47 +02:00
parent f8193a6ab5
commit 22976abe92
66 changed files with 8598 additions and 0 deletions
+55
View File
@@ -0,0 +1,55 @@
import React from 'react';
import { useTranslation } from 'react-i18next';
import { Card } from '@/components/ui/Card';
import { Badge } from '@/components/ui/Badge';
export function DashboardPage() {
const { t } = useTranslation();
const stats = [
{ label: 'Firmen', value: '24', change: '+3', variant: 'success' as const },
{ label: 'Kontakte', value: '156', change: '+12', variant: 'success' as const },
{ label: 'Offene Aufgaben', value: '8', change: '-2', variant: 'warning' as const },
{ label: 'E-Mails heute', value: '34', change: '+5', variant: 'info' as const },
];
const activities = [
{ user: 'Anna Schmidt', action: 'hat Firma TechCorp GmbH erstellt', time: 'vor 5 Minuten' },
{ user: 'Max Müller', action: 'hat Kontakt Lisa Weber aktualisiert', time: 'vor 12 Minuten' },
{ user: 'Anna Schmidt', action: 'hat 3 Kontakte importiert', time: 'vor 1 Stunde' },
{ user: 'System', action: 'Backup erfolgreich erstellt', time: 'vor 2 Stunden' },
];
return (
<div className="p-6 max-w-7xl mx-auto" data-testid="dashboard-page">
<h1 className="text-2xl font-bold text-secondary-900 mb-6">{t('nav.dashboard')}</h1>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4 mb-8">
{stats.map((stat) => (
<Card key={stat.label}>
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-secondary-500">{stat.label}</p>
<p className="text-2xl font-bold text-secondary-900 mt-1">{stat.value}</p>
</div>
<Badge variant={stat.variant} dot>{stat.change}</Badge>
</div>
</Card>
))}
</div>
<Card title="Letzte Aktivitäten">
<ul className="space-y-3" role="list">
{activities.map((activity, idx) => (
<li key={idx} className="flex items-start gap-3 text-sm">
<span className="w-2 h-2 rounded-full bg-primary-500 mt-1.5 flex-shrink-0" aria-hidden="true" />
<div>
<span className="font-medium text-secondary-900">{activity.user}</span>
<span className="text-secondary-600"> {activity.action}</span>
<span className="text-secondary-400 block mt-0.5">{activity.time}</span>
</div>
</li>
))}
</ul>
</Card>
</div>
);
}
+92
View File
@@ -0,0 +1,92 @@
import React, { useState } from 'react';
import { useNavigate, Link } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
import { useLogin } from '@/api/hooks';
import { useUIStore } from '@/store/uiStore';
import { Button } from '@/components/ui/Button';
import { Input } from '@/components/ui/Input';
const loginSchema = z.object({
email: z.string().email(),
password: z.string().min(1),
});
type LoginFormData = z.infer<typeof loginSchema>;
export function LoginPage() {
const { t } = useTranslation();
const navigate = useNavigate();
const loginMutation = useLogin();
const { addToast } = useUIStore();
const [submitError, setSubmitError] = useState<string | null>(null);
const {
register,
handleSubmit,
formState: { errors },
} = useForm<LoginFormData>({
resolver: zodResolver(loginSchema),
defaultValues: { email: '', password: '' },
});
const onSubmit = async (data: LoginFormData) => {
setSubmitError(null);
try {
await loginMutation.mutateAsync(data);
addToast({ type: 'success', message: t('auth.login') + ' erfolgreich' });
navigate('/dashboard');
} catch (error: any) {
const msg = error.message || t('auth.loginFailed');
setSubmitError(msg);
addToast({ type: 'error', message: msg });
}
};
return (
<div className="min-h-screen flex items-center justify-center bg-secondary-50 px-4" data-testid="login-page">
<div className="w-full max-w-md">
<div className="text-center mb-8">
<h1 className="text-3xl font-bold text-primary-600">leocrm</h1>
<p className="text-secondary-500 mt-2">{t('app.tagline')}</p>
</div>
<div className="bg-white rounded-lg shadow-md p-8">
<h2 className="text-xl font-semibold text-secondary-900 mb-6">{t('auth.login')}</h2>
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4" noValidate aria-label={t('auth.login')}>
<Input
label={t('auth.email')}
type="email"
autoComplete="email"
required
error={errors.email?.message}
{...register('email')}
/>
<Input
label={t('auth.password')}
type="password"
autoComplete="current-password"
required
error={errors.password?.message}
{...register('password')}
/>
{submitError && (
<div role="alert" className="text-sm text-danger-600 bg-danger-50 rounded-md p-3">
{submitError}
</div>
)}
<Button type="submit" fullWidth isLoading={loginMutation.isPending}>
{t('auth.loginButton')}
</Button>
</form>
<div className="mt-4 text-center">
<Link to="/password-reset" className="text-sm text-primary-600 hover:text-primary-700 focus:outline-none focus-visible:ring-2 focus-visible:ring-primary-500 rounded">
{t('auth.forgotPassword')}
</Link>
</div>
</div>
</div>
</div>
);
}
+106
View File
@@ -0,0 +1,106 @@
import React, { useState } from 'react';
import { Link, useSearchParams } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
import { usePasswordResetConfirm } from '@/api/hooks';
import { Button } from '@/components/ui/Button';
import { Input } from '@/components/ui/Input';
const resetConfirmSchema = z.object({
password: z.string().min(8),
confirmPassword: z.string().min(8),
}).refine((data) => data.password === data.confirmPassword, {
message: 'Passwords do not match',
path: ['confirmPassword'],
});
type ResetConfirmFormData = z.infer<typeof resetConfirmSchema>;
export function PasswordResetConfirmPage() {
const { t } = useTranslation();
const [searchParams] = useSearchParams();
const token = searchParams.get('token') || '';
const resetMutation = usePasswordResetConfirm();
const [result, setResult] = useState<'success' | 'error' | null>(null);
const {
register,
handleSubmit,
formState: { errors },
} = useForm<ResetConfirmFormData>({
resolver: zodResolver(resetConfirmSchema),
defaultValues: { password: '', confirmPassword: '' },
});
const onSubmit = async (data: ResetConfirmFormData) => {
try {
await resetMutation.mutateAsync({ token, password: data.password });
setResult('success');
} catch {
setResult('error');
}
};
if (result === 'success') {
return (
<div className="min-h-screen flex items-center justify-center bg-secondary-50 px-4" data-testid="reset-confirm-page">
<div className="w-full max-w-md">
<div className="bg-white rounded-lg shadow-md p-8 text-center">
<p className="text-secondary-700 mb-6">{t('auth.resetConfirmSuccess')}</p>
<Link to="/login" className="text-sm text-primary-600 hover:text-primary-700">
{t('auth.backToLogin')}
</Link>
</div>
</div>
</div>
);
}
return (
<div className="min-h-screen flex items-center justify-center bg-secondary-50 px-4" data-testid="reset-confirm-page">
<div className="w-full max-w-md">
<div className="text-center mb-8">
<h1 className="text-3xl font-bold text-primary-600">leocrm</h1>
</div>
<div className="bg-white rounded-lg shadow-md p-8">
<h2 className="text-xl font-semibold text-secondary-900 mb-2">{t('auth.resetConfirmTitle')}</h2>
<p className="text-sm text-secondary-500 mb-6">{t('auth.resetConfirmDesc')}</p>
{result === 'error' && (
<div role="alert" className="text-sm text-danger-600 bg-danger-50 rounded-md p-3 mb-4">
{t('auth.resetConfirmFailed')}
</div>
)}
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4" noValidate>
<Input
label={t('auth.newPassword')}
type="password"
autoComplete="new-password"
required
error={errors.password?.message}
helperText={t('auth.passwordTooShort')}
{...register('password')}
/>
<Input
label={t('auth.confirmPassword')}
type="password"
autoComplete="new-password"
required
error={errors.confirmPassword?.message}
{...register('confirmPassword')}
/>
<Button type="submit" fullWidth isLoading={resetMutation.isPending}>
{t('auth.resetConfirmButton')}
</Button>
</form>
<div className="mt-4 text-center">
<Link to="/login" className="text-sm text-primary-600 hover:text-primary-700">
{t('auth.backToLogin')}
</Link>
</div>
</div>
</div>
</div>
);
}
@@ -0,0 +1,91 @@
import React, { useState } from 'react';
import { Link } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
import { usePasswordResetRequest } from '@/api/hooks';
import { Button } from '@/components/ui/Button';
import { Input } from '@/components/ui/Input';
const resetRequestSchema = z.object({
email: z.string().email(),
});
type ResetRequestFormData = z.infer<typeof resetRequestSchema>;
export function PasswordResetRequestPage() {
const { t } = useTranslation();
const resetMutation = usePasswordResetRequest();
const [submitted, setSubmitted] = useState(false);
const {
register,
handleSubmit,
formState: { errors },
} = useForm<ResetRequestFormData>({
resolver: zodResolver(resetRequestSchema),
defaultValues: { email: '' },
});
const onSubmit = async (data: ResetRequestFormData) => {
try {
await resetMutation.mutateAsync(data);
setSubmitted(true);
} catch {
setSubmitted(true);
}
};
if (submitted) {
return (
<div className="min-h-screen flex items-center justify-center bg-secondary-50 px-4" data-testid="reset-request-page">
<div className="w-full max-w-md">
<div className="bg-white rounded-lg shadow-md p-8 text-center">
<div className="mb-4 w-12 h-12 mx-auto rounded-full bg-success-100 flex items-center justify-center" aria-hidden="true">
<svg className="w-6 h-6 text-success-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
</div>
<p className="text-secondary-700 mb-6">{t('auth.resetRequestSuccess')}</p>
<Link to="/login" className="text-sm text-primary-600 hover:text-primary-700">
{t('auth.backToLogin')}
</Link>
</div>
</div>
</div>
);
}
return (
<div className="min-h-screen flex items-center justify-center bg-secondary-50 px-4" data-testid="reset-request-page">
<div className="w-full max-w-md">
<div className="text-center mb-8">
<h1 className="text-3xl font-bold text-primary-600">leocrm</h1>
</div>
<div className="bg-white rounded-lg shadow-md p-8">
<h2 className="text-xl font-semibold text-secondary-900 mb-2">{t('auth.resetRequestTitle')}</h2>
<p className="text-sm text-secondary-500 mb-6">{t('auth.resetRequestDesc')}</p>
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4" noValidate>
<Input
label={t('auth.email')}
type="email"
autoComplete="email"
required
error={errors.email?.message}
{...register('email')}
/>
<Button type="submit" fullWidth isLoading={resetMutation.isPending}>
{t('auth.resetRequestButton')}
</Button>
</form>
<div className="mt-4 text-center">
<Link to="/login" className="text-sm text-primary-600 hover:text-primary-700">
{t('auth.backToLogin')}
</Link>
</div>
</div>
</div>
</div>
);
}
+55
View File
@@ -0,0 +1,55 @@
import React from 'react';
import { useTranslation } from 'react-i18next';
import { Card } from '@/components/ui/Card';
import { Select } from '@/components/ui/Select';
import { useUIStore } from '@/store/uiStore';
export function SettingsPage() {
const { t, i18n } = useTranslation();
const { theme, setTheme, locale, setLocale } = useUIStore();
const handleLocaleChange = (newLocale: string) => {
setLocale(newLocale as 'de' | 'en');
i18n.changeLanguage(newLocale);
};
const handleThemeChange = (newTheme: string) => {
setTheme(newTheme as 'light' | 'dark' | 'system');
if (newTheme === 'dark') {
document.documentElement.classList.add('dark');
} else {
document.documentElement.classList.remove('dark');
}
};
return (
<div className="p-6 max-w-4xl mx-auto" data-testid="settings-page">
<h1 className="text-2xl font-bold text-secondary-900 mb-6">{t('settings.title')}</h1>
<div className="space-y-6">
<Card title={t('settings.language')} description="Wählen Sie Ihre bevorzugte Sprache">
<Select
options={[
{ value: 'de', label: 'Deutsch' },
{ value: 'en', label: 'English' },
]}
value={locale}
onChange={(e) => handleLocaleChange(e.target.value)}
aria-label={t('settings.language')}
/>
</Card>
<Card title={t('settings.theme')} description="Wählen Sie Ihr bevorzugtes Design">
<Select
options={[
{ value: 'light', label: t('settings.light') },
{ value: 'dark', label: t('settings.dark') },
{ value: 'system', label: t('settings.system') },
]}
value={theme}
onChange={(e) => handleThemeChange(e.target.value)}
aria-label={t('settings.theme')}
/>
</Card>
</div>
</div>
);
}