[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:
77
website/app/admin/brandbook/_components/ColorsTab.tsx
Normal file
77
website/app/admin/brandbook/_components/ColorsTab.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
105
website/app/admin/brandbook/_components/ComponentsTab.tsx
Normal file
105
website/app/admin/brandbook/_components/ComponentsTab.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
117
website/app/admin/brandbook/_components/LogoTab.tsx
Normal file
117
website/app/admin/brandbook/_components/LogoTab.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
69
website/app/admin/brandbook/_components/TypographyTab.tsx
Normal file
69
website/app/admin/brandbook/_components/TypographyTab.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
101
website/app/admin/brandbook/_components/VoiceTab.tsx
Normal file
101
website/app/admin/brandbook/_components/VoiceTab.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
115
website/app/admin/brandbook/_components/types.ts
Normal file
115
website/app/admin/brandbook/_components/types.ts
Normal 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'
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
287
website/app/admin/compliance/export/_components/ExportWizard.tsx
Normal file
287
website/app/admin/compliance/export/_components/ExportWizard.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
46
website/app/admin/compliance/export/_components/types.ts
Normal file
46
website/app/admin/compliance/export/_components/types.ts
Normal 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`
|
||||
}
|
||||
110
website/app/admin/compliance/export/_components/useExport.ts
Normal file
110
website/app/admin/compliance/export/_components/useExport.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
141
website/app/admin/compliance/risks/_components/RiskForm.tsx
Normal file
141
website/app/admin/compliance/risks/_components/RiskForm.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
76
website/app/admin/compliance/risks/_components/RiskList.tsx
Normal file
76
website/app/admin/compliance/risks/_components/RiskList.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
65
website/app/admin/compliance/risks/_components/types.ts
Normal file
65
website/app/admin/compliance/risks/_components/types.ts
Normal 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
|
||||
}
|
||||
163
website/app/admin/compliance/risks/_components/useRisks.ts
Normal file
163
website/app/admin/compliance/risks/_components/useRisks.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
83
website/app/admin/consent/_components/DocumentsTab.tsx
Normal file
83
website/app/admin/consent/_components/DocumentsTab.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
68
website/app/admin/consent/_components/EmailsTab.tsx
Normal file
68
website/app/admin/consent/_components/EmailsTab.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
109
website/app/admin/consent/_components/GdprTab.tsx
Normal file
109
website/app/admin/consent/_components/GdprTab.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
31
website/app/admin/consent/_components/StatsTab.tsx
Normal file
31
website/app/admin/consent/_components/StatsTab.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
97
website/app/admin/consent/_components/VersionsTab.tsx
Normal file
97
website/app/admin/consent/_components/VersionsTab.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
77
website/app/admin/consent/_components/types.ts
Normal file
77
website/app/admin/consent/_components/types.ts
Normal 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: '🔄',
|
||||
}
|
||||
80
website/app/admin/consent/_components/useConsent.ts
Normal file
80
website/app/admin/consent/_components/useConsent.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
360
website/app/admin/game/wizard/_components/WizardComponents.tsx
Normal file
360
website/app/admin/game/wizard/_components/WizardComponents.tsx
Normal 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
|
||||
}
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
78
website/app/admin/middleware/_components/EventsTab.tsx
Normal file
78
website/app/admin/middleware/_components/EventsTab.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
69
website/app/admin/middleware/_components/LoggingTab.tsx
Normal file
69
website/app/admin/middleware/_components/LoggingTab.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
68
website/app/admin/middleware/_components/OverviewTab.tsx
Normal file
68
website/app/admin/middleware/_components/OverviewTab.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
150
website/app/admin/middleware/_components/RateLimitingTab.tsx
Normal file
150
website/app/admin/middleware/_components/RateLimitingTab.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
109
website/app/admin/middleware/_components/SecurityTab.tsx
Normal file
109
website/app/admin/middleware/_components/SecurityTab.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
100
website/app/admin/middleware/_components/types.ts
Normal file
100
website/app/admin/middleware/_components/types.ts
Normal 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
|
||||
}
|
||||
128
website/app/admin/middleware/_components/useMiddleware.ts
Normal file
128
website/app/admin/middleware/_components/useMiddleware.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
@@ -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} />}
|
||||
</>
|
||||
)}
|
||||
|
||||
|
||||
257
website/app/mail/_components/EmailDetail.tsx
Normal file
257
website/app/mail/_components/EmailDetail.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
82
website/app/mail/_components/EmailListItem.tsx
Normal file
82
website/app/mail/_components/EmailListItem.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
106
website/app/mail/_components/MailSidebar.tsx
Normal file
106
website/app/mail/_components/MailSidebar.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
88
website/app/mail/_components/types.ts
Normal file
88
website/app/mail/_components/types.ts
Normal 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',
|
||||
}
|
||||
109
website/app/mail/_components/useMail.ts
Normal file
109
website/app/mail/_components/useMail.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user