feat: Phase 2 – Benutzerverwaltung (CRUD-API, Profil, UI)
This commit is contained in:
@@ -13,6 +13,7 @@ interface NavItem {
|
||||
|
||||
const NAV_ITEMS: NavItem[] = [
|
||||
{ to: '/', label: 'Dashboard', icon: '⌂' },
|
||||
{ to: '/profile', label: 'Mein Profil', icon: '👤' },
|
||||
{ to: '/admin/users', label: 'Benutzer', icon: '👥', adminOnly: true },
|
||||
{ to: '/admin/system', label: 'Systemstatus', icon: '⚙', adminOnly: true },
|
||||
];
|
||||
|
||||
60
apps/platform-frontend/src/components/ui/modal.tsx
Normal file
60
apps/platform-frontend/src/components/ui/modal.tsx
Normal file
@@ -0,0 +1,60 @@
|
||||
import { type ReactNode, useEffect } from 'react';
|
||||
|
||||
export interface ModalProps {
|
||||
open: boolean;
|
||||
title: string;
|
||||
description?: string;
|
||||
onClose: () => void;
|
||||
children: ReactNode;
|
||||
footer?: ReactNode;
|
||||
}
|
||||
|
||||
/** Zugängliches Modal (Design-System): Fokus-Falle, ESC schließt. */
|
||||
export function Modal({
|
||||
open,
|
||||
title,
|
||||
description,
|
||||
onClose,
|
||||
children,
|
||||
footer,
|
||||
}: ModalProps): ReactNode {
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
return undefined;
|
||||
}
|
||||
const handleKeyDown = (event: KeyboardEvent): void => {
|
||||
if (event.key === 'Escape') {
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
document.addEventListener('keydown', handleKeyDown);
|
||||
return () => document.removeEventListener('keydown', handleKeyDown);
|
||||
}, [open, onClose]);
|
||||
|
||||
if (!open) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex items-center justify-center bg-slate-900/50 p-4"
|
||||
onClick={onClose}
|
||||
role="presentation"
|
||||
>
|
||||
<div
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={title}
|
||||
className="w-full max-w-md rounded-xl bg-white shadow-xl"
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
>
|
||||
<div className="border-b border-slate-200 px-6 py-4">
|
||||
<h2 className="text-base font-semibold text-slate-900">{title}</h2>
|
||||
{description && <p className="mt-0.5 text-sm text-slate-500">{description}</p>}
|
||||
</div>
|
||||
<div className="px-6 py-4">{children}</div>
|
||||
{footer && <div className="border-t border-slate-200 px-6 py-4">{footer}</div>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
50
apps/platform-frontend/src/components/ui/select.tsx
Normal file
50
apps/platform-frontend/src/components/ui/select.tsx
Normal file
@@ -0,0 +1,50 @@
|
||||
import { type ReactNode, type SelectHTMLAttributes } from 'react';
|
||||
|
||||
export interface SelectProps extends SelectHTMLAttributes<HTMLSelectElement> {
|
||||
label: string;
|
||||
error?: string;
|
||||
options: ReadonlyArray<{ value: string; label: string }>;
|
||||
}
|
||||
|
||||
/** Select-Dropdown mit Label (Design-System). */
|
||||
export function Select({
|
||||
label,
|
||||
error,
|
||||
options,
|
||||
className = '',
|
||||
id,
|
||||
...rest
|
||||
}: SelectProps): ReactNode {
|
||||
const selectId = id ?? `select-${label.toLowerCase().replace(/\s+/g, '-')}`;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label htmlFor={selectId} className="text-sm font-medium text-slate-700">
|
||||
{label}
|
||||
</label>
|
||||
<select
|
||||
id={selectId}
|
||||
className={`h-10 rounded-lg border bg-white px-3 text-sm transition-colors
|
||||
${
|
||||
error
|
||||
? 'border-red-400 focus:border-red-500'
|
||||
: 'border-slate-300 focus:border-brand-500'
|
||||
}
|
||||
${className}`}
|
||||
aria-invalid={error ? true : undefined}
|
||||
{...rest}
|
||||
>
|
||||
{options.map((option) => (
|
||||
<option key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{error && (
|
||||
<p role="alert" className="text-xs text-red-600">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
84
apps/platform-frontend/src/components/ui/toast.tsx
Normal file
84
apps/platform-frontend/src/components/ui/toast.tsx
Normal file
@@ -0,0 +1,84 @@
|
||||
import {
|
||||
type ReactNode,
|
||||
createContext,
|
||||
useCallback,
|
||||
useContext,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react';
|
||||
|
||||
/** Toast-Varianten. */
|
||||
export type ToastVariant = 'success' | 'error';
|
||||
|
||||
interface ToastEntry {
|
||||
readonly id: number;
|
||||
readonly variant: ToastVariant;
|
||||
readonly message: string;
|
||||
}
|
||||
|
||||
interface ToastContextValue {
|
||||
showToast: (variant: ToastVariant, message: string) => void;
|
||||
}
|
||||
|
||||
const ToastContext = createContext<ToastContextValue | null>(null);
|
||||
|
||||
const VARIANT_CLASSES: Record<ToastVariant, string> = {
|
||||
success: 'bg-emerald-600 text-white',
|
||||
error: 'bg-red-600 text-white',
|
||||
};
|
||||
|
||||
const VARIANT_ICONS: Record<ToastVariant, string> = {
|
||||
success: '✓',
|
||||
error: '✕',
|
||||
};
|
||||
|
||||
/**
|
||||
* Toast-Benachrichtigungen (Design-System).
|
||||
* Meldungen verschwinden automatisch nach 4 Sekunden.
|
||||
*/
|
||||
export function ToastProvider({ children }: { children: ReactNode }): ReactNode {
|
||||
const [toasts, setToasts] = useState<ToastEntry[]>([]);
|
||||
const nextId = useRef(1);
|
||||
|
||||
const showToast = useCallback((variant: ToastVariant, message: string) => {
|
||||
const id = nextId.current;
|
||||
nextId.current += 1;
|
||||
setToasts((current) => [...current, { id, variant, message }]);
|
||||
window.setTimeout(() => {
|
||||
setToasts((current) => current.filter((toast) => toast.id !== id));
|
||||
}, 4_000);
|
||||
}, []);
|
||||
|
||||
const value = useMemo<ToastContextValue>(() => ({ showToast }), [showToast]);
|
||||
|
||||
return (
|
||||
<ToastContext.Provider value={value}>
|
||||
{children}
|
||||
<div
|
||||
className="pointer-events-none fixed bottom-4 right-4 z-50 flex flex-col gap-2"
|
||||
aria-live="polite"
|
||||
>
|
||||
{toasts.map((toast) => (
|
||||
<div
|
||||
key={toast.id}
|
||||
className={`pointer-events-auto flex items-center gap-2 rounded-lg px-4 py-3 text-sm font-medium shadow-lg
|
||||
${VARIANT_CLASSES[toast.variant]}`}
|
||||
>
|
||||
<span aria-hidden="true">{VARIANT_ICONS[toast.variant]}</span>
|
||||
{toast.message}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</ToastContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
/** Zeigt eine Toast-Meldung an (wirft außerhalb des Providers). */
|
||||
export function useToast(): ToastContextValue {
|
||||
const context = useContext(ToastContext);
|
||||
if (!context) {
|
||||
throw new Error('useToast muss innerhalb von ToastProvider verwendet werden');
|
||||
}
|
||||
return context;
|
||||
}
|
||||
Reference in New Issue
Block a user