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
+162
View File
@@ -0,0 +1,162 @@
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { apiPost, apiGet, apiPatch, apiDelete } from './client';
import { useAuthStore } from '@/store/authStore';
export interface LoginPayload {
email: string;
password: string;
}
export interface PasswordResetRequestPayload {
email: string;
}
export interface PasswordResetConfirmPayload {
token: string;
password: string;
}
export interface PaginatedResponse<T> {
items: T[];
total: number;
page: number;
page_size: number;
}
export function useLogin() {
const { setUser, setError } = useAuthStore();
return useMutation({
mutationFn: (payload: LoginPayload) =>
apiPost('/auth/login', payload),
onSuccess: (data: any) => {
setUser(data.user || data);
setError(null);
},
onError: (error: any) => {
setError(error.message || 'Login failed');
},
});
}
export function useLogout() {
const { logout } = useAuthStore();
const queryClient = useQueryClient();
return useMutation({
mutationFn: () => apiPost('/auth/logout'),
onSettled: () => {
logout();
queryClient.clear();
},
});
}
export function useCurrentUser() {
const { setUser, setAuthenticated } = useAuthStore();
return useQuery({
queryKey: ['currentUser'],
queryFn: async () => {
const data = await apiGet<any>('/auth/me');
setUser(data.user || data);
setAuthenticated(true);
return data;
},
retry: false,
staleTime: 5 * 60 * 1000,
});
}
export function usePasswordResetRequest() {
return useMutation({
mutationFn: (payload: PasswordResetRequestPayload) =>
apiPost('/auth/password-reset/request', payload),
});
}
export function usePasswordResetConfirm() {
return useMutation({
mutationFn: (payload: PasswordResetConfirmPayload) =>
apiPost('/auth/password-reset/confirm', payload),
});
}
export function useSwitchTenant() {
const { setTenant } = useAuthStore();
const queryClient = useQueryClient();
return useMutation({
mutationFn: (tenantId: string) =>
apiPost('/auth/switch-tenant', { tenant_id: tenantId }),
onSuccess: (data: any) => {
setTenant(data.tenant || data);
queryClient.invalidateQueries();
},
});
}
export function useUsers(page = 1, pageSize = 25) {
return useQuery({
queryKey: ['users', page, pageSize],
queryFn: () =>
apiGet<PaginatedResponse<any>>(`/users?page=${page}&page_size=${pageSize}`),
});
}
export function useCompanies(page = 1, pageSize = 25, search?: string) {
const params = new URLSearchParams({ page: String(page), page_size: String(pageSize) });
if (search) params.set('search', search);
return useQuery({
queryKey: ['companies', page, pageSize, search],
queryFn: () =>
apiGet<PaginatedResponse<any>>(`/companies?${params.toString()}`),
});
}
export function useContacts(page = 1, pageSize = 25, search?: string) {
const params = new URLSearchParams({ page: String(page), page_size: String(pageSize) });
if (search) params.set('search', search);
return useQuery({
queryKey: ['contacts', page, pageSize, search],
queryFn: () =>
apiGet<PaginatedResponse<any>>(`/contacts?${params.toString()}`),
});
}
export function useNotifications() {
return useQuery({
queryKey: ['notifications'],
queryFn: () => apiGet<PaginatedResponse<any>>('/notifications'),
});
}
export function useUnreadNotificationCount() {
return useQuery({
queryKey: ['notifications', 'unread-count'],
queryFn: () => apiGet<number>('/notifications/unread-count'),
});
}
export function useMarkNotificationRead() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (id: string) => apiPatch(`/notifications/${id}/read`),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['notifications'] });
},
});
}
export function useDeleteNotification() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (id: string) => apiDelete(`/notifications/${id}`),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['notifications'] });
},
});
}
export function usePlugins() {
return useQuery({
queryKey: ['plugins'],
queryFn: () => apiGet<any[]>('/plugins'),
});
}