feat: Phase 2 – Benutzerverwaltung (CRUD-API, Profil, UI)

This commit is contained in:
MPM Dev
2026-10-06 14:58:36 +02:00
parent a7e1c421f2
commit e87dc1f836
25 changed files with 2056 additions and 75 deletions

View File

@@ -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 },
];

View 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>
);
}

View 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>
);
}

View 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;
}