Split two oversized page files into _components/ directories following Next.js 15 conventions and the 500-LOC hard cap: - loeschfristen/page.tsx (2322 LOC -> 412 LOC orchestrator + 6 components) - dsb-portal/page.tsx (2068 LOC -> 135 LOC orchestrator + 9 components) All component files stay under 500 lines. Build verified. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
368 lines
16 KiB
TypeScript
368 lines
16 KiB
TypeScript
'use client'
|
|
|
|
import React, { useState, useEffect, useCallback, useRef } from 'react'
|
|
|
|
// =============================================================================
|
|
// TOAST
|
|
// =============================================================================
|
|
|
|
export interface ToastMessage {
|
|
id: number
|
|
message: string
|
|
type: 'success' | 'error'
|
|
}
|
|
|
|
let toastIdCounter = 0
|
|
|
|
export function useToast() {
|
|
const [toasts, setToasts] = useState<ToastMessage[]>([])
|
|
|
|
const addToast = useCallback((message: string, type: 'success' | 'error' = 'success') => {
|
|
const id = ++toastIdCounter
|
|
setToasts((prev) => [...prev, { id, message, type }])
|
|
setTimeout(() => {
|
|
setToasts((prev) => prev.filter((t) => t.id !== id))
|
|
}, 3500)
|
|
}, [])
|
|
|
|
return { toasts, addToast }
|
|
}
|
|
|
|
export function ToastContainer({ toasts }: { toasts: ToastMessage[] }) {
|
|
if (toasts.length === 0) return null
|
|
return (
|
|
<div className="fixed top-4 right-4 z-[100] flex flex-col gap-2">
|
|
{toasts.map((t) => (
|
|
<div
|
|
key={t.id}
|
|
className={`px-5 py-3 rounded-lg shadow-lg text-sm font-medium text-white transition-all animate-slide-in ${
|
|
t.type === 'success' ? 'bg-green-600' : 'bg-red-600'
|
|
}`}
|
|
>
|
|
{t.message}
|
|
</div>
|
|
))}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
// =============================================================================
|
|
// LOADING SKELETON
|
|
// =============================================================================
|
|
|
|
export function Skeleton({ className = '' }: { className?: string }) {
|
|
return <div className={`animate-pulse bg-gray-200 rounded ${className}`} />
|
|
}
|
|
|
|
export function DashboardSkeleton() {
|
|
return (
|
|
<div className="space-y-6">
|
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
|
|
{Array.from({ length: 4 }).map((_, i) => (
|
|
<Skeleton key={i} className="h-28 rounded-xl" />
|
|
))}
|
|
</div>
|
|
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-6">
|
|
{Array.from({ length: 3 }).map((_, i) => (
|
|
<Skeleton key={i} className="h-64 rounded-xl" />
|
|
))}
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
// =============================================================================
|
|
// MODAL
|
|
// =============================================================================
|
|
|
|
export function Modal({
|
|
open, onClose, title, children, maxWidth = 'max-w-lg',
|
|
}: {
|
|
open: boolean; onClose: () => void; title: string
|
|
children: React.ReactNode; maxWidth?: string
|
|
}) {
|
|
const overlayRef = useRef<HTMLDivElement>(null)
|
|
|
|
useEffect(() => {
|
|
if (!open) return
|
|
const handleEsc = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose() }
|
|
document.addEventListener('keydown', handleEsc)
|
|
return () => document.removeEventListener('keydown', handleEsc)
|
|
}, [open, onClose])
|
|
|
|
if (!open) return null
|
|
|
|
return (
|
|
<div ref={overlayRef}
|
|
className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm"
|
|
onClick={(e) => { if (e.target === overlayRef.current) onClose() }}>
|
|
<div className={`bg-white rounded-2xl shadow-2xl w-full ${maxWidth} mx-4 overflow-hidden`}>
|
|
<div className="flex items-center justify-between px-6 py-4 border-b border-gray-200 bg-purple-50">
|
|
<h3 className="text-lg font-semibold text-purple-900">{title}</h3>
|
|
<button onClick={onClose} className="p-1 rounded-lg hover:bg-purple-100 transition-colors text-purple-600">
|
|
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
|
</svg>
|
|
</button>
|
|
</div>
|
|
<div className="px-6 py-4 max-h-[70vh] overflow-y-auto">{children}</div>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
// =============================================================================
|
|
// STAT CARD, PROGRESS BARS, BADGE
|
|
// =============================================================================
|
|
|
|
export function StatCard({
|
|
title, value, icon, accent = false,
|
|
}: {
|
|
title: string; value: string | number; icon: React.ReactNode; accent?: boolean
|
|
}) {
|
|
return (
|
|
<div className={`rounded-xl border p-5 transition-all ${
|
|
accent ? 'bg-red-50 border-red-200' : 'bg-white border-gray-200 hover:border-purple-300'
|
|
}`}>
|
|
<div className="flex items-center gap-3">
|
|
<div className={`w-10 h-10 rounded-lg flex items-center justify-center ${
|
|
accent ? 'bg-red-100 text-red-600' : 'bg-purple-100 text-purple-600'
|
|
}`}>{icon}</div>
|
|
<div>
|
|
<p className="text-sm text-gray-500">{title}</p>
|
|
<p className={`text-2xl font-bold ${accent ? 'text-red-700' : 'text-gray-900'}`}>{value}</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
export function ComplianceBar({ score }: { score: number }) {
|
|
const color = score < 40 ? 'bg-red-500' : score < 70 ? 'bg-yellow-500' : 'bg-green-500'
|
|
const textColor = score < 40 ? 'text-red-700' : score < 70 ? 'text-yellow-700' : 'text-green-700'
|
|
return (
|
|
<div className="flex items-center gap-2">
|
|
<div className="flex-1 h-2.5 bg-gray-100 rounded-full overflow-hidden">
|
|
<div className={`h-full rounded-full transition-all ${color}`} style={{ width: `${Math.min(score, 100)}%` }} />
|
|
</div>
|
|
<span className={`text-xs font-semibold min-w-[36px] text-right ${textColor}`}>{score}%</span>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
export function HoursBar({ used, budget }: { used: number; budget: number }) {
|
|
const pct = budget > 0 ? Math.min((used / budget) * 100, 100) : 0
|
|
const over = used > budget
|
|
return (
|
|
<div className="flex items-center gap-2">
|
|
<div className="flex-1 h-2.5 bg-gray-100 rounded-full overflow-hidden">
|
|
<div className={`h-full rounded-full transition-all ${over ? 'bg-red-500' : 'bg-purple-500'}`} style={{ width: `${pct}%` }} />
|
|
</div>
|
|
<span className={`text-xs font-medium min-w-[60px] text-right ${over ? 'text-red-600' : 'text-gray-600'}`}>
|
|
{used}h / {budget}h
|
|
</span>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
export function Badge({ label, className = '' }: { label: string; className?: string }) {
|
|
return (
|
|
<span className={`inline-flex items-center px-2 py-0.5 text-xs font-medium rounded-full border ${className}`}>
|
|
{label}
|
|
</span>
|
|
)
|
|
}
|
|
|
|
// =============================================================================
|
|
// FORM COMPONENTS
|
|
// =============================================================================
|
|
|
|
export function FormLabel({ children, htmlFor }: { children: React.ReactNode; htmlFor?: string }) {
|
|
return <label htmlFor={htmlFor} className="block text-sm font-medium text-gray-700 mb-1">{children}</label>
|
|
}
|
|
|
|
export function FormInput({
|
|
id, type = 'text', value, onChange, placeholder, required, min, max, step,
|
|
}: {
|
|
id?: string; type?: string; value: string | number; onChange: (val: string) => void
|
|
placeholder?: string; required?: boolean; min?: string | number; max?: string | number; step?: string | number
|
|
}) {
|
|
return (
|
|
<input id={id} type={type} value={value} onChange={(e) => onChange(e.target.value)}
|
|
placeholder={placeholder} required={required} min={min} max={max} step={step}
|
|
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-purple-500 focus:border-purple-500" />
|
|
)
|
|
}
|
|
|
|
export function FormTextarea({
|
|
id, value, onChange, placeholder, rows = 3,
|
|
}: { id?: string; value: string; onChange: (val: string) => void; placeholder?: string; rows?: number }) {
|
|
return (
|
|
<textarea id={id} value={value} onChange={(e) => onChange(e.target.value)}
|
|
placeholder={placeholder} rows={rows}
|
|
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-purple-500 focus:border-purple-500 resize-none" />
|
|
)
|
|
}
|
|
|
|
export function FormSelect({
|
|
id, value, onChange, options, placeholder,
|
|
}: {
|
|
id?: string; value: string; onChange: (val: string) => void
|
|
options: { value: string; label: string }[]; placeholder?: string
|
|
}) {
|
|
return (
|
|
<select id={id} value={value} onChange={(e) => onChange(e.target.value)}
|
|
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-purple-500 focus:border-purple-500 bg-white">
|
|
{placeholder && <option value="" disabled>{placeholder}</option>}
|
|
{options.map((o) => <option key={o.value} value={o.value}>{o.label}</option>)}
|
|
</select>
|
|
)
|
|
}
|
|
|
|
export function PrimaryButton({
|
|
onClick, disabled, children, type = 'button', className = '',
|
|
}: {
|
|
onClick?: () => void; disabled?: boolean; children: React.ReactNode
|
|
type?: 'button' | 'submit'; className?: string
|
|
}) {
|
|
return (
|
|
<button type={type} onClick={onClick} disabled={disabled}
|
|
className={`px-4 py-2 rounded-lg bg-purple-600 text-white text-sm font-medium hover:bg-purple-700 focus:ring-2 focus:ring-purple-500 focus:ring-offset-2 disabled:opacity-50 disabled:cursor-not-allowed transition-colors ${className}`}>
|
|
{children}
|
|
</button>
|
|
)
|
|
}
|
|
|
|
export function SecondaryButton({
|
|
onClick, disabled, children, type = 'button', className = '',
|
|
}: {
|
|
onClick?: () => void; disabled?: boolean; children: React.ReactNode
|
|
type?: 'button' | 'submit'; className?: string
|
|
}) {
|
|
return (
|
|
<button type={type} onClick={onClick} disabled={disabled}
|
|
className={`px-4 py-2 rounded-lg border border-gray-300 text-gray-700 text-sm font-medium hover:bg-gray-50 focus:ring-2 focus:ring-purple-500 focus:ring-offset-2 disabled:opacity-50 disabled:cursor-not-allowed transition-colors ${className}`}>
|
|
{children}
|
|
</button>
|
|
)
|
|
}
|
|
|
|
// =============================================================================
|
|
// ERROR / EMPTY STATES
|
|
// =============================================================================
|
|
|
|
export function ErrorState({ message, onRetry }: { message: string; onRetry: () => void }) {
|
|
return (
|
|
<div className="flex flex-col items-center justify-center py-16 text-center">
|
|
<div className="w-16 h-16 rounded-full bg-red-100 flex items-center justify-center mb-4">
|
|
<svg className="w-8 h-8 text-red-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}
|
|
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.964-.833-2.732 0L4.082 16.5c-.77.833.192 2.5 1.732 2.5z" />
|
|
</svg>
|
|
</div>
|
|
<p className="text-gray-700 font-medium mb-1">Fehler beim Laden</p>
|
|
<p className="text-sm text-gray-500 mb-4 max-w-md">{message}</p>
|
|
<PrimaryButton onClick={onRetry}>Erneut versuchen</PrimaryButton>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
export function EmptyState({ icon, title, description }: { icon: React.ReactNode; title: string; description: string }) {
|
|
return (
|
|
<div className="flex flex-col items-center justify-center py-12 text-center">
|
|
<div className="w-14 h-14 rounded-full bg-purple-50 flex items-center justify-center mb-3 text-purple-400">{icon}</div>
|
|
<p className="text-gray-700 font-medium">{title}</p>
|
|
<p className="text-sm text-gray-400 mt-1">{description}</p>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
// =============================================================================
|
|
// ICONS
|
|
// =============================================================================
|
|
|
|
export function IconUsers({ className = 'w-5 h-5' }: { className?: string }) {
|
|
return (<svg className={className} fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="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" />
|
|
</svg>)
|
|
}
|
|
|
|
export function IconClock({ className = 'w-5 h-5' }: { className?: string }) {
|
|
return (<svg className={className} fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
|
|
</svg>)
|
|
}
|
|
|
|
export function IconTask({ className = 'w-5 h-5' }: { className?: string }) {
|
|
return (<svg className={className} fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-6 9l2 2 4-4" />
|
|
</svg>)
|
|
}
|
|
|
|
export function IconAlert({ className = 'w-5 h-5' }: { className?: string }) {
|
|
return (<svg className={className} fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.964-.833-2.732 0L4.082 16.5c-.77.833.192 2.5 1.732 2.5z" />
|
|
</svg>)
|
|
}
|
|
|
|
export function IconBack({ className = 'w-5 h-5' }: { className?: string }) {
|
|
return (<svg className={className} fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 19l-7-7m0 0l7-7m-7 7h18" />
|
|
</svg>)
|
|
}
|
|
|
|
export function IconPlus({ className = 'w-4 h-4' }: { className?: string }) {
|
|
return (<svg className={className} fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
|
|
</svg>)
|
|
}
|
|
|
|
export function IconCheck({ className = 'w-4 h-4' }: { className?: string }) {
|
|
return (<svg className={className} fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
|
|
</svg>)
|
|
}
|
|
|
|
export function IconMail({ className = 'w-5 h-5' }: { className?: string }) {
|
|
return (<svg className={className} fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="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" />
|
|
</svg>)
|
|
}
|
|
|
|
export function IconSettings({ className = 'w-5 h-5' }: { className?: string }) {
|
|
return (<svg className={className} fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="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.066 2.573c1.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.573 1.066c-.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.066-2.573c-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" />
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
|
|
</svg>)
|
|
}
|
|
|
|
export function IconRefresh({ className = 'w-4 h-4' }: { className?: string }) {
|
|
return (<svg className={className} fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
|
|
</svg>)
|
|
}
|
|
|
|
export function IconInbound({ className = 'w-4 h-4' }: { className?: string }) {
|
|
return (<svg className={className} fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M11 19l-7-7 7-7m8 14l-7-7 7-7" />
|
|
</svg>)
|
|
}
|
|
|
|
export function IconOutbound({ className = 'w-4 h-4' }: { className?: string }) {
|
|
return (<svg className={className} fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 5l7 7-7 7M5 5l7 7-7 7" />
|
|
</svg>)
|
|
}
|
|
|
|
export function IconShield({ className = 'w-6 h-6' }: { className?: string }) {
|
|
return (<svg className={className} fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z" />
|
|
</svg>)
|
|
}
|
|
|
|
export function IconCalendar({ className = 'w-4 h-4' }: { className?: string }) {
|
|
return (<svg className={className} fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" />
|
|
</svg>)
|
|
}
|