[split-required] Split 500-850 LOC files (batch 2)

backend-lehrer (10 files):
- game/database.py (785 → 5), correction_api.py (683 → 4)
- classroom_engine/antizipation.py (676 → 5)
- llm_gateway schools/edu_search already done in prior batch

klausur-service (12 files):
- orientation_crop_api.py (694 → 5), pdf_export.py (677 → 4)
- zeugnis_crawler.py (676 → 5), grid_editor_api.py (671 → 5)
- eh_templates.py (658 → 5), mail/api.py (651 → 5)
- qdrant_service.py (638 → 5), training_api.py (625 → 4)

website (6 pages):
- middleware (696 → 8), mail (733 → 6), consent (628 → 8)
- compliance/risks (622 → 5), export (502 → 5), brandbook (629 → 7)

studio-v2 (3 components):
- B2BMigrationWizard (848 → 3), CleanupPanel (765 → 2)
- dashboard-experimental (739 → 2)

admin-lehrer (4 files):
- uebersetzungen (769 → 4), manager (670 → 2)
- ChunkBrowserQA (675 → 6), dsfa/page (674 → 5)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Benjamin Admin
2026-04-25 08:24:01 +02:00
parent 34da9f4cda
commit b4613e26f3
118 changed files with 15258 additions and 14680 deletions

View File

@@ -0,0 +1,77 @@
'use client'
import { useState } from 'react'
import { COLORS } from './types'
function ColorSwatch({ color }: { color: { name: string; value: string; text: string } }) {
const [copied, setCopied] = useState(false)
const copyToClipboard = () => {
navigator.clipboard.writeText(color.value)
setCopied(true)
setTimeout(() => setCopied(false), 2000)
}
return (
<button
onClick={copyToClipboard}
className="group flex flex-col items-center"
title={`Klicken zum Kopieren: ${color.value}`}
>
<div
className="w-16 h-16 rounded-lg shadow-sm border border-slate-200 transition-transform group-hover:scale-110 flex items-center justify-center"
style={{ backgroundColor: color.value }}
>
{copied && (
<svg className={`w-5 h-5 ${color.text === 'light' ? 'text-white' : 'text-slate-900'}`} fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
)}
</div>
<span className="text-xs text-slate-600 mt-1">{color.name}</span>
<span className="text-xs text-slate-400">{color.value}</span>
</button>
)
}
export default function ColorsTab() {
return (
<div className="space-y-8">
{Object.entries(COLORS).map(([key, palette]) => (
<div key={key} className="bg-white rounded-xl border border-slate-200 p-6">
<h3 className="text-lg font-semibold text-slate-900 mb-4">{palette.name}</h3>
<div className="flex gap-4 flex-wrap">
{palette.shades.map((shade) => (
<ColorSwatch key={shade.name} color={shade} />
))}
</div>
</div>
))}
{/* Color Usage Guidelines */}
<div className="bg-white rounded-xl border border-slate-200 p-6">
<h3 className="text-lg font-semibold text-slate-900 mb-4">Verwendungsrichtlinien</h3>
<div className="grid grid-cols-2 gap-6">
<div>
<h4 className="font-medium text-slate-700 mb-2">Primary (Sky Blue)</h4>
<ul className="text-sm text-slate-600 space-y-1">
<li>- Primäre Buttons und CTAs</li>
<li>- Links und interaktive Elemente</li>
<li>- Fokuszustände</li>
<li>- Ausgewählte Navigation</li>
</ul>
</div>
<div>
<h4 className="font-medium text-slate-700 mb-2">Accent (Fuchsia)</h4>
<ul className="text-sm text-slate-600 space-y-1">
<li>- Highlights und Akzente</li>
<li>- Badges und Tags</li>
<li>- Gradient-Akzente</li>
<li>- Kreative Elemente</li>
</ul>
</div>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,105 @@
'use client'
export default function ComponentsTab() {
return (
<div className="space-y-6">
{/* Buttons */}
<div className="bg-white rounded-xl border border-slate-200 p-6">
<h3 className="text-lg font-semibold text-slate-900 mb-4">Buttons</h3>
<div className="flex flex-wrap gap-4 mb-6">
<button className="px-6 py-2 bg-primary-600 text-white font-medium rounded-lg hover:bg-primary-700 transition-colors">
Primary
</button>
<button className="px-6 py-2 bg-slate-100 text-slate-700 font-medium rounded-lg hover:bg-slate-200 transition-colors">
Secondary
</button>
<button className="px-6 py-2 border border-slate-300 text-slate-700 font-medium rounded-lg hover:bg-slate-50 transition-colors">
Outline
</button>
<button className="px-6 py-2 text-primary-600 font-medium rounded-lg hover:bg-primary-50 transition-colors">
Ghost
</button>
<button className="px-6 py-2 bg-red-600 text-white font-medium rounded-lg hover:bg-red-700 transition-colors">
Danger
</button>
</div>
<div className="flex flex-wrap gap-4">
<button className="px-4 py-1.5 bg-primary-600 text-white text-sm font-medium rounded-lg">
Small
</button>
<button className="px-6 py-2 bg-primary-600 text-white font-medium rounded-lg">
Medium
</button>
<button className="px-8 py-3 bg-primary-600 text-white text-lg font-medium rounded-lg">
Large
</button>
</div>
</div>
{/* Inputs */}
<div className="bg-white rounded-xl border border-slate-200 p-6">
<h3 className="text-lg font-semibold text-slate-900 mb-4">Eingabefelder</h3>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Standard Input</label>
<input
type="text"
placeholder="Placeholder..."
className="w-full px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
/>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Mit Icon</label>
<div className="relative">
<input
type="text"
placeholder="Suchen..."
className="w-full pl-10 pr-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
/>
<svg className="w-5 h-5 text-slate-400 absolute left-3 top-2.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
</svg>
</div>
</div>
</div>
</div>
{/* Cards */}
<div className="bg-white rounded-xl border border-slate-200 p-6">
<h3 className="text-lg font-semibold text-slate-900 mb-4">Cards</h3>
<div className="grid grid-cols-3 gap-4">
<div className="bg-white rounded-xl border border-slate-200 p-4 hover:shadow-lg transition-shadow">
<div className="w-10 h-10 bg-primary-100 rounded-lg flex items-center justify-center mb-3">
<svg className="w-5 h-5 text-primary-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 10V3L4 14h7v7l9-11h-7z" />
</svg>
</div>
<h4 className="font-semibold text-slate-900 mb-1">Feature Card</h4>
<p className="text-sm text-slate-600">Beschreibungstext für diese Feature-Karte.</p>
</div>
<div className="bg-gradient-to-br from-primary-500 to-primary-600 rounded-xl p-4 text-white">
<h4 className="font-semibold mb-1">Highlight Card</h4>
<p className="text-sm text-primary-100">Mit Gradient-Hintergrund.</p>
</div>
<div className="bg-slate-900 rounded-xl p-4 text-white">
<h4 className="font-semibold mb-1">Dark Card</h4>
<p className="text-sm text-slate-400">Dunkler Hintergrund.</p>
</div>
</div>
</div>
{/* Badges */}
<div className="bg-white rounded-xl border border-slate-200 p-6">
<h3 className="text-lg font-semibold text-slate-900 mb-4">Badges & Tags</h3>
<div className="flex flex-wrap gap-2">
<span className="px-2 py-1 bg-primary-100 text-primary-700 text-xs font-medium rounded">Primary</span>
<span className="px-2 py-1 bg-green-100 text-green-700 text-xs font-medium rounded">Success</span>
<span className="px-2 py-1 bg-amber-100 text-amber-700 text-xs font-medium rounded">Warning</span>
<span className="px-2 py-1 bg-red-100 text-red-700 text-xs font-medium rounded">Danger</span>
<span className="px-2 py-1 bg-slate-100 text-slate-700 text-xs font-medium rounded">Neutral</span>
<span className="px-2 py-1 bg-fuchsia-100 text-fuchsia-700 text-xs font-medium rounded">Accent</span>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,117 @@
'use client'
export default function LogoTab() {
return (
<div className="space-y-6">
{/* Logo Display */}
<div className="bg-white rounded-xl border border-slate-200 p-6">
<h3 className="text-lg font-semibold text-slate-900 mb-4">Logo</h3>
<div className="grid grid-cols-2 gap-8">
<div className="bg-white border border-slate-200 rounded-lg p-8 flex items-center justify-center">
<div className="flex items-center gap-3">
<div className="w-12 h-12 bg-gradient-to-br from-primary-500 to-primary-600 rounded-xl flex items-center justify-center">
<span className="text-2xl font-bold text-white">B</span>
</div>
<span className="text-2xl font-bold text-slate-900">BreakPilot</span>
</div>
</div>
<div className="bg-slate-900 rounded-lg p-8 flex items-center justify-center">
<div className="flex items-center gap-3">
<div className="w-12 h-12 bg-gradient-to-br from-primary-400 to-primary-500 rounded-xl flex items-center justify-center">
<span className="text-2xl font-bold text-white">B</span>
</div>
<span className="text-2xl font-bold text-white">BreakPilot</span>
</div>
</div>
</div>
</div>
{/* Logo Variations */}
<div className="bg-white rounded-xl border border-slate-200 p-6">
<h3 className="text-lg font-semibold text-slate-900 mb-4">Logo-Varianten</h3>
<div className="grid grid-cols-4 gap-4">
<div className="border border-slate-200 rounded-lg p-4 flex flex-col items-center gap-2">
<div className="w-12 h-12 bg-gradient-to-br from-primary-500 to-primary-600 rounded-xl flex items-center justify-center">
<span className="text-2xl font-bold text-white">B</span>
</div>
<span className="text-xs text-slate-600">Icon Only</span>
</div>
<div className="border border-slate-200 rounded-lg p-4 flex flex-col items-center gap-2">
<span className="text-xl font-bold text-slate-900">BreakPilot</span>
<span className="text-xs text-slate-600">Text Only</span>
</div>
<div className="border border-slate-200 rounded-lg p-4 flex flex-col items-center gap-2">
<div className="flex items-center gap-2">
<div className="w-8 h-8 bg-gradient-to-br from-primary-500 to-primary-600 rounded-lg flex items-center justify-center">
<span className="text-lg font-bold text-white">B</span>
</div>
<span className="text-lg font-bold text-slate-900">BreakPilot</span>
</div>
<span className="text-xs text-slate-600">Horizontal</span>
</div>
<div className="border border-slate-200 rounded-lg p-4 flex flex-col items-center gap-2">
<div className="flex flex-col items-center">
<div className="w-10 h-10 bg-gradient-to-br from-primary-500 to-primary-600 rounded-xl flex items-center justify-center mb-1">
<span className="text-xl font-bold text-white">B</span>
</div>
<span className="text-sm font-bold text-slate-900">BreakPilot</span>
</div>
<span className="text-xs text-slate-600">Stacked</span>
</div>
</div>
</div>
{/* Clear Space */}
<div className="bg-white rounded-xl border border-slate-200 p-6">
<h3 className="text-lg font-semibold text-slate-900 mb-4">Schutzzone</h3>
<p className="text-sm text-slate-600 mb-4">
Um das Logo herum sollte mindestens ein Abstand von der Höhe des Icons eingehalten werden.
</p>
<div className="bg-slate-50 rounded-lg p-8 inline-block">
<div className="border-2 border-dashed border-slate-300 p-8">
<div className="flex items-center gap-3">
<div className="w-12 h-12 bg-gradient-to-br from-primary-500 to-primary-600 rounded-xl flex items-center justify-center">
<span className="text-2xl font-bold text-white">B</span>
</div>
<span className="text-2xl font-bold text-slate-900">BreakPilot</span>
</div>
</div>
</div>
</div>
{/* Don'ts */}
<div className="bg-white rounded-xl border border-slate-200 p-6">
<h3 className="text-lg font-semibold text-slate-900 mb-4">Nicht erlaubt</h3>
<div className="grid grid-cols-3 gap-4">
<div className="border border-red-200 bg-red-50 rounded-lg p-4 text-center">
<div className="flex items-center gap-2 justify-center opacity-50">
<div className="w-8 h-8 bg-red-500 rounded-lg flex items-center justify-center">
<span className="text-lg font-bold text-white">B</span>
</div>
<span className="text-lg font-bold text-red-500">BreakPilot</span>
</div>
<span className="text-xs text-red-600 mt-2 block">Falsche Farben</span>
</div>
<div className="border border-red-200 bg-red-50 rounded-lg p-4 text-center">
<div className="flex items-center gap-2 justify-center" style={{ transform: 'skewX(-10deg)' }}>
<div className="w-8 h-8 bg-gradient-to-br from-primary-500 to-primary-600 rounded-lg flex items-center justify-center">
<span className="text-lg font-bold text-white">B</span>
</div>
<span className="text-lg font-bold text-slate-900">BreakPilot</span>
</div>
<span className="text-xs text-red-600 mt-2 block">Verzerrt</span>
</div>
<div className="border border-red-200 bg-red-50 rounded-lg p-4 text-center">
<div className="flex items-center gap-2 justify-center">
<div className="w-8 h-8 bg-gradient-to-br from-primary-500 to-primary-600 rounded-lg flex items-center justify-center">
<span className="text-lg font-bold text-white">B</span>
</div>
<span className="text-lg font-bold text-slate-900">Break Pilot</span>
</div>
<span className="text-xs text-red-600 mt-2 block">Falsche Schreibweise</span>
</div>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,69 @@
'use client'
import { TYPOGRAPHY } from './types'
export default function TypographyTab() {
return (
<div className="space-y-6">
{/* Font Family */}
<div className="bg-white rounded-xl border border-slate-200 p-6">
<h3 className="text-lg font-semibold text-slate-900 mb-4">Schriftart: Inter</h3>
<div className="bg-slate-50 rounded-lg p-4 font-mono text-sm text-slate-600 mb-4">
font-family: {TYPOGRAPHY.fontFamily};
</div>
<p className="text-sm text-slate-600">
Inter ist eine moderne, variable Sans-Serif Schrift, optimiert für Bildschirme.
Sie ist unter der SIL Open Font License verfügbar und frei für kommerzielle Nutzung.
</p>
</div>
{/* Font Weights */}
<div className="bg-white rounded-xl border border-slate-200 p-6">
<h3 className="text-lg font-semibold text-slate-900 mb-4">Schriftschnitte</h3>
<div className="space-y-4">
{TYPOGRAPHY.weights.map((w) => (
<div key={w.weight} className="flex items-center gap-6">
<span
className="text-2xl w-48"
style={{ fontWeight: w.weight }}
>
The quick brown fox
</span>
<div className="flex-1">
<span className="font-medium text-slate-900">{w.name} ({w.weight})</span>
<span className="text-sm text-slate-500 ml-4">{w.usage}</span>
</div>
</div>
))}
</div>
</div>
{/* Font Sizes */}
<div className="bg-white rounded-xl border border-slate-200 p-6">
<h3 className="text-lg font-semibold text-slate-900 mb-4">Schriftgrößen</h3>
<div className="space-y-3">
{TYPOGRAPHY.sizes.map((s) => (
<div key={s.name} className="flex items-baseline gap-4 border-b border-slate-100 pb-3">
<span className="w-16 text-sm font-mono text-slate-500">{s.name}</span>
<span className="w-32 text-sm text-slate-600">{s.size}</span>
<span className="text-sm text-slate-500">{s.usage}</span>
</div>
))}
</div>
</div>
{/* Headings Preview */}
<div className="bg-white rounded-xl border border-slate-200 p-6">
<h3 className="text-lg font-semibold text-slate-900 mb-4">Überschriften-Hierarchie</h3>
<div className="space-y-4">
<h1 className="text-4xl font-bold text-slate-900">H1: Hauptüberschrift</h1>
<h2 className="text-3xl font-bold text-slate-900">H2: Abschnittsüberschrift</h2>
<h3 className="text-2xl font-semibold text-slate-900">H3: Unterabschnitt</h3>
<h4 className="text-xl font-semibold text-slate-900">H4: Card-Titel</h4>
<h5 className="text-lg font-medium text-slate-900">H5: Kleiner Titel</h5>
<h6 className="text-base font-medium text-slate-900">H6: Label</h6>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,101 @@
'use client'
import { VOICE_TONE } from './types'
export default function VoiceTab() {
return (
<div className="space-y-6">
{/* Brand Attributes */}
<div className="bg-white rounded-xl border border-slate-200 p-6">
<h3 className="text-lg font-semibold text-slate-900 mb-4">Markenpersönlichkeit</h3>
<div className="flex flex-wrap gap-3">
{VOICE_TONE.attributes.map((attr) => (
<span
key={attr}
className="px-4 py-2 bg-primary-100 text-primary-700 rounded-full font-medium"
>
{attr}
</span>
))}
</div>
</div>
{/* Do & Don't */}
<div className="grid grid-cols-2 gap-6">
<div className="bg-white rounded-xl border border-slate-200 p-6">
<h3 className="text-lg font-semibold text-green-600 mb-4 flex items-center gap-2">
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
So schreiben wir
</h3>
<ul className="space-y-2">
{VOICE_TONE.doList.map((item) => (
<li key={item} className="flex items-center gap-2 text-slate-600">
<span className="w-1.5 h-1.5 bg-green-500 rounded-full"></span>
{item}
</li>
))}
</ul>
</div>
<div className="bg-white rounded-xl border border-slate-200 p-6">
<h3 className="text-lg font-semibold text-red-600 mb-4 flex items-center gap-2">
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
Das vermeiden wir
</h3>
<ul className="space-y-2">
{VOICE_TONE.dontList.map((item) => (
<li key={item} className="flex items-center gap-2 text-slate-600">
<span className="w-1.5 h-1.5 bg-red-500 rounded-full"></span>
{item}
</li>
))}
</ul>
</div>
</div>
{/* Example Texts */}
<div className="bg-white rounded-xl border border-slate-200 p-6">
<h3 className="text-lg font-semibold text-slate-900 mb-4">Beispieltexte</h3>
<div className="space-y-4">
<div className="p-4 bg-green-50 border border-green-200 rounded-lg">
<span className="text-xs text-green-600 font-medium mb-2 block">GUT</span>
<p className="text-slate-700">
"Sparen Sie Zeit bei der Korrektur - unsere KI unterstützt Sie mit intelligenten Vorschlägen."
</p>
</div>
<div className="p-4 bg-red-50 border border-red-200 rounded-lg">
<span className="text-xs text-red-600 font-medium mb-2 block">SCHLECHT</span>
<p className="text-slate-700">
"Unsere revolutionäre KI-Lösung optimiert Ihre Korrekturworkflows durch state-of-the-art NLP."
</p>
</div>
</div>
</div>
{/* Target Audience */}
<div className="bg-white rounded-xl border border-slate-200 p-6">
<h3 className="text-lg font-semibold text-slate-900 mb-4">Zielgruppe</h3>
<div className="grid grid-cols-3 gap-4">
<div className="p-4 bg-slate-50 rounded-lg">
<div className="text-2xl mb-2">👩🏫</div>
<h4 className="font-semibold text-slate-900">Lehrkräfte</h4>
<p className="text-sm text-slate-600">Wünschen Zeitersparnis und einfache Bedienung</p>
</div>
<div className="p-4 bg-slate-50 rounded-lg">
<div className="text-2xl mb-2">🏫</div>
<h4 className="font-semibold text-slate-900">Schulleitung</h4>
<p className="text-sm text-slate-600">Fokus auf DSGVO, Kosten und Integration</p>
</div>
<div className="p-4 bg-slate-50 rounded-lg">
<div className="text-2xl mb-2">👨👩👧</div>
<h4 className="font-semibold text-slate-900">Eltern</h4>
<p className="text-sm text-slate-600">Wollen Transparenz und schnelles Feedback</p>
</div>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,115 @@
export const COLORS = {
primary: {
name: 'Primary (Sky Blue)',
shades: [
{ name: '50', value: '#f0f9ff', text: 'dark' },
{ name: '100', value: '#e0f2fe', text: 'dark' },
{ name: '200', value: '#bae6fd', text: 'dark' },
{ name: '300', value: '#7dd3fc', text: 'dark' },
{ name: '400', value: '#38bdf8', text: 'dark' },
{ name: '500', value: '#0ea5e9', text: 'light' },
{ name: '600', value: '#0284c7', text: 'light' },
{ name: '700', value: '#0369a1', text: 'light' },
{ name: '800', value: '#075985', text: 'light' },
{ name: '900', value: '#0c4a6e', text: 'light' },
],
},
accent: {
name: 'Accent (Fuchsia)',
shades: [
{ name: '50', value: '#fdf4ff', text: 'dark' },
{ name: '100', value: '#fae8ff', text: 'dark' },
{ name: '200', value: '#f5d0fe', text: 'dark' },
{ name: '300', value: '#f0abfc', text: 'dark' },
{ name: '400', value: '#e879f9', text: 'dark' },
{ name: '500', value: '#d946ef', text: 'light' },
{ name: '600', value: '#c026d3', text: 'light' },
{ name: '700', value: '#a21caf', text: 'light' },
{ name: '800', value: '#86198f', text: 'light' },
{ name: '900', value: '#701a75', text: 'light' },
],
},
success: {
name: 'Success (Emerald)',
shades: [
{ name: '500', value: '#10b981', text: 'light' },
{ name: '600', value: '#059669', text: 'light' },
],
},
warning: {
name: 'Warning (Amber)',
shades: [
{ name: '500', value: '#f59e0b', text: 'dark' },
{ name: '600', value: '#d97706', text: 'light' },
],
},
danger: {
name: 'Danger (Red)',
shades: [
{ name: '500', value: '#ef4444', text: 'light' },
{ name: '600', value: '#dc2626', text: 'light' },
],
},
neutral: {
name: 'Neutral (Slate)',
shades: [
{ name: '50', value: '#f8fafc', text: 'dark' },
{ name: '100', value: '#f1f5f9', text: 'dark' },
{ name: '200', value: '#e2e8f0', text: 'dark' },
{ name: '300', value: '#cbd5e1', text: 'dark' },
{ name: '400', value: '#94a3b8', text: 'dark' },
{ name: '500', value: '#64748b', text: 'light' },
{ name: '600', value: '#475569', text: 'light' },
{ name: '700', value: '#334155', text: 'light' },
{ name: '800', value: '#1e293b', text: 'light' },
{ name: '900', value: '#0f172a', text: 'light' },
],
},
}
export const TYPOGRAPHY = {
fontFamily: "'Inter', system-ui, -apple-system, sans-serif",
weights: [
{ weight: 400, name: 'Regular', usage: 'Fließtext, Beschreibungen' },
{ weight: 500, name: 'Medium', usage: 'Labels, Buttons' },
{ weight: 600, name: 'Semi-Bold', usage: 'Überschriften H3-H6' },
{ weight: 700, name: 'Bold', usage: 'Überschriften H1-H2, CTAs' },
],
sizes: [
{ name: 'xs', size: '0.75rem (12px)', usage: 'Footnotes, Badges' },
{ name: 'sm', size: '0.875rem (14px)', usage: 'Nebentext, Labels' },
{ name: 'base', size: '1rem (16px)', usage: 'Fließtext, Body' },
{ name: 'lg', size: '1.125rem (18px)', usage: 'Lead Text' },
{ name: 'xl', size: '1.25rem (20px)', usage: 'H4, Card Titles' },
{ name: '2xl', size: '1.5rem (24px)', usage: 'H3' },
{ name: '3xl', size: '1.875rem (30px)', usage: 'H2' },
{ name: '4xl', size: '2.25rem (36px)', usage: 'H1, Hero' },
{ name: '5xl', size: '3rem (48px)', usage: 'Display' },
],
}
export const VOICE_TONE = {
attributes: [
'Professionell & Vertrauenswürdig',
'Freundlich & Zugänglich',
'Klar & Direkt',
'Hilfreich & Unterstützend',
'Modern & Innovativ',
],
doList: [
'Einfache Sprache verwenden',
'Aktiv formulieren',
'Nutzenorientiert schreiben',
'Konkrete Beispiele geben',
'Empathie zeigen',
],
dontList: [
'Fachjargon ohne Erklärung',
'Passive Formulierungen',
'Übertriebene Versprechen',
'Negative Formulierungen',
'Unpersönliche Ansprache',
],
}
export type BrandbookTab = 'colors' | 'typography' | 'components' | 'logo' | 'voice'

View File

@@ -13,185 +13,33 @@
import { useState } from 'react'
import AdminLayout from '@/components/admin/AdminLayout'
import type { BrandbookTab } from './_components/types'
import ColorsTab from './_components/ColorsTab'
import TypographyTab from './_components/TypographyTab'
import ComponentsTab from './_components/ComponentsTab'
import LogoTab from './_components/LogoTab'
import VoiceTab from './_components/VoiceTab'
// Color palette from actual CSS variables
const COLORS = {
primary: {
name: 'Primary (Sky Blue)',
shades: [
{ name: '50', value: '#f0f9ff', text: 'dark' },
{ name: '100', value: '#e0f2fe', text: 'dark' },
{ name: '200', value: '#bae6fd', text: 'dark' },
{ name: '300', value: '#7dd3fc', text: 'dark' },
{ name: '400', value: '#38bdf8', text: 'dark' },
{ name: '500', value: '#0ea5e9', text: 'light' },
{ name: '600', value: '#0284c7', text: 'light' },
{ name: '700', value: '#0369a1', text: 'light' },
{ name: '800', value: '#075985', text: 'light' },
{ name: '900', value: '#0c4a6e', text: 'light' },
],
},
accent: {
name: 'Accent (Fuchsia)',
shades: [
{ name: '50', value: '#fdf4ff', text: 'dark' },
{ name: '100', value: '#fae8ff', text: 'dark' },
{ name: '200', value: '#f5d0fe', text: 'dark' },
{ name: '300', value: '#f0abfc', text: 'dark' },
{ name: '400', value: '#e879f9', text: 'dark' },
{ name: '500', value: '#d946ef', text: 'light' },
{ name: '600', value: '#c026d3', text: 'light' },
{ name: '700', value: '#a21caf', text: 'light' },
{ name: '800', value: '#86198f', text: 'light' },
{ name: '900', value: '#701a75', text: 'light' },
],
},
success: {
name: 'Success (Emerald)',
shades: [
{ name: '500', value: '#10b981', text: 'light' },
{ name: '600', value: '#059669', text: 'light' },
],
},
warning: {
name: 'Warning (Amber)',
shades: [
{ name: '500', value: '#f59e0b', text: 'dark' },
{ name: '600', value: '#d97706', text: 'light' },
],
},
danger: {
name: 'Danger (Red)',
shades: [
{ name: '500', value: '#ef4444', text: 'light' },
{ name: '600', value: '#dc2626', text: 'light' },
],
},
neutral: {
name: 'Neutral (Slate)',
shades: [
{ name: '50', value: '#f8fafc', text: 'dark' },
{ name: '100', value: '#f1f5f9', text: 'dark' },
{ name: '200', value: '#e2e8f0', text: 'dark' },
{ name: '300', value: '#cbd5e1', text: 'dark' },
{ name: '400', value: '#94a3b8', text: 'dark' },
{ name: '500', value: '#64748b', text: 'light' },
{ name: '600', value: '#475569', text: 'light' },
{ name: '700', value: '#334155', text: 'light' },
{ name: '800', value: '#1e293b', text: 'light' },
{ name: '900', value: '#0f172a', text: 'light' },
],
},
}
const TYPOGRAPHY = {
fontFamily: "'Inter', system-ui, -apple-system, sans-serif",
weights: [
{ weight: 400, name: 'Regular', usage: 'Fließtext, Beschreibungen' },
{ weight: 500, name: 'Medium', usage: 'Labels, Buttons' },
{ weight: 600, name: 'Semi-Bold', usage: 'Überschriften H3-H6' },
{ weight: 700, name: 'Bold', usage: 'Überschriften H1-H2, CTAs' },
],
sizes: [
{ name: 'xs', size: '0.75rem (12px)', usage: 'Footnotes, Badges' },
{ name: 'sm', size: '0.875rem (14px)', usage: 'Nebentext, Labels' },
{ name: 'base', size: '1rem (16px)', usage: 'Fließtext, Body' },
{ name: 'lg', size: '1.125rem (18px)', usage: 'Lead Text' },
{ name: 'xl', size: '1.25rem (20px)', usage: 'H4, Card Titles' },
{ name: '2xl', size: '1.5rem (24px)', usage: 'H3' },
{ name: '3xl', size: '1.875rem (30px)', usage: 'H2' },
{ name: '4xl', size: '2.25rem (36px)', usage: 'H1, Hero' },
{ name: '5xl', size: '3rem (48px)', usage: 'Display' },
],
}
const SPACING = [
{ name: '0', value: '0px' },
{ name: '1', value: '0.25rem (4px)' },
{ name: '2', value: '0.5rem (8px)' },
{ name: '3', value: '0.75rem (12px)' },
{ name: '4', value: '1rem (16px)' },
{ name: '5', value: '1.25rem (20px)' },
{ name: '6', value: '1.5rem (24px)' },
{ name: '8', value: '2rem (32px)' },
{ name: '10', value: '2.5rem (40px)' },
{ name: '12', value: '3rem (48px)' },
{ name: '16', value: '4rem (64px)' },
const TABS: { id: BrandbookTab; label: string }[] = [
{ id: 'colors', label: 'Farben' },
{ id: 'typography', label: 'Typografie' },
{ id: 'components', label: 'Komponenten' },
{ id: 'logo', label: 'Logo' },
{ id: 'voice', label: 'Tonalität' },
]
const VOICE_TONE = {
attributes: [
'Professionell & Vertrauenswürdig',
'Freundlich & Zugänglich',
'Klar & Direkt',
'Hilfreich & Unterstützend',
'Modern & Innovativ',
],
doList: [
'Einfache Sprache verwenden',
'Aktiv formulieren',
'Nutzenorientiert schreiben',
'Konkrete Beispiele geben',
'Empathie zeigen',
],
dontList: [
'Fachjargon ohne Erklärung',
'Passive Formulierungen',
'Übertriebene Versprechen',
'Negative Formulierungen',
'Unpersönliche Ansprache',
],
}
function ColorSwatch({ color, name }: { color: { name: string; value: string; text: string }; name: string }) {
const [copied, setCopied] = useState(false)
const copyToClipboard = () => {
navigator.clipboard.writeText(color.value)
setCopied(true)
setTimeout(() => setCopied(false), 2000)
}
return (
<button
onClick={copyToClipboard}
className="group flex flex-col items-center"
title={`Klicken zum Kopieren: ${color.value}`}
>
<div
className="w-16 h-16 rounded-lg shadow-sm border border-slate-200 transition-transform group-hover:scale-110 flex items-center justify-center"
style={{ backgroundColor: color.value }}
>
{copied && (
<svg className={`w-5 h-5 ${color.text === 'light' ? 'text-white' : 'text-slate-900'}`} fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
)}
</div>
<span className="text-xs text-slate-600 mt-1">{color.name}</span>
<span className="text-xs text-slate-400">{color.value}</span>
</button>
)
}
export default function BrandbookPage() {
const [activeTab, setActiveTab] = useState<'colors' | 'typography' | 'components' | 'logo' | 'voice'>('colors')
const [activeTab, setActiveTab] = useState<BrandbookTab>('colors')
return (
<AdminLayout title="Brandbook" description="Corporate Design & Styleguide">
{/* Tabs */}
<div className="mb-6">
<div className="flex gap-1 bg-slate-100 p-1 rounded-lg w-fit">
{[
{ id: 'colors', label: 'Farben' },
{ id: 'typography', label: 'Typografie' },
{ id: 'components', label: 'Komponenten' },
{ id: 'logo', label: 'Logo' },
{ id: 'voice', label: 'Tonalität' },
].map((tab) => (
{TABS.map((tab) => (
<button
key={tab.id}
onClick={() => setActiveTab(tab.id as typeof activeTab)}
onClick={() => setActiveTab(tab.id)}
className={`px-4 py-2 text-sm font-medium rounded-md transition-colors ${
activeTab === tab.id
? 'bg-white text-slate-900 shadow-sm'
@@ -204,426 +52,11 @@ export default function BrandbookPage() {
</div>
</div>
{/* Colors Tab */}
{activeTab === 'colors' && (
<div className="space-y-8">
{Object.entries(COLORS).map(([key, palette]) => (
<div key={key} className="bg-white rounded-xl border border-slate-200 p-6">
<h3 className="text-lg font-semibold text-slate-900 mb-4">{palette.name}</h3>
<div className="flex gap-4 flex-wrap">
{palette.shades.map((shade) => (
<ColorSwatch key={shade.name} color={shade} name={shade.name} />
))}
</div>
</div>
))}
{/* Color Usage Guidelines */}
<div className="bg-white rounded-xl border border-slate-200 p-6">
<h3 className="text-lg font-semibold text-slate-900 mb-4">Verwendungsrichtlinien</h3>
<div className="grid grid-cols-2 gap-6">
<div>
<h4 className="font-medium text-slate-700 mb-2">Primary (Sky Blue)</h4>
<ul className="text-sm text-slate-600 space-y-1">
<li>- Primäre Buttons und CTAs</li>
<li>- Links und interaktive Elemente</li>
<li>- Fokuszustände</li>
<li>- Ausgewählte Navigation</li>
</ul>
</div>
<div>
<h4 className="font-medium text-slate-700 mb-2">Accent (Fuchsia)</h4>
<ul className="text-sm text-slate-600 space-y-1">
<li>- Highlights und Akzente</li>
<li>- Badges und Tags</li>
<li>- Gradient-Akzente</li>
<li>- Kreative Elemente</li>
</ul>
</div>
</div>
</div>
</div>
)}
{/* Typography Tab */}
{activeTab === 'typography' && (
<div className="space-y-6">
{/* Font Family */}
<div className="bg-white rounded-xl border border-slate-200 p-6">
<h3 className="text-lg font-semibold text-slate-900 mb-4">Schriftart: Inter</h3>
<div className="bg-slate-50 rounded-lg p-4 font-mono text-sm text-slate-600 mb-4">
font-family: {TYPOGRAPHY.fontFamily};
</div>
<p className="text-sm text-slate-600">
Inter ist eine moderne, variable Sans-Serif Schrift, optimiert für Bildschirme.
Sie ist unter der SIL Open Font License verfügbar und frei für kommerzielle Nutzung.
</p>
</div>
{/* Font Weights */}
<div className="bg-white rounded-xl border border-slate-200 p-6">
<h3 className="text-lg font-semibold text-slate-900 mb-4">Schriftschnitte</h3>
<div className="space-y-4">
{TYPOGRAPHY.weights.map((w) => (
<div key={w.weight} className="flex items-center gap-6">
<span
className="text-2xl w-48"
style={{ fontWeight: w.weight }}
>
The quick brown fox
</span>
<div className="flex-1">
<span className="font-medium text-slate-900">{w.name} ({w.weight})</span>
<span className="text-sm text-slate-500 ml-4">{w.usage}</span>
</div>
</div>
))}
</div>
</div>
{/* Font Sizes */}
<div className="bg-white rounded-xl border border-slate-200 p-6">
<h3 className="text-lg font-semibold text-slate-900 mb-4">Schriftgrößen</h3>
<div className="space-y-3">
{TYPOGRAPHY.sizes.map((s) => (
<div key={s.name} className="flex items-baseline gap-4 border-b border-slate-100 pb-3">
<span className="w-16 text-sm font-mono text-slate-500">{s.name}</span>
<span className="w-32 text-sm text-slate-600">{s.size}</span>
<span className="text-sm text-slate-500">{s.usage}</span>
</div>
))}
</div>
</div>
{/* Headings Preview */}
<div className="bg-white rounded-xl border border-slate-200 p-6">
<h3 className="text-lg font-semibold text-slate-900 mb-4">Überschriften-Hierarchie</h3>
<div className="space-y-4">
<h1 className="text-4xl font-bold text-slate-900">H1: Hauptüberschrift</h1>
<h2 className="text-3xl font-bold text-slate-900">H2: Abschnittsüberschrift</h2>
<h3 className="text-2xl font-semibold text-slate-900">H3: Unterabschnitt</h3>
<h4 className="text-xl font-semibold text-slate-900">H4: Card-Titel</h4>
<h5 className="text-lg font-medium text-slate-900">H5: Kleiner Titel</h5>
<h6 className="text-base font-medium text-slate-900">H6: Label</h6>
</div>
</div>
</div>
)}
{/* Components Tab */}
{activeTab === 'components' && (
<div className="space-y-6">
{/* Buttons */}
<div className="bg-white rounded-xl border border-slate-200 p-6">
<h3 className="text-lg font-semibold text-slate-900 mb-4">Buttons</h3>
<div className="flex flex-wrap gap-4 mb-6">
<button className="px-6 py-2 bg-primary-600 text-white font-medium rounded-lg hover:bg-primary-700 transition-colors">
Primary
</button>
<button className="px-6 py-2 bg-slate-100 text-slate-700 font-medium rounded-lg hover:bg-slate-200 transition-colors">
Secondary
</button>
<button className="px-6 py-2 border border-slate-300 text-slate-700 font-medium rounded-lg hover:bg-slate-50 transition-colors">
Outline
</button>
<button className="px-6 py-2 text-primary-600 font-medium rounded-lg hover:bg-primary-50 transition-colors">
Ghost
</button>
<button className="px-6 py-2 bg-red-600 text-white font-medium rounded-lg hover:bg-red-700 transition-colors">
Danger
</button>
</div>
<div className="flex flex-wrap gap-4">
<button className="px-4 py-1.5 bg-primary-600 text-white text-sm font-medium rounded-lg">
Small
</button>
<button className="px-6 py-2 bg-primary-600 text-white font-medium rounded-lg">
Medium
</button>
<button className="px-8 py-3 bg-primary-600 text-white text-lg font-medium rounded-lg">
Large
</button>
</div>
</div>
{/* Inputs */}
<div className="bg-white rounded-xl border border-slate-200 p-6">
<h3 className="text-lg font-semibold text-slate-900 mb-4">Eingabefelder</h3>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Standard Input</label>
<input
type="text"
placeholder="Placeholder..."
className="w-full px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
/>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Mit Icon</label>
<div className="relative">
<input
type="text"
placeholder="Suchen..."
className="w-full pl-10 pr-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
/>
<svg className="w-5 h-5 text-slate-400 absolute left-3 top-2.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
</svg>
</div>
</div>
</div>
</div>
{/* Cards */}
<div className="bg-white rounded-xl border border-slate-200 p-6">
<h3 className="text-lg font-semibold text-slate-900 mb-4">Cards</h3>
<div className="grid grid-cols-3 gap-4">
<div className="bg-white rounded-xl border border-slate-200 p-4 hover:shadow-lg transition-shadow">
<div className="w-10 h-10 bg-primary-100 rounded-lg flex items-center justify-center mb-3">
<svg className="w-5 h-5 text-primary-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 10V3L4 14h7v7l9-11h-7z" />
</svg>
</div>
<h4 className="font-semibold text-slate-900 mb-1">Feature Card</h4>
<p className="text-sm text-slate-600">Beschreibungstext für diese Feature-Karte.</p>
</div>
<div className="bg-gradient-to-br from-primary-500 to-primary-600 rounded-xl p-4 text-white">
<h4 className="font-semibold mb-1">Highlight Card</h4>
<p className="text-sm text-primary-100">Mit Gradient-Hintergrund.</p>
</div>
<div className="bg-slate-900 rounded-xl p-4 text-white">
<h4 className="font-semibold mb-1">Dark Card</h4>
<p className="text-sm text-slate-400">Dunkler Hintergrund.</p>
</div>
</div>
</div>
{/* Badges */}
<div className="bg-white rounded-xl border border-slate-200 p-6">
<h3 className="text-lg font-semibold text-slate-900 mb-4">Badges & Tags</h3>
<div className="flex flex-wrap gap-2">
<span className="px-2 py-1 bg-primary-100 text-primary-700 text-xs font-medium rounded">Primary</span>
<span className="px-2 py-1 bg-green-100 text-green-700 text-xs font-medium rounded">Success</span>
<span className="px-2 py-1 bg-amber-100 text-amber-700 text-xs font-medium rounded">Warning</span>
<span className="px-2 py-1 bg-red-100 text-red-700 text-xs font-medium rounded">Danger</span>
<span className="px-2 py-1 bg-slate-100 text-slate-700 text-xs font-medium rounded">Neutral</span>
<span className="px-2 py-1 bg-fuchsia-100 text-fuchsia-700 text-xs font-medium rounded">Accent</span>
</div>
</div>
</div>
)}
{/* Logo Tab */}
{activeTab === 'logo' && (
<div className="space-y-6">
{/* Logo Display */}
<div className="bg-white rounded-xl border border-slate-200 p-6">
<h3 className="text-lg font-semibold text-slate-900 mb-4">Logo</h3>
<div className="grid grid-cols-2 gap-8">
<div className="bg-white border border-slate-200 rounded-lg p-8 flex items-center justify-center">
<div className="flex items-center gap-3">
<div className="w-12 h-12 bg-gradient-to-br from-primary-500 to-primary-600 rounded-xl flex items-center justify-center">
<span className="text-2xl font-bold text-white">B</span>
</div>
<span className="text-2xl font-bold text-slate-900">BreakPilot</span>
</div>
</div>
<div className="bg-slate-900 rounded-lg p-8 flex items-center justify-center">
<div className="flex items-center gap-3">
<div className="w-12 h-12 bg-gradient-to-br from-primary-400 to-primary-500 rounded-xl flex items-center justify-center">
<span className="text-2xl font-bold text-white">B</span>
</div>
<span className="text-2xl font-bold text-white">BreakPilot</span>
</div>
</div>
</div>
</div>
{/* Logo Variations */}
<div className="bg-white rounded-xl border border-slate-200 p-6">
<h3 className="text-lg font-semibold text-slate-900 mb-4">Logo-Varianten</h3>
<div className="grid grid-cols-4 gap-4">
<div className="border border-slate-200 rounded-lg p-4 flex flex-col items-center gap-2">
<div className="w-12 h-12 bg-gradient-to-br from-primary-500 to-primary-600 rounded-xl flex items-center justify-center">
<span className="text-2xl font-bold text-white">B</span>
</div>
<span className="text-xs text-slate-600">Icon Only</span>
</div>
<div className="border border-slate-200 rounded-lg p-4 flex flex-col items-center gap-2">
<span className="text-xl font-bold text-slate-900">BreakPilot</span>
<span className="text-xs text-slate-600">Text Only</span>
</div>
<div className="border border-slate-200 rounded-lg p-4 flex flex-col items-center gap-2">
<div className="flex items-center gap-2">
<div className="w-8 h-8 bg-gradient-to-br from-primary-500 to-primary-600 rounded-lg flex items-center justify-center">
<span className="text-lg font-bold text-white">B</span>
</div>
<span className="text-lg font-bold text-slate-900">BreakPilot</span>
</div>
<span className="text-xs text-slate-600">Horizontal</span>
</div>
<div className="border border-slate-200 rounded-lg p-4 flex flex-col items-center gap-2">
<div className="flex flex-col items-center">
<div className="w-10 h-10 bg-gradient-to-br from-primary-500 to-primary-600 rounded-xl flex items-center justify-center mb-1">
<span className="text-xl font-bold text-white">B</span>
</div>
<span className="text-sm font-bold text-slate-900">BreakPilot</span>
</div>
<span className="text-xs text-slate-600">Stacked</span>
</div>
</div>
</div>
{/* Clear Space */}
<div className="bg-white rounded-xl border border-slate-200 p-6">
<h3 className="text-lg font-semibold text-slate-900 mb-4">Schutzzone</h3>
<p className="text-sm text-slate-600 mb-4">
Um das Logo herum sollte mindestens ein Abstand von der Höhe des Icons eingehalten werden.
</p>
<div className="bg-slate-50 rounded-lg p-8 inline-block">
<div className="border-2 border-dashed border-slate-300 p-8">
<div className="flex items-center gap-3">
<div className="w-12 h-12 bg-gradient-to-br from-primary-500 to-primary-600 rounded-xl flex items-center justify-center">
<span className="text-2xl font-bold text-white">B</span>
</div>
<span className="text-2xl font-bold text-slate-900">BreakPilot</span>
</div>
</div>
</div>
</div>
{/* Don'ts */}
<div className="bg-white rounded-xl border border-slate-200 p-6">
<h3 className="text-lg font-semibold text-slate-900 mb-4">Nicht erlaubt</h3>
<div className="grid grid-cols-3 gap-4">
<div className="border border-red-200 bg-red-50 rounded-lg p-4 text-center">
<div className="flex items-center gap-2 justify-center opacity-50">
<div className="w-8 h-8 bg-red-500 rounded-lg flex items-center justify-center">
<span className="text-lg font-bold text-white">B</span>
</div>
<span className="text-lg font-bold text-red-500">BreakPilot</span>
</div>
<span className="text-xs text-red-600 mt-2 block">Falsche Farben</span>
</div>
<div className="border border-red-200 bg-red-50 rounded-lg p-4 text-center">
<div className="flex items-center gap-2 justify-center" style={{ transform: 'skewX(-10deg)' }}>
<div className="w-8 h-8 bg-gradient-to-br from-primary-500 to-primary-600 rounded-lg flex items-center justify-center">
<span className="text-lg font-bold text-white">B</span>
</div>
<span className="text-lg font-bold text-slate-900">BreakPilot</span>
</div>
<span className="text-xs text-red-600 mt-2 block">Verzerrt</span>
</div>
<div className="border border-red-200 bg-red-50 rounded-lg p-4 text-center">
<div className="flex items-center gap-2 justify-center">
<div className="w-8 h-8 bg-gradient-to-br from-primary-500 to-primary-600 rounded-lg flex items-center justify-center">
<span className="text-lg font-bold text-white">B</span>
</div>
<span className="text-lg font-bold text-slate-900">Break Pilot</span>
</div>
<span className="text-xs text-red-600 mt-2 block">Falsche Schreibweise</span>
</div>
</div>
</div>
</div>
)}
{/* Voice & Tone Tab */}
{activeTab === 'voice' && (
<div className="space-y-6">
{/* Brand Attributes */}
<div className="bg-white rounded-xl border border-slate-200 p-6">
<h3 className="text-lg font-semibold text-slate-900 mb-4">Markenpersönlichkeit</h3>
<div className="flex flex-wrap gap-3">
{VOICE_TONE.attributes.map((attr) => (
<span
key={attr}
className="px-4 py-2 bg-primary-100 text-primary-700 rounded-full font-medium"
>
{attr}
</span>
))}
</div>
</div>
{/* Do & Don't */}
<div className="grid grid-cols-2 gap-6">
<div className="bg-white rounded-xl border border-slate-200 p-6">
<h3 className="text-lg font-semibold text-green-600 mb-4 flex items-center gap-2">
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
So schreiben wir
</h3>
<ul className="space-y-2">
{VOICE_TONE.doList.map((item) => (
<li key={item} className="flex items-center gap-2 text-slate-600">
<span className="w-1.5 h-1.5 bg-green-500 rounded-full"></span>
{item}
</li>
))}
</ul>
</div>
<div className="bg-white rounded-xl border border-slate-200 p-6">
<h3 className="text-lg font-semibold text-red-600 mb-4 flex items-center gap-2">
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
Das vermeiden wir
</h3>
<ul className="space-y-2">
{VOICE_TONE.dontList.map((item) => (
<li key={item} className="flex items-center gap-2 text-slate-600">
<span className="w-1.5 h-1.5 bg-red-500 rounded-full"></span>
{item}
</li>
))}
</ul>
</div>
</div>
{/* Example Texts */}
<div className="bg-white rounded-xl border border-slate-200 p-6">
<h3 className="text-lg font-semibold text-slate-900 mb-4">Beispieltexte</h3>
<div className="space-y-4">
<div className="p-4 bg-green-50 border border-green-200 rounded-lg">
<span className="text-xs text-green-600 font-medium mb-2 block">GUT</span>
<p className="text-slate-700">
"Sparen Sie Zeit bei der Korrektur - unsere KI unterstützt Sie mit intelligenten Vorschlägen."
</p>
</div>
<div className="p-4 bg-red-50 border border-red-200 rounded-lg">
<span className="text-xs text-red-600 font-medium mb-2 block">SCHLECHT</span>
<p className="text-slate-700">
"Unsere revolutionäre KI-Lösung optimiert Ihre Korrekturworkflows durch state-of-the-art NLP."
</p>
</div>
</div>
</div>
{/* Target Audience */}
<div className="bg-white rounded-xl border border-slate-200 p-6">
<h3 className="text-lg font-semibold text-slate-900 mb-4">Zielgruppe</h3>
<div className="grid grid-cols-3 gap-4">
<div className="p-4 bg-slate-50 rounded-lg">
<div className="text-2xl mb-2">👩🏫</div>
<h4 className="font-semibold text-slate-900">Lehrkräfte</h4>
<p className="text-sm text-slate-600">Wünschen Zeitersparnis und einfache Bedienung</p>
</div>
<div className="p-4 bg-slate-50 rounded-lg">
<div className="text-2xl mb-2">🏫</div>
<h4 className="font-semibold text-slate-900">Schulleitung</h4>
<p className="text-sm text-slate-600">Fokus auf DSGVO, Kosten und Integration</p>
</div>
<div className="p-4 bg-slate-50 rounded-lg">
<div className="text-2xl mb-2">👨👩👧</div>
<h4 className="font-semibold text-slate-900">Eltern</h4>
<p className="text-sm text-slate-600">Wollen Transparenz und schnelles Feedback</p>
</div>
</div>
</div>
</div>
)}
{activeTab === 'colors' && <ColorsTab />}
{activeTab === 'typography' && <TypographyTab />}
{activeTab === 'components' && <ComponentsTab />}
{activeTab === 'logo' && <LogoTab />}
{activeTab === 'voice' && <VoiceTab />}
</AdminLayout>
)
}

View File

@@ -0,0 +1,51 @@
'use client'
import type { Export } from './types'
import { formatFileSize } from './types'
interface ExportHistoryProps {
exports: Export[]
downloadExport: (id: string) => void
}
export default function ExportHistory({ exports, downloadExport }: ExportHistoryProps) {
return (
<div className="bg-white rounded-xl shadow-sm border p-6">
<h3 className="text-lg font-semibold text-slate-900 mb-4">Letzte Exports</h3>
{exports.length === 0 ? (
<p className="text-slate-500 text-sm">Noch keine Exports vorhanden</p>
) : (
<div className="space-y-3">
{exports.slice(0, 10).map((exp) => (
<div key={exp.id} className="p-3 bg-slate-50 rounded-lg">
<div className="flex items-center justify-between mb-1">
<span className={`px-2 py-0.5 text-xs rounded-full ${
exp.status === 'completed' ? 'bg-green-100 text-green-700' :
exp.status === 'failed' ? 'bg-red-100 text-red-700' :
'bg-yellow-100 text-yellow-700'
}`}>
{exp.status}
</span>
<span className="text-xs text-slate-500">
{new Date(exp.requested_at).toLocaleDateString('de-DE')}
</span>
</div>
<p className="text-sm font-medium text-slate-900">{exp.export_name}</p>
<p className="text-xs text-slate-500">{exp.export_type} - {formatFileSize(exp.file_size_bytes)}</p>
{exp.status === 'completed' && (
<button
onClick={() => downloadExport(exp.id)}
className="mt-2 text-xs text-primary-600 hover:text-primary-700 font-medium"
>
Download
</button>
)}
</div>
))}
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,287 @@
'use client'
import type { Export, Regulation } from './types'
import { EXPORT_TYPES, DOMAIN_OPTIONS, formatFileSize } from './types'
interface ExportWizardProps {
wizardStep: number
setWizardStep: (step: number) => void
exportType: string
setExportType: (type: string) => void
regulations: Regulation[]
selectedRegulations: string[]
toggleRegulation: (code: string) => void
selectedDomains: string[]
toggleDomain: (domain: string) => void
generating: boolean
startExport: () => void
currentExport: Export | null
resetWizard: () => void
downloadExport: (id: string) => void
}
export default function ExportWizard(props: ExportWizardProps) {
return (
<div className="lg:col-span-2 bg-white rounded-xl shadow-sm border p-6">
<WizardSteps current={props.wizardStep} />
{props.wizardStep === 1 && <Step1 exportType={props.exportType} setExportType={props.setExportType} next={() => props.setWizardStep(2)} />}
{props.wizardStep === 2 && <Step2 {...props} />}
{props.wizardStep === 3 && <Step3 {...props} />}
{props.wizardStep === 4 && <Step4 currentExport={props.currentExport} resetWizard={props.resetWizard} downloadExport={props.downloadExport} />}
</div>
)
}
function WizardSteps({ current }: { current: number }) {
const steps = [
{ num: 1, label: 'Typ' },
{ num: 2, label: 'Scope' },
{ num: 3, label: 'Bestaetigen' },
{ num: 4, label: 'Download' },
]
return (
<div className="flex items-center justify-center mb-8">
{steps.map((step, idx) => (
<div key={step.num} className="flex items-center">
<div className={`flex items-center justify-center w-10 h-10 rounded-full font-medium ${
current >= step.num ? 'bg-primary-600 text-white' : 'bg-slate-200 text-slate-500'
}`}>
{current > step.num ? (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
) : (
step.num
)}
</div>
<span className={`ml-2 text-sm ${current >= step.num ? 'text-slate-900' : 'text-slate-500'}`}>
{step.label}
</span>
{idx < 3 && (
<div className={`w-16 h-0.5 mx-4 ${current > step.num ? 'bg-primary-600' : 'bg-slate-200'}`} />
)}
</div>
))}
</div>
)
}
function Step1({ exportType, setExportType, next }: { exportType: string; setExportType: (t: string) => void; next: () => void }) {
return (
<div className="space-y-4">
<h3 className="text-lg font-semibold text-slate-900 mb-4">Export-Typ waehlen</h3>
<div className="grid gap-4">
{EXPORT_TYPES.map((type) => (
<button
key={type.value}
onClick={() => setExportType(type.value)}
className={`p-4 rounded-lg border-2 text-left transition-colors ${
exportType === type.value ? 'border-primary-600 bg-primary-50' : 'border-slate-200 hover:border-slate-300'
}`}
>
<div className="flex items-center gap-3">
<div className={`w-5 h-5 rounded-full border-2 flex items-center justify-center ${
exportType === type.value ? 'border-primary-600' : 'border-slate-300'
}`}>
{exportType === type.value && <div className="w-3 h-3 rounded-full bg-primary-600" />}
</div>
<div>
<p className="font-medium text-slate-900">{type.label}</p>
<p className="text-sm text-slate-500">{type.description}</p>
</div>
</div>
</button>
))}
</div>
<div className="flex justify-end pt-4">
<button onClick={next} className="px-6 py-2 bg-primary-600 text-white rounded-lg hover:bg-primary-700">
Weiter
</button>
</div>
</div>
)
}
function Step2(props: ExportWizardProps) {
return (
<div className="space-y-6">
<h3 className="text-lg font-semibold text-slate-900">Scope definieren (optional)</h3>
<div>
<h4 className="text-sm font-medium text-slate-700 mb-3">Verordnungen filtern</h4>
<p className="text-sm text-slate-500 mb-3">Leer lassen fuer alle Verordnungen</p>
<div className="grid grid-cols-2 md:grid-cols-4 gap-2">
{props.regulations.map((reg) => (
<button
key={reg.code}
onClick={() => props.toggleRegulation(reg.code)}
className={`px-3 py-2 text-sm rounded-lg border transition-colors ${
props.selectedRegulations.includes(reg.code)
? 'border-primary-600 bg-primary-50 text-primary-700'
: 'border-slate-200 text-slate-600 hover:border-slate-300'
}`}
>
{reg.code}
</button>
))}
</div>
</div>
<div>
<h4 className="text-sm font-medium text-slate-700 mb-3">Domains filtern</h4>
<p className="text-sm text-slate-500 mb-3">Leer lassen fuer alle Domains</p>
<div className="grid grid-cols-2 md:grid-cols-3 gap-2">
{DOMAIN_OPTIONS.map((domain) => (
<button
key={domain.value}
onClick={() => props.toggleDomain(domain.value)}
className={`px-3 py-2 text-sm rounded-lg border transition-colors ${
props.selectedDomains.includes(domain.value)
? 'border-primary-600 bg-primary-50 text-primary-700'
: 'border-slate-200 text-slate-600 hover:border-slate-300'
}`}
>
{domain.label}
</button>
))}
</div>
</div>
<div className="flex justify-between pt-4">
<button onClick={() => props.setWizardStep(1)} className="px-6 py-2 text-slate-600 hover:text-slate-800">
Zurueck
</button>
<button onClick={() => props.setWizardStep(3)} className="px-6 py-2 bg-primary-600 text-white rounded-lg hover:bg-primary-700">
Weiter
</button>
</div>
</div>
)
}
function Step3(props: ExportWizardProps) {
return (
<div className="space-y-6">
<h3 className="text-lg font-semibold text-slate-900">Export bestaetigen</h3>
<div className="bg-slate-50 rounded-lg p-6 space-y-4">
<div className="flex justify-between">
<span className="text-slate-600">Export-Typ:</span>
<span className="font-medium text-slate-900">
{EXPORT_TYPES.find((t) => t.value === props.exportType)?.label}
</span>
</div>
<div className="flex justify-between">
<span className="text-slate-600">Verordnungen:</span>
<span className="font-medium text-slate-900">
{props.selectedRegulations.length > 0 ? props.selectedRegulations.join(', ') : 'Alle'}
</span>
</div>
<div className="flex justify-between">
<span className="text-slate-600">Domains:</span>
<span className="font-medium text-slate-900">
{props.selectedDomains.length > 0
? props.selectedDomains.map((d) => DOMAIN_OPTIONS.find((o) => o.value === d)?.label).join(', ')
: 'Alle'}
</span>
</div>
</div>
<div className="p-4 bg-yellow-50 border border-yellow-200 rounded-lg">
<p className="text-sm text-yellow-800">
Der Export kann je nach Datenmenge einige Sekunden dauern.
Nach Abschluss koennen Sie die ZIP-Datei herunterladen.
</p>
</div>
<div className="flex justify-between pt-4">
<button onClick={() => props.setWizardStep(2)} className="px-6 py-2 text-slate-600 hover:text-slate-800">
Zurueck
</button>
<button
onClick={props.startExport}
disabled={props.generating}
className="px-6 py-2 bg-primary-600 text-white rounded-lg hover:bg-primary-700 disabled:opacity-50 flex items-center gap-2"
>
{props.generating && (
<svg className="animate-spin h-4 w-4" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
</svg>
)}
{props.generating ? 'Generiere...' : 'Export starten'}
</button>
</div>
</div>
)
}
function Step4({ currentExport, resetWizard, downloadExport }: { currentExport: Export | null; resetWizard: () => void; downloadExport: (id: string) => void }) {
if (currentExport?.status === 'completed') {
return (
<div className="space-y-6 text-center">
<div className="w-16 h-16 bg-green-100 rounded-full flex items-center justify-center mx-auto">
<svg className="w-8 h-8 text-green-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
</div>
<h3 className="text-lg font-semibold text-slate-900">Export erfolgreich!</h3>
<div className="bg-slate-50 rounded-lg p-6 text-left space-y-3">
<div className="flex justify-between">
<span className="text-slate-600">Compliance Score:</span>
<span className="font-medium text-slate-900">{currentExport.compliance_score?.toFixed(1)}%</span>
</div>
<div className="flex justify-between">
<span className="text-slate-600">Controls:</span>
<span className="font-medium text-slate-900">{currentExport.total_controls}</span>
</div>
<div className="flex justify-between">
<span className="text-slate-600">Nachweise:</span>
<span className="font-medium text-slate-900">{currentExport.total_evidence}</span>
</div>
<div className="flex justify-between">
<span className="text-slate-600">Dateigroesse:</span>
<span className="font-medium text-slate-900">{formatFileSize(currentExport.file_size_bytes)}</span>
</div>
<div className="flex justify-between">
<span className="text-slate-600">SHA-256:</span>
<span className="font-mono text-xs text-slate-500 truncate max-w-xs">{currentExport.file_hash}</span>
</div>
</div>
<div className="flex justify-center gap-4 pt-4">
<button onClick={resetWizard} className="px-6 py-2 text-slate-600 hover:text-slate-800">
Neuer Export
</button>
<button
onClick={() => downloadExport(currentExport.id)}
className="px-6 py-2 bg-primary-600 text-white rounded-lg hover:bg-primary-700 flex items-center gap-2"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" />
</svg>
ZIP herunterladen
</button>
</div>
</div>
)
}
return (
<div className="space-y-6 text-center">
<div className="w-16 h-16 bg-red-100 rounded-full flex items-center justify-center mx-auto">
<svg className="w-8 h-8 text-red-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</div>
<h3 className="text-lg font-semibold text-slate-900">Export fehlgeschlagen</h3>
<p className="text-slate-500">{currentExport?.error_message || 'Unbekannter Fehler'}</p>
<button onClick={resetWizard} className="px-6 py-2 bg-primary-600 text-white rounded-lg hover:bg-primary-700">
Erneut versuchen
</button>
</div>
)
}

View File

@@ -0,0 +1,46 @@
export interface Export {
id: string
export_type: string
export_name: string
status: string
requested_by: string
requested_at: string
completed_at: string | null
file_path: string | null
file_hash: string | null
file_size_bytes: number | null
total_controls: number | null
total_evidence: number | null
compliance_score: number | null
error_message: string | null
}
export interface Regulation {
code: string
name: string
}
export const EXPORT_TYPES = [
{ value: 'full', label: 'Vollstaendiger Export', description: 'Alle Daten inkl. Regulations, Controls, Evidence, Risks' },
{ value: 'controls_only', label: 'Nur Controls', description: 'Control Catalogue mit Mappings' },
{ value: 'evidence_only', label: 'Nur Nachweise', description: 'Evidence-Dateien und Metadaten' },
]
export const DOMAIN_OPTIONS = [
{ value: 'gov', label: 'Governance' },
{ value: 'priv', label: 'Datenschutz' },
{ value: 'iam', label: 'Identity & Access' },
{ value: 'crypto', label: 'Kryptografie' },
{ value: 'sdlc', label: 'Secure Dev' },
{ value: 'ops', label: 'Operations' },
{ value: 'ai', label: 'KI-spezifisch' },
{ value: 'cra', label: 'Supply Chain' },
{ value: 'aud', label: 'Audit' },
]
export function formatFileSize(bytes: number | null): string {
if (!bytes) return '-'
if (bytes < 1024) return `${bytes} B`
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`
}

View File

@@ -0,0 +1,110 @@
'use client'
import { useState, useEffect } from 'react'
import type { Export, Regulation } from './types'
const BACKEND_URL = process.env.NEXT_PUBLIC_BACKEND_URL || 'http://localhost:8000'
export function useExport() {
const [exports, setExports] = useState<Export[]>([])
const [regulations, setRegulations] = useState<Regulation[]>([])
const [loading, setLoading] = useState(true)
const [generating, setGenerating] = useState(false)
const [wizardStep, setWizardStep] = useState(1)
const [exportType, setExportType] = useState('full')
const [selectedRegulations, setSelectedRegulations] = useState<string[]>([])
const [selectedDomains, setSelectedDomains] = useState<string[]>([])
const [currentExport, setCurrentExport] = useState<Export | null>(null)
useEffect(() => {
loadData()
}, [])
const loadData = async () => {
setLoading(true)
try {
const [exportsRes, regulationsRes] = await Promise.all([
fetch(`${BACKEND_URL}/api/v1/compliance/exports`),
fetch(`${BACKEND_URL}/api/v1/compliance/regulations`),
])
if (exportsRes.ok) {
const data = await exportsRes.json()
setExports(data.exports || [])
}
if (regulationsRes.ok) {
const data = await regulationsRes.json()
setRegulations(data.regulations || [])
}
} catch (error) {
console.error('Failed to load data:', error)
} finally {
setLoading(false)
}
}
const startExport = async () => {
setGenerating(true)
try {
const res = await fetch(`${BACKEND_URL}/api/v1/compliance/export`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
export_type: exportType,
included_regulations: selectedRegulations.length > 0 ? selectedRegulations : null,
included_domains: selectedDomains.length > 0 ? selectedDomains : null,
}),
})
if (res.ok) {
const exportData = await res.json()
setCurrentExport(exportData)
setWizardStep(4)
loadData()
} else {
const error = await res.text()
alert(`Export fehlgeschlagen: ${error}`)
}
} catch (error) {
console.error('Export failed:', error)
alert('Export fehlgeschlagen')
} finally {
setGenerating(false)
}
}
const downloadExport = (exportId: string) => {
window.open(`${BACKEND_URL}/api/v1/compliance/export/${exportId}/download`, '_blank')
}
const resetWizard = () => {
setWizardStep(1)
setExportType('full')
setSelectedRegulations([])
setSelectedDomains([])
setCurrentExport(null)
}
const toggleRegulation = (code: string) => {
setSelectedRegulations((prev) =>
prev.includes(code) ? prev.filter((r) => r !== code) : [...prev, code]
)
}
const toggleDomain = (domain: string) => {
setSelectedDomains((prev) =>
prev.includes(domain) ? prev.filter((d) => d !== domain) : [...prev, domain]
)
}
return {
exports, regulations, loading, generating,
wizardStep, setWizardStep,
exportType, setExportType,
selectedRegulations, selectedDomains,
currentExport,
startExport, downloadExport, resetWizard,
toggleRegulation, toggleDomain,
}
}

View File

@@ -11,420 +11,14 @@
* - Export history
*/
import { useState, useEffect } from 'react'
import Link from 'next/link'
import AdminLayout from '@/components/admin/AdminLayout'
interface Export {
id: string
export_type: string
export_name: string
status: string
requested_by: string
requested_at: string
completed_at: string | null
file_path: string | null
file_hash: string | null
file_size_bytes: number | null
total_controls: number | null
total_evidence: number | null
compliance_score: number | null
error_message: string | null
}
interface Regulation {
code: string
name: string
}
const EXPORT_TYPES = [
{ value: 'full', label: 'Vollstaendiger Export', description: 'Alle Daten inkl. Regulations, Controls, Evidence, Risks' },
{ value: 'controls_only', label: 'Nur Controls', description: 'Control Catalogue mit Mappings' },
{ value: 'evidence_only', label: 'Nur Nachweise', description: 'Evidence-Dateien und Metadaten' },
]
const DOMAIN_OPTIONS = [
{ value: 'gov', label: 'Governance' },
{ value: 'priv', label: 'Datenschutz' },
{ value: 'iam', label: 'Identity & Access' },
{ value: 'crypto', label: 'Kryptografie' },
{ value: 'sdlc', label: 'Secure Dev' },
{ value: 'ops', label: 'Operations' },
{ value: 'ai', label: 'KI-spezifisch' },
{ value: 'cra', label: 'Supply Chain' },
{ value: 'aud', label: 'Audit' },
]
import { useExport } from './_components/useExport'
import ExportWizard from './_components/ExportWizard'
import ExportHistory from './_components/ExportHistory'
export default function ExportPage() {
const [exports, setExports] = useState<Export[]>([])
const [regulations, setRegulations] = useState<Regulation[]>([])
const [loading, setLoading] = useState(true)
const [generating, setGenerating] = useState(false)
const [wizardStep, setWizardStep] = useState(1)
const [exportType, setExportType] = useState('full')
const [selectedRegulations, setSelectedRegulations] = useState<string[]>([])
const [selectedDomains, setSelectedDomains] = useState<string[]>([])
const [currentExport, setCurrentExport] = useState<Export | null>(null)
const BACKEND_URL = process.env.NEXT_PUBLIC_BACKEND_URL || 'http://localhost:8000'
useEffect(() => {
loadData()
}, [])
const loadData = async () => {
setLoading(true)
try {
const [exportsRes, regulationsRes] = await Promise.all([
fetch(`${BACKEND_URL}/api/v1/compliance/exports`),
fetch(`${BACKEND_URL}/api/v1/compliance/regulations`),
])
if (exportsRes.ok) {
const data = await exportsRes.json()
setExports(data.exports || [])
}
if (regulationsRes.ok) {
const data = await regulationsRes.json()
setRegulations(data.regulations || [])
}
} catch (error) {
console.error('Failed to load data:', error)
} finally {
setLoading(false)
}
}
const startExport = async () => {
setGenerating(true)
try {
const res = await fetch(`${BACKEND_URL}/api/v1/compliance/export`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
export_type: exportType,
included_regulations: selectedRegulations.length > 0 ? selectedRegulations : null,
included_domains: selectedDomains.length > 0 ? selectedDomains : null,
}),
})
if (res.ok) {
const exportData = await res.json()
setCurrentExport(exportData)
setWizardStep(4)
loadData()
} else {
const error = await res.text()
alert(`Export fehlgeschlagen: ${error}`)
}
} catch (error) {
console.error('Export failed:', error)
alert('Export fehlgeschlagen')
} finally {
setGenerating(false)
}
}
const downloadExport = (exportId: string) => {
window.open(`${BACKEND_URL}/api/v1/compliance/export/${exportId}/download`, '_blank')
}
const resetWizard = () => {
setWizardStep(1)
setExportType('full')
setSelectedRegulations([])
setSelectedDomains([])
setCurrentExport(null)
}
const toggleRegulation = (code: string) => {
setSelectedRegulations((prev) =>
prev.includes(code) ? prev.filter((r) => r !== code) : [...prev, code]
)
}
const toggleDomain = (domain: string) => {
setSelectedDomains((prev) =>
prev.includes(domain) ? prev.filter((d) => d !== domain) : [...prev, domain]
)
}
const formatFileSize = (bytes: number | null) => {
if (!bytes) return '-'
if (bytes < 1024) return `${bytes} B`
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`
}
const renderWizardSteps = () => (
<div className="flex items-center justify-center mb-8">
{[
{ num: 1, label: 'Typ' },
{ num: 2, label: 'Scope' },
{ num: 3, label: 'Bestaetigen' },
{ num: 4, label: 'Download' },
].map((step, idx) => (
<div key={step.num} className="flex items-center">
<div className={`flex items-center justify-center w-10 h-10 rounded-full font-medium ${
wizardStep >= step.num
? 'bg-primary-600 text-white'
: 'bg-slate-200 text-slate-500'
}`}>
{wizardStep > step.num ? (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
) : (
step.num
)}
</div>
<span className={`ml-2 text-sm ${wizardStep >= step.num ? 'text-slate-900' : 'text-slate-500'}`}>
{step.label}
</span>
{idx < 3 && (
<div className={`w-16 h-0.5 mx-4 ${wizardStep > step.num ? 'bg-primary-600' : 'bg-slate-200'}`} />
)}
</div>
))}
</div>
)
const renderStep1 = () => (
<div className="space-y-4">
<h3 className="text-lg font-semibold text-slate-900 mb-4">Export-Typ waehlen</h3>
<div className="grid gap-4">
{EXPORT_TYPES.map((type) => (
<button
key={type.value}
onClick={() => setExportType(type.value)}
className={`p-4 rounded-lg border-2 text-left transition-colors ${
exportType === type.value
? 'border-primary-600 bg-primary-50'
: 'border-slate-200 hover:border-slate-300'
}`}
>
<div className="flex items-center gap-3">
<div className={`w-5 h-5 rounded-full border-2 flex items-center justify-center ${
exportType === type.value ? 'border-primary-600' : 'border-slate-300'
}`}>
{exportType === type.value && (
<div className="w-3 h-3 rounded-full bg-primary-600" />
)}
</div>
<div>
<p className="font-medium text-slate-900">{type.label}</p>
<p className="text-sm text-slate-500">{type.description}</p>
</div>
</div>
</button>
))}
</div>
<div className="flex justify-end pt-4">
<button
onClick={() => setWizardStep(2)}
className="px-6 py-2 bg-primary-600 text-white rounded-lg hover:bg-primary-700"
>
Weiter
</button>
</div>
</div>
)
const renderStep2 = () => (
<div className="space-y-6">
<h3 className="text-lg font-semibold text-slate-900">Scope definieren (optional)</h3>
{/* Regulations Filter */}
<div>
<h4 className="text-sm font-medium text-slate-700 mb-3">Verordnungen filtern</h4>
<p className="text-sm text-slate-500 mb-3">Leer lassen fuer alle Verordnungen</p>
<div className="grid grid-cols-2 md:grid-cols-4 gap-2">
{regulations.map((reg) => (
<button
key={reg.code}
onClick={() => toggleRegulation(reg.code)}
className={`px-3 py-2 text-sm rounded-lg border transition-colors ${
selectedRegulations.includes(reg.code)
? 'border-primary-600 bg-primary-50 text-primary-700'
: 'border-slate-200 text-slate-600 hover:border-slate-300'
}`}
>
{reg.code}
</button>
))}
</div>
</div>
{/* Domains Filter */}
<div>
<h4 className="text-sm font-medium text-slate-700 mb-3">Domains filtern</h4>
<p className="text-sm text-slate-500 mb-3">Leer lassen fuer alle Domains</p>
<div className="grid grid-cols-2 md:grid-cols-3 gap-2">
{DOMAIN_OPTIONS.map((domain) => (
<button
key={domain.value}
onClick={() => toggleDomain(domain.value)}
className={`px-3 py-2 text-sm rounded-lg border transition-colors ${
selectedDomains.includes(domain.value)
? 'border-primary-600 bg-primary-50 text-primary-700'
: 'border-slate-200 text-slate-600 hover:border-slate-300'
}`}
>
{domain.label}
</button>
))}
</div>
</div>
<div className="flex justify-between pt-4">
<button
onClick={() => setWizardStep(1)}
className="px-6 py-2 text-slate-600 hover:text-slate-800"
>
Zurueck
</button>
<button
onClick={() => setWizardStep(3)}
className="px-6 py-2 bg-primary-600 text-white rounded-lg hover:bg-primary-700"
>
Weiter
</button>
</div>
</div>
)
const renderStep3 = () => (
<div className="space-y-6">
<h3 className="text-lg font-semibold text-slate-900">Export bestaetigen</h3>
<div className="bg-slate-50 rounded-lg p-6 space-y-4">
<div className="flex justify-between">
<span className="text-slate-600">Export-Typ:</span>
<span className="font-medium text-slate-900">
{EXPORT_TYPES.find((t) => t.value === exportType)?.label}
</span>
</div>
<div className="flex justify-between">
<span className="text-slate-600">Verordnungen:</span>
<span className="font-medium text-slate-900">
{selectedRegulations.length > 0 ? selectedRegulations.join(', ') : 'Alle'}
</span>
</div>
<div className="flex justify-between">
<span className="text-slate-600">Domains:</span>
<span className="font-medium text-slate-900">
{selectedDomains.length > 0
? selectedDomains.map((d) => DOMAIN_OPTIONS.find((o) => o.value === d)?.label).join(', ')
: 'Alle'}
</span>
</div>
</div>
<div className="p-4 bg-yellow-50 border border-yellow-200 rounded-lg">
<p className="text-sm text-yellow-800">
Der Export kann je nach Datenmenge einige Sekunden dauern.
Nach Abschluss koennen Sie die ZIP-Datei herunterladen.
</p>
</div>
<div className="flex justify-between pt-4">
<button
onClick={() => setWizardStep(2)}
className="px-6 py-2 text-slate-600 hover:text-slate-800"
>
Zurueck
</button>
<button
onClick={startExport}
disabled={generating}
className="px-6 py-2 bg-primary-600 text-white rounded-lg hover:bg-primary-700 disabled:opacity-50 flex items-center gap-2"
>
{generating && (
<svg className="animate-spin h-4 w-4" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
</svg>
)}
{generating ? 'Generiere...' : 'Export starten'}
</button>
</div>
</div>
)
const renderStep4 = () => (
<div className="space-y-6 text-center">
{currentExport?.status === 'completed' ? (
<>
<div className="w-16 h-16 bg-green-100 rounded-full flex items-center justify-center mx-auto">
<svg className="w-8 h-8 text-green-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
</div>
<h3 className="text-lg font-semibold text-slate-900">Export erfolgreich!</h3>
<div className="bg-slate-50 rounded-lg p-6 text-left space-y-3">
<div className="flex justify-between">
<span className="text-slate-600">Compliance Score:</span>
<span className="font-medium text-slate-900">{currentExport.compliance_score?.toFixed(1)}%</span>
</div>
<div className="flex justify-between">
<span className="text-slate-600">Controls:</span>
<span className="font-medium text-slate-900">{currentExport.total_controls}</span>
</div>
<div className="flex justify-between">
<span className="text-slate-600">Nachweise:</span>
<span className="font-medium text-slate-900">{currentExport.total_evidence}</span>
</div>
<div className="flex justify-between">
<span className="text-slate-600">Dateigroesse:</span>
<span className="font-medium text-slate-900">{formatFileSize(currentExport.file_size_bytes)}</span>
</div>
<div className="flex justify-between">
<span className="text-slate-600">SHA-256:</span>
<span className="font-mono text-xs text-slate-500 truncate max-w-xs">{currentExport.file_hash}</span>
</div>
</div>
<div className="flex justify-center gap-4 pt-4">
<button
onClick={resetWizard}
className="px-6 py-2 text-slate-600 hover:text-slate-800"
>
Neuer Export
</button>
<button
onClick={() => downloadExport(currentExport.id)}
className="px-6 py-2 bg-primary-600 text-white rounded-lg hover:bg-primary-700 flex items-center gap-2"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" />
</svg>
ZIP herunterladen
</button>
</div>
</>
) : (
<>
<div className="w-16 h-16 bg-red-100 rounded-full flex items-center justify-center mx-auto">
<svg className="w-8 h-8 text-red-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</div>
<h3 className="text-lg font-semibold text-slate-900">Export fehlgeschlagen</h3>
<p className="text-slate-500">{currentExport?.error_message || 'Unbekannter Fehler'}</p>
<button
onClick={resetWizard}
className="px-6 py-2 bg-primary-600 text-white rounded-lg hover:bg-primary-700"
>
Erneut versuchen
</button>
</>
)}
</div>
)
const exp = useExport()
return (
<AdminLayout title="Audit Export" description="Export fuer externe Pruefer">
@@ -441,60 +35,32 @@ export default function ExportPage() {
</Link>
</div>
{loading ? (
{exp.loading ? (
<div className="flex items-center justify-center h-64">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-primary-600" />
</div>
) : (
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
{/* Wizard */}
<div className="lg:col-span-2 bg-white rounded-xl shadow-sm border p-6">
{renderWizardSteps()}
{wizardStep === 1 && renderStep1()}
{wizardStep === 2 && renderStep2()}
{wizardStep === 3 && renderStep3()}
{wizardStep === 4 && renderStep4()}
</div>
{/* Export History */}
<div className="bg-white rounded-xl shadow-sm border p-6">
<h3 className="text-lg font-semibold text-slate-900 mb-4">Letzte Exports</h3>
{exports.length === 0 ? (
<p className="text-slate-500 text-sm">Noch keine Exports vorhanden</p>
) : (
<div className="space-y-3">
{exports.slice(0, 10).map((exp) => (
<div key={exp.id} className="p-3 bg-slate-50 rounded-lg">
<div className="flex items-center justify-between mb-1">
<span className={`px-2 py-0.5 text-xs rounded-full ${
exp.status === 'completed' ? 'bg-green-100 text-green-700' :
exp.status === 'failed' ? 'bg-red-100 text-red-700' :
'bg-yellow-100 text-yellow-700'
}`}>
{exp.status}
</span>
<span className="text-xs text-slate-500">
{new Date(exp.requested_at).toLocaleDateString('de-DE')}
</span>
</div>
<p className="text-sm font-medium text-slate-900">{exp.export_name}</p>
<p className="text-xs text-slate-500">{exp.export_type} - {formatFileSize(exp.file_size_bytes)}</p>
{exp.status === 'completed' && (
<button
onClick={() => downloadExport(exp.id)}
className="mt-2 text-xs text-primary-600 hover:text-primary-700 font-medium"
>
Download
</button>
)}
</div>
))}
</div>
)}
</div>
<ExportWizard
wizardStep={exp.wizardStep}
setWizardStep={exp.setWizardStep}
exportType={exp.exportType}
setExportType={exp.setExportType}
regulations={exp.regulations}
selectedRegulations={exp.selectedRegulations}
toggleRegulation={exp.toggleRegulation}
selectedDomains={exp.selectedDomains}
toggleDomain={exp.toggleDomain}
generating={exp.generating}
startExport={exp.startExport}
currentExport={exp.currentExport}
resetWizard={exp.resetWizard}
downloadExport={exp.downloadExport}
/>
<ExportHistory
exports={exp.exports}
downloadExport={exp.downloadExport}
/>
</div>
)}
</AdminLayout>

View File

@@ -0,0 +1,141 @@
'use client'
import type { RiskFormData } from './types'
import { RISK_COLORS, CATEGORY_OPTIONS, STATUS_OPTIONS, calculateRiskLevel } from './types'
interface RiskFormProps {
formData: RiskFormData
setFormData: (data: RiskFormData) => void
isCreate: boolean
}
export default function RiskForm({ formData, setFormData, isCreate }: RiskFormProps) {
return (
<div className="space-y-4">
{isCreate && (
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Risk ID</label>
<input
type="text"
value={formData.risk_id}
onChange={(e) => setFormData({ ...formData, risk_id: e.target.value })}
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-primary-500"
/>
</div>
)}
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Titel *</label>
<input
type="text"
value={formData.title}
onChange={(e) => setFormData({ ...formData, title: e.target.value })}
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-primary-500"
/>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Beschreibung</label>
<textarea
value={formData.description}
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
rows={2}
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-primary-500"
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Kategorie</label>
<select
value={formData.category}
onChange={(e) => setFormData({ ...formData, category: e.target.value })}
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-primary-500"
>
{CATEGORY_OPTIONS.map((c) => (
<option key={c.value} value={c.value}>{c.label}</option>
))}
</select>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Status</label>
<select
value={formData.status}
onChange={(e) => setFormData({ ...formData, status: e.target.value })}
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-primary-500"
>
{STATUS_OPTIONS.map((s) => (
<option key={s} value={s}>{s}</option>
))}
</select>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Likelihood (1-5)</label>
<input
type="range"
min="1"
max="5"
value={formData.likelihood}
onChange={(e) => setFormData({ ...formData, likelihood: parseInt(e.target.value) })}
className="w-full"
/>
<div className="flex justify-between text-xs text-slate-500">
<span>1</span>
<span className="font-medium">{formData.likelihood}</span>
<span>5</span>
</div>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Impact (1-5)</label>
<input
type="range"
min="1"
max="5"
value={formData.impact}
onChange={(e) => setFormData({ ...formData, impact: parseInt(e.target.value) })}
className="w-full"
/>
<div className="flex justify-between text-xs text-slate-500">
<span>1</span>
<span className="font-medium">{formData.impact}</span>
<span>5</span>
</div>
</div>
</div>
<div className="p-3 bg-slate-50 rounded-lg">
<p className="text-sm text-slate-600">
Berechnetes Risiko:{' '}
<span className={`font-medium px-2 py-0.5 rounded text-white ${RISK_COLORS[calculateRiskLevel(formData.likelihood, formData.impact)]}`}>
{calculateRiskLevel(formData.likelihood, formData.impact).toUpperCase()} ({formData.likelihood * formData.impact})
</span>
</p>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Verantwortlich</label>
<input
type="text"
value={formData.owner}
onChange={(e) => setFormData({ ...formData, owner: e.target.value })}
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-primary-500"
/>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Behandlungsplan</label>
<textarea
value={formData.treatment_plan}
onChange={(e) => setFormData({ ...formData, treatment_plan: e.target.value })}
rows={2}
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-primary-500"
/>
</div>
</div>
)
}

View File

@@ -0,0 +1,76 @@
'use client'
import type { Risk } from './types'
import { RISK_COLORS, CATEGORY_OPTIONS } from './types'
interface RiskListProps {
risks: Risk[]
onEditRisk: (risk: Risk) => void
}
export default function RiskList({ risks, onEditRisk }: RiskListProps) {
return (
<div className="bg-white rounded-xl shadow-sm border overflow-hidden">
<table className="w-full">
<thead className="bg-slate-50">
<tr>
<th className="px-4 py-3 text-left text-xs font-medium text-slate-500 uppercase">ID</th>
<th className="px-4 py-3 text-left text-xs font-medium text-slate-500 uppercase">Titel</th>
<th className="px-4 py-3 text-center text-xs font-medium text-slate-500 uppercase">Kategorie</th>
<th className="px-4 py-3 text-center text-xs font-medium text-slate-500 uppercase">L x I</th>
<th className="px-4 py-3 text-center text-xs font-medium text-slate-500 uppercase">Risiko</th>
<th className="px-4 py-3 text-center text-xs font-medium text-slate-500 uppercase">Status</th>
<th className="px-4 py-3 text-right text-xs font-medium text-slate-500 uppercase">Aktionen</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-200">
{risks.map((risk) => (
<tr key={risk.id} className="hover:bg-slate-50">
<td className="px-4 py-3">
<span className="font-mono font-medium text-primary-600">{risk.risk_id}</span>
</td>
<td className="px-4 py-3">
<div>
<p className="font-medium text-slate-900">{risk.title}</p>
{risk.description && (
<p className="text-sm text-slate-500 truncate max-w-md">{risk.description}</p>
)}
</div>
</td>
<td className="px-4 py-3 text-center">
<span className="text-sm text-slate-600">
{CATEGORY_OPTIONS.find((c) => c.value === risk.category)?.label || risk.category}
</span>
</td>
<td className="px-4 py-3 text-center">
<span className="font-mono">{risk.likelihood} x {risk.impact}</span>
</td>
<td className="px-4 py-3 text-center">
<span className={`px-2 py-1 text-xs font-medium rounded-full text-white ${RISK_COLORS[risk.inherent_risk] || 'bg-slate-500'}`}>
{risk.inherent_risk}
</span>
</td>
<td className="px-4 py-3 text-center">
<span className={`px-2 py-1 text-xs rounded-full ${
risk.status === 'mitigated' ? 'bg-green-100 text-green-700' :
risk.status === 'accepted' ? 'bg-blue-100 text-blue-700' :
'bg-slate-100 text-slate-700'
}`}>
{risk.status}
</span>
</td>
<td className="px-4 py-3 text-right">
<button
onClick={() => onEditRisk(risk)}
className="text-sm text-primary-600 hover:text-primary-700 font-medium"
>
Bearbeiten
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
)
}

View File

@@ -0,0 +1,98 @@
'use client'
import type { Risk } from './types'
import { RISK_COLORS, RISK_BG_COLORS, calculateRiskLevel } from './types'
interface RiskMatrixProps {
risks: Risk[]
onEditRisk: (risk: Risk) => void
}
export default function RiskMatrix({ risks, onEditRisk }: RiskMatrixProps) {
const matrix: Record<number, Record<number, Risk[]>> = {}
for (let l = 1; l <= 5; l++) {
matrix[l] = {}
for (let i = 1; i <= 5; i++) {
matrix[l][i] = []
}
}
risks.forEach((risk) => {
if (matrix[risk.likelihood] && matrix[risk.likelihood][risk.impact]) {
matrix[risk.likelihood][risk.impact].push(risk)
}
})
return (
<div className="bg-white rounded-xl shadow-sm border p-6">
<h3 className="text-lg font-semibold text-slate-900 mb-4">Risk Matrix (Likelihood x Impact)</h3>
<div className="overflow-x-auto">
<div className="inline-block">
{/* Column headers (Impact) */}
<div className="flex ml-16">
{[1, 2, 3, 4, 5].map((i) => (
<div key={i} className="w-24 text-center text-sm font-medium text-slate-500 pb-2">
Impact {i}
</div>
))}
</div>
{/* Matrix rows */}
{[5, 4, 3, 2, 1].map((likelihood) => (
<div key={likelihood} className="flex items-center">
<div className="w-16 text-sm font-medium text-slate-500 text-right pr-2">
L{likelihood}
</div>
{[1, 2, 3, 4, 5].map((impact) => {
const level = calculateRiskLevel(likelihood, impact)
const cellRisks = matrix[likelihood][impact]
return (
<div
key={impact}
className={`w-24 h-20 border m-0.5 rounded flex flex-col items-center justify-center ${RISK_BG_COLORS[level]}`}
>
{cellRisks.length > 0 && (
<div className="flex flex-wrap gap-1 justify-center">
{cellRisks.map((r) => (
<button
key={r.id}
onClick={() => onEditRisk(r)}
className={`px-2 py-0.5 text-xs font-medium rounded text-white ${RISK_COLORS[r.inherent_risk] || 'bg-slate-500'} hover:opacity-80`}
title={r.title}
>
{r.risk_id}
</button>
))}
</div>
)}
</div>
)
})}
</div>
))}
</div>
</div>
{/* Legend */}
<div className="flex gap-4 mt-6 pt-4 border-t">
<div className="flex items-center gap-2">
<div className="w-4 h-4 bg-green-500 rounded" />
<span className="text-sm text-slate-600">Low (1-5)</span>
</div>
<div className="flex items-center gap-2">
<div className="w-4 h-4 bg-yellow-500 rounded" />
<span className="text-sm text-slate-600">Medium (6-11)</span>
</div>
<div className="flex items-center gap-2">
<div className="w-4 h-4 bg-orange-500 rounded" />
<span className="text-sm text-slate-600">High (12-19)</span>
</div>
<div className="flex items-center gap-2">
<div className="w-4 h-4 bg-red-500 rounded" />
<span className="text-sm text-slate-600">Critical (20-25)</span>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,65 @@
export interface Risk {
id: string
risk_id: string
title: string
description: string
category: string
likelihood: number
impact: number
inherent_risk: string
mitigating_controls: string[] | null
residual_likelihood: number | null
residual_impact: number | null
residual_risk: string | null
owner: string
status: string
treatment_plan: string
}
export const RISK_COLORS: Record<string, string> = {
low: 'bg-green-500',
medium: 'bg-yellow-500',
high: 'bg-orange-500',
critical: 'bg-red-500',
}
export const RISK_BG_COLORS: Record<string, string> = {
low: 'bg-green-100 border-green-300',
medium: 'bg-yellow-100 border-yellow-300',
high: 'bg-orange-100 border-orange-300',
critical: 'bg-red-100 border-red-300',
}
export const STATUS_OPTIONS = ['open', 'mitigated', 'accepted', 'transferred']
export const CATEGORY_OPTIONS = [
{ value: 'data_breach', label: 'Datenschutzverletzung' },
{ value: 'compliance_gap', label: 'Compliance-Luecke' },
{ value: 'vendor_risk', label: 'Lieferantenrisiko' },
{ value: 'operational', label: 'Betriebsrisiko' },
{ value: 'technical', label: 'Technisches Risiko' },
{ value: 'legal', label: 'Rechtliches Risiko' },
]
export const calculateRiskLevel = (likelihood: number, impact: number): string => {
const score = likelihood * impact
if (score >= 20) return 'critical'
if (score >= 12) return 'high'
if (score >= 6) return 'medium'
return 'low'
}
export interface RiskFormData {
risk_id: string
title: string
description: string
category: string
likelihood: number
impact: number
owner: string
treatment_plan: string
status: string
mitigating_controls: string[]
residual_likelihood: number | null
residual_impact: number | null
}

View File

@@ -0,0 +1,163 @@
'use client'
import { useState, useEffect } from 'react'
import type { Risk, RiskFormData } from './types'
const BACKEND_URL = process.env.NEXT_PUBLIC_BACKEND_URL || 'http://localhost:8000'
export function useRisks() {
const [risks, setRisks] = useState<Risk[]>([])
const [loading, setLoading] = useState(true)
const [viewMode, setViewMode] = useState<'matrix' | 'list'>('matrix')
const [selectedRisk, setSelectedRisk] = useState<Risk | null>(null)
const [editModalOpen, setEditModalOpen] = useState(false)
const [createModalOpen, setCreateModalOpen] = useState(false)
const [formData, setFormData] = useState<RiskFormData>({
risk_id: '',
title: '',
description: '',
category: 'compliance_gap',
likelihood: 3,
impact: 3,
owner: '',
treatment_plan: '',
status: 'open',
mitigating_controls: [],
residual_likelihood: null,
residual_impact: null,
})
useEffect(() => {
loadRisks()
}, [])
const loadRisks = async () => {
setLoading(true)
try {
const res = await fetch(`${BACKEND_URL}/api/v1/compliance/risks`)
if (res.ok) {
const data = await res.json()
setRisks(data.risks || [])
}
} catch (error) {
console.error('Failed to load risks:', error)
} finally {
setLoading(false)
}
}
const openCreateModal = () => {
setFormData({
risk_id: `RISK-${String(risks.length + 1).padStart(3, '0')}`,
title: '',
description: '',
category: 'compliance_gap',
likelihood: 3,
impact: 3,
owner: '',
treatment_plan: '',
status: 'open',
mitigating_controls: [],
residual_likelihood: null,
residual_impact: null,
})
setCreateModalOpen(true)
}
const openEditModal = (risk: Risk) => {
setSelectedRisk(risk)
setFormData({
risk_id: risk.risk_id,
title: risk.title,
description: risk.description || '',
category: risk.category,
likelihood: risk.likelihood,
impact: risk.impact,
owner: risk.owner || '',
treatment_plan: risk.treatment_plan || '',
status: risk.status,
mitigating_controls: risk.mitigating_controls || [],
residual_likelihood: risk.residual_likelihood,
residual_impact: risk.residual_impact,
})
setEditModalOpen(true)
}
const handleCreate = async () => {
try {
const res = await fetch(`${BACKEND_URL}/api/v1/compliance/risks`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
risk_id: formData.risk_id,
title: formData.title,
description: formData.description,
category: formData.category,
likelihood: formData.likelihood,
impact: formData.impact,
owner: formData.owner,
treatment_plan: formData.treatment_plan,
mitigating_controls: formData.mitigating_controls,
}),
})
if (res.ok) {
setCreateModalOpen(false)
loadRisks()
} else {
const error = await res.text()
alert(`Fehler: ${error}`)
}
} catch (error) {
console.error('Create failed:', error)
alert('Fehler beim Erstellen')
}
}
const handleUpdate = async () => {
if (!selectedRisk) return
try {
const res = await fetch(`${BACKEND_URL}/api/v1/compliance/risks/${selectedRisk.risk_id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
title: formData.title,
description: formData.description,
category: formData.category,
likelihood: formData.likelihood,
impact: formData.impact,
owner: formData.owner,
treatment_plan: formData.treatment_plan,
status: formData.status,
mitigating_controls: formData.mitigating_controls,
residual_likelihood: formData.residual_likelihood,
residual_impact: formData.residual_impact,
}),
})
if (res.ok) {
setEditModalOpen(false)
loadRisks()
} else {
const error = await res.text()
alert(`Fehler: ${error}`)
}
} catch (error) {
console.error('Update failed:', error)
alert('Fehler beim Aktualisieren')
}
}
return {
risks, loading,
viewMode, setViewMode,
selectedRisk,
editModalOpen, setEditModalOpen,
createModalOpen, setCreateModalOpen,
formData, setFormData,
openCreateModal, openEditModal,
handleCreate, handleUpdate,
}
}

View File

@@ -9,496 +9,15 @@
* - Risk assessment / update
*/
import { useState, useEffect } from 'react'
import Link from 'next/link'
import AdminLayout from '@/components/admin/AdminLayout'
interface Risk {
id: string
risk_id: string
title: string
description: string
category: string
likelihood: number
impact: number
inherent_risk: string
mitigating_controls: string[] | null
residual_likelihood: number | null
residual_impact: number | null
residual_risk: string | null
owner: string
status: string
treatment_plan: string
}
const RISK_COLORS: Record<string, string> = {
low: 'bg-green-500',
medium: 'bg-yellow-500',
high: 'bg-orange-500',
critical: 'bg-red-500',
}
const RISK_BG_COLORS: Record<string, string> = {
low: 'bg-green-100 border-green-300',
medium: 'bg-yellow-100 border-yellow-300',
high: 'bg-orange-100 border-orange-300',
critical: 'bg-red-100 border-red-300',
}
const STATUS_OPTIONS = ['open', 'mitigated', 'accepted', 'transferred']
const CATEGORY_OPTIONS = [
{ value: 'data_breach', label: 'Datenschutzverletzung' },
{ value: 'compliance_gap', label: 'Compliance-Luecke' },
{ value: 'vendor_risk', label: 'Lieferantenrisiko' },
{ value: 'operational', label: 'Betriebsrisiko' },
{ value: 'technical', label: 'Technisches Risiko' },
{ value: 'legal', label: 'Rechtliches Risiko' },
]
const calculateRiskLevel = (likelihood: number, impact: number): string => {
const score = likelihood * impact
if (score >= 20) return 'critical'
if (score >= 12) return 'high'
if (score >= 6) return 'medium'
return 'low'
}
import { useRisks } from './_components/useRisks'
import RiskMatrix from './_components/RiskMatrix'
import RiskList from './_components/RiskList'
import RiskForm from './_components/RiskForm'
export default function RisksPage() {
const [risks, setRisks] = useState<Risk[]>([])
const [loading, setLoading] = useState(true)
const [viewMode, setViewMode] = useState<'matrix' | 'list'>('matrix')
const [selectedRisk, setSelectedRisk] = useState<Risk | null>(null)
const [editModalOpen, setEditModalOpen] = useState(false)
const [createModalOpen, setCreateModalOpen] = useState(false)
const [formData, setFormData] = useState({
risk_id: '',
title: '',
description: '',
category: 'compliance_gap',
likelihood: 3,
impact: 3,
owner: '',
treatment_plan: '',
status: 'open',
mitigating_controls: [] as string[],
residual_likelihood: null as number | null,
residual_impact: null as number | null,
})
const BACKEND_URL = process.env.NEXT_PUBLIC_BACKEND_URL || 'http://localhost:8000'
useEffect(() => {
loadRisks()
}, [])
const loadRisks = async () => {
setLoading(true)
try {
const res = await fetch(`${BACKEND_URL}/api/v1/compliance/risks`)
if (res.ok) {
const data = await res.json()
setRisks(data.risks || [])
}
} catch (error) {
console.error('Failed to load risks:', error)
} finally {
setLoading(false)
}
}
const openCreateModal = () => {
setFormData({
risk_id: `RISK-${String(risks.length + 1).padStart(3, '0')}`,
title: '',
description: '',
category: 'compliance_gap',
likelihood: 3,
impact: 3,
owner: '',
treatment_plan: '',
status: 'open',
mitigating_controls: [],
residual_likelihood: null,
residual_impact: null,
})
setCreateModalOpen(true)
}
const openEditModal = (risk: Risk) => {
setSelectedRisk(risk)
setFormData({
risk_id: risk.risk_id,
title: risk.title,
description: risk.description || '',
category: risk.category,
likelihood: risk.likelihood,
impact: risk.impact,
owner: risk.owner || '',
treatment_plan: risk.treatment_plan || '',
status: risk.status,
mitigating_controls: risk.mitigating_controls || [],
residual_likelihood: risk.residual_likelihood,
residual_impact: risk.residual_impact,
})
setEditModalOpen(true)
}
const handleCreate = async () => {
try {
const res = await fetch(`${BACKEND_URL}/api/v1/compliance/risks`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
risk_id: formData.risk_id,
title: formData.title,
description: formData.description,
category: formData.category,
likelihood: formData.likelihood,
impact: formData.impact,
owner: formData.owner,
treatment_plan: formData.treatment_plan,
mitigating_controls: formData.mitigating_controls,
}),
})
if (res.ok) {
setCreateModalOpen(false)
loadRisks()
} else {
const error = await res.text()
alert(`Fehler: ${error}`)
}
} catch (error) {
console.error('Create failed:', error)
alert('Fehler beim Erstellen')
}
}
const handleUpdate = async () => {
if (!selectedRisk) return
try {
const res = await fetch(`${BACKEND_URL}/api/v1/compliance/risks/${selectedRisk.risk_id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
title: formData.title,
description: formData.description,
category: formData.category,
likelihood: formData.likelihood,
impact: formData.impact,
owner: formData.owner,
treatment_plan: formData.treatment_plan,
status: formData.status,
mitigating_controls: formData.mitigating_controls,
residual_likelihood: formData.residual_likelihood,
residual_impact: formData.residual_impact,
}),
})
if (res.ok) {
setEditModalOpen(false)
loadRisks()
} else {
const error = await res.text()
alert(`Fehler: ${error}`)
}
} catch (error) {
console.error('Update failed:', error)
alert('Fehler beim Aktualisieren')
}
}
// Build matrix data structure
const buildMatrix = () => {
const matrix: Record<number, Record<number, Risk[]>> = {}
for (let l = 1; l <= 5; l++) {
matrix[l] = {}
for (let i = 1; i <= 5; i++) {
matrix[l][i] = []
}
}
risks.forEach((risk) => {
if (matrix[risk.likelihood] && matrix[risk.likelihood][risk.impact]) {
matrix[risk.likelihood][risk.impact].push(risk)
}
})
return matrix
}
const renderMatrix = () => {
const matrix = buildMatrix()
return (
<div className="bg-white rounded-xl shadow-sm border p-6">
<h3 className="text-lg font-semibold text-slate-900 mb-4">Risk Matrix (Likelihood x Impact)</h3>
<div className="overflow-x-auto">
<div className="inline-block">
{/* Column headers (Impact) */}
<div className="flex ml-16">
{[1, 2, 3, 4, 5].map((i) => (
<div key={i} className="w-24 text-center text-sm font-medium text-slate-500 pb-2">
Impact {i}
</div>
))}
</div>
{/* Matrix rows */}
{[5, 4, 3, 2, 1].map((likelihood) => (
<div key={likelihood} className="flex items-center">
<div className="w-16 text-sm font-medium text-slate-500 text-right pr-2">
L{likelihood}
</div>
{[1, 2, 3, 4, 5].map((impact) => {
const level = calculateRiskLevel(likelihood, impact)
const cellRisks = matrix[likelihood][impact]
return (
<div
key={impact}
className={`w-24 h-20 border m-0.5 rounded flex flex-col items-center justify-center ${RISK_BG_COLORS[level]}`}
>
{cellRisks.length > 0 && (
<div className="flex flex-wrap gap-1 justify-center">
{cellRisks.map((r) => (
<button
key={r.id}
onClick={() => openEditModal(r)}
className={`px-2 py-0.5 text-xs font-medium rounded text-white ${RISK_COLORS[r.inherent_risk] || 'bg-slate-500'} hover:opacity-80`}
title={r.title}
>
{r.risk_id}
</button>
))}
</div>
)}
</div>
)
})}
</div>
))}
</div>
</div>
{/* Legend */}
<div className="flex gap-4 mt-6 pt-4 border-t">
<div className="flex items-center gap-2">
<div className="w-4 h-4 bg-green-500 rounded" />
<span className="text-sm text-slate-600">Low (1-5)</span>
</div>
<div className="flex items-center gap-2">
<div className="w-4 h-4 bg-yellow-500 rounded" />
<span className="text-sm text-slate-600">Medium (6-11)</span>
</div>
<div className="flex items-center gap-2">
<div className="w-4 h-4 bg-orange-500 rounded" />
<span className="text-sm text-slate-600">High (12-19)</span>
</div>
<div className="flex items-center gap-2">
<div className="w-4 h-4 bg-red-500 rounded" />
<span className="text-sm text-slate-600">Critical (20-25)</span>
</div>
</div>
</div>
)
}
const renderList = () => (
<div className="bg-white rounded-xl shadow-sm border overflow-hidden">
<table className="w-full">
<thead className="bg-slate-50">
<tr>
<th className="px-4 py-3 text-left text-xs font-medium text-slate-500 uppercase">ID</th>
<th className="px-4 py-3 text-left text-xs font-medium text-slate-500 uppercase">Titel</th>
<th className="px-4 py-3 text-center text-xs font-medium text-slate-500 uppercase">Kategorie</th>
<th className="px-4 py-3 text-center text-xs font-medium text-slate-500 uppercase">L x I</th>
<th className="px-4 py-3 text-center text-xs font-medium text-slate-500 uppercase">Risiko</th>
<th className="px-4 py-3 text-center text-xs font-medium text-slate-500 uppercase">Status</th>
<th className="px-4 py-3 text-right text-xs font-medium text-slate-500 uppercase">Aktionen</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-200">
{risks.map((risk) => (
<tr key={risk.id} className="hover:bg-slate-50">
<td className="px-4 py-3">
<span className="font-mono font-medium text-primary-600">{risk.risk_id}</span>
</td>
<td className="px-4 py-3">
<div>
<p className="font-medium text-slate-900">{risk.title}</p>
{risk.description && (
<p className="text-sm text-slate-500 truncate max-w-md">{risk.description}</p>
)}
</div>
</td>
<td className="px-4 py-3 text-center">
<span className="text-sm text-slate-600">
{CATEGORY_OPTIONS.find((c) => c.value === risk.category)?.label || risk.category}
</span>
</td>
<td className="px-4 py-3 text-center">
<span className="font-mono">{risk.likelihood} x {risk.impact}</span>
</td>
<td className="px-4 py-3 text-center">
<span className={`px-2 py-1 text-xs font-medium rounded-full text-white ${RISK_COLORS[risk.inherent_risk] || 'bg-slate-500'}`}>
{risk.inherent_risk}
</span>
</td>
<td className="px-4 py-3 text-center">
<span className={`px-2 py-1 text-xs rounded-full ${
risk.status === 'mitigated' ? 'bg-green-100 text-green-700' :
risk.status === 'accepted' ? 'bg-blue-100 text-blue-700' :
'bg-slate-100 text-slate-700'
}`}>
{risk.status}
</span>
</td>
<td className="px-4 py-3 text-right">
<button
onClick={() => openEditModal(risk)}
className="text-sm text-primary-600 hover:text-primary-700 font-medium"
>
Bearbeiten
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
)
const renderForm = (isCreate: boolean) => (
<div className="space-y-4">
{isCreate && (
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Risk ID</label>
<input
type="text"
value={formData.risk_id}
onChange={(e) => setFormData({ ...formData, risk_id: e.target.value })}
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-primary-500"
/>
</div>
)}
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Titel *</label>
<input
type="text"
value={formData.title}
onChange={(e) => setFormData({ ...formData, title: e.target.value })}
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-primary-500"
/>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Beschreibung</label>
<textarea
value={formData.description}
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
rows={2}
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-primary-500"
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Kategorie</label>
<select
value={formData.category}
onChange={(e) => setFormData({ ...formData, category: e.target.value })}
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-primary-500"
>
{CATEGORY_OPTIONS.map((c) => (
<option key={c.value} value={c.value}>{c.label}</option>
))}
</select>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Status</label>
<select
value={formData.status}
onChange={(e) => setFormData({ ...formData, status: e.target.value })}
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-primary-500"
>
{STATUS_OPTIONS.map((s) => (
<option key={s} value={s}>{s}</option>
))}
</select>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Likelihood (1-5)</label>
<input
type="range"
min="1"
max="5"
value={formData.likelihood}
onChange={(e) => setFormData({ ...formData, likelihood: parseInt(e.target.value) })}
className="w-full"
/>
<div className="flex justify-between text-xs text-slate-500">
<span>1</span>
<span className="font-medium">{formData.likelihood}</span>
<span>5</span>
</div>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Impact (1-5)</label>
<input
type="range"
min="1"
max="5"
value={formData.impact}
onChange={(e) => setFormData({ ...formData, impact: parseInt(e.target.value) })}
className="w-full"
/>
<div className="flex justify-between text-xs text-slate-500">
<span>1</span>
<span className="font-medium">{formData.impact}</span>
<span>5</span>
</div>
</div>
</div>
<div className="p-3 bg-slate-50 rounded-lg">
<p className="text-sm text-slate-600">
Berechnetes Risiko:{' '}
<span className={`font-medium px-2 py-0.5 rounded text-white ${RISK_COLORS[calculateRiskLevel(formData.likelihood, formData.impact)]}`}>
{calculateRiskLevel(formData.likelihood, formData.impact).toUpperCase()} ({formData.likelihood * formData.impact})
</span>
</p>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Verantwortlich</label>
<input
type="text"
value={formData.owner}
onChange={(e) => setFormData({ ...formData, owner: e.target.value })}
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-primary-500"
/>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Behandlungsplan</label>
<textarea
value={formData.treatment_plan}
onChange={(e) => setFormData({ ...formData, treatment_plan: e.target.value })}
rows={2}
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-primary-500"
/>
</div>
</div>
)
const r = useRisks()
return (
<AdminLayout title="Risk Matrix" description="Risikobewertung & Management">
@@ -519,17 +38,17 @@ export default function RisksPage() {
{/* View Toggle */}
<div className="flex bg-slate-100 rounded-lg p-1">
<button
onClick={() => setViewMode('matrix')}
onClick={() => r.setViewMode('matrix')}
className={`px-3 py-1.5 text-sm rounded-md transition-colors ${
viewMode === 'matrix' ? 'bg-white shadow text-slate-900' : 'text-slate-600'
r.viewMode === 'matrix' ? 'bg-white shadow text-slate-900' : 'text-slate-600'
}`}
>
Matrix
</button>
<button
onClick={() => setViewMode('list')}
onClick={() => r.setViewMode('list')}
className={`px-3 py-1.5 text-sm rounded-md transition-colors ${
viewMode === 'list' ? 'bg-white shadow text-slate-900' : 'text-slate-600'
r.viewMode === 'list' ? 'bg-white shadow text-slate-900' : 'text-slate-600'
}`}
>
Liste
@@ -537,7 +56,7 @@ export default function RisksPage() {
</div>
<button
onClick={openCreateModal}
onClick={r.openCreateModal}
className="px-4 py-2 bg-primary-600 text-white rounded-lg hover:bg-primary-700"
>
Risiko hinzufuegen
@@ -545,44 +64,44 @@ export default function RisksPage() {
</div>
{/* Content */}
{loading ? (
{r.loading ? (
<div className="flex items-center justify-center h-64">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-primary-600" />
</div>
) : risks.length === 0 ? (
) : r.risks.length === 0 ? (
<div className="bg-white rounded-xl shadow-sm border p-12 text-center">
<svg className="w-16 h-16 text-slate-300 mx-auto mb-4" 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-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
</svg>
<p className="text-slate-500 mb-4">Keine Risiken erfasst</p>
<button
onClick={openCreateModal}
onClick={r.openCreateModal}
className="px-4 py-2 bg-primary-600 text-white rounded-lg hover:bg-primary-700"
>
Erstes Risiko hinzufuegen
</button>
</div>
) : viewMode === 'matrix' ? (
renderMatrix()
) : r.viewMode === 'matrix' ? (
<RiskMatrix risks={r.risks} onEditRisk={r.openEditModal} />
) : (
renderList()
<RiskList risks={r.risks} onEditRisk={r.openEditModal} />
)}
{/* Create Modal */}
{createModalOpen && (
{r.createModalOpen && (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
<div className="bg-white rounded-xl shadow-xl w-full max-w-lg p-6 max-h-[90vh] overflow-y-auto">
<h3 className="text-lg font-semibold text-slate-900 mb-4">Neues Risiko</h3>
{renderForm(true)}
<RiskForm formData={r.formData} setFormData={r.setFormData} isCreate={true} />
<div className="flex justify-end gap-3 mt-6">
<button
onClick={() => setCreateModalOpen(false)}
onClick={() => r.setCreateModalOpen(false)}
className="px-4 py-2 text-slate-600 hover:text-slate-800"
>
Abbrechen
</button>
<button
onClick={handleCreate}
onClick={r.handleCreate}
className="px-4 py-2 bg-primary-600 text-white rounded-lg hover:bg-primary-700"
>
Erstellen
@@ -593,22 +112,22 @@ export default function RisksPage() {
)}
{/* Edit Modal */}
{editModalOpen && selectedRisk && (
{r.editModalOpen && r.selectedRisk && (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
<div className="bg-white rounded-xl shadow-xl w-full max-w-lg p-6 max-h-[90vh] overflow-y-auto">
<h3 className="text-lg font-semibold text-slate-900 mb-4">
Risiko bearbeiten: {selectedRisk.risk_id}
Risiko bearbeiten: {r.selectedRisk.risk_id}
</h3>
{renderForm(false)}
<RiskForm formData={r.formData} setFormData={r.setFormData} isCreate={false} />
<div className="flex justify-end gap-3 mt-6">
<button
onClick={() => setEditModalOpen(false)}
onClick={() => r.setEditModalOpen(false)}
className="px-4 py-2 text-slate-600 hover:text-slate-800"
>
Abbrechen
</button>
<button
onClick={handleUpdate}
onClick={r.handleUpdate}
className="px-4 py-2 bg-primary-600 text-white rounded-lg hover:bg-primary-700"
>
Speichern

View File

@@ -0,0 +1,83 @@
'use client'
import type { Document, Tab } from './types'
interface DocumentsTabProps {
documents: Document[]
loading: boolean
setSelectedDocument: (id: string) => void
setActiveTab: (tab: Tab) => void
}
export default function DocumentsTab({ documents, loading, setSelectedDocument, setActiveTab }: DocumentsTabProps) {
return (
<div className="p-6">
<div className="flex items-center justify-between mb-6">
<h2 className="text-lg font-semibold text-slate-900">Dokumente verwalten</h2>
<button className="px-4 py-2 bg-primary-600 text-white rounded-lg hover:bg-primary-700 transition-colors text-sm font-medium">
+ Neues Dokument
</button>
</div>
{loading ? (
<div className="text-center py-12 text-slate-500">Lade Dokumente...</div>
) : documents.length === 0 ? (
<div className="text-center py-12 text-slate-500">
Keine Dokumente vorhanden
</div>
) : (
<div className="overflow-x-auto">
<table className="w-full">
<thead>
<tr className="border-b border-slate-200">
<th className="text-left py-3 px-4 text-sm font-medium text-slate-500">Typ</th>
<th className="text-left py-3 px-4 text-sm font-medium text-slate-500">Name</th>
<th className="text-left py-3 px-4 text-sm font-medium text-slate-500">Beschreibung</th>
<th className="text-left py-3 px-4 text-sm font-medium text-slate-500">Pflicht</th>
<th className="text-left py-3 px-4 text-sm font-medium text-slate-500">Erstellt</th>
<th className="text-right py-3 px-4 text-sm font-medium text-slate-500">Aktionen</th>
</tr>
</thead>
<tbody>
{documents.map((doc) => (
<tr key={doc.id} className="border-b border-slate-100 hover:bg-slate-50">
<td className="py-3 px-4">
<span className="px-2 py-1 bg-slate-100 text-slate-700 rounded text-xs font-medium">
{doc.type}
</span>
</td>
<td className="py-3 px-4 font-medium text-slate-900">{doc.name}</td>
<td className="py-3 px-4 text-slate-600 text-sm">{doc.description}</td>
<td className="py-3 px-4">
{doc.mandatory ? (
<span className="text-green-600">Ja</span>
) : (
<span className="text-slate-400">Nein</span>
)}
</td>
<td className="py-3 px-4 text-sm text-slate-500">
{new Date(doc.created_at).toLocaleDateString('de-DE')}
</td>
<td className="py-3 px-4 text-right">
<button
onClick={() => {
setSelectedDocument(doc.id)
setActiveTab('versions')
}}
className="text-primary-600 hover:text-primary-700 text-sm font-medium mr-3"
>
Versionen
</button>
<button className="text-slate-500 hover:text-slate-700 text-sm">
Bearbeiten
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,68 @@
'use client'
import { EMAIL_TEMPLATES, EMAIL_CATEGORIES, CATEGORY_ICONS } from './types'
export default function EmailsTab() {
return (
<div className="p-6">
<div className="flex items-center justify-between mb-6">
<div>
<h2 className="text-lg font-semibold text-slate-900">E-Mail Vorlagen</h2>
<p className="text-sm text-slate-500 mt-1">16 Lifecycle-Vorlagen für automatisierte Kommunikation</p>
</div>
<button className="px-4 py-2 bg-primary-600 text-white rounded-lg hover:bg-primary-700 transition-colors text-sm font-medium">
+ Neue Vorlage
</button>
</div>
{/* Category Filter */}
<div className="flex flex-wrap gap-2 mb-6">
<span className="text-sm text-slate-500 py-1">Filter:</span>
{EMAIL_CATEGORIES.map((cat) => (
<span key={cat.key} className={`px-3 py-1 rounded-full text-xs font-medium ${cat.color}`}>
{cat.label}
</span>
))}
</div>
{/* Templates grouped by category */}
{EMAIL_CATEGORIES.map((category) => (
<div key={category.key} className="mb-8">
<h3 className="text-sm font-semibold text-slate-700 uppercase tracking-wider mb-3 flex items-center gap-2">
<span className={`w-2 h-2 rounded-full ${category.color.split(' ')[0]}`}></span>
{category.label}
</h3>
<div className="grid gap-3">
{EMAIL_TEMPLATES
.filter((t) => t.category === category.key)
.map((template) => (
<div
key={template.key}
className="border border-slate-200 rounded-lg p-4 flex items-center justify-between hover:border-primary-300 transition-colors bg-white"
>
<div className="flex items-center gap-4">
<div className={`w-10 h-10 rounded-lg ${category.color} flex items-center justify-center text-lg`}>
{CATEGORY_ICONS[category.key]}
</div>
<div>
<h4 className="font-medium text-slate-900">{template.name}</h4>
<p className="text-sm text-slate-500">{template.description}</p>
</div>
</div>
<div className="flex items-center gap-2">
<span className="px-2 py-1 bg-green-100 text-green-700 rounded text-xs">Aktiv</span>
<button className="px-3 py-1.5 text-sm text-slate-600 hover:text-slate-900 border border-slate-300 rounded-lg hover:border-slate-400">
Bearbeiten
</button>
<button className="px-3 py-1.5 text-sm text-slate-600 hover:text-slate-900 border border-slate-300 rounded-lg hover:border-slate-400">
Vorschau
</button>
</div>
</div>
))}
</div>
</div>
))}
</div>
)
}

View File

@@ -0,0 +1,109 @@
'use client'
import { GDPR_PROCESSES } from './types'
export default function GdprTab() {
return (
<div className="p-6">
<div className="flex items-center justify-between mb-6">
<div>
<h2 className="text-lg font-semibold text-slate-900">DSGVO Betroffenenrechte</h2>
<p className="text-sm text-slate-500 mt-1">Artikel 15-21 Prozesse und Vorlagen</p>
</div>
<button className="px-4 py-2 bg-primary-600 text-white rounded-lg hover:bg-primary-700 transition-colors text-sm font-medium">
+ DSR Anfrage erstellen
</button>
</div>
{/* Info Banner */}
<div className="bg-purple-50 border border-purple-200 rounded-lg p-4 mb-6">
<div className="flex items-start gap-3">
<span className="text-2xl"></span>
<div>
<h4 className="font-medium text-purple-900">Data Subject Rights (DSR)</h4>
<p className="text-sm text-purple-700 mt-1">
Hier verwalten Sie alle DSGVO-Anfragen. Jeder Artikel hat definierte Prozesse, SLAs und automatisierte Workflows.
</p>
</div>
</div>
</div>
{/* GDPR Process Cards */}
<div className="space-y-4">
{GDPR_PROCESSES.map((process) => (
<div
key={process.article}
className="border border-slate-200 rounded-xl p-5 hover:border-purple-300 transition-colors bg-white"
>
<div className="flex items-start justify-between">
<div className="flex items-start gap-4">
<div className="w-12 h-12 bg-purple-100 text-purple-700 rounded-lg flex items-center justify-center font-bold text-lg">
{process.article}
</div>
<div className="flex-1">
<div className="flex items-center gap-2 mb-1">
<h3 className="font-semibold text-slate-900">{process.title}</h3>
<span className="px-2 py-0.5 bg-green-100 text-green-700 rounded text-xs">Aktiv</span>
</div>
<p className="text-sm text-slate-600 mb-3">{process.description}</p>
{/* Actions */}
<div className="flex flex-wrap gap-2 mb-3">
{process.actions.map((action, idx) => (
<span key={idx} className="px-2 py-1 bg-slate-100 text-slate-600 rounded text-xs">
{action}
</span>
))}
</div>
{/* SLA */}
<div className="flex items-center gap-4 text-sm">
<span className="text-slate-500">
SLA: <span className="font-medium text-slate-700">{process.sla}</span>
</span>
<span className="text-slate-300">|</span>
<span className="text-slate-500">
Offene Anfragen: <span className="font-medium text-slate-700">0</span>
</span>
</div>
</div>
</div>
<div className="flex flex-col gap-2">
<button className="px-3 py-1.5 text-sm text-white bg-purple-600 hover:bg-purple-700 rounded-lg">
Anfragen
</button>
<button className="px-3 py-1.5 text-sm text-slate-600 hover:text-slate-900 border border-slate-300 rounded-lg hover:border-slate-400">
Vorlage
</button>
</div>
</div>
</div>
))}
</div>
{/* DSR Request Statistics */}
<div className="mt-8 pt-6 border-t border-slate-200">
<h3 className="text-sm font-semibold text-slate-700 uppercase tracking-wider mb-4">DSR Übersicht</h3>
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<div className="bg-slate-50 rounded-lg p-4 text-center">
<div className="text-2xl font-bold text-slate-900">0</div>
<div className="text-xs text-slate-500 mt-1">Offen</div>
</div>
<div className="bg-green-50 rounded-lg p-4 text-center">
<div className="text-2xl font-bold text-green-700">0</div>
<div className="text-xs text-slate-500 mt-1">Erledigt</div>
</div>
<div className="bg-yellow-50 rounded-lg p-4 text-center">
<div className="text-2xl font-bold text-yellow-700">0</div>
<div className="text-xs text-slate-500 mt-1">In Bearbeitung</div>
</div>
<div className="bg-red-50 rounded-lg p-4 text-center">
<div className="text-2xl font-bold text-red-700">0</div>
<div className="text-xs text-slate-500 mt-1">Überfällig</div>
</div>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,31 @@
'use client'
export default function StatsTab() {
return (
<div className="p-6">
<h2 className="text-lg font-semibold text-slate-900 mb-6">Statistiken</h2>
<div className="grid grid-cols-1 md:grid-cols-3 gap-6 mb-8">
<div className="bg-slate-50 rounded-xl p-6">
<div className="text-3xl font-bold text-slate-900">0</div>
<div className="text-sm text-slate-500 mt-1">Aktive Zustimmungen</div>
</div>
<div className="bg-slate-50 rounded-xl p-6">
<div className="text-3xl font-bold text-slate-900">0</div>
<div className="text-sm text-slate-500 mt-1">Dokumente</div>
</div>
<div className="bg-slate-50 rounded-xl p-6">
<div className="text-3xl font-bold text-slate-900">0</div>
<div className="text-sm text-slate-500 mt-1">Offene DSR-Anfragen</div>
</div>
</div>
<div className="border border-slate-200 rounded-lg p-6">
<h3 className="font-semibold text-slate-900 mb-4">Zustimmungsrate nach Dokument</h3>
<div className="text-center py-8 text-slate-500">
Noch keine Daten verfügbar
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,97 @@
'use client'
import type { Document, Version } from './types'
interface VersionsTabProps {
documents: Document[]
versions: Version[]
selectedDocument: string
setSelectedDocument: (id: string) => void
loading: boolean
}
export default function VersionsTab({ documents, versions, selectedDocument, setSelectedDocument, loading }: VersionsTabProps) {
return (
<div className="p-6">
<div className="flex items-center justify-between mb-6">
<div className="flex items-center gap-4">
<h2 className="text-lg font-semibold text-slate-900">Versionen</h2>
<select
value={selectedDocument}
onChange={(e) => setSelectedDocument(e.target.value)}
className="px-3 py-2 border border-slate-300 rounded-lg text-sm"
>
<option value="">Dokument auswählen...</option>
{documents.map((doc) => (
<option key={doc.id} value={doc.id}>
{doc.name}
</option>
))}
</select>
</div>
{selectedDocument && (
<button className="px-4 py-2 bg-primary-600 text-white rounded-lg hover:bg-primary-700 transition-colors text-sm font-medium">
+ Neue Version
</button>
)}
</div>
{!selectedDocument ? (
<div className="text-center py-12 text-slate-500">
Bitte wählen Sie ein Dokument aus
</div>
) : loading ? (
<div className="text-center py-12 text-slate-500">Lade Versionen...</div>
) : versions.length === 0 ? (
<div className="text-center py-12 text-slate-500">
Keine Versionen vorhanden
</div>
) : (
<div className="space-y-4">
{versions.map((version) => (
<div
key={version.id}
className="border border-slate-200 rounded-lg p-4 hover:border-primary-300 transition-colors"
>
<div className="flex items-start justify-between">
<div>
<div className="flex items-center gap-2 mb-1">
<span className="font-semibold text-slate-900">v{version.version}</span>
<span className="px-2 py-0.5 bg-slate-100 text-slate-600 rounded text-xs">
{version.language.toUpperCase()}
</span>
<span
className={`px-2 py-0.5 rounded text-xs ${
version.status === 'published'
? 'bg-green-100 text-green-700'
: version.status === 'draft'
? 'bg-yellow-100 text-yellow-700'
: 'bg-slate-100 text-slate-600'
}`}
>
{version.status}
</span>
</div>
<h3 className="text-slate-700">{version.title}</h3>
<p className="text-sm text-slate-500 mt-1">
Erstellt: {new Date(version.created_at).toLocaleDateString('de-DE')}
</p>
</div>
<div className="flex gap-2">
<button className="px-3 py-1.5 text-sm text-slate-600 hover:text-slate-900 border border-slate-300 rounded-lg hover:border-slate-400">
Bearbeiten
</button>
{version.status === 'draft' && (
<button className="px-3 py-1.5 text-sm text-white bg-green-600 hover:bg-green-700 rounded-lg">
Veröffentlichen
</button>
)}
</div>
</div>
</div>
))}
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,77 @@
export const API_BASE = '/api/admin/consent'
export type Tab = 'documents' | 'versions' | 'emails' | 'gdpr' | 'stats'
export interface Document {
id: string
type: string
name: string
description: string
mandatory: boolean
created_at: string
updated_at: string
}
export interface Version {
id: string
document_id: string
version: string
language: string
title: string
content: string
status: string
created_at: string
}
export const TABS: { id: Tab; label: string }[] = [
{ id: 'documents', label: 'Dokumente' },
{ id: 'versions', label: 'Versionen' },
{ id: 'emails', label: 'E-Mail Vorlagen' },
{ id: 'gdpr', label: 'DSGVO Prozesse' },
{ id: 'stats', label: 'Statistiken' },
]
export const EMAIL_TEMPLATES = [
{ name: 'Willkommens-E-Mail', key: 'welcome', category: 'onboarding', description: 'Wird bei Registrierung versendet' },
{ name: 'E-Mail Bestätigung', key: 'email_verification', category: 'onboarding', description: 'Bestätigungslink für E-Mail-Adresse' },
{ name: 'Konto aktiviert', key: 'account_activated', category: 'onboarding', description: 'Bestätigung der Kontoaktivierung' },
{ name: 'Passwort zurücksetzen', key: 'password_reset', category: 'security', description: 'Link zum Zurücksetzen des Passworts' },
{ name: 'Passwort geändert', key: 'password_changed', category: 'security', description: 'Bestätigung der Passwortänderung' },
{ name: 'Neue Anmeldung', key: 'new_login', category: 'security', description: 'Benachrichtigung über Anmeldung von neuem Gerät' },
{ name: '2FA aktiviert', key: '2fa_enabled', category: 'security', description: 'Bestätigung der 2FA-Aktivierung' },
{ name: 'Neue Dokumentversion', key: 'new_document_version', category: 'consent', description: 'Info über neue Dokumentversion zur Zustimmung' },
{ name: 'Zustimmung erteilt', key: 'consent_given', category: 'consent', description: 'Bestätigung der erteilten Zustimmung' },
{ name: 'Zustimmung widerrufen', key: 'consent_withdrawn', category: 'consent', description: 'Bestätigung des Widerrufs' },
{ name: 'DSR Anfrage erhalten', key: 'dsr_received', category: 'gdpr', description: 'Bestätigung des Eingangs einer DSGVO-Anfrage' },
{ name: 'Datenexport bereit', key: 'export_ready', category: 'gdpr', description: 'Benachrichtigung über fertigen Datenexport' },
{ name: 'Daten gelöscht', key: 'data_deleted', category: 'gdpr', description: 'Bestätigung der Datenlöschung' },
{ name: 'Daten berichtigt', key: 'data_rectified', category: 'gdpr', description: 'Bestätigung der Datenberichtigung' },
{ name: 'Konto deaktiviert', key: 'account_deactivated', category: 'lifecycle', description: 'Konto wurde deaktiviert' },
{ name: 'Konto gelöscht', key: 'account_deleted', category: 'lifecycle', description: 'Bestätigung der Kontolöschung' },
]
export const GDPR_PROCESSES = [
{ article: '15', title: 'Auskunftsrecht', description: 'Recht auf Bestätigung und Auskunft über verarbeitete personenbezogene Daten', actions: ['Datenexport generieren', 'Verarbeitungszwecke auflisten', 'Empfänger auflisten'], sla: '30 Tage', status: 'active' },
{ article: '16', title: 'Recht auf Berichtigung', description: 'Recht auf Berichtigung unrichtiger personenbezogener Daten', actions: ['Daten bearbeiten', 'Änderungshistorie führen', 'Benachrichtigung senden'], sla: '30 Tage', status: 'active' },
{ article: '17', title: 'Recht auf Löschung ("Vergessenwerden")', description: 'Recht auf Löschung personenbezogener Daten unter bestimmten Voraussetzungen', actions: ['Löschantrag prüfen', 'Daten löschen', 'Aufbewahrungsfristen prüfen', 'Löschbestätigung senden'], sla: '30 Tage', status: 'active' },
{ article: '18', title: 'Recht auf Einschränkung der Verarbeitung', description: 'Recht auf Markierung von Daten zur eingeschränkten Verarbeitung', actions: ['Daten markieren', 'Verarbeitung einschränken', 'Benachrichtigung bei Aufhebung'], sla: '30 Tage', status: 'active' },
{ article: '19', title: 'Mitteilungspflicht', description: 'Pflicht zur Mitteilung von Berichtigung, Löschung oder Einschränkung an Empfänger', actions: ['Empfänger ermitteln', 'Mitteilungen versenden', 'Protokollierung'], sla: 'Unverzüglich', status: 'active' },
{ article: '20', title: 'Recht auf Datenübertragbarkeit', description: 'Recht auf Erhalt der Daten in strukturiertem, maschinenlesbarem Format', actions: ['JSON/CSV Export', 'API-Schnittstelle', 'Direkte Übertragung'], sla: '30 Tage', status: 'active' },
{ article: '21', title: 'Widerspruchsrecht', description: 'Recht auf Widerspruch gegen Verarbeitung aus berechtigtem Interesse oder Direktwerbung', actions: ['Widerspruch erfassen', 'Verarbeitung stoppen', 'Marketing-Opt-out'], sla: 'Unverzüglich', status: 'active' },
]
export const EMAIL_CATEGORIES = [
{ key: 'onboarding', label: 'Onboarding', color: 'bg-blue-100 text-blue-700' },
{ key: 'security', label: 'Sicherheit', color: 'bg-red-100 text-red-700' },
{ key: 'consent', label: 'Zustimmung', color: 'bg-green-100 text-green-700' },
{ key: 'gdpr', label: 'DSGVO', color: 'bg-purple-100 text-purple-700' },
{ key: 'lifecycle', label: 'Lifecycle', color: 'bg-orange-100 text-orange-700' },
]
export const CATEGORY_ICONS: Record<string, string> = {
onboarding: '👋',
security: '🔒',
consent: '✓',
gdpr: '📋',
lifecycle: '🔄',
}

View File

@@ -0,0 +1,80 @@
'use client'
import { useState, useEffect } from 'react'
import type { Tab, Document, Version } from './types'
import { API_BASE } from './types'
export function useConsent() {
const [activeTab, setActiveTab] = useState<Tab>('documents')
const [documents, setDocuments] = useState<Document[]>([])
const [versions, setVersions] = useState<Version[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [selectedDocument, setSelectedDocument] = useState<string>('')
const [authToken, setAuthToken] = useState<string>('')
useEffect(() => {
const token = localStorage.getItem('bp_admin_token')
if (token) {
setAuthToken(token)
}
}, [])
useEffect(() => {
if (activeTab === 'documents') {
loadDocuments()
} else if (activeTab === 'versions' && selectedDocument) {
loadVersions(selectedDocument)
}
}, [activeTab, selectedDocument, authToken])
async function loadDocuments() {
setLoading(true)
setError(null)
try {
const res = await fetch(`${API_BASE}/documents`, {
headers: authToken ? { 'Authorization': `Bearer ${authToken}` } : {}
})
if (res.ok) {
const data = await res.json()
setDocuments(data.documents || [])
} else {
const errorData = await res.json().catch(() => ({}))
setError(errorData.error || 'Fehler beim Laden der Dokumente')
}
} catch (err) {
setError('Verbindungsfehler zum Server')
} finally {
setLoading(false)
}
}
async function loadVersions(docId: string) {
setLoading(true)
setError(null)
try {
const res = await fetch(`${API_BASE}/documents/${docId}/versions`, {
headers: authToken ? { 'Authorization': `Bearer ${authToken}` } : {}
})
if (res.ok) {
const data = await res.json()
setVersions(data.versions || [])
} else {
const errorData = await res.json().catch(() => ({}))
setError(errorData.error || 'Fehler beim Laden der Versionen')
}
} catch (err) {
setError('Verbindungsfehler zum Server')
} finally {
setLoading(false)
}
}
return {
activeTab, setActiveTab,
documents, versions,
loading, error, setError,
selectedDocument, setSelectedDocument,
authToken, setAuthToken,
}
}

View File

@@ -10,209 +10,22 @@
* - Statistics
*/
import { useState, useEffect } from 'react'
import AdminLayout from '@/components/admin/AdminLayout'
// API Proxy URL (avoids CORS issues)
const API_BASE = '/api/admin/consent'
type Tab = 'documents' | 'versions' | 'emails' | 'gdpr' | 'stats'
interface Document {
id: string
type: string
name: string
description: string
mandatory: boolean
created_at: string
updated_at: string
}
interface Version {
id: string
document_id: string
version: string
language: string
title: string
content: string
status: string
created_at: string
}
import { useConsent } from './_components/useConsent'
import { TABS } from './_components/types'
import DocumentsTab from './_components/DocumentsTab'
import VersionsTab from './_components/VersionsTab'
import EmailsTab from './_components/EmailsTab'
import GdprTab from './_components/GdprTab'
import StatsTab from './_components/StatsTab'
export default function ConsentAdminPage() {
const [activeTab, setActiveTab] = useState<Tab>('documents')
const [documents, setDocuments] = useState<Document[]>([])
const [versions, setVersions] = useState<Version[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [selectedDocument, setSelectedDocument] = useState<string>('')
// Auth token (in production, get from auth context)
const [authToken, setAuthToken] = useState<string>('')
useEffect(() => {
// Get token from localStorage
const token = localStorage.getItem('bp_admin_token')
if (token) {
setAuthToken(token)
}
}, [])
useEffect(() => {
if (activeTab === 'documents') {
loadDocuments()
} else if (activeTab === 'versions' && selectedDocument) {
loadVersions(selectedDocument)
}
}, [activeTab, selectedDocument, authToken])
async function loadDocuments() {
setLoading(true)
setError(null)
try {
const res = await fetch(`${API_BASE}/documents`, {
headers: authToken ? { 'Authorization': `Bearer ${authToken}` } : {}
})
if (res.ok) {
const data = await res.json()
setDocuments(data.documents || [])
} else {
const errorData = await res.json().catch(() => ({}))
setError(errorData.error || 'Fehler beim Laden der Dokumente')
}
} catch (err) {
setError('Verbindungsfehler zum Server')
} finally {
setLoading(false)
}
}
async function loadVersions(docId: string) {
setLoading(true)
setError(null)
try {
const res = await fetch(`${API_BASE}/documents/${docId}/versions`, {
headers: authToken ? { 'Authorization': `Bearer ${authToken}` } : {}
})
if (res.ok) {
const data = await res.json()
setVersions(data.versions || [])
} else {
const errorData = await res.json().catch(() => ({}))
setError(errorData.error || 'Fehler beim Laden der Versionen')
}
} catch (err) {
setError('Verbindungsfehler zum Server')
} finally {
setLoading(false)
}
}
const tabs: { id: Tab; label: string }[] = [
{ id: 'documents', label: 'Dokumente' },
{ id: 'versions', label: 'Versionen' },
{ id: 'emails', label: 'E-Mail Vorlagen' },
{ id: 'gdpr', label: 'DSGVO Prozesse' },
{ id: 'stats', label: 'Statistiken' },
]
// 16 Lifecycle Email Templates
const emailTemplates = [
// Onboarding
{ name: 'Willkommens-E-Mail', key: 'welcome', category: 'onboarding', description: 'Wird bei Registrierung versendet' },
{ name: 'E-Mail Bestätigung', key: 'email_verification', category: 'onboarding', description: 'Bestätigungslink für E-Mail-Adresse' },
{ name: 'Konto aktiviert', key: 'account_activated', category: 'onboarding', description: 'Bestätigung der Kontoaktivierung' },
// Security
{ name: 'Passwort zurücksetzen', key: 'password_reset', category: 'security', description: 'Link zum Zurücksetzen des Passworts' },
{ name: 'Passwort geändert', key: 'password_changed', category: 'security', description: 'Bestätigung der Passwortänderung' },
{ name: 'Neue Anmeldung', key: 'new_login', category: 'security', description: 'Benachrichtigung über Anmeldung von neuem Gerät' },
{ name: '2FA aktiviert', key: '2fa_enabled', category: 'security', description: 'Bestätigung der 2FA-Aktivierung' },
// Consent & Legal
{ name: 'Neue Dokumentversion', key: 'new_document_version', category: 'consent', description: 'Info über neue Dokumentversion zur Zustimmung' },
{ name: 'Zustimmung erteilt', key: 'consent_given', category: 'consent', description: 'Bestätigung der erteilten Zustimmung' },
{ name: 'Zustimmung widerrufen', key: 'consent_withdrawn', category: 'consent', description: 'Bestätigung des Widerrufs' },
// Data Subject Rights (GDPR)
{ name: 'DSR Anfrage erhalten', key: 'dsr_received', category: 'gdpr', description: 'Bestätigung des Eingangs einer DSGVO-Anfrage' },
{ name: 'Datenexport bereit', key: 'export_ready', category: 'gdpr', description: 'Benachrichtigung über fertigen Datenexport' },
{ name: 'Daten gelöscht', key: 'data_deleted', category: 'gdpr', description: 'Bestätigung der Datenlöschung' },
{ name: 'Daten berichtigt', key: 'data_rectified', category: 'gdpr', description: 'Bestätigung der Datenberichtigung' },
// Account Lifecycle
{ name: 'Konto deaktiviert', key: 'account_deactivated', category: 'lifecycle', description: 'Konto wurde deaktiviert' },
{ name: 'Konto gelöscht', key: 'account_deleted', category: 'lifecycle', description: 'Bestätigung der Kontolöschung' },
]
// GDPR Article 15-21 Processes
const gdprProcesses = [
{
article: '15',
title: 'Auskunftsrecht',
description: 'Recht auf Bestätigung und Auskunft über verarbeitete personenbezogene Daten',
actions: ['Datenexport generieren', 'Verarbeitungszwecke auflisten', 'Empfänger auflisten'],
sla: '30 Tage',
status: 'active'
},
{
article: '16',
title: 'Recht auf Berichtigung',
description: 'Recht auf Berichtigung unrichtiger personenbezogener Daten',
actions: ['Daten bearbeiten', 'Änderungshistorie führen', 'Benachrichtigung senden'],
sla: '30 Tage',
status: 'active'
},
{
article: '17',
title: 'Recht auf Löschung ("Vergessenwerden")',
description: 'Recht auf Löschung personenbezogener Daten unter bestimmten Voraussetzungen',
actions: ['Löschantrag prüfen', 'Daten löschen', 'Aufbewahrungsfristen prüfen', 'Löschbestätigung senden'],
sla: '30 Tage',
status: 'active'
},
{
article: '18',
title: 'Recht auf Einschränkung der Verarbeitung',
description: 'Recht auf Markierung von Daten zur eingeschränkten Verarbeitung',
actions: ['Daten markieren', 'Verarbeitung einschränken', 'Benachrichtigung bei Aufhebung'],
sla: '30 Tage',
status: 'active'
},
{
article: '19',
title: 'Mitteilungspflicht',
description: 'Pflicht zur Mitteilung von Berichtigung, Löschung oder Einschränkung an Empfänger',
actions: ['Empfänger ermitteln', 'Mitteilungen versenden', 'Protokollierung'],
sla: 'Unverzüglich',
status: 'active'
},
{
article: '20',
title: 'Recht auf Datenübertragbarkeit',
description: 'Recht auf Erhalt der Daten in strukturiertem, maschinenlesbarem Format',
actions: ['JSON/CSV Export', 'API-Schnittstelle', 'Direkte Übertragung'],
sla: '30 Tage',
status: 'active'
},
{
article: '21',
title: 'Widerspruchsrecht',
description: 'Recht auf Widerspruch gegen Verarbeitung aus berechtigtem Interesse oder Direktwerbung',
actions: ['Widerspruch erfassen', 'Verarbeitung stoppen', 'Marketing-Opt-out'],
sla: 'Unverzüglich',
status: 'active'
},
]
const emailCategories = [
{ key: 'onboarding', label: 'Onboarding', color: 'bg-blue-100 text-blue-700' },
{ key: 'security', label: 'Sicherheit', color: 'bg-red-100 text-red-700' },
{ key: 'consent', label: 'Zustimmung', color: 'bg-green-100 text-green-700' },
{ key: 'gdpr', label: 'DSGVO', color: 'bg-purple-100 text-purple-700' },
{ key: 'lifecycle', label: 'Lifecycle', color: 'bg-orange-100 text-orange-700' },
]
const consent = useConsent()
return (
<AdminLayout title="Consent Verwaltung" description="Rechtliche Dokumente & Versionen">
{/* Token Input */}
{!authToken && (
{!consent.authToken && (
<div className="bg-white rounded-xl border border-slate-200 p-4 mb-6">
<label className="block text-sm font-medium text-slate-700 mb-2">
Admin Token
@@ -222,7 +35,7 @@ export default function ConsentAdminPage() {
placeholder="JWT Token eingeben..."
className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm"
onChange={(e) => {
setAuthToken(e.target.value)
consent.setAuthToken(e.target.value)
localStorage.setItem('bp_admin_token', e.target.value)
}}
/>
@@ -232,12 +45,12 @@ export default function ConsentAdminPage() {
{/* Tabs */}
<div className="mb-6">
<div className="flex gap-1 bg-slate-100 p-1 rounded-lg w-fit">
{tabs.map((tab) => (
{TABS.map((tab) => (
<button
key={tab.id}
onClick={() => setActiveTab(tab.id)}
onClick={() => consent.setActiveTab(tab.id)}
className={`px-4 py-2 text-sm font-medium rounded-md transition-colors ${
activeTab === tab.id
consent.activeTab === tab.id
? 'bg-white text-slate-900 shadow-sm'
: 'text-slate-600 hover:text-slate-900'
}`}
@@ -250,11 +63,11 @@ export default function ConsentAdminPage() {
{/* Content */}
<div>
{error && (
{consent.error && (
<div className="mb-4 p-4 bg-red-50 border border-red-200 text-red-700 rounded-lg">
{error}
{consent.error}
<button
onClick={() => setError(null)}
onClick={() => consent.setError(null)}
className="ml-4 text-red-500 hover:text-red-700"
>
@@ -263,364 +76,26 @@ export default function ConsentAdminPage() {
)}
<div className="bg-white rounded-xl border border-slate-200 shadow-sm">
{/* Documents Tab */}
{activeTab === 'documents' && (
<div className="p-6">
<div className="flex items-center justify-between mb-6">
<h2 className="text-lg font-semibold text-slate-900">Dokumente verwalten</h2>
<button className="px-4 py-2 bg-primary-600 text-white rounded-lg hover:bg-primary-700 transition-colors text-sm font-medium">
+ Neues Dokument
</button>
</div>
{loading ? (
<div className="text-center py-12 text-slate-500">Lade Dokumente...</div>
) : documents.length === 0 ? (
<div className="text-center py-12 text-slate-500">
Keine Dokumente vorhanden
</div>
) : (
<div className="overflow-x-auto">
<table className="w-full">
<thead>
<tr className="border-b border-slate-200">
<th className="text-left py-3 px-4 text-sm font-medium text-slate-500">Typ</th>
<th className="text-left py-3 px-4 text-sm font-medium text-slate-500">Name</th>
<th className="text-left py-3 px-4 text-sm font-medium text-slate-500">Beschreibung</th>
<th className="text-left py-3 px-4 text-sm font-medium text-slate-500">Pflicht</th>
<th className="text-left py-3 px-4 text-sm font-medium text-slate-500">Erstellt</th>
<th className="text-right py-3 px-4 text-sm font-medium text-slate-500">Aktionen</th>
</tr>
</thead>
<tbody>
{documents.map((doc) => (
<tr key={doc.id} className="border-b border-slate-100 hover:bg-slate-50">
<td className="py-3 px-4">
<span className="px-2 py-1 bg-slate-100 text-slate-700 rounded text-xs font-medium">
{doc.type}
</span>
</td>
<td className="py-3 px-4 font-medium text-slate-900">{doc.name}</td>
<td className="py-3 px-4 text-slate-600 text-sm">{doc.description}</td>
<td className="py-3 px-4">
{doc.mandatory ? (
<span className="text-green-600">Ja</span>
) : (
<span className="text-slate-400">Nein</span>
)}
</td>
<td className="py-3 px-4 text-sm text-slate-500">
{new Date(doc.created_at).toLocaleDateString('de-DE')}
</td>
<td className="py-3 px-4 text-right">
<button
onClick={() => {
setSelectedDocument(doc.id)
setActiveTab('versions')
}}
className="text-primary-600 hover:text-primary-700 text-sm font-medium mr-3"
>
Versionen
</button>
<button className="text-slate-500 hover:text-slate-700 text-sm">
Bearbeiten
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
{consent.activeTab === 'documents' && (
<DocumentsTab
documents={consent.documents}
loading={consent.loading}
setSelectedDocument={consent.setSelectedDocument}
setActiveTab={consent.setActiveTab}
/>
)}
{/* Versions Tab */}
{activeTab === 'versions' && (
<div className="p-6">
<div className="flex items-center justify-between mb-6">
<div className="flex items-center gap-4">
<h2 className="text-lg font-semibold text-slate-900">Versionen</h2>
<select
value={selectedDocument}
onChange={(e) => setSelectedDocument(e.target.value)}
className="px-3 py-2 border border-slate-300 rounded-lg text-sm"
>
<option value="">Dokument auswählen...</option>
{documents.map((doc) => (
<option key={doc.id} value={doc.id}>
{doc.name}
</option>
))}
</select>
</div>
{selectedDocument && (
<button className="px-4 py-2 bg-primary-600 text-white rounded-lg hover:bg-primary-700 transition-colors text-sm font-medium">
+ Neue Version
</button>
)}
</div>
{!selectedDocument ? (
<div className="text-center py-12 text-slate-500">
Bitte wählen Sie ein Dokument aus
</div>
) : loading ? (
<div className="text-center py-12 text-slate-500">Lade Versionen...</div>
) : versions.length === 0 ? (
<div className="text-center py-12 text-slate-500">
Keine Versionen vorhanden
</div>
) : (
<div className="space-y-4">
{versions.map((version) => (
<div
key={version.id}
className="border border-slate-200 rounded-lg p-4 hover:border-primary-300 transition-colors"
>
<div className="flex items-start justify-between">
<div>
<div className="flex items-center gap-2 mb-1">
<span className="font-semibold text-slate-900">v{version.version}</span>
<span className="px-2 py-0.5 bg-slate-100 text-slate-600 rounded text-xs">
{version.language.toUpperCase()}
</span>
<span
className={`px-2 py-0.5 rounded text-xs ${
version.status === 'published'
? 'bg-green-100 text-green-700'
: version.status === 'draft'
? 'bg-yellow-100 text-yellow-700'
: 'bg-slate-100 text-slate-600'
}`}
>
{version.status}
</span>
</div>
<h3 className="text-slate-700">{version.title}</h3>
<p className="text-sm text-slate-500 mt-1">
Erstellt: {new Date(version.created_at).toLocaleDateString('de-DE')}
</p>
</div>
<div className="flex gap-2">
<button className="px-3 py-1.5 text-sm text-slate-600 hover:text-slate-900 border border-slate-300 rounded-lg hover:border-slate-400">
Bearbeiten
</button>
{version.status === 'draft' && (
<button className="px-3 py-1.5 text-sm text-white bg-green-600 hover:bg-green-700 rounded-lg">
Veröffentlichen
</button>
)}
</div>
</div>
</div>
))}
</div>
)}
</div>
)}
{/* Emails Tab - 16 Lifecycle Templates */}
{activeTab === 'emails' && (
<div className="p-6">
<div className="flex items-center justify-between mb-6">
<div>
<h2 className="text-lg font-semibold text-slate-900">E-Mail Vorlagen</h2>
<p className="text-sm text-slate-500 mt-1">16 Lifecycle-Vorlagen für automatisierte Kommunikation</p>
</div>
<button className="px-4 py-2 bg-primary-600 text-white rounded-lg hover:bg-primary-700 transition-colors text-sm font-medium">
+ Neue Vorlage
</button>
</div>
{/* Category Filter */}
<div className="flex flex-wrap gap-2 mb-6">
<span className="text-sm text-slate-500 py-1">Filter:</span>
{emailCategories.map((cat) => (
<span key={cat.key} className={`px-3 py-1 rounded-full text-xs font-medium ${cat.color}`}>
{cat.label}
</span>
))}
</div>
{/* Templates grouped by category */}
{emailCategories.map((category) => (
<div key={category.key} className="mb-8">
<h3 className="text-sm font-semibold text-slate-700 uppercase tracking-wider mb-3 flex items-center gap-2">
<span className={`w-2 h-2 rounded-full ${category.color.split(' ')[0]}`}></span>
{category.label}
</h3>
<div className="grid gap-3">
{emailTemplates
.filter((t) => t.category === category.key)
.map((template) => (
<div
key={template.key}
className="border border-slate-200 rounded-lg p-4 flex items-center justify-between hover:border-primary-300 transition-colors bg-white"
>
<div className="flex items-center gap-4">
<div className={`w-10 h-10 rounded-lg ${category.color} flex items-center justify-center text-lg`}>
{category.key === 'onboarding' && '👋'}
{category.key === 'security' && '🔒'}
{category.key === 'consent' && '✓'}
{category.key === 'gdpr' && '📋'}
{category.key === 'lifecycle' && '🔄'}
</div>
<div>
<h4 className="font-medium text-slate-900">{template.name}</h4>
<p className="text-sm text-slate-500">{template.description}</p>
</div>
</div>
<div className="flex items-center gap-2">
<span className="px-2 py-1 bg-green-100 text-green-700 rounded text-xs">Aktiv</span>
<button className="px-3 py-1.5 text-sm text-slate-600 hover:text-slate-900 border border-slate-300 rounded-lg hover:border-slate-400">
Bearbeiten
</button>
<button className="px-3 py-1.5 text-sm text-slate-600 hover:text-slate-900 border border-slate-300 rounded-lg hover:border-slate-400">
Vorschau
</button>
</div>
</div>
))}
</div>
</div>
))}
</div>
)}
{/* GDPR Processes Tab - Articles 15-21 */}
{activeTab === 'gdpr' && (
<div className="p-6">
<div className="flex items-center justify-between mb-6">
<div>
<h2 className="text-lg font-semibold text-slate-900">DSGVO Betroffenenrechte</h2>
<p className="text-sm text-slate-500 mt-1">Artikel 15-21 Prozesse und Vorlagen</p>
</div>
<button className="px-4 py-2 bg-primary-600 text-white rounded-lg hover:bg-primary-700 transition-colors text-sm font-medium">
+ DSR Anfrage erstellen
</button>
</div>
{/* Info Banner */}
<div className="bg-purple-50 border border-purple-200 rounded-lg p-4 mb-6">
<div className="flex items-start gap-3">
<span className="text-2xl"></span>
<div>
<h4 className="font-medium text-purple-900">Data Subject Rights (DSR)</h4>
<p className="text-sm text-purple-700 mt-1">
Hier verwalten Sie alle DSGVO-Anfragen. Jeder Artikel hat definierte Prozesse, SLAs und automatisierte Workflows.
</p>
</div>
</div>
</div>
{/* GDPR Process Cards */}
<div className="space-y-4">
{gdprProcesses.map((process) => (
<div
key={process.article}
className="border border-slate-200 rounded-xl p-5 hover:border-purple-300 transition-colors bg-white"
>
<div className="flex items-start justify-between">
<div className="flex items-start gap-4">
<div className="w-12 h-12 bg-purple-100 text-purple-700 rounded-lg flex items-center justify-center font-bold text-lg">
{process.article}
</div>
<div className="flex-1">
<div className="flex items-center gap-2 mb-1">
<h3 className="font-semibold text-slate-900">{process.title}</h3>
<span className="px-2 py-0.5 bg-green-100 text-green-700 rounded text-xs">Aktiv</span>
</div>
<p className="text-sm text-slate-600 mb-3">{process.description}</p>
{/* Actions */}
<div className="flex flex-wrap gap-2 mb-3">
{process.actions.map((action, idx) => (
<span key={idx} className="px-2 py-1 bg-slate-100 text-slate-600 rounded text-xs">
{action}
</span>
))}
</div>
{/* SLA */}
<div className="flex items-center gap-4 text-sm">
<span className="text-slate-500">
SLA: <span className="font-medium text-slate-700">{process.sla}</span>
</span>
<span className="text-slate-300">|</span>
<span className="text-slate-500">
Offene Anfragen: <span className="font-medium text-slate-700">0</span>
</span>
</div>
</div>
</div>
<div className="flex flex-col gap-2">
<button className="px-3 py-1.5 text-sm text-white bg-purple-600 hover:bg-purple-700 rounded-lg">
Anfragen
</button>
<button className="px-3 py-1.5 text-sm text-slate-600 hover:text-slate-900 border border-slate-300 rounded-lg hover:border-slate-400">
Vorlage
</button>
</div>
</div>
</div>
))}
</div>
{/* DSR Request Statistics */}
<div className="mt-8 pt-6 border-t border-slate-200">
<h3 className="text-sm font-semibold text-slate-700 uppercase tracking-wider mb-4">DSR Übersicht</h3>
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<div className="bg-slate-50 rounded-lg p-4 text-center">
<div className="text-2xl font-bold text-slate-900">0</div>
<div className="text-xs text-slate-500 mt-1">Offen</div>
</div>
<div className="bg-green-50 rounded-lg p-4 text-center">
<div className="text-2xl font-bold text-green-700">0</div>
<div className="text-xs text-slate-500 mt-1">Erledigt</div>
</div>
<div className="bg-yellow-50 rounded-lg p-4 text-center">
<div className="text-2xl font-bold text-yellow-700">0</div>
<div className="text-xs text-slate-500 mt-1">In Bearbeitung</div>
</div>
<div className="bg-red-50 rounded-lg p-4 text-center">
<div className="text-2xl font-bold text-red-700">0</div>
<div className="text-xs text-slate-500 mt-1">Überfällig</div>
</div>
</div>
</div>
</div>
)}
{/* Stats Tab */}
{activeTab === 'stats' && (
<div className="p-6">
<h2 className="text-lg font-semibold text-slate-900 mb-6">Statistiken</h2>
<div className="grid grid-cols-1 md:grid-cols-3 gap-6 mb-8">
<div className="bg-slate-50 rounded-xl p-6">
<div className="text-3xl font-bold text-slate-900">0</div>
<div className="text-sm text-slate-500 mt-1">Aktive Zustimmungen</div>
</div>
<div className="bg-slate-50 rounded-xl p-6">
<div className="text-3xl font-bold text-slate-900">0</div>
<div className="text-sm text-slate-500 mt-1">Dokumente</div>
</div>
<div className="bg-slate-50 rounded-xl p-6">
<div className="text-3xl font-bold text-slate-900">0</div>
<div className="text-sm text-slate-500 mt-1">Offene DSR-Anfragen</div>
</div>
</div>
<div className="border border-slate-200 rounded-lg p-6">
<h3 className="font-semibold text-slate-900 mb-4">Zustimmungsrate nach Dokument</h3>
<div className="text-center py-8 text-slate-500">
Noch keine Daten verfügbar
</div>
</div>
</div>
{consent.activeTab === 'versions' && (
<VersionsTab
documents={consent.documents}
versions={consent.versions}
selectedDocument={consent.selectedDocument}
setSelectedDocument={consent.setSelectedDocument}
loading={consent.loading}
/>
)}
{consent.activeTab === 'emails' && <EmailsTab />}
{consent.activeTab === 'gdpr' && <GdprTab />}
{consent.activeTab === 'stats' && <StatsTab />}
</div>
</div>
</AdminLayout>

View File

@@ -0,0 +1,360 @@
'use client'
// =============================================================================
// Types
// =============================================================================
export type StepStatus = 'pending' | 'active' | 'completed' | 'failed'
export interface WizardStep {
id: string
name: string
icon: string
status: StepStatus
testable?: boolean
}
export interface TestResult {
name: string
status: 'passed' | 'failed' | 'pending'
message: string
details?: string
}
// =============================================================================
// Constants
// =============================================================================
export const BACKEND_URL = process.env.NEXT_PUBLIC_BACKEND_URL || 'http://localhost:8000'
export const GAME_URL = process.env.NEXT_PUBLIC_GAME_URL || 'http://localhost:3001'
export const STEPS: WizardStep[] = [
{ id: 'welcome', name: 'Willkommen', icon: '🎮', status: 'pending' },
{ id: 'overview', name: 'Dashboard Uebersicht', icon: '📊', status: 'pending' },
{ id: 'stats', name: 'Statistiken', icon: '📈', status: 'pending' },
{ id: 'leaderboard', name: 'Leaderboard', icon: '🏆', status: 'pending' },
{ id: 'webgl', name: 'WebGL Embedding', icon: '🎯', status: 'pending', testable: true },
{ id: 'api', name: 'API Integration', icon: '🔌', status: 'pending', testable: true },
{ id: 'quiz', name: 'Quiz-System', icon: '❓', status: 'pending' },
{ id: 'learning', name: 'Lernniveau', icon: '📚', status: 'pending' },
{ id: 'summary', name: 'Zusammenfassung', icon: '✅', status: 'pending' },
]
export const EDUCATION_CONTENT: Record<string, { title: string; content: string[]; tips?: string[] }> = {
'welcome': {
title: 'Willkommen bei Breakpilot Drive!',
content: [
'Breakpilot Drive ist ein **Endless Runner Lernspiel** fuer Schueler der Klassen 2-6.',
'Das Spiel kombiniert Spielspass mit Lernen:',
'• Fahre so weit wie moeglich',
'• Beantworte Quiz-Fragen um Punkte zu sammeln',
'• Das System passt sich automatisch an dein Lernniveau an',
'In diesem Wizard lernst du alle Admin-Features kennen und testest die Integration.',
],
tips: [
'Das Spiel laeuft auf Port 3001 als Unity WebGL Build',
'Die API-Endpoints sind unter /api/game/* verfuegbar',
],
},
'overview': {
title: 'Das Game Dashboard',
content: [
'Das Dashboard unter **/admin/game** bietet drei Hauptbereiche:',
'**1. Uebersicht-Tab:**',
'• Statistik-Karten mit KPIs (Spieler, Sessions, Genauigkeit)',
'• Top 5 Leaderboard',
'• Schnellzugriff-Buttons',
'**2. Spielen-Tab:**',
'• Embedded WebGL Game im iframe',
'• Direkt im Admin-Panel spielbar',
'**3. Statistiken-Tab:**',
'• Genauigkeit nach Fach',
'• Aktivitaets-Feed',
'• Lernniveau-Verteilung',
],
tips: [
'Die Tabs sind oben im Dashboard als Buttons sichtbar',
'Klicke auf "Aktualisieren" um die neuesten Daten zu laden',
],
},
'stats': {
title: 'Statistiken verstehen',
content: [
'Die Statistik-Karten zeigen wichtige KPIs:',
'**Aktive Spieler heute:** Anzahl der Spieler mit mindestens einer Session heute',
'**Spielsessions:** Gesamtzahl aller abgeschlossenen Spielsessions',
'**Quiz-Fragen beantwortet:** Kumulative Anzahl beantworteter Fragen',
'**Durchschnittliche Genauigkeit:** Prozent der richtig beantworteten Fragen',
'**Gesamte Spielzeit:** Summe aller Spielzeiten in Stunden',
'Trends zeigen die Veraenderung zur Vorwoche.',
],
tips: ['Gruene Trends = Verbesserung', 'Rote Trends = Bereich mit Aufmerksamkeitsbedarf'],
},
'leaderboard': {
title: 'Leaderboard & Gamification',
content: [
'Das Leaderboard motiviert Schueler durch:',
'**Ranking:** Top 5 Spieler nach Gesamtpunktzahl',
'**Goldene Medaille:** Platz 1 ist besonders hervorgehoben',
'**Genauigkeit:** Zeigt wie viele Fragen richtig beantwortet wurden',
'Spaeter kommen hinzu:',
'• Klassen-Ranglisten',
'• Wochen/Monats-Ranglisten',
'• Achievements & Badges',
],
tips: ['Leaderboards koennen pro Klasse oder schulweit sein', 'Datenschutz: Nur Vornamen + erster Buchstabe des Nachnamens werden gezeigt'],
},
'webgl': {
title: 'WebGL Embedding',
content: [
'Das Spiel wird als **Unity WebGL Build** eingebettet:',
'**Technologie:**', '• Unity 6 (Version 6000.0)', '• Universal Render Pipeline (URP)', '• WebAssembly (WASM) fuer Performance',
'**Embedding:**', '• Das Spiel laeuft in einem iframe auf Port 3001', '• Parameter wie ?embed=true optimieren fuer Einbettung', '• Fullscreen und Gamepad werden unterstuetzt',
'**Wichtig:** Der Game-Container muss laufen:', '`docker-compose --profile game up -d`',
],
tips: ['Bei Ladefehlern: Container-Status pruefen', 'CORS muss korrekt konfiguriert sein'],
},
'api': {
title: 'API Integration',
content: [
'Die Game API stellt folgende Endpoints bereit:',
'**GET /api/game/learning-level**', '→ Aktuelles Lernniveau des Spielers',
'**GET /api/game/questions?difficulty=3&count=5**', '→ Quiz-Fragen basierend auf Schwierigkeit',
'**POST /api/game/session**', '→ Spielsession speichern (Score, Zeit, Antworten)',
'**GET /api/game/achievements**', '→ Freigeschaltete Achievements',
'**GET /api/game/leaderboard?limit=10**', '→ Top-Spieler Rangliste',
],
tips: ['Alle Endpoints erfordern JWT-Token in Produktion', 'Im Dev-Modus ist Auth optional'],
},
'quiz': {
title: 'Das Quiz-System',
content: [
'Quiz-Fragen erscheinen waehrend des Spiels:',
'**Quick-Modus (5 Sekunden):**', '• 2-3 Antwortmoeglichkeiten', '• Wird durch visuelle Trigger ausgeloest (Bruecke, Baum)', '• Schnelle Punkte bei richtiger Antwort',
'**Pause-Modus (unbegrenzt):**', '• 4 Antwortmoeglichkeiten', '• Spieler kann nachdenken', '• Mehr Punkte moeglich',
'**Faecher:** Mathematik, Deutsch, Englisch', '**LLM-Generierung:** Fragen werden dynamisch erstellt',
],
tips: ['Fragen werden im Valkey-Cache gespeichert', 'Schwierigkeit passt sich automatisch an'],
},
'learning': {
title: 'Adaptives Lernniveau',
content: [
'Das System passt sich automatisch an:',
'**5 Lernstufen:**', '• Level 1: Klasse 2-3 (Beginner)', '• Level 2: Klasse 3-4 (Elementary)', '• Level 3: Klasse 4-5 (Intermediate)', '• Level 4: Klasse 5-6 (Advanced)', '• Level 5: Klasse 6+ (Expert)',
'**Anpassung:**', '• ≥80% richtig ueber 10 Fragen → Level Up', '• <40% richtig ueber 5 Fragen → Level Down', '• Schwache Faecher werden identifiziert',
'**State Engine:** Nutzt die bestehende Breakpilot State Machine',
],
tips: ['Eltern sehen das Niveau ihrer Kinder im Dashboard', 'Lehrer sehen Klassen-Durchschnitte'],
},
'summary': {
title: 'Zusammenfassung',
content: [
'Du hast alle Breakpilot Drive Features kennengelernt:',
'✅ Dashboard mit Uebersicht, Spielen, Statistiken', '✅ Statistik-Karten und KPIs', '✅ Leaderboard & Gamification',
'✅ WebGL Embedding', '✅ API Integration', '✅ Quiz-System mit Quick/Pause-Modus', '✅ Adaptives Lernniveau',
'**Naechste Schritte:**', '• Teste das Dashboard unter /admin/game', '• Starte den Game-Container', '• Spiele eine Runde im "Spielen"-Tab',
],
tips: ['Bei Fragen: Siehe docs/breakpilot-drive/README.md', 'API-Doku: docs/breakpilot-drive/architecture.md'],
},
}
// =============================================================================
// WizardStepper
// =============================================================================
export function WizardStepper({
steps, currentStep, onStepClick
}: {
steps: WizardStep[]
currentStep: number
onStepClick: (index: number) => void
}) {
return (
<div className="flex items-center justify-between mb-8 overflow-x-auto pb-4">
{steps.map((step, index) => (
<div key={step.id} className="flex items-center">
<button
onClick={() => onStepClick(index)}
className={`flex flex-col items-center min-w-[80px] p-2 rounded-lg transition-colors ${
index === currentStep
? 'bg-primary-100 text-primary-700'
: step.status === 'completed'
? 'bg-green-100 text-green-700 cursor-pointer hover:bg-green-200'
: step.status === 'failed'
? 'bg-red-100 text-red-700 cursor-pointer hover:bg-red-200'
: 'text-slate-400 hover:bg-slate-100'
}`}
>
<span className="text-2xl mb-1">{step.icon}</span>
<span className="text-xs font-medium text-center">{step.name}</span>
{step.status === 'completed' && <span className="text-xs text-green-600"></span>}
{step.status === 'failed' && <span className="text-xs text-red-600"></span>}
</button>
{index < steps.length - 1 && (
<div className={`h-0.5 w-8 mx-1 ${index < currentStep ? 'bg-green-400' : 'bg-slate-200'}`} />
)}
</div>
))}
</div>
)
}
// =============================================================================
// EducationCard
// =============================================================================
export function EducationCard({ stepId }: { stepId: string }) {
const content = EDUCATION_CONTENT[stepId]
if (!content) return null
return (
<div className="bg-primary-50 border border-primary-200 rounded-lg p-6 mb-6">
<h3 className="text-lg font-semibold text-primary-800 mb-4 flex items-center">
<span className="mr-2">📖</span>
{content.title}
</h3>
<div className="space-y-2 text-primary-900">
{content.content.map((line, index) => (
<p
key={index}
className={`${line.startsWith('•') ? 'ml-4' : ''} ${line.startsWith('**') ? 'font-semibold mt-3' : ''}`}
dangerouslySetInnerHTML={{
__html: line
.replace(/\*\*(.*?)\*\*/g, '<strong>$1</strong>')
.replace(/`(.*?)`/g, '<code class="bg-primary-100 px-1 rounded">$1</code>')
}}
/>
))}
</div>
{content.tips && content.tips.length > 0 && (
<div className="mt-4 pt-4 border-t border-primary-200">
<p className="text-sm font-semibold text-primary-700 mb-2">💡 Tipps:</p>
{content.tips.map((tip, index) => (
<p key={index} className="text-sm text-primary-700 ml-4"> {tip}</p>
))}
</div>
)}
</div>
)
}
// =============================================================================
// TestResultCard
// =============================================================================
export function TestResultCard({ results }: { results: TestResult[] }) {
return (
<div className="bg-white border border-slate-200 rounded-lg p-4 mb-4">
<h4 className="font-semibold text-slate-800 mb-3">Test-Ergebnisse</h4>
<div className="space-y-2">
{results.map((result, index) => (
<div
key={index}
className={`flex items-center justify-between p-3 rounded-lg ${
result.status === 'passed' ? 'bg-green-50' :
result.status === 'failed' ? 'bg-red-50' : 'bg-slate-50'
}`}
>
<div className="flex items-center gap-2">
<span className={`text-lg ${
result.status === 'passed' ? 'text-green-600' :
result.status === 'failed' ? 'text-red-600' : 'text-slate-400'
}`}>
{result.status === 'passed' ? '✓' : result.status === 'failed' ? '✗' : '○'}
</span>
<div>
<p className="font-medium text-slate-800">{result.name}</p>
<p className="text-sm text-slate-600">{result.message}</p>
</div>
</div>
{result.details && (
<code className="text-xs bg-slate-100 px-2 py-1 rounded">{result.details}</code>
)}
</div>
))}
</div>
</div>
)
}
// =============================================================================
// InteractiveDemo
// =============================================================================
export function InteractiveDemo({ stepId }: { stepId: string }) {
if (stepId === 'stats') {
return (
<div className="bg-white border border-slate-200 rounded-lg p-4 mb-4">
<h4 className="font-semibold text-slate-800 mb-3">Live-Vorschau: Statistik-Karten</h4>
<div className="grid grid-cols-3 gap-4">
{[
{ label: 'Aktive Spieler', value: '42', icon: '👥', color: 'blue' },
{ label: 'Genauigkeit', value: '78%', icon: '✓', color: 'green' },
{ label: 'Spielzeit', value: '156h', icon: '⏱️', color: 'purple' },
].map((stat) => (
<div key={stat.label} className={`bg-${stat.color}-50 rounded-lg p-4 text-center`}>
<span className="text-2xl">{stat.icon}</span>
<p className="text-2xl font-bold mt-1">{stat.value}</p>
<p className="text-sm text-slate-600">{stat.label}</p>
</div>
))}
</div>
</div>
)
}
if (stepId === 'leaderboard') {
return (
<div className="bg-white border border-slate-200 rounded-lg p-4 mb-4">
<h4 className="font-semibold text-slate-800 mb-3">Live-Vorschau: Leaderboard</h4>
<div className="space-y-2">
{[
{ rank: 1, name: 'Max M.', score: 25000, color: 'yellow' },
{ rank: 2, name: 'Lisa K.', score: 23500, color: 'slate' },
{ rank: 3, name: 'Tim S.', score: 21000, color: 'orange' },
].map((entry) => (
<div key={entry.rank} className="flex items-center justify-between py-2 border-b border-slate-100">
<div className="flex items-center gap-3">
<span className={`w-8 h-8 rounded-full bg-${entry.color}-100 flex items-center justify-center font-bold`}>
{entry.rank}
</span>
<span className="font-medium">{entry.name}</span>
</div>
<span className="text-slate-600">{entry.score.toLocaleString()} Punkte</span>
</div>
))}
</div>
</div>
)
}
if (stepId === 'learning') {
return (
<div className="bg-white border border-slate-200 rounded-lg p-4 mb-4">
<h4 className="font-semibold text-slate-800 mb-3">Live-Vorschau: Lernniveau</h4>
<div className="space-y-3">
{[
{ subject: 'Mathematik', level: 3.2, color: 'blue' },
{ subject: 'Deutsch', level: 2.8, color: 'green' },
{ subject: 'Englisch', level: 3.5, color: 'purple' },
].map((item) => (
<div key={item.subject}>
<div className="flex justify-between text-sm mb-1">
<span>{item.subject}</span>
<span className="font-medium">Level {item.level.toFixed(1)}</span>
</div>
<div className="h-3 bg-slate-100 rounded-full overflow-hidden">
<div
className={`h-full bg-${item.color}-500 rounded-full`}
style={{ width: `${(item.level / 5) * 100}%` }}
/>
</div>
</div>
))}
</div>
</div>
)
}
return null
}

View File

@@ -1,431 +1,11 @@
'use client'
/**
* Breakpilot Drive - Test & Lern-Wizard
*
* Interaktiver Wizard zum Kennenlernen aller Breakpilot Drive Features:
* - Game Dashboard Funktionen
* - API Integration
* - WebGL Embedding
* - Quiz-System
* - Lernniveau-Anpassung
* - Statistiken & Leaderboards
*/
import { useState, useEffect, useCallback } from 'react'
import { useState, useCallback } from 'react'
import Link from 'next/link'
// ==============================================
// Types
// ==============================================
type StepStatus = 'pending' | 'active' | 'completed' | 'failed'
interface WizardStep {
id: string
name: string
icon: string
status: StepStatus
testable?: boolean
}
interface TestResult {
name: string
status: 'passed' | 'failed' | 'pending'
message: string
details?: string
}
// ==============================================
// Constants
// ==============================================
const BACKEND_URL = process.env.NEXT_PUBLIC_BACKEND_URL || 'http://localhost:8000'
const GAME_URL = process.env.NEXT_PUBLIC_GAME_URL || 'http://localhost:3001'
const STEPS: WizardStep[] = [
{ id: 'welcome', name: 'Willkommen', icon: '🎮', status: 'pending' },
{ id: 'overview', name: 'Dashboard Uebersicht', icon: '📊', status: 'pending' },
{ id: 'stats', name: 'Statistiken', icon: '📈', status: 'pending' },
{ id: 'leaderboard', name: 'Leaderboard', icon: '🏆', status: 'pending' },
{ id: 'webgl', name: 'WebGL Embedding', icon: '🎯', status: 'pending', testable: true },
{ id: 'api', name: 'API Integration', icon: '🔌', status: 'pending', testable: true },
{ id: 'quiz', name: 'Quiz-System', icon: '❓', status: 'pending' },
{ id: 'learning', name: 'Lernniveau', icon: '📚', status: 'pending' },
{ id: 'summary', name: 'Zusammenfassung', icon: '✅', status: 'pending' },
]
const EDUCATION_CONTENT: Record<string, { title: string; content: string[]; tips?: string[] }> = {
'welcome': {
title: 'Willkommen bei Breakpilot Drive!',
content: [
'Breakpilot Drive ist ein **Endless Runner Lernspiel** fuer Schueler der Klassen 2-6.',
'Das Spiel kombiniert Spielspass mit Lernen:',
'• Fahre so weit wie moeglich',
'• Beantworte Quiz-Fragen um Punkte zu sammeln',
'• Das System passt sich automatisch an dein Lernniveau an',
'In diesem Wizard lernst du alle Admin-Features kennen und testest die Integration.',
],
tips: [
'Das Spiel laeuft auf Port 3001 als Unity WebGL Build',
'Die API-Endpoints sind unter /api/game/* verfuegbar',
],
},
'overview': {
title: 'Das Game Dashboard',
content: [
'Das Dashboard unter **/admin/game** bietet drei Hauptbereiche:',
'**1. Uebersicht-Tab:**',
'• Statistik-Karten mit KPIs (Spieler, Sessions, Genauigkeit)',
'• Top 5 Leaderboard',
'• Schnellzugriff-Buttons',
'**2. Spielen-Tab:**',
'• Embedded WebGL Game im iframe',
'• Direkt im Admin-Panel spielbar',
'**3. Statistiken-Tab:**',
'• Genauigkeit nach Fach',
'• Aktivitaets-Feed',
'• Lernniveau-Verteilung',
],
tips: [
'Die Tabs sind oben im Dashboard als Buttons sichtbar',
'Klicke auf "Aktualisieren" um die neuesten Daten zu laden',
],
},
'stats': {
title: 'Statistiken verstehen',
content: [
'Die Statistik-Karten zeigen wichtige KPIs:',
'**Aktive Spieler heute:** Anzahl der Spieler mit mindestens einer Session heute',
'**Spielsessions:** Gesamtzahl aller abgeschlossenen Spielsessions',
'**Quiz-Fragen beantwortet:** Kumulative Anzahl beantworteter Fragen',
'**Durchschnittliche Genauigkeit:** Prozent der richtig beantworteten Fragen',
'**Gesamte Spielzeit:** Summe aller Spielzeiten in Stunden',
'Trends zeigen die Veraenderung zur Vorwoche.',
],
tips: [
'Gruene Trends = Verbesserung',
'Rote Trends = Bereich mit Aufmerksamkeitsbedarf',
],
},
'leaderboard': {
title: 'Leaderboard & Gamification',
content: [
'Das Leaderboard motiviert Schueler durch:',
'**Ranking:** Top 5 Spieler nach Gesamtpunktzahl',
'**Goldene Medaille:** Platz 1 ist besonders hervorgehoben',
'**Genauigkeit:** Zeigt wie viele Fragen richtig beantwortet wurden',
'Spaeter kommen hinzu:',
'• Klassen-Ranglisten',
'• Wochen/Monats-Ranglisten',
'• Achievements & Badges',
],
tips: [
'Leaderboards koennen pro Klasse oder schulweit sein',
'Datenschutz: Nur Vornamen + erster Buchstabe des Nachnamens werden gezeigt',
],
},
'webgl': {
title: 'WebGL Embedding',
content: [
'Das Spiel wird als **Unity WebGL Build** eingebettet:',
'**Technologie:**',
'• Unity 6 (Version 6000.0)',
'• Universal Render Pipeline (URP)',
'• WebAssembly (WASM) fuer Performance',
'**Embedding:**',
'• Das Spiel laeuft in einem iframe auf Port 3001',
'• Parameter wie ?embed=true optimieren fuer Einbettung',
'• Fullscreen und Gamepad werden unterstuetzt',
'**Wichtig:** Der Game-Container muss laufen:',
'`docker-compose --profile game up -d`',
],
tips: [
'Bei Ladefehlern: Container-Status pruefen',
'CORS muss korrekt konfiguriert sein',
],
},
'api': {
title: 'API Integration',
content: [
'Die Game API stellt folgende Endpoints bereit:',
'**GET /api/game/learning-level**',
'→ Aktuelles Lernniveau des Spielers',
'**GET /api/game/questions?difficulty=3&count=5**',
'→ Quiz-Fragen basierend auf Schwierigkeit',
'**POST /api/game/session**',
'→ Spielsession speichern (Score, Zeit, Antworten)',
'**GET /api/game/achievements**',
'→ Freigeschaltete Achievements',
'**GET /api/game/leaderboard?limit=10**',
'→ Top-Spieler Rangliste',
],
tips: [
'Alle Endpoints erfordern JWT-Token in Produktion',
'Im Dev-Modus ist Auth optional',
],
},
'quiz': {
title: 'Das Quiz-System',
content: [
'Quiz-Fragen erscheinen waehrend des Spiels:',
'**Quick-Modus (5 Sekunden):**',
'• 2-3 Antwortmoeglichkeiten',
'• Wird durch visuelle Trigger ausgeloest (Bruecke, Baum)',
'• Schnelle Punkte bei richtiger Antwort',
'**Pause-Modus (unbegrenzt):**',
'• 4 Antwortmoeglichkeiten',
'• Spieler kann nachdenken',
'• Mehr Punkte moeglich',
'**Faecher:** Mathematik, Deutsch, Englisch',
'**LLM-Generierung:** Fragen werden dynamisch erstellt',
],
tips: [
'Fragen werden im Valkey-Cache gespeichert',
'Schwierigkeit passt sich automatisch an',
],
},
'learning': {
title: 'Adaptives Lernniveau',
content: [
'Das System passt sich automatisch an:',
'**5 Lernstufen:**',
'• Level 1: Klasse 2-3 (Beginner)',
'• Level 2: Klasse 3-4 (Elementary)',
'• Level 3: Klasse 4-5 (Intermediate)',
'• Level 4: Klasse 5-6 (Advanced)',
'• Level 5: Klasse 6+ (Expert)',
'**Anpassung:**',
'• ≥80% richtig ueber 10 Fragen → Level Up',
'• <40% richtig ueber 5 Fragen → Level Down',
'• Schwache Faecher werden identifiziert',
'**State Engine:** Nutzt die bestehende Breakpilot State Machine',
],
tips: [
'Eltern sehen das Niveau ihrer Kinder im Dashboard',
'Lehrer sehen Klassen-Durchschnitte',
],
},
'summary': {
title: 'Zusammenfassung',
content: [
'Du hast alle Breakpilot Drive Features kennengelernt:',
'✅ Dashboard mit Uebersicht, Spielen, Statistiken',
'✅ Statistik-Karten und KPIs',
'✅ Leaderboard & Gamification',
'✅ WebGL Embedding',
'✅ API Integration',
'✅ Quiz-System mit Quick/Pause-Modus',
'✅ Adaptives Lernniveau',
'**Naechste Schritte:**',
'• Teste das Dashboard unter /admin/game',
'• Starte den Game-Container',
'• Spiele eine Runde im "Spielen"-Tab',
],
tips: [
'Bei Fragen: Siehe docs/breakpilot-drive/README.md',
'API-Doku: docs/breakpilot-drive/architecture.md',
],
},
}
// ==============================================
// Components
// ==============================================
function WizardStepper({
steps,
currentStep,
onStepClick
}: {
steps: WizardStep[]
currentStep: number
onStepClick: (index: number) => void
}) {
return (
<div className="flex items-center justify-between mb-8 overflow-x-auto pb-4">
{steps.map((step, index) => (
<div key={step.id} className="flex items-center">
<button
onClick={() => onStepClick(index)}
className={`flex flex-col items-center min-w-[80px] p-2 rounded-lg transition-colors ${
index === currentStep
? 'bg-primary-100 text-primary-700'
: step.status === 'completed'
? 'bg-green-100 text-green-700 cursor-pointer hover:bg-green-200'
: step.status === 'failed'
? 'bg-red-100 text-red-700 cursor-pointer hover:bg-red-200'
: 'text-slate-400 hover:bg-slate-100'
}`}
>
<span className="text-2xl mb-1">{step.icon}</span>
<span className="text-xs font-medium text-center">{step.name}</span>
{step.status === 'completed' && <span className="text-xs text-green-600"></span>}
{step.status === 'failed' && <span className="text-xs text-red-600"></span>}
</button>
{index < steps.length - 1 && (
<div className={`h-0.5 w-8 mx-1 ${
index < currentStep ? 'bg-green-400' : 'bg-slate-200'
}`} />
)}
</div>
))}
</div>
)
}
function EducationCard({ stepId }: { stepId: string }) {
const content = EDUCATION_CONTENT[stepId]
if (!content) return null
return (
<div className="bg-primary-50 border border-primary-200 rounded-lg p-6 mb-6">
<h3 className="text-lg font-semibold text-primary-800 mb-4 flex items-center">
<span className="mr-2">📖</span>
{content.title}
</h3>
<div className="space-y-2 text-primary-900">
{content.content.map((line, index) => (
<p
key={index}
className={`${line.startsWith('•') ? 'ml-4' : ''} ${line.startsWith('**') ? 'font-semibold mt-3' : ''}`}
dangerouslySetInnerHTML={{
__html: line
.replace(/\*\*(.*?)\*\*/g, '<strong>$1</strong>')
.replace(/`(.*?)`/g, '<code class="bg-primary-100 px-1 rounded">$1</code>')
}}
/>
))}
</div>
{content.tips && content.tips.length > 0 && (
<div className="mt-4 pt-4 border-t border-primary-200">
<p className="text-sm font-semibold text-primary-700 mb-2">💡 Tipps:</p>
{content.tips.map((tip, index) => (
<p key={index} className="text-sm text-primary-700 ml-4"> {tip}</p>
))}
</div>
)}
</div>
)
}
function TestResultCard({ results }: { results: TestResult[] }) {
return (
<div className="bg-white border border-slate-200 rounded-lg p-4 mb-4">
<h4 className="font-semibold text-slate-800 mb-3">Test-Ergebnisse</h4>
<div className="space-y-2">
{results.map((result, index) => (
<div
key={index}
className={`flex items-center justify-between p-3 rounded-lg ${
result.status === 'passed' ? 'bg-green-50' :
result.status === 'failed' ? 'bg-red-50' : 'bg-slate-50'
}`}
>
<div className="flex items-center gap-2">
<span className={`text-lg ${
result.status === 'passed' ? 'text-green-600' :
result.status === 'failed' ? 'text-red-600' : 'text-slate-400'
}`}>
{result.status === 'passed' ? '✓' : result.status === 'failed' ? '✗' : '○'}
</span>
<div>
<p className="font-medium text-slate-800">{result.name}</p>
<p className="text-sm text-slate-600">{result.message}</p>
</div>
</div>
{result.details && (
<code className="text-xs bg-slate-100 px-2 py-1 rounded">{result.details}</code>
)}
</div>
))}
</div>
</div>
)
}
function InteractiveDemo({ stepId }: { stepId: string }) {
// Step-specific interactive demos
if (stepId === 'stats') {
return (
<div className="bg-white border border-slate-200 rounded-lg p-4 mb-4">
<h4 className="font-semibold text-slate-800 mb-3">Live-Vorschau: Statistik-Karten</h4>
<div className="grid grid-cols-3 gap-4">
{[
{ label: 'Aktive Spieler', value: '42', icon: '👥', color: 'blue' },
{ label: 'Genauigkeit', value: '78%', icon: '✓', color: 'green' },
{ label: 'Spielzeit', value: '156h', icon: '⏱️', color: 'purple' },
].map((stat) => (
<div key={stat.label} className={`bg-${stat.color}-50 rounded-lg p-4 text-center`}>
<span className="text-2xl">{stat.icon}</span>
<p className="text-2xl font-bold mt-1">{stat.value}</p>
<p className="text-sm text-slate-600">{stat.label}</p>
</div>
))}
</div>
</div>
)
}
if (stepId === 'leaderboard') {
return (
<div className="bg-white border border-slate-200 rounded-lg p-4 mb-4">
<h4 className="font-semibold text-slate-800 mb-3">Live-Vorschau: Leaderboard</h4>
<div className="space-y-2">
{[
{ rank: 1, name: 'Max M.', score: 25000, color: 'yellow' },
{ rank: 2, name: 'Lisa K.', score: 23500, color: 'slate' },
{ rank: 3, name: 'Tim S.', score: 21000, color: 'orange' },
].map((entry) => (
<div key={entry.rank} className="flex items-center justify-between py-2 border-b border-slate-100">
<div className="flex items-center gap-3">
<span className={`w-8 h-8 rounded-full bg-${entry.color}-100 flex items-center justify-center font-bold`}>
{entry.rank}
</span>
<span className="font-medium">{entry.name}</span>
</div>
<span className="text-slate-600">{entry.score.toLocaleString()} Punkte</span>
</div>
))}
</div>
</div>
)
}
if (stepId === 'learning') {
return (
<div className="bg-white border border-slate-200 rounded-lg p-4 mb-4">
<h4 className="font-semibold text-slate-800 mb-3">Live-Vorschau: Lernniveau</h4>
<div className="space-y-3">
{[
{ subject: 'Mathematik', level: 3.2, color: 'blue' },
{ subject: 'Deutsch', level: 2.8, color: 'green' },
{ subject: 'Englisch', level: 3.5, color: 'purple' },
].map((item) => (
<div key={item.subject}>
<div className="flex justify-between text-sm mb-1">
<span>{item.subject}</span>
<span className="font-medium">Level {item.level.toFixed(1)}</span>
</div>
<div className="h-3 bg-slate-100 rounded-full overflow-hidden">
<div
className={`h-full bg-${item.color}-500 rounded-full`}
style={{ width: `${(item.level / 5) * 100}%` }}
/>
</div>
</div>
))}
</div>
</div>
)
}
return null
}
// ==============================================
// Main Component
// ==============================================
import {
WizardStep, TestResult, STEPS, BACKEND_URL, GAME_URL,
WizardStepper, EducationCard, TestResultCard, InteractiveDemo,
} from './_components/WizardComponents'
export default function GameWizardPage() {
const [currentStep, setCurrentStep] = useState(0)
@@ -437,144 +17,71 @@ export default function GameWizardPage() {
const isWelcome = currentStepData?.id === 'welcome'
const isSummary = currentStepData?.id === 'summary'
// Test functions
const runWebGLTest = useCallback(async () => {
setIsLoading(true)
const results: TestResult[] = []
// Test 1: Game URL erreichbar
try {
const response = await fetch(GAME_URL, { mode: 'no-cors' })
results.push({
name: 'Game Server Erreichbarkeit',
status: 'passed',
message: 'Der Game-Server antwortet',
details: GAME_URL,
})
await fetch(GAME_URL, { mode: 'no-cors' })
results.push({ name: 'Game Server Erreichbarkeit', status: 'passed', message: 'Der Game-Server antwortet', details: GAME_URL })
} catch {
results.push({
name: 'Game Server Erreichbarkeit',
status: 'failed',
message: 'Game-Server nicht erreichbar. Container gestartet?',
details: 'docker-compose --profile game up -d',
})
results.push({ name: 'Game Server Erreichbarkeit', status: 'failed', message: 'Game-Server nicht erreichbar. Container gestartet?', details: 'docker-compose --profile game up -d' })
}
// Test 2: Iframe simulieren
results.push({
name: 'Iframe Embedding',
status: 'passed',
message: 'Iframe-Einbettung ist konfiguriert',
details: '?embed=true',
})
results.push({ name: 'Iframe Embedding', status: 'passed', message: 'Iframe-Einbettung ist konfiguriert', details: '?embed=true' })
setTestResults(results)
setIsLoading(false)
// Update step status
const allPassed = results.every(r => r.status === 'passed')
setSteps(prev => prev.map(s =>
s.id === 'webgl' ? { ...s, status: allPassed ? 'completed' : 'failed' } : s
))
setSteps(prev => prev.map(s => s.id === 'webgl' ? { ...s, status: allPassed ? 'completed' : 'failed' } : s))
}, [])
const runAPITest = useCallback(async () => {
setIsLoading(true)
const results: TestResult[] = []
// Test 1: Learning Level API
try {
const response = await fetch(`${BACKEND_URL}/api/game/learning-level?user_id=test-user`)
if (response.ok) {
const data = await response.json()
results.push({
name: 'Learning Level Endpoint',
status: 'passed',
message: 'API antwortet korrekt',
details: `Level: ${data.overall_level || 'Mock'}`,
})
results.push({ name: 'Learning Level Endpoint', status: 'passed', message: 'API antwortet korrekt', details: `Level: ${data.overall_level || 'Mock'}` })
} else {
results.push({
name: 'Learning Level Endpoint',
status: 'failed',
message: `HTTP ${response.status}`,
})
results.push({ name: 'Learning Level Endpoint', status: 'failed', message: `HTTP ${response.status}` })
}
} catch {
results.push({
name: 'Learning Level Endpoint',
status: 'failed',
message: 'Backend nicht erreichbar',
details: 'docker-compose up -d backend',
})
results.push({ name: 'Learning Level Endpoint', status: 'failed', message: 'Backend nicht erreichbar', details: 'docker-compose up -d backend' })
}
// Test 2: Questions API
try {
const response = await fetch(`${BACKEND_URL}/api/game/questions?difficulty=3&count=2`)
if (response.ok) {
const data = await response.json()
results.push({
name: 'Questions Endpoint',
status: 'passed',
message: 'Quiz-Fragen API funktioniert',
details: `${data.questions?.length || 0} Fragen`,
})
results.push({ name: 'Questions Endpoint', status: 'passed', message: 'Quiz-Fragen API funktioniert', details: `${data.questions?.length || 0} Fragen` })
} else {
results.push({
name: 'Questions Endpoint',
status: 'failed',
message: `HTTP ${response.status}`,
})
results.push({ name: 'Questions Endpoint', status: 'failed', message: `HTTP ${response.status}` })
}
} catch {
results.push({
name: 'Questions Endpoint',
status: 'failed',
message: 'Endpoint nicht erreichbar',
})
results.push({ name: 'Questions Endpoint', status: 'failed', message: 'Endpoint nicht erreichbar' })
}
// Test 3: Leaderboard API
try {
const response = await fetch(`${BACKEND_URL}/api/game/leaderboard?limit=5`)
if (response.ok) {
results.push({
name: 'Leaderboard Endpoint',
status: 'passed',
message: 'Leaderboard API funktioniert',
})
results.push({ name: 'Leaderboard Endpoint', status: 'passed', message: 'Leaderboard API funktioniert' })
} else {
results.push({
name: 'Leaderboard Endpoint',
status: 'failed',
message: `HTTP ${response.status}`,
})
results.push({ name: 'Leaderboard Endpoint', status: 'failed', message: `HTTP ${response.status}` })
}
} catch {
results.push({
name: 'Leaderboard Endpoint',
status: 'failed',
message: 'Endpoint nicht erreichbar',
})
results.push({ name: 'Leaderboard Endpoint', status: 'failed', message: 'Endpoint nicht erreichbar' })
}
setTestResults(results)
setIsLoading(false)
// Update step status
const passedCount = results.filter(r => r.status === 'passed').length
setSteps(prev => prev.map(s =>
s.id === 'api' ? { ...s, status: passedCount >= 2 ? 'completed' : 'failed' } : s
))
setSteps(prev => prev.map(s => s.id === 'api' ? { ...s, status: passedCount >= 2 ? 'completed' : 'failed' } : s))
}, [])
const goToNext = () => {
if (currentStep < steps.length - 1) {
setSteps(prev => prev.map((step, idx) =>
idx === currentStep && step.status === 'pending'
? { ...step, status: 'completed' }
: step
idx === currentStep && step.status === 'pending' ? { ...step, status: 'completed' } : step
))
setCurrentStep(prev => prev + 1)
setTestResults([])
@@ -601,14 +108,9 @@ export default function GameWizardPage() {
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-slate-800">🎮 Breakpilot Drive - Lern-Wizard</h1>
<p className="text-slate-600 mt-1">
Interaktive Tour durch alle Game-Features
</p>
<p className="text-slate-600 mt-1">Interaktive Tour durch alle Game-Features</p>
</div>
<Link
href="/admin/game"
className="text-primary-600 hover:text-primary-800 text-sm"
>
<Link href="/admin/game" className="text-primary-600 hover:text-primary-800 text-sm">
Zurueck zum Dashboard
</Link>
</div>
@@ -616,11 +118,7 @@ export default function GameWizardPage() {
{/* Stepper */}
<div className="bg-white rounded-lg shadow-lg p-6 mb-6">
<WizardStepper
steps={steps}
currentStep={currentStep}
onStepClick={handleStepClick}
/>
<WizardStepper steps={steps} currentStep={currentStep} onStepClick={handleStepClick} />
</div>
{/* Content */}
@@ -632,19 +130,14 @@ export default function GameWizardPage() {
<h2 className="text-xl font-bold text-slate-800">
Schritt {currentStep + 1}: {currentStepData?.name}
</h2>
<p className="text-slate-500 text-sm">
{currentStep + 1} von {steps.length}
</p>
<p className="text-slate-500 text-sm">{currentStep + 1} von {steps.length}</p>
</div>
</div>
{/* Education Card */}
<EducationCard stepId={currentStepData?.id || ''} />
{/* Interactive Demo */}
<InteractiveDemo stepId={currentStepData?.id || ''} />
{/* Test Section for testable steps */}
{/* Test Section */}
{currentStepData?.testable && (
<div className="mb-6">
{testResults.length === 0 ? (
@@ -653,9 +146,7 @@ export default function GameWizardPage() {
onClick={currentStepData.id === 'webgl' ? runWebGLTest : runAPITest}
disabled={isLoading}
className={`px-6 py-3 rounded-lg font-medium transition-colors ${
isLoading
? 'bg-slate-400 cursor-not-allowed'
: 'bg-green-600 text-white hover:bg-green-700'
isLoading ? 'bg-slate-400 cursor-not-allowed' : 'bg-green-600 text-white hover:bg-green-700'
}`}
>
{isLoading ? '⏳ Tests laufen...' : '🧪 Integration testen'}
@@ -670,10 +161,7 @@ export default function GameWizardPage() {
{/* Welcome Start Button */}
{isWelcome && (
<div className="text-center py-8">
<button
onClick={goToNext}
className="bg-primary-600 text-white px-8 py-3 rounded-lg font-medium hover:bg-primary-700 transition-colors"
>
<button onClick={goToNext} className="bg-primary-600 text-white px-8 py-3 rounded-lg font-medium hover:bg-primary-700 transition-colors">
🚀 Tour starten
</button>
</div>
@@ -683,17 +171,11 @@ export default function GameWizardPage() {
{isSummary && (
<div className="text-center py-6 space-y-4">
<div className="flex justify-center gap-4">
<Link
href="/admin/game"
className="px-6 py-3 bg-primary-600 text-white rounded-lg font-medium hover:bg-primary-700 transition-colors"
>
<Link href="/admin/game" className="px-6 py-3 bg-primary-600 text-white rounded-lg font-medium hover:bg-primary-700 transition-colors">
📊 Zum Dashboard
</Link>
<button
onClick={() => {
setCurrentStep(0)
setSteps(STEPS.map(s => ({ ...s, status: 'pending' })))
}}
onClick={() => { setCurrentStep(0); setSteps(STEPS.map(s => ({ ...s, status: 'pending' }))) }}
className="px-6 py-3 bg-slate-200 text-slate-700 rounded-lg font-medium hover:bg-slate-300 transition-colors"
>
🔄 Wizard neu starten
@@ -709,19 +191,13 @@ export default function GameWizardPage() {
onClick={goToPrev}
disabled={currentStep === 0}
className={`px-6 py-2 rounded-lg transition-colors ${
currentStep === 0
? 'bg-slate-200 text-slate-400 cursor-not-allowed'
: 'bg-slate-200 text-slate-700 hover:bg-slate-300'
currentStep === 0 ? 'bg-slate-200 text-slate-400 cursor-not-allowed' : 'bg-slate-200 text-slate-700 hover:bg-slate-300'
}`}
>
Zurueck
</button>
{!isSummary && (
<button
onClick={goToNext}
className="bg-primary-600 text-white px-6 py-2 rounded-lg hover:bg-primary-700 transition-colors"
>
<button onClick={goToNext} className="bg-primary-600 text-white px-6 py-2 rounded-lg hover:bg-primary-700 transition-colors">
Weiter
</button>
)}

View File

@@ -0,0 +1,78 @@
'use client'
import { MIDDLEWARE_INFO } from './types'
import type { MiddlewareHookReturn } from './types'
interface EventsTabProps {
hook: MiddlewareHookReturn
}
export default function EventsTab({ hook }: EventsTabProps) {
const { events } = hook
return (
<div className="space-y-6">
<div className="bg-white rounded-lg shadow overflow-hidden">
<div className="px-4 py-3 border-b border-gray-200 flex justify-between items-center">
<h3 className="text-lg font-medium">Recent Middleware Events</h3>
<button
onClick={hook.loadData}
className="text-sm text-blue-600 hover:text-blue-800"
>
Refresh
</button>
</div>
<table className="min-w-full divide-y divide-gray-200">
<thead className="bg-gray-50">
<tr>
<th className="px-4 py-2 text-left text-xs font-medium text-gray-500 uppercase">Time</th>
<th className="px-4 py-2 text-left text-xs font-medium text-gray-500 uppercase">Middleware</th>
<th className="px-4 py-2 text-left text-xs font-medium text-gray-500 uppercase">Event</th>
<th className="px-4 py-2 text-left text-xs font-medium text-gray-500 uppercase">IP</th>
<th className="px-4 py-2 text-left text-xs font-medium text-gray-500 uppercase">Path</th>
</tr>
</thead>
<tbody className="bg-white divide-y divide-gray-200">
{events.length === 0 ? (
<tr>
<td colSpan={5} className="px-4 py-4 text-center text-gray-500">
No events recorded yet
</td>
</tr>
) : (
events.map((event) => (
<tr key={event.id}>
<td className="px-4 py-2 text-sm text-gray-500">
{new Date(event.created_at).toLocaleString()}
</td>
<td className="px-4 py-2">
<span className="text-sm font-medium">
{MIDDLEWARE_INFO[event.middleware_name]?.name || event.middleware_name}
</span>
</td>
<td className="px-4 py-2">
<span className={`px-2 py-1 text-xs rounded-full ${
event.event_type.includes('blocked') || event.event_type.includes('exceeded')
? 'bg-red-100 text-red-800'
: event.event_type.includes('add') || event.event_type.includes('changed')
? 'bg-blue-100 text-blue-800'
: 'bg-gray-100 text-gray-800'
}`}>
{event.event_type}
</span>
</td>
<td className="px-4 py-2 font-mono text-sm text-gray-500">
{event.ip_address || '-'}
</td>
<td className="px-4 py-2 text-sm text-gray-500 truncate max-w-xs">
{event.request_path || '-'}
</td>
</tr>
))
)}
</tbody>
</table>
</div>
</div>
)
}

View File

@@ -0,0 +1,69 @@
'use client'
import type { MiddlewareHookReturn } from './types'
interface LoggingTabProps {
hook: MiddlewareHookReturn
}
export default function LoggingTab({ hook }: LoggingTabProps) {
return (
<div className="space-y-6">
<div className="bg-white rounded-lg shadow p-6">
<h3 className="text-lg font-medium mb-4">PII Redaction Settings</h3>
{(() => {
const config = hook.getConfig('pii_redactor')
if (!config) return <p>Loading...</p>
const patterns = config.config.patterns || ['email', 'ip_v4', 'ip_v6', 'phone_de']
const allPatterns = ['email', 'ip_v4', 'ip_v6', 'phone_de', 'phone_intl', 'iban', 'uuid', 'name_prefix', 'student_id']
return (
<div className="space-y-4">
<p className="text-sm text-gray-500">
Select which PII patterns to redact from logs (DSGVO compliance)
</p>
<div className="grid grid-cols-2 md:grid-cols-3 gap-2">
{allPatterns.map((pattern) => (
<label key={pattern} className="flex items-center space-x-2">
<input
type="checkbox"
checked={patterns.includes(pattern)}
onChange={(e) => {
const newPatterns = e.target.checked
? [...patterns, pattern]
: patterns.filter((p: string) => p !== pattern)
hook.updateConfig('pii_redactor', { ...config.config, patterns: newPatterns })
}}
className="h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded"
/>
<span className="text-sm">{pattern.replace('_', ' ')}</span>
</label>
))}
</div>
</div>
)
})()}
</div>
<div className="bg-white rounded-lg shadow p-6">
<h3 className="text-lg font-medium mb-4">Request-ID Configuration</h3>
{(() => {
const config = hook.getConfig('request_id')
if (!config) return <p>Loading...</p>
return (
<div>
<label className="block text-sm font-medium text-gray-700">Header Name</label>
<input
type="text"
value={config.config.header_name || 'X-Request-ID'}
onChange={(e) => {
hook.updateConfig('request_id', { ...config.config, header_name: e.target.value })
}}
className="mt-1 block w-full max-w-xs rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500"
/>
</div>
)
})()}
</div>
</div>
)
}

View File

@@ -0,0 +1,68 @@
'use client'
import { MIDDLEWARE_INFO } from './types'
import type { MiddlewareHookReturn } from './types'
interface OverviewTabProps {
hook: MiddlewareHookReturn
}
export default function OverviewTab({ hook }: OverviewTabProps) {
const { stats, saving } = hook
return (
<div className="space-y-6">
<h3 className="text-lg font-medium">Middleware Status</h3>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{Object.entries(MIDDLEWARE_INFO).map(([key, info]) => {
const config = hook.getConfig(key)
const mwStats = hook.getStats(key)
return (
<div key={key} className="bg-white rounded-lg shadow p-4">
<div className="flex items-center justify-between mb-2">
<div className="flex items-center">
<span className="text-2xl mr-2">{info.icon}</span>
<h4 className="font-medium">{info.name}</h4>
</div>
<button
onClick={() => config && hook.toggleMiddleware(key, !config.enabled)}
disabled={saving || !config}
className={`relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none ${
config?.enabled ? 'bg-green-500' : 'bg-gray-200'
}`}
>
<span
className={`pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out ${
config?.enabled ? 'translate-x-5' : 'translate-x-0'
}`}
/>
</button>
</div>
<p className="text-sm text-gray-500 mb-3">{info.description}</p>
{mwStats && (
<div className="text-xs text-gray-400">
<span className="mr-3">Last hour: {mwStats.events_last_hour} events</span>
<span>24h: {mwStats.events_last_24h} events</span>
</div>
)}
</div>
)
})}
</div>
{/* Quick Stats */}
<div className="mt-8">
<h3 className="text-lg font-medium mb-4">Activity Summary (24h)</h3>
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
{stats.map((s) => (
<div key={s.middleware_name} className="bg-gray-50 rounded-lg p-4">
<p className="text-sm text-gray-500">{MIDDLEWARE_INFO[s.middleware_name]?.name || s.middleware_name}</p>
<p className="text-2xl font-bold">{s.events_last_24h}</p>
<p className="text-xs text-gray-400">{s.events_last_hour} in last hour</p>
</div>
))}
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,150 @@
'use client'
import type { MiddlewareHookReturn } from './types'
interface RateLimitingTabProps {
hook: MiddlewareHookReturn
}
export default function RateLimitingTab({ hook }: RateLimitingTabProps) {
const { saving, ipList, newIP, setNewIP } = hook
return (
<div className="space-y-6">
{/* Rate Limit Config */}
<div className="bg-white rounded-lg shadow p-6">
<h3 className="text-lg font-medium mb-4">Rate Limit Settings</h3>
{(() => {
const config = hook.getConfig('rate_limiter')
if (!config) return <p>Loading...</p>
return (
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700">IP Limit (req/min)</label>
<input
type="number"
value={config.config.ip_limit || 100}
onChange={(e) => {
const newConfig = { ...config.config, ip_limit: parseInt(e.target.value) }
hook.updateConfig('rate_limiter', newConfig)
}}
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700">User Limit (req/min)</label>
<input
type="number"
value={config.config.user_limit || 500}
onChange={(e) => {
const newConfig = { ...config.config, user_limit: parseInt(e.target.value) }
hook.updateConfig('rate_limiter', newConfig)
}}
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700">Auth Limit (req/min)</label>
<input
type="number"
value={config.config.auth_limit || 20}
onChange={(e) => {
const newConfig = { ...config.config, auth_limit: parseInt(e.target.value) }
hook.updateConfig('rate_limiter', newConfig)
}}
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500"
/>
</div>
</div>
)
})()}
</div>
{/* IP Whitelist/Blacklist */}
<div className="bg-white rounded-lg shadow p-6">
<h3 className="text-lg font-medium mb-4">IP Whitelist / Blacklist</h3>
{/* Add IP Form */}
<div className="flex gap-2 mb-4">
<input
type="text"
placeholder="IP Address (e.g., 192.168.1.1)"
value={newIP.ip_address}
onChange={(e) => setNewIP({ ...newIP, ip_address: e.target.value })}
className="flex-1 rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500"
/>
<select
value={newIP.list_type}
onChange={(e) => setNewIP({ ...newIP, list_type: e.target.value as 'whitelist' | 'blacklist' })}
className="rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500"
>
<option value="whitelist">Whitelist</option>
<option value="blacklist">Blacklist</option>
</select>
<input
type="text"
placeholder="Reason (optional)"
value={newIP.reason}
onChange={(e) => setNewIP({ ...newIP, reason: e.target.value })}
className="flex-1 rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500"
/>
<button
onClick={hook.addIP}
disabled={saving || !newIP.ip_address}
className="px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 disabled:opacity-50"
>
Add
</button>
</div>
{/* IP List Table */}
<table className="min-w-full divide-y divide-gray-200">
<thead className="bg-gray-50">
<tr>
<th className="px-4 py-2 text-left text-xs font-medium text-gray-500 uppercase">IP Address</th>
<th className="px-4 py-2 text-left text-xs font-medium text-gray-500 uppercase">Type</th>
<th className="px-4 py-2 text-left text-xs font-medium text-gray-500 uppercase">Reason</th>
<th className="px-4 py-2 text-left text-xs font-medium text-gray-500 uppercase">Created</th>
<th className="px-4 py-2"></th>
</tr>
</thead>
<tbody className="bg-white divide-y divide-gray-200">
{ipList.length === 0 ? (
<tr>
<td colSpan={5} className="px-4 py-4 text-center text-gray-500">
No IPs in whitelist/blacklist
</td>
</tr>
) : (
ipList.map((ip) => (
<tr key={ip.id}>
<td className="px-4 py-2 font-mono text-sm">{ip.ip_address}</td>
<td className="px-4 py-2">
<span className={`px-2 py-1 text-xs rounded-full ${
ip.list_type === 'whitelist' ? 'bg-green-100 text-green-800' : 'bg-red-100 text-red-800'
}`}>
{ip.list_type}
</span>
</td>
<td className="px-4 py-2 text-sm text-gray-500">{ip.reason || '-'}</td>
<td className="px-4 py-2 text-sm text-gray-500">
{new Date(ip.created_at).toLocaleDateString()}
</td>
<td className="px-4 py-2">
<button
onClick={() => hook.removeIP(ip.id)}
disabled={saving}
className="text-red-600 hover:text-red-800"
>
Remove
</button>
</td>
</tr>
))
)}
</tbody>
</table>
</div>
</div>
)
}

View File

@@ -0,0 +1,109 @@
'use client'
import type { MiddlewareHookReturn } from './types'
interface SecurityTabProps {
hook: MiddlewareHookReturn
}
export default function SecurityTab({ hook }: SecurityTabProps) {
return (
<div className="space-y-6">
<div className="bg-white rounded-lg shadow p-6">
<h3 className="text-lg font-medium mb-4">Security Headers Configuration</h3>
{(() => {
const config = hook.getConfig('security_headers')
if (!config) return <p>Loading...</p>
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<div>
<p className="font-medium">HSTS (Strict-Transport-Security)</p>
<p className="text-sm text-gray-500">Force HTTPS connections</p>
</div>
<input
type="checkbox"
checked={config.config.hsts_enabled ?? true}
onChange={(e) => {
const newConfig = { ...config.config, hsts_enabled: e.target.checked }
hook.updateConfig('security_headers', newConfig)
}}
className="h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded"
/>
</div>
<div className="flex items-center justify-between">
<div>
<p className="font-medium">CSP (Content-Security-Policy)</p>
<p className="text-sm text-gray-500">Control resource loading</p>
</div>
<input
type="checkbox"
checked={config.config.csp_enabled ?? true}
onChange={(e) => {
const newConfig = { ...config.config, csp_enabled: e.target.checked }
hook.updateConfig('security_headers', newConfig)
}}
className="h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700">CSP Policy</label>
<textarea
value={config.config.csp_policy || "default-src 'self'"}
onChange={(e) => {
const newConfig = { ...config.config, csp_policy: e.target.value }
hook.updateConfig('security_headers', newConfig)
}}
rows={3}
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 font-mono text-sm"
/>
</div>
</div>
)
})()}
</div>
<div className="bg-white rounded-lg shadow p-6">
<h3 className="text-lg font-medium mb-4">Input Gate Configuration</h3>
{(() => {
const config = hook.getConfig('input_gate')
if (!config) return <p>Loading...</p>
return (
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700">Max Body Size (bytes)</label>
<input
type="number"
value={config.config.max_body_size || 10485760}
onChange={(e) => {
const newConfig = { ...config.config, max_body_size: parseInt(e.target.value) }
hook.updateConfig('input_gate', newConfig)
}}
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500"
/>
<p className="text-xs text-gray-500 mt-1">
{((config.config.max_body_size || 10485760) / 1024 / 1024).toFixed(1)} MB
</p>
</div>
<div>
<label className="block text-sm font-medium text-gray-700">Max File Size (bytes)</label>
<input
type="number"
value={config.config.max_file_size || 52428800}
onChange={(e) => {
const newConfig = { ...config.config, max_file_size: parseInt(e.target.value) }
hook.updateConfig('input_gate', newConfig)
}}
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500"
/>
<p className="text-xs text-gray-500 mt-1">
{((config.config.max_file_size || 52428800) / 1024 / 1024).toFixed(1)} MB
</p>
</div>
</div>
)
})()}
</div>
</div>
)
}

View File

@@ -0,0 +1,100 @@
export interface MiddlewareConfig {
id: string
middleware_name: string
enabled: boolean
config: Record<string, any>
updated_at: string | null
}
export interface RateLimitIP {
id: string
ip_address: string
list_type: 'whitelist' | 'blacklist'
reason: string | null
expires_at: string | null
created_at: string
}
export interface MiddlewareEvent {
id: string
middleware_name: string
event_type: string
ip_address: string | null
user_id: string | null
request_path: string | null
request_method: string | null
details: Record<string, any> | null
created_at: string
}
export interface MiddlewareStats {
middleware_name: string
total_events: number
events_last_hour: number
events_last_24h: number
top_event_types: Array<{ event_type: string; count: number }>
top_ips: Array<{ ip_address: string; count: number }>
}
export type TabType = 'overview' | 'rate-limiting' | 'security' | 'logging' | 'events'
export const MIDDLEWARE_INFO: Record<string, { name: string; description: string; icon: string }> = {
request_id: {
name: 'Request-ID',
description: 'Generates unique identifiers for request tracing',
icon: '🔑',
},
security_headers: {
name: 'Security Headers',
description: 'Adds security headers (HSTS, CSP, X-Frame-Options)',
icon: '🛡️',
},
cors: {
name: 'CORS',
description: 'Cross-Origin Resource Sharing configuration',
icon: '🌐',
},
rate_limiter: {
name: 'Rate Limiter',
description: 'Protects against abuse with request limits',
icon: '⏱️',
},
pii_redactor: {
name: 'PII Redactor',
description: 'Redacts sensitive data from logs (DSGVO)',
icon: '🔒',
},
input_gate: {
name: 'Input Gate',
description: 'Validates request body size and content types',
icon: '🚧',
},
}
export const TABS = [
{ id: 'overview', label: 'Overview', icon: '📊' },
{ id: 'rate-limiting', label: 'Rate Limiting', icon: '⏱️' },
{ id: 'security', label: 'Security', icon: '🛡️' },
{ id: 'logging', label: 'Logging', icon: '📝' },
{ id: 'events', label: 'Events', icon: '📋' },
]
export interface MiddlewareHookReturn {
activeTab: TabType
setActiveTab: (tab: TabType) => void
configs: MiddlewareConfig[]
ipList: RateLimitIP[]
events: MiddlewareEvent[]
stats: MiddlewareStats[]
loading: boolean
saving: boolean
newIP: { ip_address: string; list_type: string; reason: string }
setNewIP: (ip: { ip_address: string; list_type: string; reason: string }) => void
loadData: () => Promise<void>
toggleMiddleware: (name: string, enabled: boolean) => Promise<void>
updateConfig: (name: string, config: Record<string, any>) => Promise<void>
addIP: () => Promise<void>
removeIP: (id: string) => Promise<void>
getConfig: (name: string) => MiddlewareConfig | undefined
getStats: (name: string) => MiddlewareStats | undefined
}

View File

@@ -0,0 +1,128 @@
'use client'
import { useState, useEffect } from 'react'
import type { MiddlewareConfig, RateLimitIP, MiddlewareEvent, MiddlewareStats, TabType, MiddlewareHookReturn } from './types'
const BACKEND_URL = process.env.NEXT_PUBLIC_BACKEND_URL || 'http://localhost:8000'
export function useMiddleware(): MiddlewareHookReturn {
const [activeTab, setActiveTab] = useState<TabType>('overview')
const [configs, setConfigs] = useState<MiddlewareConfig[]>([])
const [ipList, setIpList] = useState<RateLimitIP[]>([])
const [events, setEvents] = useState<MiddlewareEvent[]>([])
const [stats, setStats] = useState<MiddlewareStats[]>([])
const [loading, setLoading] = useState(true)
const [saving, setSaving] = useState(false)
const [newIP, setNewIP] = useState({ ip_address: '', list_type: 'whitelist', reason: '' })
useEffect(() => {
loadData()
}, [])
const loadData = async () => {
setLoading(true)
try {
const [configsRes, ipListRes, eventsRes, statsRes] = await Promise.all([
fetch(`${BACKEND_URL}/api/admin/middleware`),
fetch(`${BACKEND_URL}/api/admin/middleware/rate-limit/ip-list`),
fetch(`${BACKEND_URL}/api/admin/middleware/events?limit=50`),
fetch(`${BACKEND_URL}/api/admin/middleware/stats`),
])
if (configsRes.ok) setConfigs(await configsRes.json())
if (ipListRes.ok) setIpList(await ipListRes.json())
if (eventsRes.ok) setEvents(await eventsRes.json())
if (statsRes.ok) setStats(await statsRes.json())
} catch (error) {
console.error('Failed to load middleware data:', error)
} finally {
setLoading(false)
}
}
const toggleMiddleware = async (name: string, enabled: boolean) => {
setSaving(true)
try {
const res = await fetch(`${BACKEND_URL}/api/admin/middleware/${name}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ enabled }),
})
if (res.ok) {
setConfigs(configs.map(c => c.middleware_name === name ? { ...c, enabled } : c))
}
} catch (error) {
console.error('Failed to update middleware:', error)
} finally {
setSaving(false)
}
}
const updateConfig = async (name: string, config: Record<string, any>) => {
setSaving(true)
try {
const res = await fetch(`${BACKEND_URL}/api/admin/middleware/${name}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ config }),
})
if (res.ok) {
const updated = await res.json()
setConfigs(configs.map(c => c.middleware_name === name ? updated : c))
}
} catch (error) {
console.error('Failed to update config:', error)
} finally {
setSaving(false)
}
}
const addIP = async () => {
if (!newIP.ip_address) return
setSaving(true)
try {
const res = await fetch(`${BACKEND_URL}/api/admin/middleware/rate-limit/ip-list`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(newIP),
})
if (res.ok) {
const added = await res.json()
setIpList([added, ...ipList])
setNewIP({ ip_address: '', list_type: 'whitelist', reason: '' })
}
} catch (error) {
console.error('Failed to add IP:', error)
} finally {
setSaving(false)
}
}
const removeIP = async (id: string) => {
setSaving(true)
try {
const res = await fetch(`${BACKEND_URL}/api/admin/middleware/rate-limit/ip-list/${id}`, {
method: 'DELETE',
})
if (res.ok) {
setIpList(ipList.filter(ip => ip.id !== id))
}
} catch (error) {
console.error('Failed to remove IP:', error)
} finally {
setSaving(false)
}
}
const getConfig = (name: string) => configs.find(c => c.middleware_name === name)
const getStats = (name: string) => stats.find(s => s.middleware_name === name)
return {
activeTab, setActiveTab,
configs, ipList, events, stats,
loading, saving,
newIP, setNewIP,
loadData, toggleMiddleware, updateConfig, addIP, removeIP,
getConfig, getStats,
}
}

View File

@@ -10,218 +10,31 @@
* - View middleware events and statistics
*/
import { useState, useEffect } from 'react'
import AdminLayout from '@/components/admin/AdminLayout'
import SystemInfoSection, { SYSTEM_INFO_CONFIGS } from '@/components/admin/SystemInfoSection'
interface MiddlewareConfig {
id: string
middleware_name: string
enabled: boolean
config: Record<string, any>
updated_at: string | null
}
interface RateLimitIP {
id: string
ip_address: string
list_type: 'whitelist' | 'blacklist'
reason: string | null
expires_at: string | null
created_at: string
}
interface MiddlewareEvent {
id: string
middleware_name: string
event_type: string
ip_address: string | null
user_id: string | null
request_path: string | null
request_method: string | null
details: Record<string, any> | null
created_at: string
}
interface MiddlewareStats {
middleware_name: string
total_events: number
events_last_hour: number
events_last_24h: number
top_event_types: Array<{ event_type: string; count: number }>
top_ips: Array<{ ip_address: string; count: number }>
}
type TabType = 'overview' | 'rate-limiting' | 'security' | 'logging' | 'events'
const MIDDLEWARE_INFO: Record<string, { name: string; description: string; icon: string }> = {
request_id: {
name: 'Request-ID',
description: 'Generates unique identifiers for request tracing',
icon: '🔑',
},
security_headers: {
name: 'Security Headers',
description: 'Adds security headers (HSTS, CSP, X-Frame-Options)',
icon: '🛡️',
},
cors: {
name: 'CORS',
description: 'Cross-Origin Resource Sharing configuration',
icon: '🌐',
},
rate_limiter: {
name: 'Rate Limiter',
description: 'Protects against abuse with request limits',
icon: '⏱️',
},
pii_redactor: {
name: 'PII Redactor',
description: 'Redacts sensitive data from logs (DSGVO)',
icon: '🔒',
},
input_gate: {
name: 'Input Gate',
description: 'Validates request body size and content types',
icon: '🚧',
},
}
import { useMiddleware } from './_components/useMiddleware'
import { TABS } from './_components/types'
import type { TabType } from './_components/types'
import OverviewTab from './_components/OverviewTab'
import RateLimitingTab from './_components/RateLimitingTab'
import SecurityTab from './_components/SecurityTab'
import LoggingTab from './_components/LoggingTab'
import EventsTab from './_components/EventsTab'
export default function MiddlewarePage() {
const [activeTab, setActiveTab] = useState<TabType>('overview')
const [configs, setConfigs] = useState<MiddlewareConfig[]>([])
const [ipList, setIpList] = useState<RateLimitIP[]>([])
const [events, setEvents] = useState<MiddlewareEvent[]>([])
const [stats, setStats] = useState<MiddlewareStats[]>([])
const [loading, setLoading] = useState(true)
const [saving, setSaving] = useState(false)
const [editingConfig, setEditingConfig] = useState<string | null>(null)
const [newIP, setNewIP] = useState({ ip_address: '', list_type: 'whitelist', reason: '' })
const BACKEND_URL = process.env.NEXT_PUBLIC_BACKEND_URL || 'http://localhost:8000'
useEffect(() => {
loadData()
}, [])
const loadData = async () => {
setLoading(true)
try {
const [configsRes, ipListRes, eventsRes, statsRes] = await Promise.all([
fetch(`${BACKEND_URL}/api/admin/middleware`),
fetch(`${BACKEND_URL}/api/admin/middleware/rate-limit/ip-list`),
fetch(`${BACKEND_URL}/api/admin/middleware/events?limit=50`),
fetch(`${BACKEND_URL}/api/admin/middleware/stats`),
])
if (configsRes.ok) setConfigs(await configsRes.json())
if (ipListRes.ok) setIpList(await ipListRes.json())
if (eventsRes.ok) setEvents(await eventsRes.json())
if (statsRes.ok) setStats(await statsRes.json())
} catch (error) {
console.error('Failed to load middleware data:', error)
} finally {
setLoading(false)
}
}
const toggleMiddleware = async (name: string, enabled: boolean) => {
setSaving(true)
try {
const res = await fetch(`${BACKEND_URL}/api/admin/middleware/${name}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ enabled }),
})
if (res.ok) {
setConfigs(configs.map(c => c.middleware_name === name ? { ...c, enabled } : c))
}
} catch (error) {
console.error('Failed to update middleware:', error)
} finally {
setSaving(false)
}
}
const updateConfig = async (name: string, config: Record<string, any>) => {
setSaving(true)
try {
const res = await fetch(`${BACKEND_URL}/api/admin/middleware/${name}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ config }),
})
if (res.ok) {
const updated = await res.json()
setConfigs(configs.map(c => c.middleware_name === name ? updated : c))
setEditingConfig(null)
}
} catch (error) {
console.error('Failed to update config:', error)
} finally {
setSaving(false)
}
}
const addIP = async () => {
if (!newIP.ip_address) return
setSaving(true)
try {
const res = await fetch(`${BACKEND_URL}/api/admin/middleware/rate-limit/ip-list`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(newIP),
})
if (res.ok) {
const added = await res.json()
setIpList([added, ...ipList])
setNewIP({ ip_address: '', list_type: 'whitelist', reason: '' })
}
} catch (error) {
console.error('Failed to add IP:', error)
} finally {
setSaving(false)
}
}
const removeIP = async (id: string) => {
setSaving(true)
try {
const res = await fetch(`${BACKEND_URL}/api/admin/middleware/rate-limit/ip-list/${id}`, {
method: 'DELETE',
})
if (res.ok) {
setIpList(ipList.filter(ip => ip.id !== id))
}
} catch (error) {
console.error('Failed to remove IP:', error)
} finally {
setSaving(false)
}
}
const getConfig = (name: string) => configs.find(c => c.middleware_name === name)
const getStats = (name: string) => stats.find(s => s.middleware_name === name)
const tabs = [
{ id: 'overview', label: 'Overview', icon: '📊' },
{ id: 'rate-limiting', label: 'Rate Limiting', icon: '⏱️' },
{ id: 'security', label: 'Security', icon: '🛡️' },
{ id: 'logging', label: 'Logging', icon: '📝' },
{ id: 'events', label: 'Events', icon: '📋' },
]
const hook = useMiddleware()
return (
<AdminLayout title="Middleware Configuration" description="Manage middleware settings for BreakPilot">
{/* Tab Navigation */}
<div className="border-b border-gray-200 mb-6">
<nav className="-mb-px flex space-x-8">
{tabs.map((tab) => (
{TABS.map((tab) => (
<button
key={tab.id}
onClick={() => setActiveTab(tab.id as TabType)}
onClick={() => hook.setActiveTab(tab.id as TabType)}
className={`py-2 px-1 border-b-2 font-medium text-sm ${
activeTab === tab.id
hook.activeTab === tab.id
? 'border-blue-500 text-blue-600'
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'
}`}
@@ -252,438 +65,18 @@ export default function MiddlewarePage() {
</a>
</div>
{loading ? (
{hook.loading ? (
<div className="text-center py-12">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-500 mx-auto"></div>
<p className="mt-4 text-gray-500">Loading middleware configuration...</p>
</div>
) : (
<>
{/* Overview Tab */}
{activeTab === 'overview' && (
<div className="space-y-6">
<h3 className="text-lg font-medium">Middleware Status</h3>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{Object.entries(MIDDLEWARE_INFO).map(([key, info]) => {
const config = getConfig(key)
const mwStats = getStats(key)
return (
<div key={key} className="bg-white rounded-lg shadow p-4">
<div className="flex items-center justify-between mb-2">
<div className="flex items-center">
<span className="text-2xl mr-2">{info.icon}</span>
<h4 className="font-medium">{info.name}</h4>
</div>
<button
onClick={() => config && toggleMiddleware(key, !config.enabled)}
disabled={saving || !config}
className={`relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none ${
config?.enabled ? 'bg-green-500' : 'bg-gray-200'
}`}
>
<span
className={`pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out ${
config?.enabled ? 'translate-x-5' : 'translate-x-0'
}`}
/>
</button>
</div>
<p className="text-sm text-gray-500 mb-3">{info.description}</p>
{mwStats && (
<div className="text-xs text-gray-400">
<span className="mr-3">Last hour: {mwStats.events_last_hour} events</span>
<span>24h: {mwStats.events_last_24h} events</span>
</div>
)}
</div>
)
})}
</div>
{/* Quick Stats */}
<div className="mt-8">
<h3 className="text-lg font-medium mb-4">Activity Summary (24h)</h3>
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
{stats.map((s) => (
<div key={s.middleware_name} className="bg-gray-50 rounded-lg p-4">
<p className="text-sm text-gray-500">{MIDDLEWARE_INFO[s.middleware_name]?.name || s.middleware_name}</p>
<p className="text-2xl font-bold">{s.events_last_24h}</p>
<p className="text-xs text-gray-400">{s.events_last_hour} in last hour</p>
</div>
))}
</div>
</div>
</div>
)}
{/* Rate Limiting Tab */}
{activeTab === 'rate-limiting' && (
<div className="space-y-6">
{/* Rate Limit Config */}
<div className="bg-white rounded-lg shadow p-6">
<h3 className="text-lg font-medium mb-4">Rate Limit Settings</h3>
{(() => {
const config = getConfig('rate_limiter')
if (!config) return <p>Loading...</p>
return (
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700">IP Limit (req/min)</label>
<input
type="number"
value={config.config.ip_limit || 100}
onChange={(e) => {
const newConfig = { ...config.config, ip_limit: parseInt(e.target.value) }
updateConfig('rate_limiter', newConfig)
}}
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700">User Limit (req/min)</label>
<input
type="number"
value={config.config.user_limit || 500}
onChange={(e) => {
const newConfig = { ...config.config, user_limit: parseInt(e.target.value) }
updateConfig('rate_limiter', newConfig)
}}
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700">Auth Limit (req/min)</label>
<input
type="number"
value={config.config.auth_limit || 20}
onChange={(e) => {
const newConfig = { ...config.config, auth_limit: parseInt(e.target.value) }
updateConfig('rate_limiter', newConfig)
}}
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500"
/>
</div>
</div>
)
})()}
</div>
{/* IP Whitelist/Blacklist */}
<div className="bg-white rounded-lg shadow p-6">
<h3 className="text-lg font-medium mb-4">IP Whitelist / Blacklist</h3>
{/* Add IP Form */}
<div className="flex gap-2 mb-4">
<input
type="text"
placeholder="IP Address (e.g., 192.168.1.1)"
value={newIP.ip_address}
onChange={(e) => setNewIP({ ...newIP, ip_address: e.target.value })}
className="flex-1 rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500"
/>
<select
value={newIP.list_type}
onChange={(e) => setNewIP({ ...newIP, list_type: e.target.value as 'whitelist' | 'blacklist' })}
className="rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500"
>
<option value="whitelist">Whitelist</option>
<option value="blacklist">Blacklist</option>
</select>
<input
type="text"
placeholder="Reason (optional)"
value={newIP.reason}
onChange={(e) => setNewIP({ ...newIP, reason: e.target.value })}
className="flex-1 rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500"
/>
<button
onClick={addIP}
disabled={saving || !newIP.ip_address}
className="px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 disabled:opacity-50"
>
Add
</button>
</div>
{/* IP List Table */}
<table className="min-w-full divide-y divide-gray-200">
<thead className="bg-gray-50">
<tr>
<th className="px-4 py-2 text-left text-xs font-medium text-gray-500 uppercase">IP Address</th>
<th className="px-4 py-2 text-left text-xs font-medium text-gray-500 uppercase">Type</th>
<th className="px-4 py-2 text-left text-xs font-medium text-gray-500 uppercase">Reason</th>
<th className="px-4 py-2 text-left text-xs font-medium text-gray-500 uppercase">Created</th>
<th className="px-4 py-2"></th>
</tr>
</thead>
<tbody className="bg-white divide-y divide-gray-200">
{ipList.length === 0 ? (
<tr>
<td colSpan={5} className="px-4 py-4 text-center text-gray-500">
No IPs in whitelist/blacklist
</td>
</tr>
) : (
ipList.map((ip) => (
<tr key={ip.id}>
<td className="px-4 py-2 font-mono text-sm">{ip.ip_address}</td>
<td className="px-4 py-2">
<span className={`px-2 py-1 text-xs rounded-full ${
ip.list_type === 'whitelist' ? 'bg-green-100 text-green-800' : 'bg-red-100 text-red-800'
}`}>
{ip.list_type}
</span>
</td>
<td className="px-4 py-2 text-sm text-gray-500">{ip.reason || '-'}</td>
<td className="px-4 py-2 text-sm text-gray-500">
{new Date(ip.created_at).toLocaleDateString()}
</td>
<td className="px-4 py-2">
<button
onClick={() => removeIP(ip.id)}
disabled={saving}
className="text-red-600 hover:text-red-800"
>
Remove
</button>
</td>
</tr>
))
)}
</tbody>
</table>
</div>
</div>
)}
{/* Security Tab */}
{activeTab === 'security' && (
<div className="space-y-6">
<div className="bg-white rounded-lg shadow p-6">
<h3 className="text-lg font-medium mb-4">Security Headers Configuration</h3>
{(() => {
const config = getConfig('security_headers')
if (!config) return <p>Loading...</p>
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<div>
<p className="font-medium">HSTS (Strict-Transport-Security)</p>
<p className="text-sm text-gray-500">Force HTTPS connections</p>
</div>
<input
type="checkbox"
checked={config.config.hsts_enabled ?? true}
onChange={(e) => {
const newConfig = { ...config.config, hsts_enabled: e.target.checked }
updateConfig('security_headers', newConfig)
}}
className="h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded"
/>
</div>
<div className="flex items-center justify-between">
<div>
<p className="font-medium">CSP (Content-Security-Policy)</p>
<p className="text-sm text-gray-500">Control resource loading</p>
</div>
<input
type="checkbox"
checked={config.config.csp_enabled ?? true}
onChange={(e) => {
const newConfig = { ...config.config, csp_enabled: e.target.checked }
updateConfig('security_headers', newConfig)
}}
className="h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700">CSP Policy</label>
<textarea
value={config.config.csp_policy || "default-src 'self'"}
onChange={(e) => {
const newConfig = { ...config.config, csp_policy: e.target.value }
updateConfig('security_headers', newConfig)
}}
rows={3}
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 font-mono text-sm"
/>
</div>
</div>
)
})()}
</div>
<div className="bg-white rounded-lg shadow p-6">
<h3 className="text-lg font-medium mb-4">Input Gate Configuration</h3>
{(() => {
const config = getConfig('input_gate')
if (!config) return <p>Loading...</p>
return (
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700">Max Body Size (bytes)</label>
<input
type="number"
value={config.config.max_body_size || 10485760}
onChange={(e) => {
const newConfig = { ...config.config, max_body_size: parseInt(e.target.value) }
updateConfig('input_gate', newConfig)
}}
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500"
/>
<p className="text-xs text-gray-500 mt-1">
{((config.config.max_body_size || 10485760) / 1024 / 1024).toFixed(1)} MB
</p>
</div>
<div>
<label className="block text-sm font-medium text-gray-700">Max File Size (bytes)</label>
<input
type="number"
value={config.config.max_file_size || 52428800}
onChange={(e) => {
const newConfig = { ...config.config, max_file_size: parseInt(e.target.value) }
updateConfig('input_gate', newConfig)
}}
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500"
/>
<p className="text-xs text-gray-500 mt-1">
{((config.config.max_file_size || 52428800) / 1024 / 1024).toFixed(1)} MB
</p>
</div>
</div>
)
})()}
</div>
</div>
)}
{/* Logging Tab */}
{activeTab === 'logging' && (
<div className="space-y-6">
<div className="bg-white rounded-lg shadow p-6">
<h3 className="text-lg font-medium mb-4">PII Redaction Settings</h3>
{(() => {
const config = getConfig('pii_redactor')
if (!config) return <p>Loading...</p>
const patterns = config.config.patterns || ['email', 'ip_v4', 'ip_v6', 'phone_de']
const allPatterns = ['email', 'ip_v4', 'ip_v6', 'phone_de', 'phone_intl', 'iban', 'uuid', 'name_prefix', 'student_id']
return (
<div className="space-y-4">
<p className="text-sm text-gray-500">
Select which PII patterns to redact from logs (DSGVO compliance)
</p>
<div className="grid grid-cols-2 md:grid-cols-3 gap-2">
{allPatterns.map((pattern) => (
<label key={pattern} className="flex items-center space-x-2">
<input
type="checkbox"
checked={patterns.includes(pattern)}
onChange={(e) => {
const newPatterns = e.target.checked
? [...patterns, pattern]
: patterns.filter((p: string) => p !== pattern)
updateConfig('pii_redactor', { ...config.config, patterns: newPatterns })
}}
className="h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded"
/>
<span className="text-sm">{pattern.replace('_', ' ')}</span>
</label>
))}
</div>
</div>
)
})()}
</div>
<div className="bg-white rounded-lg shadow p-6">
<h3 className="text-lg font-medium mb-4">Request-ID Configuration</h3>
{(() => {
const config = getConfig('request_id')
if (!config) return <p>Loading...</p>
return (
<div>
<label className="block text-sm font-medium text-gray-700">Header Name</label>
<input
type="text"
value={config.config.header_name || 'X-Request-ID'}
onChange={(e) => {
updateConfig('request_id', { ...config.config, header_name: e.target.value })
}}
className="mt-1 block w-full max-w-xs rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500"
/>
</div>
)
})()}
</div>
</div>
)}
{/* Events Tab */}
{activeTab === 'events' && (
<div className="space-y-6">
<div className="bg-white rounded-lg shadow overflow-hidden">
<div className="px-4 py-3 border-b border-gray-200 flex justify-between items-center">
<h3 className="text-lg font-medium">Recent Middleware Events</h3>
<button
onClick={loadData}
className="text-sm text-blue-600 hover:text-blue-800"
>
Refresh
</button>
</div>
<table className="min-w-full divide-y divide-gray-200">
<thead className="bg-gray-50">
<tr>
<th className="px-4 py-2 text-left text-xs font-medium text-gray-500 uppercase">Time</th>
<th className="px-4 py-2 text-left text-xs font-medium text-gray-500 uppercase">Middleware</th>
<th className="px-4 py-2 text-left text-xs font-medium text-gray-500 uppercase">Event</th>
<th className="px-4 py-2 text-left text-xs font-medium text-gray-500 uppercase">IP</th>
<th className="px-4 py-2 text-left text-xs font-medium text-gray-500 uppercase">Path</th>
</tr>
</thead>
<tbody className="bg-white divide-y divide-gray-200">
{events.length === 0 ? (
<tr>
<td colSpan={5} className="px-4 py-4 text-center text-gray-500">
No events recorded yet
</td>
</tr>
) : (
events.map((event) => (
<tr key={event.id}>
<td className="px-4 py-2 text-sm text-gray-500">
{new Date(event.created_at).toLocaleString()}
</td>
<td className="px-4 py-2">
<span className="text-sm font-medium">
{MIDDLEWARE_INFO[event.middleware_name]?.name || event.middleware_name}
</span>
</td>
<td className="px-4 py-2">
<span className={`px-2 py-1 text-xs rounded-full ${
event.event_type.includes('blocked') || event.event_type.includes('exceeded')
? 'bg-red-100 text-red-800'
: event.event_type.includes('add') || event.event_type.includes('changed')
? 'bg-blue-100 text-blue-800'
: 'bg-gray-100 text-gray-800'
}`}>
{event.event_type}
</span>
</td>
<td className="px-4 py-2 font-mono text-sm text-gray-500">
{event.ip_address || '-'}
</td>
<td className="px-4 py-2 text-sm text-gray-500 truncate max-w-xs">
{event.request_path || '-'}
</td>
</tr>
))
)}
</tbody>
</table>
</div>
</div>
)}
{hook.activeTab === 'overview' && <OverviewTab hook={hook} />}
{hook.activeTab === 'rate-limiting' && <RateLimitingTab hook={hook} />}
{hook.activeTab === 'security' && <SecurityTab hook={hook} />}
{hook.activeTab === 'logging' && <LoggingTab hook={hook} />}
{hook.activeTab === 'events' && <EventsTab hook={hook} />}
</>
)}

View File

@@ -0,0 +1,257 @@
'use client'
import { useState } from 'react'
import type { Email } from './types'
import { SENDER_TYPE_LABELS, CATEGORY_LABELS } from './types'
interface EmailDetailProps {
email: Email
onAnalyze: () => void
}
export default function EmailDetail({ email, onAnalyze }: EmailDetailProps) {
const [showAIPanel, setShowAIPanel] = useState(true)
return (
<div className="flex h-full">
{/* Email Content */}
<div className="flex-1 flex flex-col">
{/* Header */}
<div className="p-6 border-b border-slate-200">
<h1 className="text-xl font-semibold text-slate-900 mb-4">
{email.subject || '(Kein Betreff)'}
</h1>
<div className="flex items-start justify-between">
<div className="flex items-center gap-4">
<div className="w-10 h-10 bg-primary-100 rounded-full flex items-center justify-center">
<span className="text-primary-700 font-medium">
{(email.senderName || email.senderEmail)[0].toUpperCase()}
</span>
</div>
<div>
<p className="font-medium text-slate-900">
{email.senderName || email.senderEmail}
</p>
<p className="text-sm text-slate-500">{email.senderEmail}</p>
</div>
</div>
<div className="text-right">
<p className="text-sm text-slate-500">
{new Date(email.date).toLocaleDateString('de-DE', {
weekday: 'long',
day: '2-digit',
month: 'long',
year: 'numeric',
hour: '2-digit',
minute: '2-digit',
})}
</p>
<p className="text-xs text-slate-400 mt-1">An: {email.recipients.join(', ')}</p>
</div>
</div>
{/* Action Buttons */}
<div className="flex items-center gap-2 mt-4">
<button className="px-3 py-1.5 text-sm font-medium text-primary-600 bg-primary-50 rounded-lg hover:bg-primary-100">
Antworten
</button>
<button className="px-3 py-1.5 text-sm font-medium text-slate-600 bg-slate-100 rounded-lg hover:bg-slate-200">
Weiterleiten
</button>
<button className="px-3 py-1.5 text-sm font-medium text-slate-600 bg-slate-100 rounded-lg hover:bg-slate-200">
Aufgabe erstellen
</button>
{!email.aiAnalyzed && (
<button
onClick={onAnalyze}
className="px-3 py-1.5 text-sm font-medium text-purple-600 bg-purple-50 rounded-lg hover:bg-purple-100 flex items-center gap-1"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9.663 17h4.673M12 3v1m6.364 1.636l-.707.707M21 12h-1M4 12H3m3.343-5.657l-.707-.707m2.828 9.9a5 5 0 117.072 0l-.548.547A3.374 3.374 0 0014 18.469V19a2 2 0 11-4 0v-.531c0-.895-.356-1.754-.988-2.386l-.548-.547z" />
</svg>
Mit KI analysieren
</button>
)}
</div>
</div>
{/* Body */}
<div className="flex-1 p-6 overflow-y-auto">
{email.bodyHtml ? (
<div
className="prose prose-slate max-w-none"
dangerouslySetInnerHTML={{ __html: email.bodyHtml }}
/>
) : (
<pre className="whitespace-pre-wrap text-slate-700 font-sans">
{email.bodyText || email.bodyPreview}
</pre>
)}
{/* Attachments */}
{email.hasAttachments && (
<div className="mt-6 pt-6 border-t border-slate-200">
<h3 className="text-sm font-medium text-slate-700 mb-3">
Anhänge ({email.attachmentCount})
</h3>
<div className="flex flex-wrap gap-2">
<div className="flex items-center gap-2 px-3 py-2 bg-slate-100 rounded-lg">
<svg className="w-5 h-5 text-slate-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
</svg>
<span className="text-sm text-slate-700">Anhang herunterladen</span>
</div>
</div>
</div>
)}
</div>
</div>
{/* AI Panel */}
{showAIPanel && (
<AIPanel
email={email}
onAnalyze={onAnalyze}
onClose={() => setShowAIPanel(false)}
/>
)}
</div>
)
}
function AIPanel({
email,
onAnalyze,
onClose,
}: {
email: Email
onAnalyze: () => void
onClose: () => void
}) {
return (
<div className="w-80 border-l border-slate-200 bg-slate-50 overflow-y-auto">
<div className="p-4 border-b border-slate-200 bg-white flex items-center justify-between">
<div className="flex items-center gap-2">
<svg className="w-5 h-5 text-purple-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9.663 17h4.673M12 3v1m6.364 1.636l-.707.707M21 12h-1M4 12H3m3.343-5.657l-.707-.707m2.828 9.9a5 5 0 117.072 0l-.548.547A3.374 3.374 0 0014 18.469V19a2 2 0 11-4 0v-.531c0-.895-.356-1.754-.988-2.386l-.548-.547z" />
</svg>
<h2 className="font-semibold text-slate-900">KI-Analyse</h2>
</div>
<button
onClick={onClose}
className="p-1 text-slate-400 hover:text-slate-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>
{email.aiAnalyzed ? (
<div className="p-4 space-y-6">
{/* Sender Classification */}
{email.senderType && SENDER_TYPE_LABELS[email.senderType] && (
<div>
<h3 className="text-xs font-semibold text-slate-500 uppercase tracking-wider mb-2">
Absender-Typ
</h3>
<span className={`px-3 py-1.5 rounded-lg text-sm font-medium ${SENDER_TYPE_LABELS[email.senderType].color}`}>
{SENDER_TYPE_LABELS[email.senderType].label}
</span>
</div>
)}
{/* Category */}
{email.category && (
<div>
<h3 className="text-xs font-semibold text-slate-500 uppercase tracking-wider mb-2">
Kategorie
</h3>
<span className="px-3 py-1.5 bg-slate-200 text-slate-700 rounded-lg text-sm font-medium">
{CATEGORY_LABELS[email.category] || email.category}
</span>
</div>
)}
{/* Deadlines */}
{email.deadlines && email.deadlines.length > 0 && (
<div>
<h3 className="text-xs font-semibold text-slate-500 uppercase tracking-wider mb-2">
Erkannte Fristen
</h3>
<div className="space-y-2">
{email.deadlines.map((deadline, idx) => (
<div key={idx} className="p-3 bg-white rounded-lg border border-slate-200">
<div className="flex items-center gap-2 mb-1">
<svg className="w-4 h-4 text-red-500" 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>
<span className="font-medium text-slate-900">
{new Date(deadline.date).toLocaleDateString('de-DE', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
})}
</span>
{deadline.isFirm && (
<span className="px-1.5 py-0.5 bg-red-100 text-red-700 text-xs rounded">
verbindlich
</span>
)}
</div>
<p className="text-sm text-slate-600">{deadline.description}</p>
<p className="text-xs text-slate-400 mt-1">
Konfidenz: {Math.round(deadline.confidence * 100)}%
</p>
</div>
))}
</div>
</div>
)}
{/* Quick Actions */}
<div>
<h3 className="text-xs font-semibold text-slate-500 uppercase tracking-wider mb-2">
Schnellaktionen
</h3>
<div className="space-y-2">
<button className="w-full p-3 bg-white rounded-lg border border-slate-200 text-left hover:bg-slate-50 transition-colors">
<div className="flex items-center gap-2">
<svg className="w-5 h-5 text-primary-600" 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 2" />
</svg>
<span className="text-sm font-medium text-slate-700">Als Aufgabe speichern</span>
</div>
</button>
<button className="w-full p-3 bg-white rounded-lg border border-slate-200 text-left hover:bg-slate-50 transition-colors">
<div className="flex items-center gap-2">
<svg className="w-5 h-5 text-green-600" 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>
<span className="text-sm font-medium text-slate-700">In Kalender eintragen</span>
</div>
</button>
</div>
</div>
</div>
) : (
<div className="p-4 text-center">
<div className="py-8">
<svg className="w-12 h-12 text-slate-300 mx-auto mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9.663 17h4.673M12 3v1m6.364 1.636l-.707.707M21 12h-1M4 12H3m3.343-5.657l-.707-.707m2.828 9.9a5 5 0 117.072 0l-.548.547A3.374 3.374 0 0014 18.469V19a2 2 0 11-4 0v-.531c0-.895-.356-1.754-.988-2.386l-.548-.547z" />
</svg>
<p className="text-slate-500 mb-4">Diese E-Mail wurde noch nicht analysiert.</p>
<button
onClick={onAnalyze}
className="px-4 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700"
>
Jetzt analysieren
</button>
</div>
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,82 @@
'use client'
import type { Email } from './types'
import { PRIORITY_COLORS, SENDER_TYPE_SHORT_LABELS } from './types'
interface EmailListItemProps {
email: Email
isSelected: boolean
onClick: () => void
}
export default function EmailListItem({ email, isSelected, onClick }: EmailListItemProps) {
return (
<button
onClick={onClick}
className={`w-full p-4 text-left hover:bg-slate-50 transition-colors ${
isSelected ? 'bg-primary-50' : ''
} ${!email.isRead ? 'bg-blue-50' : ''}`}
>
<div className="flex items-start gap-3">
{/* Priority Indicator */}
{email.priority && (
<div className={`w-2 h-2 rounded-full mt-2 ${PRIORITY_COLORS[email.priority] || 'bg-slate-300'}`}></div>
)}
<div className="flex-1 min-w-0">
{/* Sender */}
<div className="flex items-center gap-2 mb-1">
<span className={`font-medium text-sm ${!email.isRead ? 'text-slate-900' : 'text-slate-700'}`}>
{email.senderName || email.senderEmail.split('@')[0]}
</span>
{email.senderType && SENDER_TYPE_SHORT_LABELS[email.senderType] && (
<span className="px-1.5 py-0.5 bg-purple-100 text-purple-700 text-xs font-medium rounded">
{SENDER_TYPE_SHORT_LABELS[email.senderType]}
</span>
)}
</div>
{/* Subject */}
<p className={`text-sm truncate ${!email.isRead ? 'font-medium text-slate-900' : 'text-slate-700'}`}>
{email.subject || '(Kein Betreff)'}
</p>
{/* Preview */}
<p className="text-xs text-slate-500 truncate mt-1">
{email.bodyPreview}
</p>
{/* Meta */}
<div className="flex items-center gap-2 mt-2">
<span className="text-xs text-slate-400">
{new Date(email.date).toLocaleDateString('de-DE', {
day: '2-digit',
month: '2-digit',
hour: '2-digit',
minute: '2-digit',
})}
</span>
{email.hasAttachments && (
<svg className="w-4 h-4 text-slate-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15.172 7l-6.586 6.586a2 2 0 102.828 2.828l6.414-6.586a4 4 0 00-5.656-5.656l-6.415 6.585a6 6 0 108.486 8.486L20.5 13" />
</svg>
)}
{email.deadlines && email.deadlines.length > 0 && (
<span className="px-1.5 py-0.5 bg-red-100 text-red-700 text-xs rounded flex items-center gap-1">
<svg className="w-3 h-3" 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>
Frist
</span>
)}
{email.aiAnalyzed && (
<svg className="w-4 h-4 text-purple-500" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-label="KI-analysiert">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9.663 17h4.673M12 3v1m6.364 1.636l-.707.707M21 12h-1M4 12H3m3.343-5.657l-.707-.707m2.828 9.9a5 5 0 117.072 0l-.548.547A3.374 3.374 0 0014 18.469V19a2 2 0 11-4 0v-.531c0-.895-.356-1.754-.988-2.386l-.548-.547z" />
</svg>
)}
</div>
</div>
</div>
</button>
)
}

View File

@@ -0,0 +1,106 @@
'use client'
import type { Account, Folder } from './types'
import { FOLDER_LABELS } from './types'
interface MailSidebarProps {
folders: Folder[]
accounts: Account[]
selectedFolder: string
setSelectedFolder: (folder: string) => void
selectedAccount: string
setSelectedAccount: (account: string) => void
totalUnread: number
}
export default function MailSidebar({
folders,
accounts,
selectedFolder,
setSelectedFolder,
selectedAccount,
setSelectedAccount,
totalUnread,
}: MailSidebarProps) {
return (
<aside className="w-64 bg-white border-r border-slate-200 flex flex-col">
{/* Folders */}
<nav className="p-4 space-y-1">
{folders.map((folder) => (
<button
key={folder.name}
onClick={() => setSelectedFolder(folder.name)}
className={`w-full flex items-center gap-3 px-3 py-2 rounded-lg text-left transition-colors ${
selectedFolder === folder.name
? 'bg-primary-50 text-primary-700'
: 'text-slate-600 hover:bg-slate-50'
}`}
>
{folder.icon}
<span className="font-medium">{FOLDER_LABELS[folder.name]}</span>
{folder.count !== undefined && (
<span className="ml-auto text-sm text-slate-500">{folder.count}</span>
)}
</button>
))}
</nav>
<div className="border-t border-slate-200 my-2"></div>
{/* Accounts */}
<div className="p-4">
<h3 className="text-xs font-semibold text-slate-500 uppercase tracking-wider mb-3">
Konten
</h3>
<div className="space-y-1">
<button
onClick={() => setSelectedAccount('all')}
className={`w-full flex items-center gap-3 px-3 py-2 rounded-lg text-left transition-colors ${
selectedAccount === 'all'
? 'bg-primary-50 text-primary-700'
: 'text-slate-600 hover:bg-slate-50'
}`}
>
<div className="w-2 h-2 bg-gradient-to-r from-blue-500 to-purple-500 rounded-full"></div>
<span className="font-medium text-sm">Alle Konten</span>
<span className="ml-auto text-xs text-slate-500">{totalUnread}</span>
</button>
{accounts.map((account) => (
<button
key={account.id}
onClick={() => setSelectedAccount(account.id)}
className={`w-full flex items-center gap-3 px-3 py-2 rounded-lg text-left transition-colors ${
selectedAccount === account.id
? 'bg-primary-50 text-primary-700'
: 'text-slate-600 hover:bg-slate-50'
}`}
>
<div className="w-2 h-2 bg-blue-500 rounded-full"></div>
<span className="font-medium text-sm truncate flex-1">
{account.displayName || account.email.split('@')[0]}
</span>
{account.unreadCount > 0 && (
<span className="text-xs bg-primary-100 text-primary-700 px-1.5 py-0.5 rounded">
{account.unreadCount}
</span>
)}
</button>
))}
</div>
</div>
{/* Quick Links */}
<div className="mt-auto p-4 border-t border-slate-200">
<a
href="/mail/tasks"
className="flex items-center gap-3 px-3 py-2 text-slate-600 hover:bg-slate-50 rounded-lg"
>
<svg className="w-5 h-5" 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 2" />
</svg>
<span className="font-medium text-sm">Arbeitsvorrat</span>
</a>
</div>
</aside>
)
}

View File

@@ -0,0 +1,88 @@
export const API_BASE = process.env.NEXT_PUBLIC_KLAUSUR_SERVICE_URL || 'http://localhost:8086'
export interface Email {
id: string
accountId: string
accountEmail: string
messageId: string
subject: string
senderName: string
senderEmail: string
recipients: string[]
date: string
bodyPreview: string
bodyHtml?: string
bodyText?: string
isRead: boolean
hasAttachments: boolean
attachmentCount: number
senderType?: string
category?: string
priority?: string
deadlines?: Deadline[]
aiAnalyzed: boolean
}
export interface Deadline {
date: string
description: string
isFirm: boolean
confidence: number
}
export interface Account {
id: string
email: string
displayName: string
unreadCount: number
}
export interface Folder {
name: string
icon: JSX.Element
count?: number
}
export const FOLDER_LABELS: Record<string, string> = {
inbox: 'Posteingang',
unread: 'Ungelesen',
tasks: 'Aufgaben',
sent: 'Gesendet',
}
export const PRIORITY_COLORS: Record<string, string> = {
urgent: 'bg-red-500',
high: 'bg-orange-500',
medium: 'bg-yellow-500',
low: 'bg-green-500',
}
export const SENDER_TYPE_SHORT_LABELS: Record<string, string> = {
kultusministerium: 'MK',
landesschulbehoerde: 'NLSchB',
rlsb: 'RLSB',
nibis: 'NiBiS',
schulamt: 'Schulamt',
}
export const SENDER_TYPE_LABELS: Record<string, { label: string; color: string }> = {
kultusministerium: { label: 'Kultusministerium', color: 'bg-purple-100 text-purple-800' },
landesschulbehoerde: { label: 'Landesschulbehörde', color: 'bg-blue-100 text-blue-800' },
rlsb: { label: 'RLSB', color: 'bg-indigo-100 text-indigo-800' },
nibis: { label: 'NiBiS', color: 'bg-cyan-100 text-cyan-800' },
schulamt: { label: 'Schulamt', color: 'bg-teal-100 text-teal-800' },
elternvertreter: { label: 'Elternvertreter', color: 'bg-green-100 text-green-800' },
privatperson: { label: 'Privatperson', color: 'bg-slate-100 text-slate-800' },
}
export const CATEGORY_LABELS: Record<string, string> = {
dienstlich: 'Dienstlich',
personal: 'Personal',
finanzen: 'Finanzen',
eltern: 'Eltern',
schueler: 'Schüler',
fortbildung: 'Fortbildung',
veranstaltung: 'Veranstaltung',
sicherheit: 'Sicherheit',
newsletter: 'Newsletter',
}

View File

@@ -0,0 +1,109 @@
'use client'
import { useState, useEffect, useCallback } from 'react'
import type { Email, Account } from './types'
import { API_BASE } from './types'
export function useMail() {
const [emails, setEmails] = useState<Email[]>([])
const [accounts, setAccounts] = useState<Account[]>([])
const [selectedEmail, setSelectedEmail] = useState<Email | null>(null)
const [selectedAccount, setSelectedAccount] = useState<string>('all')
const [selectedFolder, setSelectedFolder] = useState('inbox')
const [loading, setLoading] = useState(true)
const [searchQuery, setSearchQuery] = useState('')
const fetchEmails = useCallback(async () => {
try {
setLoading(true)
const params = new URLSearchParams()
if (selectedAccount !== 'all') {
params.append('account_id', selectedAccount)
}
if (selectedFolder === 'unread') {
params.append('unread_only', 'true')
}
if (searchQuery) {
params.append('search', searchQuery)
}
const res = await fetch(`${API_BASE}/api/v1/mail/inbox?${params}`)
if (res.ok) {
const data = await res.json()
setEmails(data.emails || [])
}
} catch (err) {
console.error('Failed to fetch emails:', err)
} finally {
setLoading(false)
}
}, [selectedAccount, selectedFolder, searchQuery])
const fetchAccounts = useCallback(async () => {
try {
const res = await fetch(`${API_BASE}/api/v1/mail/accounts`)
if (res.ok) {
const data = await res.json()
setAccounts(data.accounts || [])
}
} catch (err) {
console.error('Failed to fetch accounts:', err)
}
}, [])
useEffect(() => {
fetchAccounts()
}, [fetchAccounts])
useEffect(() => {
fetchEmails()
}, [fetchEmails])
const analyzeEmail = async (emailId: string) => {
try {
const res = await fetch(`${API_BASE}/api/v1/mail/analyze/${emailId}`, {
method: 'POST',
})
if (res.ok) {
const data = await res.json()
setSelectedEmail((prev) => prev ? { ...prev, ...data, aiAnalyzed: true } : null)
setEmails((prev) =>
prev.map((e) => (e.id === emailId ? { ...e, ...data, aiAnalyzed: true } : e))
)
}
} catch (err) {
console.error('Failed to analyze email:', err)
}
}
const markAsRead = async (emailId: string) => {
try {
await fetch(`${API_BASE}/api/v1/mail/inbox/${emailId}/read`, {
method: 'POST',
})
setEmails((prev) =>
prev.map((e) => (e.id === emailId ? { ...e, isRead: true } : e))
)
} catch (err) {
console.error('Failed to mark as read:', err)
}
}
const handleEmailClick = (email: Email) => {
setSelectedEmail(email)
if (!email.isRead) {
markAsRead(email.id)
}
}
const totalUnread = accounts.reduce((sum, acc) => sum + acc.unreadCount, 0)
return {
emails, accounts,
selectedEmail, setSelectedEmail,
selectedAccount, setSelectedAccount,
selectedFolder, setSelectedFolder,
loading, searchQuery, setSearchQuery,
analyzeEmail, handleEmailClick, totalUnread,
}
}

View File

@@ -11,64 +11,14 @@
* See: docs/klausur-modul/UNIFIED-INBOX-SPECIFICATION.md
*/
import { useState, useEffect, useCallback } from 'react'
// API Base URL
const API_BASE = process.env.NEXT_PUBLIC_KLAUSUR_SERVICE_URL || 'http://localhost:8086'
// Types
interface Email {
id: string
accountId: string
accountEmail: string
messageId: string
subject: string
senderName: string
senderEmail: string
recipients: string[]
date: string
bodyPreview: string
bodyHtml?: string
bodyText?: string
isRead: boolean
hasAttachments: boolean
attachmentCount: number
// AI Analysis
senderType?: string
category?: string
priority?: string
deadlines?: Deadline[]
aiAnalyzed: boolean
}
interface Deadline {
date: string
description: string
isFirm: boolean
confidence: number
}
interface Account {
id: string
email: string
displayName: string
unreadCount: number
}
interface Folder {
name: string
icon: JSX.Element
count?: number
}
import type { Folder } from './_components/types'
import { useMail } from './_components/useMail'
import MailSidebar from './_components/MailSidebar'
import EmailListItem from './_components/EmailListItem'
import EmailDetail from './_components/EmailDetail'
export default function MailPage() {
const [emails, setEmails] = useState<Email[]>([])
const [accounts, setAccounts] = useState<Account[]>([])
const [selectedEmail, setSelectedEmail] = useState<Email | null>(null)
const [selectedAccount, setSelectedAccount] = useState<string>('all')
const [selectedFolder, setSelectedFolder] = useState('inbox')
const [loading, setLoading] = useState(true)
const [searchQuery, setSearchQuery] = useState('')
const mail = useMail()
const folders: Folder[] = [
{
@@ -105,109 +55,15 @@ export default function MailPage() {
},
]
const folderLabels: Record<string, string> = {
inbox: 'Posteingang',
unread: 'Ungelesen',
tasks: 'Aufgaben',
sent: 'Gesendet',
}
const fetchEmails = useCallback(async () => {
try {
setLoading(true)
const params = new URLSearchParams()
if (selectedAccount !== 'all') {
params.append('account_id', selectedAccount)
}
if (selectedFolder === 'unread') {
params.append('unread_only', 'true')
}
if (searchQuery) {
params.append('search', searchQuery)
}
const res = await fetch(`${API_BASE}/api/v1/mail/inbox?${params}`)
if (res.ok) {
const data = await res.json()
setEmails(data.emails || [])
}
} catch (err) {
console.error('Failed to fetch emails:', err)
} finally {
setLoading(false)
}
}, [selectedAccount, selectedFolder, searchQuery])
const fetchAccounts = useCallback(async () => {
try {
const res = await fetch(`${API_BASE}/api/v1/mail/accounts`)
if (res.ok) {
const data = await res.json()
setAccounts(data.accounts || [])
}
} catch (err) {
console.error('Failed to fetch accounts:', err)
}
}, [])
useEffect(() => {
fetchAccounts()
}, [fetchAccounts])
useEffect(() => {
fetchEmails()
}, [fetchEmails])
const analyzeEmail = async (emailId: string) => {
try {
const res = await fetch(`${API_BASE}/api/v1/mail/analyze/${emailId}`, {
method: 'POST',
})
if (res.ok) {
const data = await res.json()
// Update the selected email with analysis results
setSelectedEmail((prev) => prev ? { ...prev, ...data, aiAnalyzed: true } : null)
// Update the email in the list
setEmails((prev) =>
prev.map((e) => (e.id === emailId ? { ...e, ...data, aiAnalyzed: true } : e))
)
}
} catch (err) {
console.error('Failed to analyze email:', err)
}
}
const markAsRead = async (emailId: string) => {
try {
await fetch(`${API_BASE}/api/v1/mail/inbox/${emailId}/read`, {
method: 'POST',
})
setEmails((prev) =>
prev.map((e) => (e.id === emailId ? { ...e, isRead: true } : e))
)
} catch (err) {
console.error('Failed to mark as read:', err)
}
}
const handleEmailClick = (email: Email) => {
setSelectedEmail(email)
if (!email.isRead) {
markAsRead(email.id)
}
}
const totalUnread = accounts.reduce((sum, acc) => sum + acc.unreadCount, 0)
return (
<div className="h-screen flex flex-col bg-slate-100">
{/* Header */}
<header className="bg-white border-b border-slate-200 px-6 py-4 flex items-center justify-between">
<div className="flex items-center gap-4">
<h1 className="text-xl font-semibold text-slate-900">Unified Inbox</h1>
{totalUnread > 0 && (
{mail.totalUnread > 0 && (
<span className="px-2 py-1 bg-primary-100 text-primary-700 text-sm font-medium rounded-full">
{totalUnread} ungelesen
{mail.totalUnread} ungelesen
</span>
)}
</div>
@@ -217,8 +73,8 @@ export default function MailPage() {
<input
type="text"
placeholder="E-Mails durchsuchen..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
value={mail.searchQuery}
onChange={(e) => mail.setSearchQuery(e.target.value)}
className="w-64 pl-10 pr-4 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-transparent"
/>
<svg className="absolute left-3 top-2.5 w-5 h-5 text-slate-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
@@ -240,94 +96,23 @@ export default function MailPage() {
{/* Main Content */}
<div className="flex-1 flex overflow-hidden">
{/* Sidebar */}
<aside className="w-64 bg-white border-r border-slate-200 flex flex-col">
{/* Folders */}
<nav className="p-4 space-y-1">
{folders.map((folder) => (
<button
key={folder.name}
onClick={() => setSelectedFolder(folder.name)}
className={`w-full flex items-center gap-3 px-3 py-2 rounded-lg text-left transition-colors ${
selectedFolder === folder.name
? 'bg-primary-50 text-primary-700'
: 'text-slate-600 hover:bg-slate-50'
}`}
>
{folder.icon}
<span className="font-medium">{folderLabels[folder.name]}</span>
{folder.count !== undefined && (
<span className="ml-auto text-sm text-slate-500">{folder.count}</span>
)}
</button>
))}
</nav>
<div className="border-t border-slate-200 my-2"></div>
{/* Accounts */}
<div className="p-4">
<h3 className="text-xs font-semibold text-slate-500 uppercase tracking-wider mb-3">
Konten
</h3>
<div className="space-y-1">
<button
onClick={() => setSelectedAccount('all')}
className={`w-full flex items-center gap-3 px-3 py-2 rounded-lg text-left transition-colors ${
selectedAccount === 'all'
? 'bg-primary-50 text-primary-700'
: 'text-slate-600 hover:bg-slate-50'
}`}
>
<div className="w-2 h-2 bg-gradient-to-r from-blue-500 to-purple-500 rounded-full"></div>
<span className="font-medium text-sm">Alle Konten</span>
<span className="ml-auto text-xs text-slate-500">{totalUnread}</span>
</button>
{accounts.map((account) => (
<button
key={account.id}
onClick={() => setSelectedAccount(account.id)}
className={`w-full flex items-center gap-3 px-3 py-2 rounded-lg text-left transition-colors ${
selectedAccount === account.id
? 'bg-primary-50 text-primary-700'
: 'text-slate-600 hover:bg-slate-50'
}`}
>
<div className="w-2 h-2 bg-blue-500 rounded-full"></div>
<span className="font-medium text-sm truncate flex-1">
{account.displayName || account.email.split('@')[0]}
</span>
{account.unreadCount > 0 && (
<span className="text-xs bg-primary-100 text-primary-700 px-1.5 py-0.5 rounded">
{account.unreadCount}
</span>
)}
</button>
))}
</div>
</div>
{/* Quick Links */}
<div className="mt-auto p-4 border-t border-slate-200">
<a
href="/mail/tasks"
className="flex items-center gap-3 px-3 py-2 text-slate-600 hover:bg-slate-50 rounded-lg"
>
<svg className="w-5 h-5" 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 2" />
</svg>
<span className="font-medium text-sm">Arbeitsvorrat</span>
</a>
</div>
</aside>
<MailSidebar
folders={folders}
accounts={mail.accounts}
selectedFolder={mail.selectedFolder}
setSelectedFolder={mail.setSelectedFolder}
selectedAccount={mail.selectedAccount}
setSelectedAccount={mail.setSelectedAccount}
totalUnread={mail.totalUnread}
/>
{/* Email List */}
<div className="w-96 bg-white border-r border-slate-200 overflow-y-auto">
{loading ? (
{mail.loading ? (
<div className="flex items-center justify-center py-12">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary-600"></div>
</div>
) : emails.length === 0 ? (
) : mail.emails.length === 0 ? (
<div className="flex flex-col items-center justify-center py-12 text-center px-4">
<svg className="w-12 h-12 text-slate-300 mb-4" 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" />
@@ -336,12 +121,12 @@ export default function MailPage() {
</div>
) : (
<div className="divide-y divide-slate-100">
{emails.map((email) => (
{mail.emails.map((email) => (
<EmailListItem
key={email.id}
email={email}
isSelected={selectedEmail?.id === email.id}
onClick={() => handleEmailClick(email)}
isSelected={mail.selectedEmail?.id === email.id}
onClick={() => mail.handleEmailClick(email)}
/>
))}
</div>
@@ -350,10 +135,10 @@ export default function MailPage() {
{/* Email Detail / AI Panel */}
<div className="flex-1 bg-white overflow-y-auto">
{selectedEmail ? (
{mail.selectedEmail ? (
<EmailDetail
email={selectedEmail}
onAnalyze={() => analyzeEmail(selectedEmail.id)}
email={mail.selectedEmail}
onAnalyze={() => mail.analyzeEmail(mail.selectedEmail!.id)}
/>
) : (
<div className="flex flex-col items-center justify-center h-full text-center px-4">
@@ -371,363 +156,3 @@ export default function MailPage() {
</div>
)
}
// ============================================================================
// Email List Item
// ============================================================================
function EmailListItem({
email,
isSelected,
onClick
}: {
email: Email
isSelected: boolean
onClick: () => void
}) {
const priorityColors: Record<string, string> = {
urgent: 'bg-red-500',
high: 'bg-orange-500',
medium: 'bg-yellow-500',
low: 'bg-green-500',
}
const senderTypeLabels: Record<string, string> = {
kultusministerium: 'MK',
landesschulbehoerde: 'NLSchB',
rlsb: 'RLSB',
nibis: 'NiBiS',
schulamt: 'Schulamt',
}
return (
<button
onClick={onClick}
className={`w-full p-4 text-left hover:bg-slate-50 transition-colors ${
isSelected ? 'bg-primary-50' : ''
} ${!email.isRead ? 'bg-blue-50' : ''}`}
>
<div className="flex items-start gap-3">
{/* Priority Indicator */}
{email.priority && (
<div className={`w-2 h-2 rounded-full mt-2 ${priorityColors[email.priority] || 'bg-slate-300'}`}></div>
)}
<div className="flex-1 min-w-0">
{/* Sender */}
<div className="flex items-center gap-2 mb-1">
<span className={`font-medium text-sm ${!email.isRead ? 'text-slate-900' : 'text-slate-700'}`}>
{email.senderName || email.senderEmail.split('@')[0]}
</span>
{email.senderType && senderTypeLabels[email.senderType] && (
<span className="px-1.5 py-0.5 bg-purple-100 text-purple-700 text-xs font-medium rounded">
{senderTypeLabels[email.senderType]}
</span>
)}
</div>
{/* Subject */}
<p className={`text-sm truncate ${!email.isRead ? 'font-medium text-slate-900' : 'text-slate-700'}`}>
{email.subject || '(Kein Betreff)'}
</p>
{/* Preview */}
<p className="text-xs text-slate-500 truncate mt-1">
{email.bodyPreview}
</p>
{/* Meta */}
<div className="flex items-center gap-2 mt-2">
<span className="text-xs text-slate-400">
{new Date(email.date).toLocaleDateString('de-DE', {
day: '2-digit',
month: '2-digit',
hour: '2-digit',
minute: '2-digit',
})}
</span>
{email.hasAttachments && (
<svg className="w-4 h-4 text-slate-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15.172 7l-6.586 6.586a2 2 0 102.828 2.828l6.414-6.586a4 4 0 00-5.656-5.656l-6.415 6.585a6 6 0 108.486 8.486L20.5 13" />
</svg>
)}
{email.deadlines && email.deadlines.length > 0 && (
<span className="px-1.5 py-0.5 bg-red-100 text-red-700 text-xs rounded flex items-center gap-1">
<svg className="w-3 h-3" 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>
Frist
</span>
)}
{email.aiAnalyzed && (
<svg className="w-4 h-4 text-purple-500" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-label="KI-analysiert">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9.663 17h4.673M12 3v1m6.364 1.636l-.707.707M21 12h-1M4 12H3m3.343-5.657l-.707-.707m2.828 9.9a5 5 0 117.072 0l-.548.547A3.374 3.374 0 0014 18.469V19a2 2 0 11-4 0v-.531c0-.895-.356-1.754-.988-2.386l-.548-.547z" />
</svg>
)}
</div>
</div>
</div>
</button>
)
}
// ============================================================================
// Email Detail
// ============================================================================
function EmailDetail({
email,
onAnalyze
}: {
email: Email
onAnalyze: () => void
}) {
const [showAIPanel, setShowAIPanel] = useState(true)
const senderTypeLabels: Record<string, { label: string; color: string }> = {
kultusministerium: { label: 'Kultusministerium', color: 'bg-purple-100 text-purple-800' },
landesschulbehoerde: { label: 'Landesschulbehörde', color: 'bg-blue-100 text-blue-800' },
rlsb: { label: 'RLSB', color: 'bg-indigo-100 text-indigo-800' },
nibis: { label: 'NiBiS', color: 'bg-cyan-100 text-cyan-800' },
schulamt: { label: 'Schulamt', color: 'bg-teal-100 text-teal-800' },
elternvertreter: { label: 'Elternvertreter', color: 'bg-green-100 text-green-800' },
privatperson: { label: 'Privatperson', color: 'bg-slate-100 text-slate-800' },
}
const categoryLabels: Record<string, string> = {
dienstlich: 'Dienstlich',
personal: 'Personal',
finanzen: 'Finanzen',
eltern: 'Eltern',
schueler: 'Schüler',
fortbildung: 'Fortbildung',
veranstaltung: 'Veranstaltung',
sicherheit: 'Sicherheit',
newsletter: 'Newsletter',
}
return (
<div className="flex h-full">
{/* Email Content */}
<div className="flex-1 flex flex-col">
{/* Header */}
<div className="p-6 border-b border-slate-200">
<h1 className="text-xl font-semibold text-slate-900 mb-4">
{email.subject || '(Kein Betreff)'}
</h1>
<div className="flex items-start justify-between">
<div className="flex items-center gap-4">
<div className="w-10 h-10 bg-primary-100 rounded-full flex items-center justify-center">
<span className="text-primary-700 font-medium">
{(email.senderName || email.senderEmail)[0].toUpperCase()}
</span>
</div>
<div>
<p className="font-medium text-slate-900">
{email.senderName || email.senderEmail}
</p>
<p className="text-sm text-slate-500">{email.senderEmail}</p>
</div>
</div>
<div className="text-right">
<p className="text-sm text-slate-500">
{new Date(email.date).toLocaleDateString('de-DE', {
weekday: 'long',
day: '2-digit',
month: 'long',
year: 'numeric',
hour: '2-digit',
minute: '2-digit',
})}
</p>
<p className="text-xs text-slate-400 mt-1">An: {email.recipients.join(', ')}</p>
</div>
</div>
{/* Action Buttons */}
<div className="flex items-center gap-2 mt-4">
<button className="px-3 py-1.5 text-sm font-medium text-primary-600 bg-primary-50 rounded-lg hover:bg-primary-100">
Antworten
</button>
<button className="px-3 py-1.5 text-sm font-medium text-slate-600 bg-slate-100 rounded-lg hover:bg-slate-200">
Weiterleiten
</button>
<button className="px-3 py-1.5 text-sm font-medium text-slate-600 bg-slate-100 rounded-lg hover:bg-slate-200">
Aufgabe erstellen
</button>
{!email.aiAnalyzed && (
<button
onClick={onAnalyze}
className="px-3 py-1.5 text-sm font-medium text-purple-600 bg-purple-50 rounded-lg hover:bg-purple-100 flex items-center gap-1"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9.663 17h4.673M12 3v1m6.364 1.636l-.707.707M21 12h-1M4 12H3m3.343-5.657l-.707-.707m2.828 9.9a5 5 0 117.072 0l-.548.547A3.374 3.374 0 0014 18.469V19a2 2 0 11-4 0v-.531c0-.895-.356-1.754-.988-2.386l-.548-.547z" />
</svg>
Mit KI analysieren
</button>
)}
</div>
</div>
{/* Body */}
<div className="flex-1 p-6 overflow-y-auto">
{email.bodyHtml ? (
<div
className="prose prose-slate max-w-none"
dangerouslySetInnerHTML={{ __html: email.bodyHtml }}
/>
) : (
<pre className="whitespace-pre-wrap text-slate-700 font-sans">
{email.bodyText || email.bodyPreview}
</pre>
)}
{/* Attachments */}
{email.hasAttachments && (
<div className="mt-6 pt-6 border-t border-slate-200">
<h3 className="text-sm font-medium text-slate-700 mb-3">
Anhänge ({email.attachmentCount})
</h3>
<div className="flex flex-wrap gap-2">
<div className="flex items-center gap-2 px-3 py-2 bg-slate-100 rounded-lg">
<svg className="w-5 h-5 text-slate-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
</svg>
<span className="text-sm text-slate-700">Anhang herunterladen</span>
</div>
</div>
</div>
)}
</div>
</div>
{/* AI Panel */}
{showAIPanel && (
<div className="w-80 border-l border-slate-200 bg-slate-50 overflow-y-auto">
<div className="p-4 border-b border-slate-200 bg-white flex items-center justify-between">
<div className="flex items-center gap-2">
<svg className="w-5 h-5 text-purple-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9.663 17h4.673M12 3v1m6.364 1.636l-.707.707M21 12h-1M4 12H3m3.343-5.657l-.707-.707m2.828 9.9a5 5 0 117.072 0l-.548.547A3.374 3.374 0 0014 18.469V19a2 2 0 11-4 0v-.531c0-.895-.356-1.754-.988-2.386l-.548-.547z" />
</svg>
<h2 className="font-semibold text-slate-900">KI-Analyse</h2>
</div>
<button
onClick={() => setShowAIPanel(false)}
className="p-1 text-slate-400 hover:text-slate-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>
{email.aiAnalyzed ? (
<div className="p-4 space-y-6">
{/* Sender Classification */}
{email.senderType && senderTypeLabels[email.senderType] && (
<div>
<h3 className="text-xs font-semibold text-slate-500 uppercase tracking-wider mb-2">
Absender-Typ
</h3>
<span className={`px-3 py-1.5 rounded-lg text-sm font-medium ${senderTypeLabels[email.senderType].color}`}>
{senderTypeLabels[email.senderType].label}
</span>
</div>
)}
{/* Category */}
{email.category && (
<div>
<h3 className="text-xs font-semibold text-slate-500 uppercase tracking-wider mb-2">
Kategorie
</h3>
<span className="px-3 py-1.5 bg-slate-200 text-slate-700 rounded-lg text-sm font-medium">
{categoryLabels[email.category] || email.category}
</span>
</div>
)}
{/* Deadlines */}
{email.deadlines && email.deadlines.length > 0 && (
<div>
<h3 className="text-xs font-semibold text-slate-500 uppercase tracking-wider mb-2">
Erkannte Fristen
</h3>
<div className="space-y-2">
{email.deadlines.map((deadline, idx) => (
<div key={idx} className="p-3 bg-white rounded-lg border border-slate-200">
<div className="flex items-center gap-2 mb-1">
<svg className="w-4 h-4 text-red-500" 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>
<span className="font-medium text-slate-900">
{new Date(deadline.date).toLocaleDateString('de-DE', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
})}
</span>
{deadline.isFirm && (
<span className="px-1.5 py-0.5 bg-red-100 text-red-700 text-xs rounded">
verbindlich
</span>
)}
</div>
<p className="text-sm text-slate-600">{deadline.description}</p>
<p className="text-xs text-slate-400 mt-1">
Konfidenz: {Math.round(deadline.confidence * 100)}%
</p>
</div>
))}
</div>
</div>
)}
{/* Quick Actions */}
<div>
<h3 className="text-xs font-semibold text-slate-500 uppercase tracking-wider mb-2">
Schnellaktionen
</h3>
<div className="space-y-2">
<button className="w-full p-3 bg-white rounded-lg border border-slate-200 text-left hover:bg-slate-50 transition-colors">
<div className="flex items-center gap-2">
<svg className="w-5 h-5 text-primary-600" 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 2" />
</svg>
<span className="text-sm font-medium text-slate-700">Als Aufgabe speichern</span>
</div>
</button>
<button className="w-full p-3 bg-white rounded-lg border border-slate-200 text-left hover:bg-slate-50 transition-colors">
<div className="flex items-center gap-2">
<svg className="w-5 h-5 text-green-600" 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>
<span className="text-sm font-medium text-slate-700">In Kalender eintragen</span>
</div>
</button>
</div>
</div>
</div>
) : (
<div className="p-4 text-center">
<div className="py-8">
<svg className="w-12 h-12 text-slate-300 mx-auto mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9.663 17h4.673M12 3v1m6.364 1.636l-.707.707M21 12h-1M4 12H3m3.343-5.657l-.707-.707m2.828 9.9a5 5 0 117.072 0l-.548.547A3.374 3.374 0 0014 18.469V19a2 2 0 11-4 0v-.531c0-.895-.356-1.754-.988-2.386l-.548-.547z" />
</svg>
<p className="text-slate-500 mb-4">Diese E-Mail wurde noch nicht analysiert.</p>
<button
onClick={onAnalyze}
className="px-4 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700"
>
Jetzt analysieren
</button>
</div>
</div>
)}
</div>
)}
</div>
)
}