[split-required] Split website + studio-v2 monoliths (Phase 3 continued)

Website (14 monoliths split):
- compliance/page.tsx (1,519 → 9), docs/audit (1,262 → 20)
- quality (1,231 → 16), alerts (1,203 → 10), docs (1,202 → 11)
- i18n.ts (1,173 → 8 language files)
- unity-bridge (1,094 → 12), backlog (1,087 → 6)
- training (1,066 → 8), rag (1,063 → 8)
- Deleted index_original.ts (4,899 LOC dead backup)

Studio-v2 (5 monoliths split):
- meet/page.tsx (1,481 → 9), messages (1,166 → 9)
- AlertsB2BContext.tsx (1,165 → 5 modules)
- alerts-b2b/page.tsx (1,019 → 6), korrektur/archiv (1,001 → 6)

All existing imports preserved. Zero new TypeScript errors.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Benjamin Admin
2026-04-24 17:52:36 +02:00
parent b681ddb131
commit 0b37c5e692
143 changed files with 15822 additions and 15889 deletions

View File

@@ -0,0 +1,120 @@
'use client'
import { useTheme } from '@/lib/ThemeContext'
import type { B2BHit } from '@/lib/AlertsB2BContext'
export function DecisionTraceModal({
hit,
onClose
}: {
hit: B2BHit
onClose: () => void
}) {
const { isDark } = useTheme()
const trace = hit.decisionTrace
return (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
<div className="absolute inset-0 bg-black/50 backdrop-blur-sm" onClick={onClose} />
<div className={`relative w-full max-w-xl rounded-3xl border p-6 max-h-[90vh] overflow-y-auto ${
isDark ? 'bg-slate-900 border-white/20' : 'bg-white border-slate-200 shadow-2xl'
}`}>
<div className="flex items-center justify-between mb-4">
<h3 className={`text-lg font-semibold ${isDark ? 'text-white' : 'text-slate-900'}`}>
Decision Trace
</h3>
<button onClick={onClose} className={`p-2 rounded-lg ${isDark ? 'hover:bg-white/10' : 'hover:bg-slate-100'}`}>
<svg className={`w-5 h-5 ${isDark ? 'text-white/60' : 'text-slate-400'}`} 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>
{trace ? (
<div className="space-y-4">
{/* Rules Triggered */}
<div className={`p-4 rounded-xl ${isDark ? 'bg-white/5' : 'bg-slate-50'}`}>
<h4 className={`text-sm font-medium mb-2 ${isDark ? 'text-white/80' : 'text-slate-700'}`}>
Regeln ausgeloest
</h4>
<div className="flex flex-wrap gap-2">
{trace.rulesTriggered.map((rule, idx) => (
<span key={idx} className={`px-2 py-1 rounded-lg text-xs ${
isDark ? 'bg-blue-500/20 text-blue-300' : 'bg-blue-100 text-blue-700'
}`}>
{rule}
</span>
))}
</div>
</div>
{/* LLM Used */}
<div className={`p-4 rounded-xl ${isDark ? 'bg-white/5' : 'bg-slate-50'}`}>
<div className="flex items-center justify-between">
<span className={`text-sm ${isDark ? 'text-white/60' : 'text-slate-500'}`}>LLM verwendet</span>
<span className={`px-2 py-1 rounded-lg text-xs ${
trace.llmUsed
? isDark ? 'bg-purple-500/20 text-purple-300' : 'bg-purple-100 text-purple-700'
: isDark ? 'bg-slate-500/20 text-slate-300' : 'bg-slate-100 text-slate-500'
}`}>
{trace.llmUsed ? `Ja (${Math.round((trace.llmConfidence || 0) * 100)}% Konfidenz)` : 'Nein'}
</span>
</div>
</div>
{/* Signals */}
<div className="space-y-3">
{trace.signals.procurementSignalsFound.length > 0 && (
<div className={`p-4 rounded-xl ${isDark ? 'bg-green-500/10' : 'bg-green-50'}`}>
<h4 className={`text-sm font-medium mb-2 ${isDark ? 'text-green-300' : 'text-green-700'}`}>
Beschaffungs-Signale
</h4>
<p className={`text-sm ${isDark ? 'text-green-200/80' : 'text-green-600'}`}>
{trace.signals.procurementSignalsFound.join(', ')}
</p>
</div>
)}
{trace.signals.publicBuyerSignalsFound.length > 0 && (
<div className={`p-4 rounded-xl ${isDark ? 'bg-blue-500/10' : 'bg-blue-50'}`}>
<h4 className={`text-sm font-medium mb-2 ${isDark ? 'text-blue-300' : 'text-blue-700'}`}>
Oeffentliche Auftraggeber
</h4>
<p className={`text-sm ${isDark ? 'text-blue-200/80' : 'text-blue-600'}`}>
{trace.signals.publicBuyerSignalsFound.join(', ')}
</p>
</div>
)}
{trace.signals.productSignalsFound.length > 0 && (
<div className={`p-4 rounded-xl ${isDark ? 'bg-amber-500/10' : 'bg-amber-50'}`}>
<h4 className={`text-sm font-medium mb-2 ${isDark ? 'text-amber-300' : 'text-amber-700'}`}>
Produkt-Signale
</h4>
<p className={`text-sm ${isDark ? 'text-amber-200/80' : 'text-amber-600'}`}>
{trace.signals.productSignalsFound.join(', ')}
</p>
</div>
)}
{trace.signals.negativesFound.length > 0 && (
<div className={`p-4 rounded-xl ${isDark ? 'bg-red-500/10' : 'bg-red-50'}`}>
<h4 className={`text-sm font-medium mb-2 ${isDark ? 'text-red-300' : 'text-red-700'}`}>
Negative Signale
</h4>
<p className={`text-sm ${isDark ? 'text-red-200/80' : 'text-red-600'}`}>
{trace.signals.negativesFound.join(', ')}
</p>
</div>
)}
</div>
</div>
) : (
<p className={`text-sm ${isDark ? 'text-white/40' : 'text-slate-400'}`}>
Kein Decision Trace verfuegbar.
</p>
)}
</div>
</div>
)
}

View File

@@ -0,0 +1,49 @@
'use client'
import { useTheme } from '@/lib/ThemeContext'
import type { B2BHit } from '@/lib/AlertsB2BContext'
import { HitCard } from './HitCard'
export function DigestView({
hits,
onHitClick
}: {
hits: B2BHit[]
onHitClick: (hit: B2BHit) => void
}) {
const { isDark } = useTheme()
return (
<div className="space-y-4">
<div className="flex items-center gap-2 mb-4">
<span className="text-2xl">📬</span>
<h3 className={`font-semibold ${isDark ? 'text-white' : 'text-slate-900'}`}>
Tages-Digest (Top 10)
</h3>
</div>
{hits.length === 0 ? (
<div className={`text-center py-8 ${isDark ? 'text-white/40' : 'text-slate-400'}`}>
<span className="text-4xl block mb-2">🎉</span>
<p>Keine relevanten Hits heute</p>
</div>
) : (
<div className="space-y-3">
{hits.map((hit, idx) => (
<div key={hit.id} className="flex items-start gap-3">
<span className={`w-6 h-6 rounded-full flex items-center justify-center text-xs font-medium ${
idx < 3
? 'bg-amber-500/20 text-amber-400'
: isDark ? 'bg-white/10 text-white/60' : 'bg-slate-100 text-slate-500'
}`}>
{idx + 1}
</span>
<div className="flex-1">
<HitCard hit={hit} onClick={() => onHitClick(hit)} />
</div>
</div>
))}
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,163 @@
'use client'
import { useState } from 'react'
import { useTheme } from '@/lib/ThemeContext'
export function EmailImportModal({
onClose,
onImport
}: {
onClose: () => void
onImport: (content: string, subject?: string) => void
}) {
const { isDark } = useTheme()
const [emailSubject, setEmailSubject] = useState('')
const [emailContent, setEmailContent] = useState('')
const [isProcessing, setIsProcessing] = useState(false)
const handleImport = async () => {
if (!emailContent.trim()) return
setIsProcessing(true)
// Simulate processing delay
await new Promise(resolve => setTimeout(resolve, 800))
onImport(emailContent, emailSubject || undefined)
setIsProcessing(false)
onClose()
}
return (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
<div className="absolute inset-0 bg-black/50 backdrop-blur-sm" onClick={onClose} />
<div className={`relative w-full max-w-2xl rounded-3xl border p-6 max-h-[90vh] overflow-y-auto ${
isDark ? 'bg-slate-900 border-white/20' : 'bg-white border-slate-200 shadow-2xl'
}`}>
<div className="flex items-center justify-between mb-6">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-xl bg-gradient-to-br from-purple-500 to-pink-500 flex items-center justify-center text-xl">
📧
</div>
<div>
<h3 className={`text-lg font-semibold ${isDark ? 'text-white' : 'text-slate-900'}`}>
E-Mail manuell einfuegen
</h3>
<p className={`text-sm ${isDark ? 'text-white/60' : 'text-slate-500'}`}>
Fuegen Sie den Inhalt einer Google Alert E-Mail ein
</p>
</div>
</div>
<button onClick={onClose} className={`p-2 rounded-lg ${isDark ? 'hover:bg-white/10' : 'hover:bg-slate-100'}`}>
<svg className={`w-5 h-5 ${isDark ? 'text-white/60' : 'text-slate-400'}`} fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<div className="space-y-4">
{/* Subject (optional) */}
<div>
<label className={`block text-sm font-medium mb-2 ${isDark ? 'text-white/80' : 'text-slate-700'}`}>
Betreff (optional)
</label>
<input
type="text"
placeholder="z.B. Google Alert - Parkscheinautomaten"
value={emailSubject}
onChange={(e) => setEmailSubject(e.target.value)}
className={`w-full px-4 py-3 rounded-xl border ${
isDark
? 'bg-white/10 border-white/20 text-white placeholder-white/40'
: 'bg-white border-slate-200 text-slate-900 placeholder-slate-400'
}`}
/>
</div>
{/* Email Content */}
<div>
<label className={`block text-sm font-medium mb-2 ${isDark ? 'text-white/80' : 'text-slate-700'}`}>
E-Mail-Inhalt *
</label>
<textarea
placeholder="Fuegen Sie hier den kompletten E-Mail-Inhalt ein...
Beispiel:
Google Alerts
Parkscheinautomaten
Tagesaktuell | 23. Januar 2026
Stadt Muenchen schreibt neue Parkscheinautomaten aus
www.muenchen.de - Die Landeshauptstadt Muenchen schreibt die Beschaffung von 150 neuen Parkscheinautomaten fuer das Stadtgebiet aus. Die Submission endet am 15.02.2026..."
value={emailContent}
onChange={(e) => setEmailContent(e.target.value)}
rows={12}
className={`w-full px-4 py-3 rounded-xl border resize-none font-mono text-sm ${
isDark
? 'bg-white/10 border-white/20 text-white placeholder-white/30'
: 'bg-white border-slate-200 text-slate-900 placeholder-slate-400'
}`}
/>
<p className={`text-xs mt-2 ${isDark ? 'text-white/40' : 'text-slate-400'}`}>
Kopieren Sie den gesamten E-Mail-Text inkl. Links aus Ihrer Google Alert E-Mail
</p>
</div>
{/* Info Box */}
<div className={`p-4 rounded-xl ${isDark ? 'bg-purple-500/10 border border-purple-500/30' : 'bg-purple-50 border border-purple-200'}`}>
<div className="flex items-start gap-3">
<span className="text-xl">🤖</span>
<div>
<p className={`text-sm font-medium ${isDark ? 'text-purple-300' : 'text-purple-700'}`}>
KI-Verarbeitung
</p>
<p className={`text-sm ${isDark ? 'text-purple-200/70' : 'text-purple-600'}`}>
Die KI analysiert den Inhalt, erkennt Beschaffungs-Signale, identifiziert
potenzielle Auftraggeber und bewertet die Relevanz fuer Ihr Unternehmen.
</p>
</div>
</div>
</div>
</div>
{/* Actions */}
<div className="flex items-center justify-end gap-3 mt-6 pt-4 border-t border-white/10">
<button
onClick={onClose}
className={`px-4 py-2 rounded-xl font-medium ${
isDark
? 'text-white/60 hover:text-white hover:bg-white/10'
: 'text-slate-500 hover:text-slate-900 hover:bg-slate-100'
}`}
>
Abbrechen
</button>
<button
onClick={handleImport}
disabled={!emailContent.trim() || isProcessing}
className={`px-6 py-2 rounded-xl font-medium transition-all flex items-center gap-2 ${
emailContent.trim() && !isProcessing
? 'bg-gradient-to-r from-purple-500 to-pink-500 text-white hover:shadow-lg hover:shadow-purple-500/30'
: isDark
? 'bg-white/10 text-white/30 cursor-not-allowed'
: 'bg-slate-200 text-slate-400 cursor-not-allowed'
}`}
>
{isProcessing ? (
<>
<svg className="w-4 h-4 animate-spin" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
Verarbeite...
</>
) : (
<>
<span>🔍</span>
Analysieren & Importieren
</>
)}
</button>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,66 @@
'use client'
import { useTheme } from '@/lib/ThemeContext'
import type { B2BHit } from '@/lib/AlertsB2BContext'
import { getImportanceLabelColor, getDecisionLabelColor, formatDeadline } from '@/lib/AlertsB2BContext'
export function HitCard({
hit,
onClick
}: {
hit: B2BHit
onClick: () => void
}) {
const { isDark } = useTheme()
return (
<button
onClick={onClick}
className={`w-full text-left p-4 rounded-xl transition-all group ${
isDark
? `bg-white/5 hover:bg-white/10 ${!hit.isRead ? 'border-l-4 border-amber-500' : ''}`
: `bg-slate-50 hover:bg-slate-100 ${!hit.isRead ? 'border-l-4 border-amber-500' : ''}`
}`}
>
<div className="flex items-start gap-3">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1 flex-wrap">
<span className={`px-2 py-0.5 rounded-full text-xs font-medium border ${getImportanceLabelColor(hit.importanceLabel, isDark)}`}>
{hit.importanceLabel}
</span>
<span className={`px-2 py-0.5 rounded-lg text-xs ${getDecisionLabelColor(hit.decisionLabel, isDark)}`}>
{hit.decisionLabel === 'relevant' ? 'Relevant' : hit.decisionLabel === 'needs_review' ? 'Pruefung' : 'Info'}
</span>
{hit.deadlineGuess && (
<span className={`text-xs ${
formatDeadline(hit.deadlineGuess).includes('Heute') || formatDeadline(hit.deadlineGuess).includes('Morgen')
? 'text-red-500 font-medium'
: isDark ? 'text-white/40' : 'text-slate-400'
}`}>
📅 {formatDeadline(hit.deadlineGuess)}
</span>
)}
{!hit.isRead && (
<span className="w-2 h-2 rounded-full bg-amber-500" />
)}
</div>
<h3 className={`font-medium text-sm mb-1 ${isDark ? 'text-white' : 'text-slate-900'}`}>
{hit.title}
</h3>
<p className={`text-xs truncate ${isDark ? 'text-white/60' : 'text-slate-500'}`}>
{hit.snippet}
</p>
<div className={`flex items-center gap-3 mt-2 text-xs ${isDark ? 'text-white/40' : 'text-slate-400'}`}>
{hit.buyerGuess && <span>🏛 {hit.buyerGuess}</span>}
{hit.countryGuess && <span>🌍 {hit.countryGuess}</span>}
</div>
</div>
<svg className={`w-5 h-5 flex-shrink-0 transition-colors ${
isDark ? 'text-white/30 group-hover:text-white/60' : 'text-slate-300 group-hover:text-slate-600'
}`} fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
</svg>
</div>
</button>
)
}

View File

@@ -0,0 +1,148 @@
'use client'
import { useState } from 'react'
import { useTheme } from '@/lib/ThemeContext'
import type { B2BHit } from '@/lib/AlertsB2BContext'
import { getImportanceLabelColor, getDecisionLabelColor, formatDeadline } from '@/lib/AlertsB2BContext'
import { DecisionTraceModal } from './DecisionTraceModal'
export function HitDetailModal({
hit,
onClose,
onFeedback
}: {
hit: B2BHit
onClose: () => void
onFeedback: (feedback: 'relevant' | 'irrelevant') => void
}) {
const { isDark } = useTheme()
const [showTrace, setShowTrace] = useState(false)
return (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
<div className="absolute inset-0 bg-black/50 backdrop-blur-sm" onClick={onClose} />
<div className={`relative w-full max-w-2xl rounded-3xl border p-8 max-h-[90vh] overflow-y-auto ${
isDark ? 'bg-slate-900 border-white/20' : 'bg-white border-slate-200 shadow-2xl'
}`}>
{/* Header */}
<div className="flex items-start justify-between mb-4">
<div className="flex items-center gap-3 flex-wrap">
<span className={`px-3 py-1 rounded-full text-xs font-medium border ${getImportanceLabelColor(hit.importanceLabel, isDark)}`}>
{hit.importanceLabel}
</span>
<span className={`px-2 py-1 rounded-lg text-xs ${getDecisionLabelColor(hit.decisionLabel, isDark)}`}>
{hit.decisionLabel === 'relevant' ? 'Relevant' : hit.decisionLabel === 'needs_review' ? 'Pruefung noetig' : 'Irrelevant'}
</span>
<span className={`text-xs ${isDark ? 'text-white/40' : 'text-slate-400'}`}>
Score: {hit.importanceScore}
</span>
</div>
<button onClick={onClose} className={`p-2 rounded-lg ${isDark ? 'hover:bg-white/10' : 'hover:bg-slate-100'}`}>
<svg className={`w-5 h-5 ${isDark ? 'text-white/60' : 'text-slate-400'}`} 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>
{/* Title */}
<h2 className={`text-xl font-bold mb-4 ${isDark ? 'text-white' : 'text-slate-900'}`}>
{hit.title}
</h2>
{/* Metadata */}
<div className={`flex flex-wrap gap-4 mb-4 text-sm ${isDark ? 'text-white/60' : 'text-slate-500'}`}>
{hit.buyerGuess && (
<span className="flex items-center gap-1">
<span>🏛</span> {hit.buyerGuess}
</span>
)}
{hit.countryGuess && (
<span className="flex items-center gap-1">
<span>🌍</span> {hit.countryGuess}
</span>
)}
{hit.deadlineGuess && (
<span className={`flex items-center gap-1 ${
formatDeadline(hit.deadlineGuess).includes('Heute') || formatDeadline(hit.deadlineGuess).includes('Morgen')
? 'text-red-500 font-medium'
: ''
}`}>
<span>📅</span> Frist: {formatDeadline(hit.deadlineGuess)}
</span>
)}
</div>
{/* Snippet */}
<div className={`rounded-xl p-4 mb-6 ${isDark ? 'bg-white/5' : 'bg-slate-50'}`}>
<p className={isDark ? 'text-white/80' : 'text-slate-600'}>
{hit.snippet}
</p>
</div>
{/* Source Info */}
<div className={`flex items-center gap-4 mb-6 text-sm ${isDark ? 'text-white/40' : 'text-slate-400'}`}>
<span className={`px-2 py-1 rounded ${isDark ? 'bg-white/10' : 'bg-slate-100'}`}>
{hit.sourceType === 'email' ? '📧 Email' : '📡 RSS'}
</span>
<a
href={hit.originalUrl}
target="_blank"
rel="noopener noreferrer"
className={`hover:underline ${isDark ? 'text-blue-400' : 'text-blue-600'}`}
>
Original ansehen
</a>
</div>
{/* Actions */}
<div className="flex items-center justify-between pt-4 border-t border-white/10">
{/* Feedback */}
<div className="flex items-center gap-2">
<span className={`text-sm ${isDark ? 'text-white/60' : 'text-slate-500'}`}>War das relevant?</span>
<button
onClick={() => onFeedback('relevant')}
className={`p-2 rounded-lg transition-all ${
hit.userFeedback === 'relevant'
? 'bg-green-500/20 text-green-400'
: isDark ? 'hover:bg-white/10 text-white/40' : 'hover:bg-slate-100 text-slate-400'
}`}
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M14 10h4.764a2 2 0 011.789 2.894l-3.5 7A2 2 0 0115.263 21h-4.017c-.163 0-.326-.02-.485-.06L7 20m7-10V5a2 2 0 00-2-2h-.095c-.5 0-.905.405-.905.905 0 .714-.211 1.412-.608 2.006L7 11v9m7-10h-2M7 20H5a2 2 0 01-2-2v-6a2 2 0 012-2h2.5" />
</svg>
</button>
<button
onClick={() => onFeedback('irrelevant')}
className={`p-2 rounded-lg transition-all ${
hit.userFeedback === 'irrelevant'
? 'bg-red-500/20 text-red-400'
: isDark ? 'hover:bg-white/10 text-white/40' : 'hover:bg-slate-100 text-slate-400'
}`}
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 14H5.236a2 2 0 01-1.789-2.894l3.5-7A2 2 0 018.736 3h4.018a2 2 0 01.485.06l3.76.94m-7 10v5a2 2 0 002 2h.096c.5 0 .905-.405.905-.904 0-.715.211-1.413.608-2.008L17 13V4m-7 10h2m5-10h2a2 2 0 012 2v6a2 2 0 01-2 2h-2.5" />
</svg>
</button>
</div>
{/* Decision Trace */}
<button
onClick={() => setShowTrace(true)}
className={`px-4 py-2 rounded-lg text-sm transition-all ${
isDark
? 'bg-white/10 text-white/80 hover:bg-white/20'
: 'bg-slate-100 text-slate-700 hover:bg-slate-200'
}`}
>
🔍 Decision Trace
</button>
</div>
</div>
{/* Decision Trace Modal */}
{showTrace && (
<DecisionTraceModal hit={hit} onClose={() => setShowTrace(false)} />
)}
</div>
)
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,130 @@
'use client'
import { useState } from 'react'
import { GlassCard } from './GlassCard'
import type { AbiturDokument } from './DokumentCard'
interface CreateKlausurFromTemplateModalProps {
template: AbiturDokument
onClose: () => void
onCreate: (title: string) => void
onFallback: () => void
isLoading: boolean
error: string | null
isDark: boolean
}
export function CreateKlausurFromTemplateModal({
template,
onClose,
onCreate,
onFallback,
isLoading,
error,
isDark,
}: CreateKlausurFromTemplateModalProps) {
const [title, setTitle] = useState(
`${template.fach} ${template.aufgabentyp || ''} ${template.jahr}`.trim()
)
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault()
onCreate(title)
}
return (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
<div className="absolute inset-0 bg-black/50 backdrop-blur-sm" onClick={onClose} />
<GlassCard className="relative w-full max-w-md" size="lg" delay={0} isDark={isDark}>
<h2 className={`text-xl font-bold mb-2 ${isDark ? 'text-white' : 'text-slate-900'}`}>
Klausur aus Vorlage erstellen
</h2>
<p className={`text-sm mb-6 ${isDark ? 'text-white/60' : 'text-slate-600'}`}>
Basierend auf: {template.thema || template.dateiname}
</p>
{error && (
<div className="mb-4 p-3 rounded-lg bg-red-500/20 border border-red-500/30 text-sm">
<p className="text-red-300 mb-2">{error}</p>
<button
onClick={onFallback}
className="text-purple-400 hover:text-purple-300 underline text-xs"
>
Zur Korrektur-Uebersicht (ohne Klausur erstellen)
</button>
</div>
)}
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label className={`block text-sm mb-2 ${isDark ? 'text-white/60' : 'text-slate-600'}`}>
Klausur-Titel
</label>
<input
type="text"
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder="z.B. Deutsch LK Q4 - Kafka"
className={`w-full p-3 rounded-xl border focus:ring-2 focus:ring-purple-500 focus:border-transparent ${
isDark
? 'bg-white/10 border-white/20 text-white placeholder-white/40'
: 'bg-slate-100 border-slate-300 text-slate-900 placeholder-slate-400'
}`}
required
autoFocus
/>
</div>
{/* Template Info */}
<div className={`p-4 rounded-xl ${isDark ? 'bg-white/5' : 'bg-slate-100'}`}>
<div className="grid grid-cols-2 gap-3 text-sm">
{[
{ label: 'Fach', value: template.fach },
{ label: 'Jahr', value: String(template.jahr) },
{ label: 'Niveau', value: template.niveau },
{ label: 'Typ', value: template.aufgabentyp || '-' },
].map(({ label, value }) => (
<div key={label}>
<span className={isDark ? 'text-white/50' : 'text-slate-500'}>{label}:</span>
<span className={`ml-2 ${isDark ? 'text-white' : 'text-slate-900'}`}>{value}</span>
</div>
))}
</div>
</div>
<div className="flex gap-3 pt-2">
<button
type="button"
onClick={onClose}
disabled={isLoading}
className={`flex-1 px-4 py-3 rounded-xl transition-colors ${
isDark
? 'bg-white/10 text-white hover:bg-white/20'
: 'bg-slate-200 text-slate-700 hover:bg-slate-300'
} disabled:opacity-50`}
>
Abbrechen
</button>
<button
type="submit"
disabled={isLoading || !title.trim()}
className="flex-1 px-4 py-3 rounded-xl bg-gradient-to-r from-purple-500 to-pink-500 text-white font-semibold hover:shadow-lg hover:shadow-purple-500/30 transition-all disabled:opacity-50"
>
{isLoading ? (
<span className="flex items-center justify-center gap-2">
<svg className="w-4 h-4 animate-spin" 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 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" />
</svg>
Erstelle...
</span>
) : (
'Klausur erstellen'
)}
</button>
</div>
</form>
</GlassCard>
</div>
)
}

View File

@@ -0,0 +1,79 @@
'use client'
import { GlassCard } from './GlassCard'
export interface AbiturDokument {
id: string
dateiname: string
fach: string
jahr: number
bundesland: string
niveau: string
dokumenttyp: string
aufgabentyp?: string
thema?: string
download_url: string
preview_url?: string
file_size?: number
page_count?: number
}
interface DokumentCardProps {
dokument: AbiturDokument
onPreview: () => void
onUseAsTemplate: () => void
delay?: number
isDark: boolean
}
export function DokumentCard({ dokument, onPreview, onUseAsTemplate, delay = 0, isDark }: DokumentCardProps) {
const typeColor = dokument.dokumenttyp === 'Erwartungshorizont' ? '#22c55e' : '#3b82f6'
return (
<GlassCard delay={delay} isDark={isDark}>
<div className="flex flex-col h-full">
{/* Header */}
<div className="flex items-start justify-between mb-3">
<div className="flex-1 min-w-0">
<h3 className={`font-semibold truncate ${isDark ? 'text-white' : 'text-slate-900'}`}>
{dokument.fach} {dokument.jahr} {dokument.niveau}
</h3>
<p className={`text-sm truncate ${isDark ? 'text-white/60' : 'text-slate-600'}`}>
{dokument.thema || dokument.aufgabentyp || dokument.dateiname}
</p>
</div>
<span
className="px-2 py-1 rounded-full text-xs font-medium flex-shrink-0 ml-2"
style={{ backgroundColor: `${typeColor}20`, color: typeColor }}
>
{dokument.dokumenttyp === 'Erwartungshorizont' ? 'EH' : 'Aufgabe'}
</span>
</div>
{/* Meta */}
<div className={`flex items-center gap-3 text-xs mb-4 ${isDark ? 'text-white/40' : 'text-slate-500'}`}>
<span>{dokument.bundesland}</span>
{dokument.page_count && <span>{dokument.page_count} Seiten</span>}
</div>
{/* Actions */}
<div className="flex gap-2 mt-auto">
<button
onClick={(e) => { e.stopPropagation(); onPreview() }}
className={`flex-1 px-3 py-2 rounded-xl text-sm font-medium transition-colors ${
isDark ? 'bg-white/10 text-white hover:bg-white/20' : 'bg-slate-100 text-slate-700 hover:bg-slate-200'
}`}
>
Vorschau
</button>
<button
onClick={(e) => { e.stopPropagation(); onUseAsTemplate() }}
className="flex-1 px-3 py-2 rounded-xl text-sm font-medium bg-gradient-to-r from-purple-500 to-pink-500 text-white hover:shadow-lg hover:shadow-purple-500/30 transition-all"
>
Verwenden
</button>
</div>
</div>
</GlassCard>
)
}

View File

@@ -0,0 +1,34 @@
'use client'
interface FilterDropdownProps {
label: string
value: string
options: string[]
onChange: (value: string) => void
isDark: boolean
}
export function FilterDropdown({ label, value, options, onChange, isDark }: FilterDropdownProps) {
const inputId = `filter-${label.toLowerCase().replace(/\s+/g, '-')}`
return (
<div className="flex flex-col gap-1">
<label htmlFor={inputId} className={`text-xs ${isDark ? 'text-white/50' : 'text-slate-500'}`}>{label}</label>
<select
id={inputId}
value={value}
onChange={(e) => onChange(e.target.value)}
className={`px-3 py-2 rounded-xl text-sm transition-colors ${
isDark
? 'bg-white/10 border-white/20 text-white'
: 'bg-white border-slate-200 text-slate-900'
} border focus:ring-2 focus:ring-purple-500 focus:border-transparent`}
>
{options.map((opt) => (
<option key={opt} value={opt} className="bg-slate-800 text-white">
{opt}
</option>
))}
</select>
</div>
)
}

View File

@@ -0,0 +1,49 @@
'use client'
import { useState, useEffect, ReactNode } from 'react'
interface GlassCardProps {
children: ReactNode
className?: string
onClick?: () => void
size?: 'sm' | 'md' | 'lg'
delay?: number
isDark?: boolean
}
export function GlassCard({ children, className = '', onClick, size = 'md', delay = 0, isDark = true }: GlassCardProps) {
const [isVisible, setIsVisible] = useState(false)
const [isHovered, setIsHovered] = useState(false)
useEffect(() => {
const timer = setTimeout(() => setIsVisible(true), delay)
return () => clearTimeout(timer)
}, [delay])
const sizeClasses = { sm: 'p-4', md: 'p-5', lg: 'p-6' }
return (
<div
className={`rounded-3xl ${sizeClasses[size]} ${onClick ? 'cursor-pointer' : ''} ${className}`}
style={{
background: isDark
? (isHovered ? 'rgba(255, 255, 255, 0.12)' : 'rgba(255, 255, 255, 0.08)')
: (isHovered ? 'rgba(255, 255, 255, 0.9)' : 'rgba(255, 255, 255, 0.7)'),
backdropFilter: 'blur(24px) saturate(180%)',
WebkitBackdropFilter: 'blur(24px) saturate(180%)',
border: isDark ? '1px solid rgba(255, 255, 255, 0.1)' : '1px solid rgba(0, 0, 0, 0.1)',
boxShadow: isDark
? '0 4px 24px rgba(0, 0, 0, 0.15), inset 0 1px 0 rgba(255, 255, 255, 0.05)'
: '0 4px 24px rgba(0, 0, 0, 0.08), inset 0 1px 0 rgba(255, 255, 255, 0.5)',
opacity: isVisible ? 1 : 0,
transform: isVisible ? `translateY(0) scale(${isHovered ? 1.01 : 1})` : 'translateY(20px)',
transition: 'all 0.4s cubic-bezier(0.34, 1.56, 0.64, 1)',
}}
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
onClick={onClick}
>
{children}
</div>
)
}

View File

@@ -0,0 +1,164 @@
'use client'
import { useState } from 'react'
import { GlassCard } from './GlassCard'
import type { AbiturDokument } from './DokumentCard'
interface PreviewModalProps {
dokument: AbiturDokument | null
onClose: () => void
onUseAsTemplate: () => void
isDark: boolean
}
export function PreviewModal({ dokument, onClose, onUseAsTemplate, isDark }: PreviewModalProps) {
const [zoom, setZoom] = useState(100)
if (!dokument) return null
return (
<div className="fixed inset-0 z-50 flex">
{/* Backdrop */}
<div className="absolute inset-0 bg-black/70 backdrop-blur-sm" onClick={onClose} />
{/* Content */}
<div className="relative flex-1 flex m-4 gap-4">
{/* PDF Viewer */}
<div className="flex-1 flex flex-col">
<GlassCard className="flex-1 flex flex-col overflow-hidden" size="sm" isDark={isDark}>
{/* Toolbar */}
<div className={`flex items-center justify-between p-3 border-b ${isDark ? 'border-white/10' : 'border-slate-200'}`}>
<div className="flex items-center gap-2">
<button
onClick={() => setZoom(z => Math.max(50, z - 25))}
className={`p-2 rounded-lg ${isDark ? 'hover:bg-white/10' : 'hover:bg-slate-100'}`}
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M20 12H4" />
</svg>
</button>
<span className={`text-sm ${isDark ? 'text-white/60' : 'text-slate-600'}`}>{zoom}%</span>
<button
onClick={() => setZoom(z => Math.min(200, z + 25))}
className={`p-2 rounded-lg ${isDark ? 'hover:bg-white/10' : 'hover:bg-slate-100'}`}
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
</svg>
</button>
</div>
<button
onClick={onClose}
className={`p-2 rounded-lg ${isDark ? 'hover:bg-white/10' : 'hover:bg-slate-100'}`}
>
<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>
{/* PDF Content */}
<div className="flex-1 overflow-auto p-4">
<div
className="mx-auto bg-white rounded-lg shadow-xl"
style={{ width: `${zoom}%`, minHeight: '800px' }}
>
{dokument.preview_url ? (
<iframe
src={dokument.preview_url}
className="w-full h-full min-h-[800px]"
title={dokument.dateiname}
/>
) : (
<div className="flex items-center justify-center h-full text-slate-400">
<div className="text-center">
<svg className="w-16 h-16 mx-auto mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} 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>
<p>Vorschau nicht verfuegbar</p>
<a
href={dokument.download_url}
target="_blank"
rel="noopener noreferrer"
className="text-purple-400 hover:underline mt-2 inline-block"
>
PDF herunterladen
</a>
</div>
</div>
)}
</div>
</div>
</GlassCard>
</div>
{/* Sidebar */}
<div className="w-80 flex flex-col gap-4">
{/* Close Button - prominent */}
<button
onClick={onClose}
className={`flex items-center gap-2 px-4 py-3 rounded-2xl font-medium transition-colors ${
isDark
? 'bg-white/10 text-white hover:bg-white/20'
: 'bg-white/70 text-slate-700 hover:bg-white/90'
}`}
style={{
backdropFilter: 'blur(24px)',
border: isDark ? '1px solid rgba(255,255,255,0.1)' : '1px solid rgba(0,0,0,0.1)',
}}
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
</svg>
Zurueck zum Archiv
</button>
<GlassCard size="md" isDark={isDark}>
<h3 className={`font-semibold mb-4 ${isDark ? 'text-white' : 'text-slate-900'}`}>Details</h3>
<div className="space-y-3">
{[
{ label: 'Fach', value: dokument.fach },
{ label: 'Jahr', value: String(dokument.jahr) },
{ label: 'Bundesland', value: dokument.bundesland },
{ label: 'Niveau', value: dokument.niveau },
].map(({ label, value }) => (
<div key={label}>
<span className={`text-xs ${isDark ? 'text-white/50' : 'text-slate-500'}`}>{label}</span>
<p className={isDark ? 'text-white' : 'text-slate-900'}>{value}</p>
</div>
))}
{dokument.thema && (
<div>
<span className={`text-xs ${isDark ? 'text-white/50' : 'text-slate-500'}`}>Thema</span>
<p className={isDark ? 'text-white' : 'text-slate-900'}>{dokument.thema}</p>
</div>
)}
</div>
</GlassCard>
<GlassCard size="md" isDark={isDark}>
<h3 className={`font-semibold mb-4 ${isDark ? 'text-white' : 'text-slate-900'}`}>Aktionen</h3>
<div className="space-y-2">
<button
onClick={onUseAsTemplate}
className="w-full px-4 py-3 rounded-xl bg-gradient-to-r from-purple-500 to-pink-500 text-white font-semibold hover:shadow-lg hover:shadow-purple-500/30 transition-all"
>
Als Vorlage verwenden
</button>
<a
href={dokument.download_url}
target="_blank"
rel="noopener noreferrer"
className={`block w-full px-4 py-3 rounded-xl text-center font-medium transition-colors ${
isDark ? 'bg-white/10 text-white hover:bg-white/20' : 'bg-slate-100 text-slate-700 hover:bg-slate-200'
}`}
>
Herunterladen
</a>
</div>
</GlassCard>
</div>
</div>
</div>
)
}

View File

@@ -1,6 +1,6 @@
'use client'
import { useState, useEffect, useCallback, useMemo } from 'react'
import { useState, useEffect, useMemo } from 'react'
import { useRouter } from 'next/navigation'
import { useTheme } from '@/lib/ThemeContext'
import { useLanguage } from '@/lib/LanguageContext'
@@ -8,507 +8,33 @@ import { Sidebar } from '@/components/Sidebar'
import { ThemeToggle } from '@/components/ThemeToggle'
import { LanguageDropdown } from '@/components/LanguageDropdown'
import { korrekturApi } from '@/lib/korrektur/api'
// =============================================================================
// TYPES
// =============================================================================
interface AbiturDokument {
id: string
dateiname: string
fach: string
jahr: number
bundesland: string
niveau: string
dokumenttyp: string
aufgabentyp?: string
thema?: string
download_url: string
preview_url?: string
file_size?: number
page_count?: number
}
interface ThemaSuggestion {
label: string
count: number
aufgabentyp: string
kategorie?: string
}
import { GlassCard } from './_components/GlassCard'
import { FilterDropdown } from './_components/FilterDropdown'
import { DokumentCard, type AbiturDokument } from './_components/DokumentCard'
import { PreviewModal } from './_components/PreviewModal'
import { CreateKlausurFromTemplateModal } from './_components/CreateKlausurFromTemplateModal'
// =============================================================================
// CONSTANTS
// =============================================================================
// Default filter options (will be updated from API)
const DEFAULT_FAECHER = ['Alle', 'Deutsch', 'Englisch', 'Mathematik', 'Geschichte', 'Politik']
const DEFAULT_JAHRE = ['Alle', '2025', '2024', '2023', '2022', '2021']
const BUNDESLAENDER = ['Alle', 'Niedersachsen', 'NRW', 'Bayern', 'Baden-Wuerttemberg', 'Hessen']
const DEFAULT_NIVEAUS = ['Alle', 'eA', 'gA']
const DEFAULT_DOKUMENTTYPEN = ['Alle', 'Aufgabe', 'Erwartungshorizont', 'Loesungshinweise']
const POPULAR_THEMES = [
'Textanalyse',
'Gedichtanalyse',
'Eroerterung',
'Dramenanalyse',
'Sprachreflexion',
'Romantik',
'Expressionismus',
'Textanalyse', 'Gedichtanalyse', 'Eroerterung', 'Dramenanalyse',
'Sprachreflexion', 'Romantik', 'Expressionismus',
]
// =============================================================================
// GLASS CARD COMPONENT
// =============================================================================
const SAMPLE_PDF = 'https://mozilla.github.io/pdf.js/web/compressed.tracemonkey-pldi-09.pdf'
interface GlassCardProps {
children: React.ReactNode
className?: string
onClick?: () => void
size?: 'sm' | 'md' | 'lg'
delay?: number
isDark?: boolean
}
function GlassCard({ children, className = '', onClick, size = 'md', delay = 0, isDark = true }: GlassCardProps) {
const [isVisible, setIsVisible] = useState(false)
const [isHovered, setIsHovered] = useState(false)
useEffect(() => {
const timer = setTimeout(() => setIsVisible(true), delay)
return () => clearTimeout(timer)
}, [delay])
const sizeClasses = { sm: 'p-4', md: 'p-5', lg: 'p-6' }
return (
<div
className={`rounded-3xl ${sizeClasses[size]} ${onClick ? 'cursor-pointer' : ''} ${className}`}
style={{
background: isDark
? (isHovered ? 'rgba(255, 255, 255, 0.12)' : 'rgba(255, 255, 255, 0.08)')
: (isHovered ? 'rgba(255, 255, 255, 0.9)' : 'rgba(255, 255, 255, 0.7)'),
backdropFilter: 'blur(24px) saturate(180%)',
WebkitBackdropFilter: 'blur(24px) saturate(180%)',
border: isDark ? '1px solid rgba(255, 255, 255, 0.1)' : '1px solid rgba(0, 0, 0, 0.1)',
boxShadow: isDark
? '0 4px 24px rgba(0, 0, 0, 0.15), inset 0 1px 0 rgba(255, 255, 255, 0.05)'
: '0 4px 24px rgba(0, 0, 0, 0.08), inset 0 1px 0 rgba(255, 255, 255, 0.5)',
opacity: isVisible ? 1 : 0,
transform: isVisible ? `translateY(0) scale(${isHovered ? 1.01 : 1})` : 'translateY(20px)',
transition: 'all 0.4s cubic-bezier(0.34, 1.56, 0.64, 1)',
}}
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
onClick={onClick}
>
{children}
</div>
)
}
// =============================================================================
// FILTER DROPDOWN
// =============================================================================
interface FilterDropdownProps {
label: string
value: string
options: string[]
onChange: (value: string) => void
isDark: boolean
}
function FilterDropdown({ label, value, options, onChange, isDark }: FilterDropdownProps) {
const inputId = `filter-${label.toLowerCase().replace(/\s+/g, '-')}`
return (
<div className="flex flex-col gap-1">
<label htmlFor={inputId} className={`text-xs ${isDark ? 'text-white/50' : 'text-slate-500'}`}>{label}</label>
<select
id={inputId}
value={value}
onChange={(e) => onChange(e.target.value)}
className={`px-3 py-2 rounded-xl text-sm transition-colors ${
isDark
? 'bg-white/10 border-white/20 text-white'
: 'bg-white border-slate-200 text-slate-900'
} border focus:ring-2 focus:ring-purple-500 focus:border-transparent`}
>
{options.map((opt) => (
<option key={opt} value={opt} className="bg-slate-800 text-white">
{opt}
</option>
))}
</select>
</div>
)
}
// =============================================================================
// DOCUMENT CARD
// =============================================================================
interface DokumentCardProps {
dokument: AbiturDokument
onPreview: () => void
onUseAsTemplate: () => void
delay?: number
isDark: boolean
}
function DokumentCard({ dokument, onPreview, onUseAsTemplate, delay = 0, isDark }: DokumentCardProps) {
const typeColor = dokument.dokumenttyp === 'Erwartungshorizont' ? '#22c55e' : '#3b82f6'
return (
<GlassCard delay={delay} isDark={isDark}>
<div className="flex flex-col h-full">
{/* Header */}
<div className="flex items-start justify-between mb-3">
<div className="flex-1 min-w-0">
<h3 className={`font-semibold truncate ${isDark ? 'text-white' : 'text-slate-900'}`}>
{dokument.fach} {dokument.jahr} {dokument.niveau}
</h3>
<p className={`text-sm truncate ${isDark ? 'text-white/60' : 'text-slate-600'}`}>
{dokument.thema || dokument.aufgabentyp || dokument.dateiname}
</p>
</div>
<span
className="px-2 py-1 rounded-full text-xs font-medium flex-shrink-0 ml-2"
style={{ backgroundColor: `${typeColor}20`, color: typeColor }}
>
{dokument.dokumenttyp === 'Erwartungshorizont' ? 'EH' : 'Aufgabe'}
</span>
</div>
{/* Meta */}
<div className={`flex items-center gap-3 text-xs mb-4 ${isDark ? 'text-white/40' : 'text-slate-500'}`}>
<span>{dokument.bundesland}</span>
{dokument.page_count && <span>{dokument.page_count} Seiten</span>}
</div>
{/* Actions */}
<div className="flex gap-2 mt-auto">
<button
onClick={(e) => { e.stopPropagation(); onPreview() }}
className={`flex-1 px-3 py-2 rounded-xl text-sm font-medium transition-colors ${
isDark ? 'bg-white/10 text-white hover:bg-white/20' : 'bg-slate-100 text-slate-700 hover:bg-slate-200'
}`}
>
Vorschau
</button>
<button
onClick={(e) => { e.stopPropagation(); onUseAsTemplate() }}
className="flex-1 px-3 py-2 rounded-xl text-sm font-medium bg-gradient-to-r from-purple-500 to-pink-500 text-white hover:shadow-lg hover:shadow-purple-500/30 transition-all"
>
Verwenden
</button>
</div>
</div>
</GlassCard>
)
}
// =============================================================================
// PREVIEW MODAL
// =============================================================================
interface PreviewModalProps {
dokument: AbiturDokument | null
onClose: () => void
onUseAsTemplate: () => void
isDark: boolean
}
function PreviewModal({ dokument, onClose, onUseAsTemplate, isDark }: PreviewModalProps) {
const [zoom, setZoom] = useState(100)
if (!dokument) return null
return (
<div className="fixed inset-0 z-50 flex">
{/* Backdrop */}
<div className="absolute inset-0 bg-black/70 backdrop-blur-sm" onClick={onClose} />
{/* Content */}
<div className="relative flex-1 flex m-4 gap-4">
{/* PDF Viewer */}
<div className="flex-1 flex flex-col">
<GlassCard className="flex-1 flex flex-col overflow-hidden" size="sm" isDark={isDark}>
{/* Toolbar */}
<div className={`flex items-center justify-between p-3 border-b ${isDark ? 'border-white/10' : 'border-slate-200'}`}>
<div className="flex items-center gap-2">
<button
onClick={() => setZoom(z => Math.max(50, z - 25))}
className={`p-2 rounded-lg ${isDark ? 'hover:bg-white/10' : 'hover:bg-slate-100'}`}
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M20 12H4" />
</svg>
</button>
<span className={`text-sm ${isDark ? 'text-white/60' : 'text-slate-600'}`}>{zoom}%</span>
<button
onClick={() => setZoom(z => Math.min(200, z + 25))}
className={`p-2 rounded-lg ${isDark ? 'hover:bg-white/10' : 'hover:bg-slate-100'}`}
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
</svg>
</button>
</div>
<button
onClick={onClose}
className={`p-2 rounded-lg ${isDark ? 'hover:bg-white/10' : 'hover:bg-slate-100'}`}
>
<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>
{/* PDF Content */}
<div className="flex-1 overflow-auto p-4">
<div
className="mx-auto bg-white rounded-lg shadow-xl"
style={{ width: `${zoom}%`, minHeight: '800px' }}
>
{dokument.preview_url ? (
<iframe
src={dokument.preview_url}
className="w-full h-full min-h-[800px]"
title={dokument.dateiname}
/>
) : (
<div className="flex items-center justify-center h-full text-slate-400">
<div className="text-center">
<svg className="w-16 h-16 mx-auto mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} 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>
<p>Vorschau nicht verfuegbar</p>
<a
href={dokument.download_url}
target="_blank"
rel="noopener noreferrer"
className="text-purple-400 hover:underline mt-2 inline-block"
>
PDF herunterladen
</a>
</div>
</div>
)}
</div>
</div>
</GlassCard>
</div>
{/* Sidebar */}
<div className="w-80 flex flex-col gap-4">
{/* Close Button - prominent */}
<button
onClick={onClose}
className={`flex items-center gap-2 px-4 py-3 rounded-2xl font-medium transition-colors ${
isDark
? 'bg-white/10 text-white hover:bg-white/20'
: 'bg-white/70 text-slate-700 hover:bg-white/90'
}`}
style={{
backdropFilter: 'blur(24px)',
border: isDark ? '1px solid rgba(255,255,255,0.1)' : '1px solid rgba(0,0,0,0.1)',
}}
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
</svg>
Zurueck zum Archiv
</button>
<GlassCard size="md" isDark={isDark}>
<h3 className={`font-semibold mb-4 ${isDark ? 'text-white' : 'text-slate-900'}`}>Details</h3>
<div className="space-y-3">
<div>
<span className={`text-xs ${isDark ? 'text-white/50' : 'text-slate-500'}`}>Fach</span>
<p className={isDark ? 'text-white' : 'text-slate-900'}>{dokument.fach}</p>
</div>
<div>
<span className={`text-xs ${isDark ? 'text-white/50' : 'text-slate-500'}`}>Jahr</span>
<p className={isDark ? 'text-white' : 'text-slate-900'}>{dokument.jahr}</p>
</div>
<div>
<span className={`text-xs ${isDark ? 'text-white/50' : 'text-slate-500'}`}>Bundesland</span>
<p className={isDark ? 'text-white' : 'text-slate-900'}>{dokument.bundesland}</p>
</div>
<div>
<span className={`text-xs ${isDark ? 'text-white/50' : 'text-slate-500'}`}>Niveau</span>
<p className={isDark ? 'text-white' : 'text-slate-900'}>{dokument.niveau}</p>
</div>
{dokument.thema && (
<div>
<span className={`text-xs ${isDark ? 'text-white/50' : 'text-slate-500'}`}>Thema</span>
<p className={isDark ? 'text-white' : 'text-slate-900'}>{dokument.thema}</p>
</div>
)}
</div>
</GlassCard>
<GlassCard size="md" isDark={isDark}>
<h3 className={`font-semibold mb-4 ${isDark ? 'text-white' : 'text-slate-900'}`}>Aktionen</h3>
<div className="space-y-2">
<button
onClick={onUseAsTemplate}
className="w-full px-4 py-3 rounded-xl bg-gradient-to-r from-purple-500 to-pink-500 text-white font-semibold hover:shadow-lg hover:shadow-purple-500/30 transition-all"
>
Als Vorlage verwenden
</button>
<a
href={dokument.download_url}
target="_blank"
rel="noopener noreferrer"
className={`block w-full px-4 py-3 rounded-xl text-center font-medium transition-colors ${
isDark ? 'bg-white/10 text-white hover:bg-white/20' : 'bg-slate-100 text-slate-700 hover:bg-slate-200'
}`}
>
Herunterladen
</a>
</div>
</GlassCard>
</div>
</div>
</div>
)
}
// =============================================================================
// CREATE KLAUSUR FROM TEMPLATE MODAL
// =============================================================================
interface CreateKlausurFromTemplateModalProps {
template: AbiturDokument
onClose: () => void
onCreate: (title: string) => void
onFallback: () => void
isLoading: boolean
error: string | null
isDark: boolean
}
function CreateKlausurFromTemplateModal({
template,
onClose,
onCreate,
onFallback,
isLoading,
error,
isDark,
}: CreateKlausurFromTemplateModalProps) {
const [title, setTitle] = useState(
`${template.fach} ${template.aufgabentyp || ''} ${template.jahr}`.trim()
)
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault()
onCreate(title)
}
return (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
<div className="absolute inset-0 bg-black/50 backdrop-blur-sm" onClick={onClose} />
<GlassCard className="relative w-full max-w-md" size="lg" delay={0} isDark={isDark}>
<h2 className={`text-xl font-bold mb-2 ${isDark ? 'text-white' : 'text-slate-900'}`}>
Klausur aus Vorlage erstellen
</h2>
<p className={`text-sm mb-6 ${isDark ? 'text-white/60' : 'text-slate-600'}`}>
Basierend auf: {template.thema || template.dateiname}
</p>
{error && (
<div className="mb-4 p-3 rounded-lg bg-red-500/20 border border-red-500/30 text-sm">
<p className="text-red-300 mb-2">{error}</p>
<button
onClick={onFallback}
className="text-purple-400 hover:text-purple-300 underline text-xs"
>
Zur Korrektur-Uebersicht (ohne Klausur erstellen)
</button>
</div>
)}
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label className={`block text-sm mb-2 ${isDark ? 'text-white/60' : 'text-slate-600'}`}>
Klausur-Titel
</label>
<input
type="text"
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder="z.B. Deutsch LK Q4 - Kafka"
className={`w-full p-3 rounded-xl border focus:ring-2 focus:ring-purple-500 focus:border-transparent ${
isDark
? 'bg-white/10 border-white/20 text-white placeholder-white/40'
: 'bg-slate-100 border-slate-300 text-slate-900 placeholder-slate-400'
}`}
required
autoFocus
/>
</div>
{/* Template Info */}
<div className={`p-4 rounded-xl ${isDark ? 'bg-white/5' : 'bg-slate-100'}`}>
<div className="grid grid-cols-2 gap-3 text-sm">
<div>
<span className={isDark ? 'text-white/50' : 'text-slate-500'}>Fach:</span>
<span className={`ml-2 ${isDark ? 'text-white' : 'text-slate-900'}`}>{template.fach}</span>
</div>
<div>
<span className={isDark ? 'text-white/50' : 'text-slate-500'}>Jahr:</span>
<span className={`ml-2 ${isDark ? 'text-white' : 'text-slate-900'}`}>{template.jahr}</span>
</div>
<div>
<span className={isDark ? 'text-white/50' : 'text-slate-500'}>Niveau:</span>
<span className={`ml-2 ${isDark ? 'text-white' : 'text-slate-900'}`}>{template.niveau}</span>
</div>
<div>
<span className={isDark ? 'text-white/50' : 'text-slate-500'}>Typ:</span>
<span className={`ml-2 ${isDark ? 'text-white' : 'text-slate-900'}`}>{template.aufgabentyp}</span>
</div>
</div>
</div>
<div className="flex gap-3 pt-2">
<button
type="button"
onClick={onClose}
disabled={isLoading}
className={`flex-1 px-4 py-3 rounded-xl transition-colors ${
isDark
? 'bg-white/10 text-white hover:bg-white/20'
: 'bg-slate-200 text-slate-700 hover:bg-slate-300'
} disabled:opacity-50`}
>
Abbrechen
</button>
<button
type="submit"
disabled={isLoading || !title.trim()}
className="flex-1 px-4 py-3 rounded-xl bg-gradient-to-r from-purple-500 to-pink-500 text-white font-semibold hover:shadow-lg hover:shadow-purple-500/30 transition-all disabled:opacity-50"
>
{isLoading ? (
<span className="flex items-center justify-center gap-2">
<svg className="w-4 h-4 animate-spin" 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 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" />
</svg>
Erstelle...
</span>
) : (
'Klausur erstellen'
)}
</button>
</div>
</form>
</GlassCard>
</div>
)
}
const MOCK_DOKUMENTE: AbiturDokument[] = [
{ id: '1', dateiname: 'Deutsch_eA_2024_Aufgabe1.pdf', fach: 'Deutsch', jahr: 2024, bundesland: 'Niedersachsen', niveau: 'eA', dokumenttyp: 'Aufgabe', aufgabentyp: 'Textanalyse', thema: 'Textanalyse: "Der Prozess" - Kafka', download_url: SAMPLE_PDF, preview_url: SAMPLE_PDF, page_count: 4 },
{ id: '2', dateiname: 'Deutsch_eA_2024_EH1.pdf', fach: 'Deutsch', jahr: 2024, bundesland: 'Niedersachsen', niveau: 'eA', dokumenttyp: 'Erwartungshorizont', aufgabentyp: 'Textanalyse', thema: 'EH zu Kafka-Analyse', download_url: SAMPLE_PDF, preview_url: SAMPLE_PDF, page_count: 8 },
{ id: '3', dateiname: 'Deutsch_gA_2024_Aufgabe2.pdf', fach: 'Deutsch', jahr: 2024, bundesland: 'Niedersachsen', niveau: 'gA', dokumenttyp: 'Aufgabe', aufgabentyp: 'Gedichtanalyse', thema: 'Gedichtvergleich Romantik', download_url: SAMPLE_PDF, preview_url: SAMPLE_PDF, page_count: 3 },
{ id: '4', dateiname: 'Deutsch_eA_2023_Aufgabe1.pdf', fach: 'Deutsch', jahr: 2023, bundesland: 'Niedersachsen', niveau: 'eA', dokumenttyp: 'Aufgabe', aufgabentyp: 'Eroerterung', thema: 'Materialgestuetzte Eroerterung: Digitalisierung', download_url: SAMPLE_PDF, preview_url: SAMPLE_PDF, page_count: 5 },
{ id: '5', dateiname: 'Deutsch_eA_2023_EH1.pdf', fach: 'Deutsch', jahr: 2023, bundesland: 'Niedersachsen', niveau: 'eA', dokumenttyp: 'Erwartungshorizont', aufgabentyp: 'Eroerterung', thema: 'EH zu Digitalisierungs-Eroerterung', download_url: SAMPLE_PDF, preview_url: SAMPLE_PDF, page_count: 10 },
{ id: '6', dateiname: 'Deutsch_gA_2023_Aufgabe3.pdf', fach: 'Deutsch', jahr: 2023, bundesland: 'Niedersachsen', niveau: 'gA', dokumenttyp: 'Aufgabe', aufgabentyp: 'Dramenanalyse', thema: 'Szenenanalyse: "Faust I"', download_url: SAMPLE_PDF, preview_url: SAMPLE_PDF, page_count: 4 },
]
// =============================================================================
// MAIN PAGE
@@ -521,8 +47,6 @@ export default function ArchivPage() {
// State
const [searchQuery, setSearchQuery] = useState('')
const [suggestions, setSuggestions] = useState<ThemaSuggestion[]>([])
const [showSuggestions, setShowSuggestions] = useState(false)
const [dokumente, setDokumente] = useState<AbiturDokument[]>([])
const [isLoading, setIsLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
@@ -543,15 +67,9 @@ export default function ArchivPage() {
const [isCreating, setIsCreating] = useState(false)
const [createError, setCreateError] = useState<string | null>(null)
// Example PDF URL (Mozilla public sample) - fallback when API fails
const SAMPLE_PDF = 'https://mozilla.github.io/pdf.js/web/compressed.tracemonkey-pldi-09.pdf'
// Available filter options (updated from API response)
const [availableFilters, setAvailableFilters] = useState<{
subjects: string[]
years: number[]
niveaus: string[]
doc_types: string[]
subjects: string[]; years: number[]; niveaus: string[]; doc_types: string[]
}>({
subjects: ['Deutsch', 'Englisch', 'Mathematik'],
years: [2025, 2024, 2023, 2022, 2021],
@@ -564,49 +82,25 @@ export default function ArchivPage() {
const loadDocuments = async () => {
setIsLoading(true)
setError(null)
try {
// Build filter params
const filters: {
subject?: string
year?: number
niveau?: string
doc_type?: string
search?: string
} = {}
const filters: { subject?: string; year?: number; niveau?: string; doc_type?: string; search?: string } = {}
if (fach !== 'Alle') filters.subject = fach
if (jahr !== 'Alle') filters.year = parseInt(jahr)
if (niveau !== 'Alle') filters.niveau = niveau
if (dokumenttyp !== 'Alle') {
// Map frontend names to API names
const docTypeMap: Record<string, string> = {
'Erwartungshorizont': 'EWH',
'Aufgabe': 'Aufgabe',
'Loesungshinweise': 'Material'
}
const docTypeMap: Record<string, string> = { 'Erwartungshorizont': 'EWH', 'Aufgabe': 'Aufgabe', 'Loesungshinweise': 'Material' }
filters.doc_type = docTypeMap[dokumenttyp] || dokumenttyp
}
if (searchQuery) filters.search = searchQuery
const response = await korrekturApi.getArchivDocuments(filters)
// Map API response to frontend interface
const mappedDokumente: AbiturDokument[] = response.documents.map((doc) => ({
id: doc.id,
dateiname: `${doc.subject}_${doc.niveau}_${doc.year}_${doc.doc_type}.pdf`,
fach: doc.subject,
jahr: doc.year,
bundesland: doc.bundesland === 'NI' ? 'Niedersachsen' : doc.bundesland,
niveau: doc.niveau,
dokumenttyp: doc.doc_type === 'EWH' ? 'Erwartungshorizont' : doc.doc_type,
aufgabentyp: doc.doc_type,
thema: doc.title,
download_url: doc.preview_url || SAMPLE_PDF,
preview_url: doc.preview_url || SAMPLE_PDF,
id: doc.id, dateiname: `${doc.subject}_${doc.niveau}_${doc.year}_${doc.doc_type}.pdf`,
fach: doc.subject, jahr: doc.year, bundesland: doc.bundesland === 'NI' ? 'Niedersachsen' : doc.bundesland,
niveau: doc.niveau, dokumenttyp: doc.doc_type === 'EWH' ? 'Erwartungshorizont' : doc.doc_type,
aufgabentyp: doc.doc_type, thema: doc.title, download_url: doc.preview_url || SAMPLE_PDF, preview_url: doc.preview_url || SAMPLE_PDF,
}))
// Update available filters from API
if (response.filters) {
setAvailableFilters({
subjects: response.filters.subjects || ['Deutsch', 'Englisch', 'Mathematik'],
@@ -616,163 +110,50 @@ export default function ArchivPage() {
})
}
// If API returns documents, use them
if (mappedDokumente.length > 0) {
setDokumente(mappedDokumente)
} else {
// API returned empty, use fallback mock data
console.warn('API returned empty documents, using fallback data')
throw new Error('Empty response')
}
} catch (err) {
console.warn('Failed to load from API, using fallback data:', err)
// Fallback to mock data
const mockDokumente: AbiturDokument[] = [
{
id: '1',
dateiname: 'Deutsch_eA_2024_Aufgabe1.pdf',
fach: 'Deutsch',
jahr: 2024,
bundesland: 'Niedersachsen',
niveau: 'eA',
dokumenttyp: 'Aufgabe',
aufgabentyp: 'Textanalyse',
thema: 'Textanalyse: "Der Prozess" - Kafka',
download_url: SAMPLE_PDF,
preview_url: SAMPLE_PDF,
page_count: 4,
},
{
id: '2',
dateiname: 'Deutsch_eA_2024_EH1.pdf',
fach: 'Deutsch',
jahr: 2024,
bundesland: 'Niedersachsen',
niveau: 'eA',
dokumenttyp: 'Erwartungshorizont',
aufgabentyp: 'Textanalyse',
thema: 'EH zu Kafka-Analyse',
download_url: SAMPLE_PDF,
preview_url: SAMPLE_PDF,
page_count: 8,
},
{
id: '3',
dateiname: 'Deutsch_gA_2024_Aufgabe2.pdf',
fach: 'Deutsch',
jahr: 2024,
bundesland: 'Niedersachsen',
niveau: 'gA',
dokumenttyp: 'Aufgabe',
aufgabentyp: 'Gedichtanalyse',
thema: 'Gedichtvergleich Romantik',
download_url: SAMPLE_PDF,
preview_url: SAMPLE_PDF,
page_count: 3,
},
{
id: '4',
dateiname: 'Deutsch_eA_2023_Aufgabe1.pdf',
fach: 'Deutsch',
jahr: 2023,
bundesland: 'Niedersachsen',
niveau: 'eA',
dokumenttyp: 'Aufgabe',
aufgabentyp: 'Eroerterung',
thema: 'Materialgestuetzte Eroerterung: Digitalisierung',
download_url: SAMPLE_PDF,
preview_url: SAMPLE_PDF,
page_count: 5,
},
{
id: '5',
dateiname: 'Deutsch_eA_2023_EH1.pdf',
fach: 'Deutsch',
jahr: 2023,
bundesland: 'Niedersachsen',
niveau: 'eA',
dokumenttyp: 'Erwartungshorizont',
aufgabentyp: 'Eroerterung',
thema: 'EH zu Digitalisierungs-Eroerterung',
download_url: SAMPLE_PDF,
preview_url: SAMPLE_PDF,
page_count: 10,
},
{
id: '6',
dateiname: 'Deutsch_gA_2023_Aufgabe3.pdf',
fach: 'Deutsch',
jahr: 2023,
bundesland: 'Niedersachsen',
niveau: 'gA',
dokumenttyp: 'Aufgabe',
aufgabentyp: 'Dramenanalyse',
thema: 'Szenenanalyse: "Faust I"',
download_url: SAMPLE_PDF,
preview_url: SAMPLE_PDF,
page_count: 4,
},
]
setDokumente(mockDokumente)
setDokumente(MOCK_DOKUMENTE)
} finally {
setIsLoading(false)
}
}
loadDocuments()
}, [fach, jahr, bundesland, niveau, dokumenttyp, searchQuery])
// Documents are now filtered by API, but keep local filtering for bundesland (API only filters NI)
// and for fallback mock data, also apply search filter locally for mock data
const filteredDokumente = useMemo(() => {
return dokumente.filter((dok) => {
// Bundesland filter (not yet supported by API)
if (bundesland !== 'Alle' && dok.bundesland !== bundesland) return false
// Search filter (for local mock data - API handles this server-side)
if (searchQuery) {
const query = searchQuery.toLowerCase()
const matchesSearch =
dok.thema?.toLowerCase().includes(query) ||
dok.fach.toLowerCase().includes(query) ||
dok.aufgabentyp?.toLowerCase().includes(query) ||
dok.dateiname.toLowerCase().includes(query)
const matchesSearch = dok.thema?.toLowerCase().includes(query) || dok.fach.toLowerCase().includes(query) ||
dok.aufgabentyp?.toLowerCase().includes(query) || dok.dateiname.toLowerCase().includes(query)
if (!matchesSearch) return false
}
return true
})
}, [dokumente, bundesland, searchQuery])
// Handle theme search
const handleThemeClick = (theme: string) => {
setSearchQuery(theme)
setShowSuggestions(false)
}
const handleThemeClick = (theme: string) => { setSearchQuery(theme) }
// Handle use as template
const handleUseAsTemplate = (dokument: AbiturDokument) => {
setSelectedTemplate(dokument)
setShowCreateModal(true)
setCreateError(null)
setSelectedTemplate(dokument); setShowCreateModal(true); setCreateError(null)
}
// Create Klausur with template
const handleCreateKlausur = async (title: string) => {
if (!selectedTemplate) return
setIsCreating(true)
setCreateError(null)
setIsCreating(true); setCreateError(null)
try {
const newKlausur = await korrekturApi.createKlausur({
title: title || `${selectedTemplate.fach} ${selectedTemplate.aufgabentyp || ''} ${selectedTemplate.jahr}`,
subject: selectedTemplate.fach,
year: selectedTemplate.jahr,
semester: 'Abitur',
modus: 'landes_abitur',
subject: selectedTemplate.fach, year: selectedTemplate.jahr, semester: 'Abitur', modus: 'landes_abitur',
})
setShowCreateModal(false)
setSelectedTemplate(null)
setShowCreateModal(false); setSelectedTemplate(null)
router.push(`/korrektur/${newKlausur.id}`)
} catch (err) {
console.error('Failed to create klausur:', err)
@@ -787,109 +168,54 @@ export default function ArchivPage() {
}
}
// Navigate to korrektur page without creating (fallback)
const handleGoToKorrektur = () => {
setShowCreateModal(false)
setSelectedTemplate(null)
router.push('/korrektur')
}
const handleGoToKorrektur = () => { setShowCreateModal(false); setSelectedTemplate(null); router.push('/korrektur') }
const activeFilters = [fach, jahr, bundesland, niveau, dokumenttyp].filter(f => f !== 'Alle').length
// Computed filter options with "Alle" prefix
const FAECHER = useMemo(() => ['Alle', ...availableFilters.subjects], [availableFilters.subjects])
const JAHRE = useMemo(() => ['Alle', ...availableFilters.years.map(String)], [availableFilters.years])
const NIVEAUS = useMemo(() => ['Alle', ...availableFilters.niveaus], [availableFilters.niveaus])
const DOKUMENTTYPEN = useMemo(() => ['Alle', ...availableFilters.doc_types.map(t => t === 'EWH' ? 'Erwartungshorizont' : t)], [availableFilters.doc_types])
return (
<div className={`min-h-screen flex relative overflow-hidden ${
isDark
? 'bg-gradient-to-br from-indigo-900 via-purple-900 to-pink-800'
: 'bg-gradient-to-br from-slate-100 via-blue-50 to-indigo-100'
}`}>
<div className={`min-h-screen flex relative overflow-hidden ${isDark ? 'bg-gradient-to-br from-indigo-900 via-purple-900 to-pink-800' : 'bg-gradient-to-br from-slate-100 via-blue-50 to-indigo-100'}`}>
{/* Animated Background */}
<div className={`absolute -top-40 -right-40 w-96 h-96 rounded-full mix-blend-multiply filter blur-3xl animate-blob ${isDark ? 'bg-purple-500 opacity-30' : 'bg-purple-300 opacity-40'}`} />
<div className={`absolute top-1/2 -left-40 w-96 h-96 rounded-full mix-blend-multiply filter blur-3xl animate-blob animation-delay-2000 ${isDark ? 'bg-pink-500 opacity-30' : 'bg-pink-300 opacity-40'}`} />
<div className={`absolute -bottom-40 right-1/3 w-96 h-96 rounded-full mix-blend-multiply filter blur-3xl animate-blob animation-delay-4000 ${isDark ? 'bg-blue-500 opacity-30' : 'bg-blue-300 opacity-40'}`} />
{/* Sidebar */}
<div className="relative z-10 p-4">
<Sidebar />
</div>
<div className="relative z-10 p-4"><Sidebar /></div>
{/* Main Content */}
<div className="flex-1 flex flex-col relative z-10 p-6 overflow-y-auto">
{/* Header */}
<div className="flex items-center justify-between mb-6">
<div>
<div className="flex items-center gap-3 mb-2">
<button
onClick={() => router.push('/korrektur')}
className={`p-2 rounded-xl transition-colors ${isDark ? 'hover:bg-white/10' : 'hover:bg-slate-200'}`}
>
<svg className={`w-5 h-5 ${isDark ? 'text-white/60' : 'text-slate-600'}`} fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
</svg>
<button onClick={() => router.push('/korrektur')} className={`p-2 rounded-xl transition-colors ${isDark ? 'hover:bg-white/10' : 'hover:bg-slate-200'}`}>
<svg className={`w-5 h-5 ${isDark ? 'text-white/60' : 'text-slate-600'}`} fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" /></svg>
</button>
<h1 className={`text-3xl font-bold ${isDark ? 'text-white' : 'text-slate-900'}`}>Abitur-Archiv</h1>
</div>
<p className={isDark ? 'text-white/50' : 'text-slate-500'}>
Zentralabitur-Materialien 2021-2025 durchsuchen
</p>
</div>
<div className="flex items-center gap-3">
<ThemeToggle />
<LanguageDropdown />
<p className={isDark ? 'text-white/50' : 'text-slate-500'}>Zentralabitur-Materialien 2021-2025 durchsuchen</p>
</div>
<div className="flex items-center gap-3"><ThemeToggle /><LanguageDropdown /></div>
</div>
{/* Search Bar */}
<GlassCard className="mb-6" size="md" delay={100} isDark={isDark}>
<div className="relative">
<div className="flex items-center gap-3">
<svg className={`w-5 h-5 ${isDark ? 'text-white/40' : 'text-slate-400'}`} fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
</svg>
<input
type="text"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
onFocus={() => setShowSuggestions(true)}
onBlur={() => setTimeout(() => setShowSuggestions(false), 200)}
placeholder="Thema suchen... z.B. Gedichtanalyse, Romantik, Kafka"
className={`flex-1 bg-transparent border-none outline-none text-lg ${
isDark ? 'text-white placeholder-white/40' : 'text-slate-900 placeholder-slate-400'
}`}
/>
<svg className={`w-5 h-5 ${isDark ? 'text-white/40' : 'text-slate-400'}`} fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" /></svg>
<input type="text" value={searchQuery} onChange={(e) => setSearchQuery(e.target.value)} placeholder="Thema suchen... z.B. Gedichtanalyse, Romantik, Kafka" className={`flex-1 bg-transparent border-none outline-none text-lg ${isDark ? 'text-white placeholder-white/40' : 'text-slate-900 placeholder-slate-400'}`} />
{searchQuery && (
<button
onClick={() => setSearchQuery('')}
className={`p-1 rounded-lg ${isDark ? 'hover:bg-white/10' : 'hover:bg-slate-200'}`}
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
<button onClick={() => setSearchQuery('')} className={`p-1 rounded-lg ${isDark ? 'hover:bg-white/10' : 'hover:bg-slate-200'}`}>
<svg className="w-4 h-4" 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>
{/* Popular Themes */}
<div className="flex flex-wrap gap-2 mt-4">
{POPULAR_THEMES.map((theme) => (
<button
key={theme}
onClick={() => handleThemeClick(theme)}
className={`px-3 py-1.5 rounded-full text-sm transition-colors ${
searchQuery === theme
? 'bg-purple-500 text-white'
: isDark
? 'bg-white/10 text-white/70 hover:bg-white/20'
: 'bg-slate-200 text-slate-600 hover:bg-slate-300'
}`}
>
{theme}
</button>
<button key={theme} onClick={() => handleThemeClick(theme)} className={`px-3 py-1.5 rounded-full text-sm transition-colors ${searchQuery === theme ? 'bg-purple-500 text-white' : isDark ? 'bg-white/10 text-white/70 hover:bg-white/20' : 'bg-slate-200 text-slate-600 hover:bg-slate-300'}`}>{theme}</button>
))}
</div>
</div>
@@ -903,27 +229,12 @@ export default function ArchivPage() {
<FilterDropdown label="Bundesland" value={bundesland} options={BUNDESLAENDER} onChange={setBundesland} isDark={isDark} />
<FilterDropdown label="Niveau" value={niveau} options={NIVEAUS} onChange={setNiveau} isDark={isDark} />
<FilterDropdown label="Typ" value={dokumenttyp} options={DOKUMENTTYPEN} onChange={setDokumenttyp} isDark={isDark} />
{activeFilters > 0 && (
<button
onClick={() => {
setFach('Alle')
setJahr('Alle')
setBundesland('Alle')
setNiveau('Alle')
setDokumenttyp('Alle')
}}
className={`px-3 py-2 rounded-xl text-sm transition-colors ${
isDark ? 'text-purple-400 hover:bg-purple-500/20' : 'text-purple-600 hover:bg-purple-100'
}`}
>
<button onClick={() => { setFach('Alle'); setJahr('Alle'); setBundesland('Alle'); setNiveau('Alle'); setDokumenttyp('Alle') }} className={`px-3 py-2 rounded-xl text-sm transition-colors ${isDark ? 'text-purple-400 hover:bg-purple-500/20' : 'text-purple-600 hover:bg-purple-100'}`}>
Filter zuruecksetzen ({activeFilters})
</button>
)}
<div className={`ml-auto text-sm ${isDark ? 'text-white/50' : 'text-slate-500'}`}>
{filteredDokumente.length} Dokumente
</div>
<div className={`ml-auto text-sm ${isDark ? 'text-white/50' : 'text-slate-500'}`}>{filteredDokumente.length} Dokumente</div>
</div>
</GlassCard>
@@ -938,9 +249,7 @@ export default function ArchivPage() {
{error && (
<GlassCard className="mb-6" size="sm" isDark={isDark}>
<div className="flex items-center gap-3 text-red-400">
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" /></svg>
<span>{error}</span>
</div>
</GlassCard>
@@ -950,21 +259,11 @@ export default function ArchivPage() {
{!isLoading && (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{filteredDokumente.map((dok, index) => (
<DokumentCard
key={dok.id}
dokument={dok}
onPreview={() => setPreviewDokument(dok)}
onUseAsTemplate={() => handleUseAsTemplate(dok)}
delay={200 + index * 50}
isDark={isDark}
/>
<DokumentCard key={dok.id} dokument={dok} onPreview={() => setPreviewDokument(dok)} onUseAsTemplate={() => handleUseAsTemplate(dok)} delay={200 + index * 50} isDark={isDark} />
))}
{filteredDokumente.length === 0 && !isLoading && (
<div className={`col-span-full text-center py-12 ${isDark ? 'text-white/50' : 'text-slate-500'}`}>
<svg className="w-16 h-16 mx-auto mb-4 opacity-50" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} 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>
<svg className="w-16 h-16 mx-auto mb-4 opacity-50" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} 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>
<p className="text-lg">Keine Dokumente gefunden</p>
<p className="text-sm mt-1">Versuchen Sie andere Filtereinstellungen</p>
</div>
@@ -973,28 +272,10 @@ export default function ArchivPage() {
)}
</div>
{/* Preview Modal */}
<PreviewModal
dokument={previewDokument}
onClose={() => setPreviewDokument(null)}
onUseAsTemplate={() => {
if (previewDokument) handleUseAsTemplate(previewDokument)
setPreviewDokument(null)
}}
isDark={isDark}
/>
<PreviewModal dokument={previewDokument} onClose={() => setPreviewDokument(null)} onUseAsTemplate={() => { if (previewDokument) handleUseAsTemplate(previewDokument); setPreviewDokument(null) }} isDark={isDark} />
{/* Create Klausur Modal */}
{showCreateModal && selectedTemplate && (
<CreateKlausurFromTemplateModal
template={selectedTemplate}
onClose={() => { setShowCreateModal(false); setSelectedTemplate(null); setCreateError(null) }}
onCreate={handleCreateKlausur}
onFallback={handleGoToKorrektur}
isLoading={isCreating}
error={createError}
isDark={isDark}
/>
<CreateKlausurFromTemplateModal template={selectedTemplate} onClose={() => { setShowCreateModal(false); setSelectedTemplate(null); setCreateError(null) }} onCreate={handleCreateKlausur} onFallback={handleGoToKorrektur} isLoading={isCreating} error={createError} isDark={isDark} />
)}
</div>
)

View File

@@ -0,0 +1,183 @@
'use client'
import { Icons, type BreakoutRoom } from './types'
interface BreakoutTabProps {
isDark: boolean
hasActiveMeeting: boolean
breakoutRooms: BreakoutRoom[]
breakoutAssignment: string
breakoutTimer: number
setBreakoutAssignment: (val: string) => void
setBreakoutTimer: (val: number) => void
addBreakoutRoom: () => void
removeBreakoutRoom: (id: string) => void
startQuickMeeting: () => void
}
export function BreakoutTab({
isDark, hasActiveMeeting, breakoutRooms,
breakoutAssignment, breakoutTimer,
setBreakoutAssignment, setBreakoutTimer,
addBreakoutRoom, removeBreakoutRoom, startQuickMeeting,
}: BreakoutTabProps) {
return (
<>
{/* Active Meeting Warning */}
{!hasActiveMeeting && (
<div className={`backdrop-blur-xl border rounded-3xl p-6 flex items-center gap-6 mb-8 ${
isDark
? 'bg-blue-500/10 border-blue-500/30'
: 'bg-blue-50 border-blue-200 shadow-lg'
}`}>
<div className="w-14 h-14 bg-gradient-to-br from-blue-400 to-blue-600 rounded-2xl flex items-center justify-center text-white shadow-lg">
{Icons.video}
</div>
<div className="flex-1">
<div className={`font-semibold ${isDark ? 'text-white' : 'text-slate-900'}`}>Kein aktives Meeting</div>
<div className={`text-sm ${isDark ? 'text-white/60' : 'text-slate-500'}`}>
Breakout-Rooms koennen nur waehrend eines aktiven Meetings erstellt werden.
</div>
</div>
<button
onClick={startQuickMeeting}
className="px-6 py-3 bg-gradient-to-r from-blue-500 to-purple-500 text-white rounded-2xl font-medium hover:shadow-lg hover:shadow-purple-500/30 transition-all hover:scale-105"
>
Meeting starten
</button>
</div>
)}
{/* How it works */}
<div className={`backdrop-blur-xl border rounded-3xl p-6 mb-8 ${
isDark ? 'bg-white/10 border-white/20' : 'bg-white/70 border-black/10 shadow-lg'
}`}>
<h2 className={`text-xl font-semibold mb-6 ${isDark ? 'text-white' : 'text-slate-900'}`}>So funktionieren Breakout-Rooms</h2>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-6">
{[
{ icon: Icons.grid, color: 'from-blue-400 to-blue-600', title: '1. Raeume erstellen', desc: 'Erstellen Sie mehrere Breakout-Rooms fuer Gruppenarbeit.' },
{ icon: Icons.users, color: 'from-purple-400 to-purple-600', title: '2. Teilnehmer zuweisen', desc: 'Weisen Sie Teilnehmer manuell oder automatisch zu.' },
{ icon: Icons.play, color: 'from-green-400 to-emerald-600', title: '3. Sessions starten', desc: 'Starten Sie alle Raeume gleichzeitig oder einzeln.' },
{ icon: Icons.clock, color: 'from-amber-400 to-orange-600', title: '4. Timer setzen', desc: 'Setzen Sie einen Timer fuer automatisches Beenden.' },
].map((step, index) => (
<div key={index} className="text-center">
<div className={`w-14 h-14 bg-gradient-to-br ${step.color} rounded-2xl flex items-center justify-center text-white shadow-lg mx-auto mb-4`}>
{step.icon}
</div>
<h4 className={`font-medium mb-2 ${isDark ? 'text-white' : 'text-slate-900'}`}>{step.title}</h4>
<p className={`text-sm ${isDark ? 'text-white/60' : 'text-slate-500'}`}>{step.desc}</p>
</div>
))}
</div>
</div>
{/* Breakout Configuration */}
<div className={`backdrop-blur-xl border rounded-3xl overflow-hidden ${
isDark ? 'bg-white/10 border-white/20' : 'bg-white/70 border-black/10 shadow-lg'
}`}>
<div className={`p-6 border-b flex items-center justify-between ${isDark ? 'border-white/10' : 'border-slate-200'}`}>
<h2 className={`text-xl font-semibold ${isDark ? 'text-white' : 'text-slate-900'}`}>Breakout-Konfiguration</h2>
<button
onClick={addBreakoutRoom}
disabled={!hasActiveMeeting}
className={`flex items-center gap-2 px-4 py-2 rounded-xl font-medium transition-all disabled:opacity-50 disabled:cursor-not-allowed ${
isDark ? 'bg-white/10 text-white hover:bg-white/20' : 'bg-slate-100 text-slate-700 hover:bg-slate-200'
}`}
>
{Icons.plus}
Raum hinzufuegen
</button>
</div>
{/* Breakout Rooms Grid */}
<div className="p-6">
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4 mb-6">
{breakoutRooms.map((room) => (
<div
key={room.id}
className={`backdrop-blur-xl border rounded-2xl p-4 transition-all ${
hasActiveMeeting
? isDark ? 'bg-white/5 border-white/10' : 'bg-white/50 border-slate-200'
: 'opacity-50'
}`}
>
<div className="flex items-center justify-between mb-3">
<span className={`font-medium ${isDark ? 'text-white' : 'text-slate-900'}`}>{room.name}</span>
<span className={`text-sm ${isDark ? 'text-white/50' : 'text-slate-500'}`}>{room.participants.length} Teilnehmer</span>
</div>
<div className={`min-h-[60px] rounded-xl p-3 text-sm ${
isDark ? 'bg-white/5 text-white/40' : 'bg-slate-50 text-slate-400'
}`}>
{room.participants.length > 0 ? room.participants.join(', ') : 'Keine Teilnehmer'}
</div>
{breakoutRooms.length > 1 && (
<button onClick={() => removeBreakoutRoom(room.id)}
className="mt-3 text-sm text-red-500 hover:text-red-400 transition-colors">
Entfernen
</button>
)}
</div>
))}
</div>
<div className={`border-t pt-6 space-y-4 ${isDark ? 'border-white/10' : 'border-slate-200'}`}>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div>
<label className={`block text-sm font-medium mb-2 ${isDark ? 'text-white/70' : 'text-slate-700'}`}>
Automatische Zuweisung
</label>
<select
value={breakoutAssignment}
onChange={(e) => setBreakoutAssignment(e.target.value)}
disabled={!hasActiveMeeting}
className={`w-full px-4 py-3 rounded-xl transition-all focus:outline-none focus:ring-2 disabled:opacity-50 disabled:cursor-not-allowed ${
isDark
? 'bg-white/10 border border-white/20 text-white focus:ring-white/30'
: 'bg-white border border-slate-300 text-slate-900 focus:ring-blue-300'
}`}
>
<option value="equal">Gleichmaessig verteilen</option>
<option value="random">Zufaellig zuweisen</option>
<option value="manual">Manuell zuweisen</option>
</select>
</div>
<div>
<label className={`block text-sm font-medium mb-2 ${isDark ? 'text-white/70' : 'text-slate-700'}`}>
Timer (Minuten)
</label>
<input
type="number"
value={breakoutTimer}
onChange={(e) => setBreakoutTimer(Number(e.target.value))}
disabled={!hasActiveMeeting}
className={`w-full px-4 py-3 rounded-xl transition-all focus:outline-none focus:ring-2 disabled:opacity-50 disabled:cursor-not-allowed ${
isDark
? 'bg-white/10 border border-white/20 text-white focus:ring-white/30'
: 'bg-white border border-slate-300 text-slate-900 focus:ring-blue-300'
}`}
/>
</div>
</div>
<div className="flex gap-3">
<button
disabled={!hasActiveMeeting}
className="px-6 py-3 bg-gradient-to-r from-blue-500 to-purple-500 text-white rounded-xl font-medium hover:shadow-lg hover:shadow-purple-500/30 transition-all hover:scale-105 disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:scale-100"
>
Breakout-Sessions starten
</button>
<button
disabled={!hasActiveMeeting}
className={`px-6 py-3 rounded-xl font-medium transition-all disabled:opacity-50 disabled:cursor-not-allowed ${
isDark ? 'bg-white/10 text-white hover:bg-white/20' : 'bg-slate-100 text-slate-700 hover:bg-slate-200'
}`}
>
Alle zurueckholen
</button>
</div>
</div>
</div>
</div>
</>
)
}

View File

@@ -0,0 +1,254 @@
'use client'
import { Icons, formatTime, formatDate, type Meeting, type MeetingStats } from './types'
interface DashboardTabProps {
isDark: boolean
loading: boolean
stats: MeetingStats
activeMeetings: Meeting[]
scheduledMeetings: Meeting[]
errorMessage: string | null
creating: boolean
setErrorMessage: (msg: string | null) => void
setShowNewMeetingModal: (show: boolean) => void
setMeetingType: (type: string) => void
startQuickMeeting: () => void
joinMeeting: (roomName: string, title: string) => void
copyMeetingLink: (roomName: string) => void
}
export function DashboardTab({
isDark, loading, stats, activeMeetings, scheduledMeetings,
errorMessage, creating,
setErrorMessage, setShowNewMeetingModal, setMeetingType,
startQuickMeeting, joinMeeting, copyMeetingLink,
}: DashboardTabProps) {
const statsData = [
{ label: 'Aktive Meetings', value: loading ? '-' : String(stats.active), icon: Icons.video, color: 'from-green-400 to-emerald-600' },
{ label: 'Geplante Termine', value: loading ? '-' : String(stats.scheduled), icon: Icons.calendar, color: 'from-blue-400 to-blue-600' },
{ label: 'Aufzeichnungen', value: loading ? '-' : String(stats.recordings), icon: Icons.record, color: 'from-red-400 to-rose-600' },
{ label: 'Teilnehmer', value: loading ? '-' : String(stats.participants), icon: Icons.users, color: 'from-amber-400 to-orange-600' },
]
return (
<>
{/* Error Banner */}
{errorMessage && (
<div className={`mb-6 p-4 rounded-xl flex items-center justify-between ${
isDark ? 'bg-red-500/20 border border-red-500/30' : 'bg-red-50 border border-red-200'
}`}>
<div className="flex items-center gap-3">
<svg className={`w-5 h-5 ${isDark ? 'text-red-400' : 'text-red-500'}`} fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<span className={isDark ? 'text-red-200' : 'text-red-700'}>{errorMessage}</span>
</div>
<button
onClick={() => setErrorMessage(null)}
className={`p-1 rounded-lg transition-colors ${isDark ? 'hover:bg-white/10' : 'hover:bg-red-100'}`}
>
<svg className={`w-4 h-4 ${isDark ? 'text-red-400' : 'text-red-500'}`} 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>
)}
{/* Quick Actions */}
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-6 mb-8">
<QuickActionCard isDark={isDark} icon={Icons.video} color="from-green-400 to-emerald-600"
title="Sofort-Meeting" subtitle="Jetzt starten" onClick={startQuickMeeting} disabled={creating} />
<QuickActionCard isDark={isDark} icon={Icons.calendar} color="from-blue-400 to-blue-600"
title="Meeting planen" subtitle="Termin festlegen"
onClick={() => { setMeetingType('scheduled'); setShowNewMeetingModal(true) }} />
<QuickActionCard isDark={isDark} icon={Icons.graduation} color="from-purple-400 to-purple-600"
title="Schulung erstellen" subtitle="Training planen"
onClick={() => { setMeetingType('training'); setShowNewMeetingModal(true) }} />
<QuickActionCard isDark={isDark} icon={Icons.users} color="from-amber-400 to-orange-600"
title="Elterngespraech" subtitle="Termin vereinbaren"
onClick={() => { setMeetingType('parent'); setShowNewMeetingModal(true) }} />
</div>
{/* Statistics */}
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-6 mb-8">
{statsData.map((stat, index) => (
<div
key={index}
className={`backdrop-blur-xl border rounded-3xl p-6 transition-all hover:scale-105 hover:shadow-xl cursor-pointer ${
isDark
? 'bg-white/10 border-white/20 hover:bg-white/15'
: 'bg-white/70 border-black/10 hover:bg-white/90 shadow-lg'
}`}
>
<div className="flex items-center justify-between mb-4">
<div className={`w-12 h-12 bg-gradient-to-br ${stat.color} rounded-2xl flex items-center justify-center text-white shadow-lg`}>
{stat.icon}
</div>
<svg className={`w-5 h-5 ${isDark ? 'text-white/40' : 'text-slate-400'}`} fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
</svg>
</div>
<p className={`text-sm mb-1 ${isDark ? 'text-white/60' : 'text-slate-600'}`}>{stat.label}</p>
<p className={`text-3xl font-bold ${isDark ? 'text-white' : 'text-slate-900'}`}>{stat.value}</p>
</div>
))}
</div>
{/* Active Meetings */}
{activeMeetings.length > 0 && (
<ActiveMeetingsList isDark={isDark} meetings={activeMeetings}
joinMeeting={joinMeeting} copyMeetingLink={copyMeetingLink} />
)}
{/* Scheduled Meetings */}
<ScheduledMeetingsList isDark={isDark} loading={loading} meetings={scheduledMeetings}
joinMeeting={joinMeeting} copyMeetingLink={copyMeetingLink}
setShowNewMeetingModal={setShowNewMeetingModal} />
</>
)
}
// ============================================
// SUB-COMPONENTS
// ============================================
function QuickActionCard({ isDark, icon, color, title, subtitle, onClick, disabled }: {
isDark: boolean; icon: React.ReactNode; color: string
title: string; subtitle: string; onClick: () => void; disabled?: boolean
}) {
return (
<button
onClick={onClick}
disabled={disabled}
className={`backdrop-blur-xl border rounded-3xl p-6 text-left transition-all hover:scale-105 hover:shadow-xl ${
isDark
? 'bg-white/10 border-white/20 hover:bg-white/15'
: 'bg-white/70 border-black/10 hover:bg-white/90 shadow-lg'
}`}
>
<div className={`w-14 h-14 bg-gradient-to-br ${color} rounded-2xl flex items-center justify-center text-white shadow-lg mb-4`}>
{icon}
</div>
<div className={`font-semibold mb-1 ${isDark ? 'text-white' : 'text-slate-900'}`}>{title}</div>
<div className={`text-sm ${isDark ? 'text-white/60' : 'text-slate-500'}`}>{subtitle}</div>
</button>
)
}
function ActiveMeetingsList({ isDark, meetings, joinMeeting, copyMeetingLink }: {
isDark: boolean; meetings: Meeting[]
joinMeeting: (roomName: string, title: string) => void
copyMeetingLink: (roomName: string) => void
}) {
return (
<div className={`backdrop-blur-xl border rounded-3xl mb-8 overflow-hidden ${
isDark ? 'bg-white/10 border-white/20' : 'bg-white/70 border-black/10 shadow-lg'
}`}>
<div className={`p-6 border-b flex items-center justify-between ${isDark ? 'border-white/10' : 'border-slate-200'}`}>
<h2 className={`text-xl font-semibold ${isDark ? 'text-white' : 'text-slate-900'}`}>Aktive Meetings</h2>
<span className="px-3 py-1 bg-green-500/20 text-green-400 text-sm font-medium rounded-full">
{meetings.length} Live
</span>
</div>
<div className="divide-y divide-white/10">
{meetings.map((meeting) => (
<div key={meeting.room_name} className={`p-6 flex items-center gap-4 transition-colors ${isDark ? 'hover:bg-white/5' : 'hover:bg-slate-50'}`}>
<div className="w-14 h-14 bg-gradient-to-br from-green-400 to-emerald-600 rounded-2xl flex items-center justify-center text-white animate-pulse">
{Icons.video}
</div>
<div className="flex-1">
<div className={`font-medium ${isDark ? 'text-white' : 'text-slate-900'}`}>{meeting.title}</div>
<div className={`flex items-center gap-4 text-sm mt-1 ${isDark ? 'text-white/50' : 'text-slate-500'}`}>
<span className="flex items-center gap-1">{Icons.users} {meeting.participants || 0} Teilnehmer</span>
{meeting.started_at && (
<span className="flex items-center gap-1">{Icons.clock} Gestartet {formatTime(meeting.started_at)}</span>
)}
</div>
</div>
<div className="flex items-center gap-2">
<button onClick={() => joinMeeting(meeting.room_name, meeting.title)}
className="px-5 py-2.5 bg-gradient-to-r from-green-500 to-emerald-500 text-white rounded-xl font-medium hover:shadow-lg hover:shadow-green-500/30 transition-all hover:scale-105">
Beitreten
</button>
<button onClick={() => copyMeetingLink(meeting.room_name)}
className={`p-2.5 rounded-xl transition-all ${isDark ? 'text-white/40 hover:text-white hover:bg-white/10' : 'text-slate-400 hover:text-slate-600 hover:bg-slate-100'}`}
title="Link kopieren">
{Icons.copy}
</button>
</div>
</div>
))}
</div>
</div>
)
}
function ScheduledMeetingsList({ isDark, loading, meetings, joinMeeting, copyMeetingLink, setShowNewMeetingModal }: {
isDark: boolean; loading: boolean; meetings: Meeting[]
joinMeeting: (roomName: string, title: string) => void
copyMeetingLink: (roomName: string) => void
setShowNewMeetingModal: (show: boolean) => void
}) {
return (
<div className={`backdrop-blur-xl border rounded-3xl overflow-hidden ${
isDark ? 'bg-white/10 border-white/20' : 'bg-white/70 border-black/10 shadow-lg'
}`}>
<div className={`p-6 border-b flex items-center justify-between ${isDark ? 'border-white/10' : 'border-slate-200'}`}>
<h2 className={`text-xl font-semibold ${isDark ? 'text-white' : 'text-slate-900'}`}>Naechste Meetings</h2>
<button className={`text-sm transition-colors ${isDark ? 'text-white/60 hover:text-white' : 'text-slate-500 hover:text-slate-900'}`}>
Alle anzeigen
</button>
</div>
{loading ? (
<div className={`p-12 text-center ${isDark ? 'text-white/40' : 'text-slate-400'}`}>Laet...</div>
) : meetings.length === 0 ? (
<div className="p-12 text-center">
<div className={`w-20 h-20 rounded-full flex items-center justify-center mx-auto mb-4 ${isDark ? 'bg-white/10' : 'bg-slate-100'}`}>
<span className="text-4xl">{Icons.calendar}</span>
</div>
<p className={`mb-4 ${isDark ? 'text-white/50' : 'text-slate-500'}`}>Keine geplanten Meetings</p>
<button onClick={() => setShowNewMeetingModal(true)} className="text-blue-500 hover:text-blue-400 font-medium">
Meeting planen
</button>
</div>
) : (
<div className={`divide-y ${isDark ? 'divide-white/10' : 'divide-slate-100'}`}>
{meetings.slice(0, 5).map((meeting) => (
<div key={meeting.room_name} className={`p-6 flex items-center gap-4 transition-colors ${isDark ? 'hover:bg-white/5' : 'hover:bg-slate-50'}`}>
<div className={`text-center min-w-[70px] px-3 py-2 rounded-2xl ${isDark ? 'bg-white/10' : 'bg-slate-100'}`}>
<div className={`text-lg font-bold ${isDark ? 'text-white' : 'text-slate-900'}`}>
{meeting.scheduled_at ? formatTime(meeting.scheduled_at) : '--:--'}
</div>
<div className={`text-xs ${isDark ? 'text-white/50' : 'text-slate-500'}`}>
{meeting.scheduled_at ? formatDate(meeting.scheduled_at) : ''}
</div>
</div>
<div className="flex-1">
<div className={`font-medium ${isDark ? 'text-white' : 'text-slate-900'}`}>{meeting.title}</div>
<div className={`flex items-center gap-4 text-sm mt-1 ${isDark ? 'text-white/50' : 'text-slate-500'}`}>
<span className="flex items-center gap-1">{Icons.clock} {meeting.duration} Min</span>
<span className="capitalize">{meeting.type}</span>
</div>
</div>
<span className={`px-3 py-1 text-xs font-medium rounded-full ${isDark ? 'bg-blue-500/20 text-blue-300' : 'bg-blue-100 text-blue-700'}`}>
Geplant
</span>
<div className="flex items-center gap-2">
<button onClick={() => joinMeeting(meeting.room_name, meeting.title)}
className="px-5 py-2.5 bg-gradient-to-r from-blue-500 to-purple-500 text-white rounded-xl font-medium hover:shadow-lg hover:shadow-purple-500/30 transition-all hover:scale-105">
Beitreten
</button>
<button onClick={() => copyMeetingLink(meeting.room_name)}
className={`p-2.5 rounded-xl transition-all ${isDark ? 'text-white/40 hover:text-white hover:bg-white/10' : 'text-slate-400 hover:text-slate-600 hover:bg-slate-100'}`}
title="Link kopieren">
{Icons.copy}
</button>
</div>
</div>
))}
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,55 @@
'use client'
import { Icons } from './types'
interface JoinMeetingModalProps {
isDark: boolean
currentMeetingUrl: string
currentMeetingTitle: string
openInNewTab: () => void
onClose: () => void
}
export function JoinMeetingModal({
isDark, currentMeetingUrl, currentMeetingTitle,
openInNewTab, onClose,
}: JoinMeetingModalProps) {
return (
<div className="fixed inset-0 bg-black/90 flex flex-col z-50">
<div className={`p-4 flex items-center justify-between ${isDark ? 'bg-slate-900' : 'bg-slate-800'}`}>
<div className="flex items-center gap-4">
<div className="w-10 h-10 bg-gradient-to-br from-blue-500 to-purple-500 rounded-xl flex items-center justify-center text-white font-bold">
BP
</div>
<div>
<div className="font-medium text-white">{currentMeetingTitle}</div>
<div className="text-sm text-white/50">BreakPilot Meet</div>
</div>
</div>
<div className="flex items-center gap-2">
<button
onClick={openInNewTab}
className="flex items-center gap-2 px-4 py-2 text-white/70 hover:text-white hover:bg-white/10 rounded-xl transition-colors"
>
{Icons.external}
Im neuen Tab oeffnen
</button>
<button
onClick={onClose}
className="p-2 text-white/50 hover:text-white hover:bg-white/10 rounded-xl transition-colors"
>
{Icons.close}
</button>
</div>
</div>
<div className="flex-1">
<iframe
src={currentMeetingUrl}
className="w-full h-full border-0"
allow="camera; microphone; fullscreen; display-capture; autoplay"
allowFullScreen
/>
</div>
</div>
)
}

View File

@@ -0,0 +1,131 @@
'use client'
import { Icons } from './types'
interface NewMeetingModalProps {
isDark: boolean
meetingType: string
meetingTitle: string
meetingDuration: number
meetingDateTime: string
enableLobby: boolean
enableRecording: boolean
muteOnStart: boolean
creating: boolean
setMeetingType: (type: string) => void
setMeetingTitle: (title: string) => void
setMeetingDuration: (duration: number) => void
setMeetingDateTime: (dateTime: string) => void
setEnableLobby: (val: boolean) => void
setEnableRecording: (val: boolean) => void
setMuteOnStart: (val: boolean) => void
onClose: () => void
onSubmit: () => void
}
export function NewMeetingModal({
isDark, meetingType, meetingTitle, meetingDuration, meetingDateTime,
enableLobby, enableRecording, muteOnStart, creating,
setMeetingType, setMeetingTitle, setMeetingDuration, setMeetingDateTime,
setEnableLobby, setEnableRecording, setMuteOnStart,
onClose, onSubmit,
}: NewMeetingModalProps) {
const inputClass = `w-full px-4 py-3 rounded-xl transition-all focus:outline-none focus:ring-2 ${
isDark
? 'bg-white/10 border border-white/20 text-white focus:ring-white/30'
: 'bg-slate-50 border border-slate-300 text-slate-900 focus:ring-blue-300'
}`
return (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
<div className="absolute inset-0 bg-black/50 backdrop-blur-sm" onClick={onClose} />
<div className={`relative w-full max-w-lg rounded-3xl border p-6 ${
isDark ? 'bg-slate-900 border-white/20' : 'bg-white border-slate-200 shadow-2xl'
}`}>
<div className="flex items-center justify-between mb-6">
<h2 className={`text-xl font-semibold ${isDark ? 'text-white' : 'text-slate-900'}`}>Neues Meeting erstellen</h2>
<button onClick={onClose}
className={`p-2 rounded-lg transition-colors ${isDark ? 'hover:bg-white/10' : 'hover:bg-slate-100'}`}>
{Icons.close}
</button>
</div>
<div className="space-y-4">
<div>
<label className={`block text-sm font-medium mb-2 ${isDark ? 'text-white/70' : 'text-slate-700'}`}>Meeting-Typ</label>
<select value={meetingType} onChange={(e) => setMeetingType(e.target.value)} className={inputClass}>
<option value="quick">Sofort-Meeting</option>
<option value="scheduled">Geplantes Meeting</option>
<option value="training">Schulung</option>
<option value="parent">Elterngespraech</option>
<option value="class">Klassenkonferenz</option>
</select>
</div>
<div>
<label className={`block text-sm font-medium mb-2 ${isDark ? 'text-white/70' : 'text-slate-700'}`}>Titel</label>
<input
type="text"
value={meetingTitle}
onChange={(e) => setMeetingTitle(e.target.value)}
placeholder="Meeting-Titel eingeben"
className={`${inputClass} ${isDark ? 'placeholder-white/40' : 'placeholder-slate-400'}`}
/>
</div>
{meetingType !== 'quick' && (
<div>
<label className={`block text-sm font-medium mb-2 ${isDark ? 'text-white/70' : 'text-slate-700'}`}>Datum & Uhrzeit</label>
<input type="datetime-local" value={meetingDateTime}
onChange={(e) => setMeetingDateTime(e.target.value)} className={inputClass} />
</div>
)}
<div>
<label className={`block text-sm font-medium mb-2 ${isDark ? 'text-white/70' : 'text-slate-700'}`}>Dauer</label>
<select value={meetingDuration} onChange={(e) => setMeetingDuration(Number(e.target.value))} className={inputClass}>
<option value={30}>30 Minuten</option>
<option value={60}>60 Minuten</option>
<option value={90}>90 Minuten</option>
<option value={120}>120 Minuten</option>
</select>
</div>
<div className="space-y-3 pt-2">
<CheckboxOption isDark={isDark} checked={enableLobby}
onChange={setEnableLobby} label="Warteraum aktivieren" />
<CheckboxOption isDark={isDark} checked={enableRecording}
onChange={setEnableRecording} label="Aufzeichnung erlauben" />
<CheckboxOption isDark={isDark} checked={muteOnStart}
onChange={setMuteOnStart} label="Teilnehmer stummschalten bei Beitritt" />
</div>
</div>
<div className={`mt-6 pt-6 border-t flex justify-end gap-3 ${isDark ? 'border-white/10' : 'border-slate-200'}`}>
<button onClick={onClose}
className={`px-5 py-2.5 rounded-xl font-medium transition-all ${
isDark ? 'text-white/60 hover:text-white hover:bg-white/10' : 'text-slate-600 hover:text-slate-900 hover:bg-slate-100'
}`}>
Abbrechen
</button>
<button onClick={onSubmit} disabled={creating}
className="px-6 py-2.5 bg-gradient-to-r from-blue-500 to-purple-500 text-white rounded-xl font-medium hover:shadow-lg hover:shadow-purple-500/30 transition-all hover:scale-105 disabled:opacity-50 disabled:hover:scale-100">
{creating ? 'Erstellen...' : meetingType === 'quick' ? 'Meeting starten' : 'Meeting erstellen'}
</button>
</div>
</div>
</div>
)
}
function CheckboxOption({ isDark, checked, onChange, label }: {
isDark: boolean; checked: boolean; onChange: (val: boolean) => void; label: string
}) {
return (
<label className={`flex items-center gap-3 cursor-pointer ${isDark ? 'text-white/70' : 'text-slate-700'}`}>
<input type="checkbox" checked={checked} onChange={(e) => onChange(e.target.checked)}
className="w-5 h-5 rounded text-blue-500 focus:ring-blue-500" />
<span className="text-sm">{label}</span>
</label>
)
}

View File

@@ -0,0 +1,196 @@
'use client'
import { Icons, formatDuration, formatFileSize, type Recording } from './types'
interface RecordingsTabProps {
isDark: boolean
loading: boolean
recordingsFilter: string
setRecordingsFilter: (filter: string) => void
filteredRecordings: Recording[]
recordings: Recording[]
totalStorageBytes: number
maxStorageGB: number
storagePercent: string
fetchData: () => void
startQuickMeeting: () => void
playRecording: (id: string) => void
viewTranscript: (recording: Recording) => void
downloadRecording: (id: string) => void
deleteRecording: (id: string) => void
}
export function RecordingsTab({
isDark, loading, recordingsFilter, setRecordingsFilter,
filteredRecordings, recordings, totalStorageBytes, maxStorageGB, storagePercent,
fetchData, startQuickMeeting,
playRecording, viewTranscript, downloadRecording, deleteRecording,
}: RecordingsTabProps) {
return (
<>
{/* Filter Tabs */}
<div className="flex flex-wrap gap-2 mb-8">
{[
{ key: 'all', label: 'Alle' },
{ key: 'uploaded', label: 'Neu' },
{ key: 'processing', label: 'In Verarbeitung' },
{ key: 'ready', label: 'Fertig' },
].map((filter) => (
<button
key={filter.key}
onClick={() => setRecordingsFilter(filter.key)}
className={`px-5 py-2.5 rounded-xl font-medium transition-all ${
recordingsFilter === filter.key
? 'bg-gradient-to-r from-blue-500 to-purple-500 text-white shadow-lg'
: isDark
? 'bg-white/10 text-white/60 hover:bg-white/20 hover:text-white'
: 'bg-white/70 text-slate-600 hover:bg-white hover:text-slate-900 shadow'
}`}
>
{filter.label}
</button>
))}
<div className="flex-1" />
<button
onClick={fetchData}
className={`flex items-center gap-2 px-5 py-2.5 rounded-xl font-medium transition-all ${
isDark
? 'bg-white/10 text-white hover:bg-white/20'
: 'bg-white/70 text-slate-700 hover:bg-white shadow'
}`}
>
{Icons.refresh}
Aktualisieren
</button>
</div>
{/* Recordings List */}
{loading ? (
<div className={`text-center py-12 ${isDark ? 'text-white/40' : 'text-slate-400'}`}>Lade Aufzeichnungen...</div>
) : filteredRecordings.length === 0 ? (
<EmptyRecordings isDark={isDark} startQuickMeeting={startQuickMeeting} />
) : (
<div className="space-y-4">
{filteredRecordings.map((recording) => (
<RecordingCard key={recording.id} isDark={isDark} recording={recording}
playRecording={playRecording} viewTranscript={viewTranscript}
downloadRecording={downloadRecording} deleteRecording={deleteRecording} />
))}
</div>
)}
{/* Storage Info */}
<div className={`backdrop-blur-xl border rounded-3xl p-6 mt-8 ${
isDark ? 'bg-white/10 border-white/20' : 'bg-white/70 border-black/10 shadow-lg'
}`}>
<h3 className={`font-medium mb-4 ${isDark ? 'text-white' : 'text-slate-900'}`}>Speicherplatz</h3>
<div className="flex justify-between text-sm mb-2">
<span className={isDark ? 'text-white/60' : 'text-slate-500'}>
{(totalStorageBytes / (1024 * 1024)).toFixed(1)} MB von {maxStorageGB} GB verwendet
</span>
<span className={isDark ? 'text-white/60' : 'text-slate-500'}>{storagePercent}%</span>
</div>
<div className={`h-3 rounded-full overflow-hidden ${isDark ? 'bg-white/10' : 'bg-slate-200'}`}>
<div
className="h-full bg-gradient-to-r from-blue-500 to-purple-500 rounded-full transition-all"
style={{ width: `${Math.min(Number(storagePercent), 100)}%` }}
/>
</div>
<p className={`text-sm mt-3 ${isDark ? 'text-white/50' : 'text-slate-500'}`}>
{recordings.length} Aufzeichnungen
</p>
</div>
</>
)
}
function EmptyRecordings({ isDark, startQuickMeeting }: { isDark: boolean; startQuickMeeting: () => void }) {
return (
<div className={`backdrop-blur-xl border rounded-3xl p-12 text-center ${
isDark ? 'bg-white/10 border-white/20' : 'bg-white/70 border-black/10 shadow-lg'
}`}>
<div className={`w-20 h-20 rounded-full flex items-center justify-center mx-auto mb-4 ${
isDark ? 'bg-white/10 text-white/40' : 'bg-slate-100 text-slate-400'
}`}>
{Icons.record}
</div>
<h3 className={`font-medium mb-2 ${isDark ? 'text-white' : 'text-slate-900'}`}>Keine Aufzeichnungen</h3>
<p className={`mb-6 ${isDark ? 'text-white/50' : 'text-slate-500'}`}>
Starten Sie eine Aufzeichnung in einem Meeting, um sie hier zu sehen.
</p>
<button
onClick={startQuickMeeting}
className="px-6 py-3 bg-gradient-to-r from-blue-500 to-purple-500 text-white rounded-xl font-medium hover:shadow-lg hover:shadow-purple-500/30 transition-all hover:scale-105"
>
Meeting starten
</button>
</div>
)
}
function RecordingCard({ isDark, recording, playRecording, viewTranscript, downloadRecording, deleteRecording }: {
isDark: boolean; recording: Recording
playRecording: (id: string) => void
viewTranscript: (recording: Recording) => void
downloadRecording: (id: string) => void
deleteRecording: (id: string) => void
}) {
return (
<div className={`backdrop-blur-xl border rounded-3xl p-6 flex items-center gap-4 transition-all hover:scale-[1.01] ${
isDark
? 'bg-white/10 border-white/20 hover:bg-white/15'
: 'bg-white/70 border-black/10 hover:bg-white/90 shadow-lg'
}`}>
<div className="w-14 h-14 bg-gradient-to-br from-red-400 to-rose-600 rounded-2xl flex items-center justify-center text-white shadow-lg">
{Icons.record}
</div>
<div className="flex-1">
<div className={`font-medium flex items-center gap-2 ${isDark ? 'text-white' : 'text-slate-900'}`}>
{recording.title || `Aufzeichnung ${recording.meeting_id}`}
{recording.status === 'processing' && (
<span className="px-2 py-0.5 bg-amber-500/20 text-amber-400 text-xs font-medium rounded-full">Verarbeitung</span>
)}
{recording.transcription_status === 'pending' && (
<span className="px-2 py-0.5 bg-blue-500/20 text-blue-400 text-xs font-medium rounded-full">Transkript ausstehend</span>
)}
{recording.transcription_status === 'completed' && (
<span className="px-2 py-0.5 bg-green-500/20 text-green-400 text-xs font-medium rounded-full">Transkript bereit</span>
)}
</div>
<div className={`text-sm mt-1 ${isDark ? 'text-white/50' : 'text-slate-500'}`}>
{new Date(recording.recorded_at).toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit', year: 'numeric' })},{' '}
{new Date(recording.recorded_at).toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' })}{' '}
| {formatDuration(recording.duration_seconds || 0)} | {formatFileSize(recording.file_size_bytes || 0)}
</div>
</div>
<div className="flex items-center gap-2">
<button onClick={() => playRecording(recording.id)}
className={`flex items-center gap-2 px-4 py-2.5 rounded-xl font-medium transition-all ${
isDark ? 'bg-white/10 text-white hover:bg-white/20' : 'bg-slate-100 text-slate-700 hover:bg-slate-200'
}`}>
{Icons.play} Abspielen
</button>
<button onClick={() => viewTranscript(recording)}
className={`flex items-center gap-2 px-4 py-2.5 rounded-xl font-medium transition-all ${
isDark ? 'bg-white/10 text-white hover:bg-white/20' : 'bg-slate-100 text-slate-700 hover:bg-slate-200'
}`}>
{Icons.document} Protokoll
</button>
<button onClick={() => downloadRecording(recording.id)}
className={`p-2.5 rounded-xl transition-all ${
isDark ? 'text-white/40 hover:text-white hover:bg-white/10' : 'text-slate-400 hover:text-slate-600 hover:bg-slate-100'
}`}
title="Herunterladen">
{Icons.download}
</button>
<button onClick={() => deleteRecording(recording.id)}
className={`p-2.5 rounded-xl transition-all ${
isDark ? 'text-white/40 hover:text-red-400 hover:bg-red-500/10' : 'text-slate-400 hover:text-red-500 hover:bg-red-50'
}`}
title="Loeschen">
{Icons.trash}
</button>
</div>
</div>
)
}

View File

@@ -0,0 +1,93 @@
'use client'
import { Icons, getBackendUrl, type Recording } from './types'
interface TranscriptModalProps {
isDark: boolean
currentRecording: Recording
transcriptText: string
transcriptLoading: boolean
startTranscription: (id: string) => void
onClose: () => void
}
export function TranscriptModal({
isDark, currentRecording, transcriptText, transcriptLoading,
startTranscription, onClose,
}: TranscriptModalProps) {
return (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
<div className="absolute inset-0 bg-black/50 backdrop-blur-sm" onClick={onClose} />
<div className={`relative w-full max-w-2xl max-h-[80vh] flex flex-col rounded-3xl border ${
isDark ? 'bg-slate-900 border-white/20' : 'bg-white border-slate-200 shadow-2xl'
}`}>
{/* Header */}
<div className={`p-6 border-b flex items-center justify-between ${isDark ? 'border-white/10' : 'border-slate-200'}`}>
<h2 className={`text-xl font-semibold ${isDark ? 'text-white' : 'text-slate-900'}`}>
Transkript: {currentRecording.title || `Aufzeichnung ${currentRecording.meeting_id}`}
</h2>
<button onClick={onClose}
className={`p-2 rounded-lg transition-colors ${isDark ? 'hover:bg-white/10' : 'hover:bg-slate-100'}`}>
{Icons.close}
</button>
</div>
{/* Content */}
<div className="flex-1 overflow-y-auto p-6">
{transcriptLoading ? (
<div className={`text-center py-8 ${isDark ? 'text-white/40' : 'text-slate-400'}`}>Lade Transkript...</div>
) : transcriptText === 'PENDING' ? (
<div className="text-center py-8">
<div className={`w-16 h-16 rounded-full flex items-center justify-center mx-auto mb-4 ${
isDark ? 'bg-amber-500/20 text-amber-400' : 'bg-amber-100 text-amber-600'
}`}>
{Icons.clock}
</div>
<h4 className={`font-medium mb-2 ${isDark ? 'text-white' : 'text-slate-900'}`}>Transkription ausstehend</h4>
<p className={`mb-6 ${isDark ? 'text-white/50' : 'text-slate-500'}`}>
Die Transkription wurde noch nicht gestartet oder ist in Bearbeitung.
</p>
<button onClick={() => startTranscription(currentRecording.id)}
className="px-6 py-3 bg-gradient-to-r from-blue-500 to-purple-500 text-white rounded-xl font-medium hover:shadow-lg hover:shadow-purple-500/30 transition-all hover:scale-105">
Transkription starten
</button>
</div>
) : (
<div className={`whitespace-pre-wrap leading-relaxed ${isDark ? 'text-white/80' : 'text-slate-700'}`}>
{transcriptText}
</div>
)}
</div>
{/* Footer */}
<div className={`p-6 border-t flex justify-end gap-3 ${isDark ? 'border-white/10' : 'border-slate-200'}`}>
{transcriptText && transcriptText !== 'PENDING' && (
<>
<DownloadButton isDark={isDark} label="WebVTT"
href={`${getBackendUrl()}/api/recordings/${currentRecording.id}/transcription/vtt`} />
<DownloadButton isDark={isDark} label="SRT"
href={`${getBackendUrl()}/api/recordings/${currentRecording.id}/transcription/srt`} />
</>
)}
<button onClick={onClose}
className={`px-5 py-2.5 rounded-xl font-medium transition-all ${
isDark ? 'text-white/60 hover:text-white hover:bg-white/10' : 'text-slate-600 hover:text-slate-900 hover:bg-slate-100'
}`}>
Schliessen
</button>
</div>
</div>
</div>
)
}
function DownloadButton({ isDark, label, href }: { isDark: boolean; label: string; href: string }) {
return (
<button onClick={() => window.location.href = href}
className={`px-4 py-2.5 rounded-xl font-medium transition-all ${
isDark ? 'bg-white/10 text-white hover:bg-white/20' : 'bg-slate-100 text-slate-700 hover:bg-slate-200'
}`}>
{label}
</button>
)
}

View File

@@ -0,0 +1,193 @@
// ============================================
// CONFIGURATION
// ============================================
// API calls now go through Next.js rewrites (see next.config.js)
// This avoids mixed-content issues when accessing via HTTPS
export const getBackendUrl = () => {
// Return empty string to use relative URLs that go through Next.js proxy
return ''
}
export const getJitsiUrl = () => {
if (typeof window === 'undefined') return 'http://localhost:8443'
const { hostname, protocol } = window.location
// Use /jitsi/ path on same origin to avoid SSL certificate issues with separate ports
return hostname === 'localhost' ? 'http://localhost:8443' : `${protocol}//${hostname}/jitsi`
}
// ============================================
// TYPES
// ============================================
export type TabType = 'dashboard' | 'breakout' | 'recordings'
export interface MeetingStats {
active: number
scheduled: number
recordings: number
participants: number
}
export interface Meeting {
room_name: string
title: string
type: string
scheduled_at?: string
duration: number
participants?: number
started_at?: string
}
export interface MeetingConfig {
enable_lobby: boolean
enable_recording: boolean
start_with_audio_muted: boolean
start_with_video_muted: boolean
}
export interface Recording {
id: string
meeting_id: string
title: string
recorded_at: string
duration_seconds: number
file_size_bytes: number
status: string
transcription_status?: string
}
export interface BreakoutRoom {
id: string
name: string
participants: string[]
}
// ============================================
// ICONS
// ============================================
import React from 'react'
export const Icons = {
video: (
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 10l4.553-2.276A1 1 0 0121 8.618v6.764a1 1 0 01-1.447.894L15 14M5 18h8a2 2 0 002-2V8a2 2 0 00-2-2H5a2 2 0 00-2 2v8a2 2 0 002 2z" />
</svg>
),
calendar: (
<svg className="w-6 h-6" 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>
),
users: (
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197M13 7a4 4 0 11-8 0 4 4 0 018 0z" />
</svg>
),
graduation: (
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 14l9-5-9-5-9 5 9 5z" />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 14l6.16-3.422a12.083 12.083 0 01.665 6.479A11.952 11.952 0 0012 20.055a11.952 11.952 0 00-6.824-2.998 12.078 12.078 0 01.665-6.479L12 14z" />
</svg>
),
record: (
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<circle cx="12" cy="12" r="10" strokeWidth={2} />
<circle cx="12" cy="12" r="4" fill="currentColor" />
</svg>
),
clock: (
<svg className="w-4 h-4" 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>
),
plus: (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
</svg>
),
copy: (
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z" />
</svg>
),
external: (
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
</svg>
),
close: (
<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>
),
grid: (
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 6a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2V6zM14 6a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2V6zM4 16a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2v-2zM14 16a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2v-2z" />
</svg>
),
play: (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M14.752 11.168l-3.197-2.132A1 1 0 0010 9.87v4.263a1 1 0 001.555.832l3.197-2.132a1 1 0 000-1.664z" />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
),
download: (
<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>
),
trash: (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
</svg>
),
document: (
<svg className="w-5 h-5" 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>
),
refresh: (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
</svg>
),
}
// ============================================
// FORMAT HELPERS
// ============================================
export const formatTime = (dateString: string) => {
const date = new Date(dateString)
return date.toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' })
}
export const formatDate = (dateString: string) => {
const date = new Date(dateString)
const today = new Date()
const tomorrow = new Date(today)
tomorrow.setDate(tomorrow.getDate() + 1)
if (date.toDateString() === today.toDateString()) return 'Heute'
if (date.toDateString() === tomorrow.toDateString()) return 'Morgen'
return date.toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit' })
}
export const formatDuration = (seconds: number) => {
const h = Math.floor(seconds / 3600)
const m = Math.floor((seconds % 3600) / 60)
const s = seconds % 60
if (h > 0) {
return `${h}:${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}`
}
return `${m}:${String(s).padStart(2, '0')}`
}
export const formatFileSize = (bytes: number) => {
if (bytes < 1024 * 1024) {
return (bytes / 1024).toFixed(1) + ' KB'
}
return (bytes / (1024 * 1024)).toFixed(1) + ' MB'
}

View File

@@ -0,0 +1,323 @@
'use client'
import { useState, useEffect } from 'react'
import {
type TabType,
type MeetingStats,
type Meeting,
type Recording,
type BreakoutRoom,
getBackendUrl,
getJitsiUrl,
} from './types'
export function useMeetPage() {
// Tab state
const [activeTab, setActiveTab] = useState<TabType>('dashboard')
const [stats, setStats] = useState<MeetingStats>({ active: 0, scheduled: 0, recordings: 0, participants: 0 })
const [scheduledMeetings, setScheduledMeetings] = useState<Meeting[]>([])
const [activeMeetings, setActiveMeetings] = useState<Meeting[]>([])
const [recordings, setRecordings] = useState<Recording[]>([])
const [recordingsFilter, setRecordingsFilter] = useState<string>('all')
const [loading, setLoading] = useState(true)
const [showNewMeetingModal, setShowNewMeetingModal] = useState(false)
const [showJoinModal, setShowJoinModal] = useState(false)
const [showTranscriptModal, setShowTranscriptModal] = useState(false)
const [currentMeetingUrl, setCurrentMeetingUrl] = useState('')
const [currentMeetingTitle, setCurrentMeetingTitle] = useState('')
const [currentRecording, setCurrentRecording] = useState<Recording | null>(null)
const [transcriptText, setTranscriptText] = useState('')
const [transcriptLoading, setTranscriptLoading] = useState(false)
// Breakout rooms state
const [breakoutRooms, setBreakoutRooms] = useState<BreakoutRoom[]>([
{ id: '1', name: 'Raum 1', participants: [] },
{ id: '2', name: 'Raum 2', participants: [] },
{ id: '3', name: 'Raum 3', participants: [] },
])
const [breakoutAssignment, setBreakoutAssignment] = useState('equal')
const [breakoutTimer, setBreakoutTimer] = useState(15)
const [hasActiveMeeting, setHasActiveMeeting] = useState(false)
// Form state
const [meetingType, setMeetingType] = useState('quick')
const [meetingTitle, setMeetingTitle] = useState('')
const [meetingDuration, setMeetingDuration] = useState(60)
const [meetingDateTime, setMeetingDateTime] = useState('')
const [enableLobby, setEnableLobby] = useState(true)
const [enableRecording, setEnableRecording] = useState(false)
const [muteOnStart, setMuteOnStart] = useState(true)
const [creating, setCreating] = useState(false)
const [errorMessage, setErrorMessage] = useState<string | null>(null)
// ============================================
// DATA FETCHING
// ============================================
useEffect(() => {
fetchData()
}, [])
const fetchData = async () => {
setLoading(true)
try {
const [statsRes, scheduledRes, activeRes, recordingsRes] = await Promise.all([
fetch(`${getBackendUrl()}/api/meetings/stats`).catch(() => null),
fetch(`${getBackendUrl()}/api/meetings/scheduled`).catch(() => null),
fetch(`${getBackendUrl()}/api/meetings/active`).catch(() => null),
fetch(`${getBackendUrl()}/api/recordings`).catch(() => null),
])
if (statsRes?.ok) {
const statsData = await statsRes.json()
setStats(statsData)
}
if (scheduledRes?.ok) {
const scheduledData = await scheduledRes.json()
setScheduledMeetings(scheduledData)
}
if (activeRes?.ok) {
const activeData = await activeRes.json()
setActiveMeetings(activeData)
setHasActiveMeeting(activeData.length > 0)
}
if (recordingsRes?.ok) {
const recordingsData = await recordingsRes.json()
setRecordings(recordingsData.recordings || recordingsData || [])
}
} catch (error) {
console.error('Failed to fetch meeting data:', error)
} finally {
setLoading(false)
}
}
// ============================================
// ACTIONS
// ============================================
const joinMeeting = (roomName: string, title: string) => {
const url = `${getJitsiUrl()}/${roomName}#config.prejoinPageEnabled=false&config.defaultLanguage=de&interfaceConfig.SHOW_JITSI_WATERMARK=false`
setCurrentMeetingUrl(url)
setCurrentMeetingTitle(title)
setShowJoinModal(true)
}
const createMeeting = async () => {
setCreating(true)
setErrorMessage(null)
try {
const response = await fetch(`${getBackendUrl()}/api/meetings/create`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
type: meetingType,
title: meetingTitle || 'Neues Meeting',
duration: meetingDuration,
scheduled_at: meetingType !== 'quick' ? meetingDateTime : null,
config: {
enable_lobby: enableLobby,
enable_recording: enableRecording,
start_with_audio_muted: muteOnStart,
start_with_video_muted: false,
},
}),
})
if (response.ok) {
const meeting = await response.json()
setShowNewMeetingModal(false)
if (meetingType === 'quick') {
joinMeeting(meeting.room_name, meetingTitle || 'Neues Meeting')
} else {
fetchData()
}
setMeetingTitle('')
setMeetingType('quick')
} else {
const errorData = await response.json().catch(() => ({}))
const errorMsg = errorData.detail || `Server-Fehler: ${response.status}`
setErrorMessage(errorMsg)
console.error('Failed to create meeting:', response.status, errorData)
}
} catch (error) {
const errorMsg = error instanceof Error ? error.message : 'Netzwerkfehler - Backend nicht erreichbar'
setErrorMessage(errorMsg)
console.error('Failed to create meeting:', error)
} finally {
setCreating(false)
}
}
const startQuickMeeting = async () => {
setCreating(true)
setErrorMessage(null)
try {
const response = await fetch(`${getBackendUrl()}/api/meetings/create`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
type: 'quick',
title: 'Sofort-Meeting',
duration: 60,
config: {
enable_lobby: false,
enable_recording: false,
start_with_audio_muted: true,
start_with_video_muted: false,
},
}),
})
if (response.ok) {
const meeting = await response.json()
joinMeeting(meeting.room_name, 'Sofort-Meeting')
} else {
const errorData = await response.json().catch(() => ({}))
const errorMsg = errorData.detail || `Server-Fehler: ${response.status}`
setErrorMessage(errorMsg)
console.error('Failed to create meeting:', response.status, errorData)
}
} catch (error) {
const errorMsg = error instanceof Error ? error.message : 'Netzwerkfehler - Backend nicht erreichbar'
setErrorMessage(errorMsg)
console.error('Failed to start quick meeting:', error)
} finally {
setCreating(false)
}
}
const openInNewTab = () => {
window.open(currentMeetingUrl, '_blank')
setShowJoinModal(false)
}
const copyMeetingLink = async (roomName: string) => {
const url = `${getJitsiUrl()}/${roomName}`
await navigator.clipboard.writeText(url)
}
// Recording actions
const playRecording = (recordingId: string) => {
window.open(`${getBackendUrl()}/meetings/recordings/${recordingId}/play`, '_blank')
}
const viewTranscript = async (recording: Recording) => {
setCurrentRecording(recording)
setShowTranscriptModal(true)
setTranscriptLoading(true)
setTranscriptText('')
try {
const response = await fetch(`${getBackendUrl()}/api/recordings/${recording.id}/transcription/text`)
if (response.ok) {
const data = await response.json()
setTranscriptText(data.text || 'Kein Transkript verfuegbar')
} else if (response.status === 404) {
setTranscriptText('PENDING')
} else {
setTranscriptText('Fehler beim Laden des Transkripts')
}
} catch {
setTranscriptText('Fehler beim Laden des Transkripts')
} finally {
setTranscriptLoading(false)
}
}
const startTranscription = async (recordingId: string) => {
try {
const response = await fetch(`${getBackendUrl()}/api/recordings/${recordingId}/transcribe`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ language: 'de', model: 'large-v3' }),
})
if (response.ok) {
alert('Transkription gestartet! Dies kann einige Minuten dauern.')
setShowTranscriptModal(false)
fetchData()
}
} catch {
alert('Fehler beim Starten der Transkription')
}
}
const downloadRecording = (recordingId: string) => {
window.location.href = `${getBackendUrl()}/api/recordings/${recordingId}/download`
}
const deleteRecording = async (recordingId: string) => {
const reason = prompt('Grund fuer die Loeschung (DSGVO-Dokumentation):')
if (!reason) return
try {
const response = await fetch(`${getBackendUrl()}/api/recordings/${recordingId}?reason=${encodeURIComponent(reason)}`, {
method: 'DELETE',
})
if (response.ok) {
fetchData()
}
} catch {
alert('Fehler beim Loeschen')
}
}
const filteredRecordings = recordings.filter((r) => {
if (recordingsFilter === 'all') return true
return r.status === recordingsFilter
})
const totalStorageBytes = recordings.reduce((sum, r) => sum + (r.file_size_bytes || 0), 0)
const maxStorageGB = 10
const storagePercent = ((totalStorageBytes / (maxStorageGB * 1024 * 1024 * 1024)) * 100).toFixed(1)
// Breakout room actions
const addBreakoutRoom = () => {
const newId = String(breakoutRooms.length + 1)
setBreakoutRooms([...breakoutRooms, { id: newId, name: `Raum ${newId}`, participants: [] }])
}
const removeBreakoutRoom = (id: string) => {
setBreakoutRooms(breakoutRooms.filter((r) => r.id !== id))
}
return {
// Tab
activeTab, setActiveTab,
// Data
stats, scheduledMeetings, activeMeetings, recordings, loading,
// Modals
showNewMeetingModal, setShowNewMeetingModal,
showJoinModal, setShowJoinModal,
showTranscriptModal, setShowTranscriptModal,
currentMeetingUrl, currentMeetingTitle,
currentRecording, transcriptText, transcriptLoading,
// Recordings
recordingsFilter, setRecordingsFilter,
filteredRecordings, totalStorageBytes, maxStorageGB, storagePercent,
// Breakout
breakoutRooms, breakoutAssignment, setBreakoutAssignment,
breakoutTimer, setBreakoutTimer, hasActiveMeeting,
addBreakoutRoom, removeBreakoutRoom,
// Form
meetingType, setMeetingType,
meetingTitle, setMeetingTitle,
meetingDuration, setMeetingDuration,
meetingDateTime, setMeetingDateTime,
enableLobby, setEnableLobby,
enableRecording, setEnableRecording,
muteOnStart, setMuteOnStart,
creating, errorMessage, setErrorMessage,
// Actions
fetchData, createMeeting, startQuickMeeting,
joinMeeting, openInNewTab, copyMeetingLink,
playRecording, viewTranscript, startTranscription,
downloadRecording, deleteRecording,
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,425 @@
'use client'
import { useRef, useEffect } from 'react'
import {
formatMessageDate,
getContactInitials,
getRoleLabel,
getRoleColor,
type Conversation,
type Message,
type Contact,
} from '@/lib/MessagesContext'
import { EmojiPicker } from './EmojiPicker'
import { TemplatesDropdown } from './TemplatesDropdown'
interface ChatAreaProps {
isDark: boolean
currentConversation: Conversation | null | undefined
currentContact: Contact | undefined
groupedMessages: { date: string; messages: Message[] }[]
messageInput: string
sendWithEmail: boolean
isSending: boolean
showEmojiPicker: boolean
showTemplates: boolean
templates: { id: string; name: string; content: string }[]
setMessageInput: (val: string) => void
setSendWithEmail: (val: boolean) => void
setShowEmojiPicker: (val: boolean) => void
setShowTemplates: (val: boolean) => void
setShowContactInfo: (val: boolean) => void
showContactInfo: boolean
handleSendMessage: () => void
handleEmojiSelect: (emoji: string) => void
handleContextMenu: (e: React.MouseEvent, messageId: string) => void
getSenderName: (senderId: string) => string
pinConversation: (id: string) => void
muteConversation: (id: string) => void
setShowNewConversation: (val: boolean) => void
}
export function ChatArea({
isDark, currentConversation, currentContact, groupedMessages,
messageInput, sendWithEmail, isSending, showEmojiPicker, showTemplates, templates,
setMessageInput, setSendWithEmail, setShowEmojiPicker, setShowTemplates,
setShowContactInfo, showContactInfo,
handleSendMessage, handleEmojiSelect, handleContextMenu,
getSenderName, pinConversation, muteConversation, setShowNewConversation,
}: ChatAreaProps) {
const messagesEndRef = useRef<HTMLDivElement>(null)
const inputRef = useRef<HTMLTextAreaElement>(null)
useEffect(() => {
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' })
}, [groupedMessages])
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault()
handleSendMessage()
}
}
return (
<div className={`flex-1 backdrop-blur-xl border rounded-3xl flex flex-col overflow-hidden ${
isDark
? 'bg-white/10 border-white/20'
: 'bg-white/70 border-black/10 shadow-xl'
}`}>
{currentConversation ? (
<>
{/* Chat Header */}
<ChatHeader isDark={isDark} conversation={currentConversation}
contact={currentContact} showContactInfo={showContactInfo}
setShowContactInfo={setShowContactInfo}
pinConversation={pinConversation} muteConversation={muteConversation} />
{/* Messages */}
<div className={`flex-1 overflow-y-auto p-4 space-y-6 ${
isDark
? 'bg-gradient-to-b from-transparent to-black/10'
: 'bg-gradient-to-b from-transparent to-slate-50/50'
}`}>
{groupedMessages.length === 0 ? (
<div className={`text-center py-12 ${isDark ? 'text-white/40' : 'text-slate-400'}`}>
<div className="w-20 h-20 mx-auto mb-4 rounded-full flex items-center justify-center text-4xl bg-gradient-to-br from-green-500/20 to-emerald-500/20">
👋
</div>
<p className="font-medium text-lg">Noch keine Nachrichten</p>
<p className="text-sm mt-1">Starten Sie die Konversation!</p>
</div>
) : (
groupedMessages.map((group, groupIndex) => (
<MessageGroup key={groupIndex} isDark={isDark} group={group}
currentConversation={currentConversation}
getSenderName={getSenderName}
handleContextMenu={handleContextMenu} />
))
)}
<div ref={messagesEndRef} />
</div>
{/* Message Input */}
<MessageInput isDark={isDark} messageInput={messageInput} sendWithEmail={sendWithEmail}
isSending={isSending} showEmojiPicker={showEmojiPicker} showTemplates={showTemplates}
templates={templates} inputRef={inputRef}
setMessageInput={setMessageInput} setSendWithEmail={setSendWithEmail}
setShowEmojiPicker={setShowEmojiPicker} setShowTemplates={setShowTemplates}
handleSendMessage={handleSendMessage} handleEmojiSelect={handleEmojiSelect}
handleKeyDown={handleKeyDown} />
</>
) : (
<EmptyState isDark={isDark} setShowNewConversation={setShowNewConversation} />
)}
</div>
)
}
// ============================================
// SUB-COMPONENTS
// ============================================
function ChatHeader({ isDark, conversation, contact, showContactInfo, setShowContactInfo, pinConversation, muteConversation }: {
isDark: boolean; conversation: Conversation; contact: Contact | undefined
showContactInfo: boolean; setShowContactInfo: (val: boolean) => void
pinConversation: (id: string) => void; muteConversation: (id: string) => void
}) {
return (
<div className={`p-4 border-b ${isDark ? 'border-white/10' : 'border-slate-200'}`}>
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="relative">
<div className={`w-12 h-12 rounded-full flex items-center justify-center text-lg font-semibold ${
conversation.is_group
? isDark
? 'bg-gradient-to-br from-purple-500/30 to-pink-500/30 text-purple-300'
: 'bg-gradient-to-br from-purple-100 to-pink-100 text-purple-700'
: contact?.online
? isDark
? 'bg-gradient-to-br from-green-500/30 to-emerald-500/30 text-green-300'
: 'bg-gradient-to-br from-green-100 to-emerald-100 text-green-700'
: isDark
? 'bg-slate-700 text-slate-300'
: 'bg-slate-200 text-slate-600'
}`}>
{conversation.title ? getContactInitials(conversation.title) : '?'}
</div>
{!conversation.is_group && contact?.online && (
<span className="absolute bottom-0 right-0 w-3 h-3 bg-green-500 rounded-full border-2 border-white dark:border-slate-900" />
)}
</div>
<div>
<h3 className={`font-semibold ${isDark ? 'text-white' : 'text-slate-900'}`}>
{conversation.title || 'Unbenannt'}
</h3>
<div className="flex items-center gap-2">
{conversation.typing ? (
<span className={`text-sm ${isDark ? 'text-green-400' : 'text-green-600'}`}>schreibt...</span>
) : contact ? (
<>
<span className={`text-xs px-2 py-0.5 rounded-full ${getRoleColor(contact.role, isDark)}`}>
{getRoleLabel(contact.role)}
</span>
{contact.student_name && (
<span className={`text-xs ${isDark ? 'text-white/60' : 'text-slate-500'}`}>
{contact.student_name}
</span>
)}
{contact.online && (
<span className={`text-xs ${isDark ? 'text-green-400' : 'text-green-600'}`}> Online</span>
)}
</>
) : conversation.is_group && (
<span className={`text-sm ${isDark ? 'text-white/60' : 'text-slate-500'}`}>
{conversation.participant_ids.length} Mitglieder
</span>
)}
</div>
</div>
</div>
{/* Actions */}
<div className="flex items-center gap-2">
<HeaderButton isDark={isDark} active={conversation.pinned}
activeColor="amber" onClick={() => pinConversation(conversation.id)}
title={conversation.pinned ? 'Nicht mehr anheften' : 'Anheften'}>
<svg className="w-5 h-5" fill={conversation.pinned ? 'currentColor' : 'none'} stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M5 5a2 2 0 012-2h10a2 2 0 012 2v16l-7-3.5L5 21V5z" />
</svg>
</HeaderButton>
<HeaderButton isDark={isDark} active={conversation.muted}
activeColor="red" onClick={() => muteConversation(conversation.id)}
title={conversation.muted ? 'Ton aktivieren' : 'Stummschalten'}>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
{conversation.muted ? (
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M5.586 15H4a1 1 0 01-1-1v-4a1 1 0 011-1h1.586l4.707-4.707C10.923 3.663 12 4.109 12 5v14c0 .891-1.077 1.337-1.707.707L5.586 15z M17 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2" />
) : (
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M15.536 8.464a5 5 0 010 7.072M18.364 5.636a9 9 0 010 12.728M5.586 15H4a1 1 0 01-1-1v-4a1 1 0 011-1h1.586l4.707-4.707C10.923 3.663 12 4.109 12 5v14c0 .891-1.077 1.337-1.707.707L5.586 15z" />
)}
</svg>
</HeaderButton>
<HeaderButton isDark={isDark} active={showContactInfo}
activeColor="green" onClick={() => setShowContactInfo(!showContactInfo)}>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
</HeaderButton>
</div>
</div>
</div>
)
}
function HeaderButton({ isDark, active, activeColor, onClick, title, children }: {
isDark: boolean; active: boolean; activeColor: string
onClick: () => void; title?: string; children: React.ReactNode
}) {
const activeClasses: Record<string, string> = {
amber: isDark ? 'bg-amber-500/20 text-amber-300' : 'bg-amber-100 text-amber-700',
red: isDark ? 'bg-red-500/20 text-red-300' : 'bg-red-100 text-red-700',
green: isDark ? 'bg-green-500/20 text-green-300' : 'bg-green-100 text-green-700',
}
return (
<button onClick={onClick} title={title}
className={`p-2 rounded-xl transition-all ${
active
? activeClasses[activeColor]
: isDark
? 'hover:bg-white/10 text-white/60'
: 'hover:bg-slate-100 text-slate-400'
}`}>
{children}
</button>
)
}
function MessageGroup({ isDark, group, currentConversation, getSenderName, handleContextMenu }: {
isDark: boolean; group: { date: string; messages: Message[] }
currentConversation: Conversation
getSenderName: (id: string) => string
handleContextMenu: (e: React.MouseEvent, id: string) => void
}) {
return (
<div>
<div className="flex items-center justify-center mb-4">
<span className={`px-4 py-1.5 rounded-full text-xs font-medium ${
isDark ? 'bg-white/10 text-white/60' : 'bg-slate-200 text-slate-600'
}`}>
{group.date}
</span>
</div>
<div className="space-y-3">
{group.messages.map((msg) => {
const isSelf = msg.sender_id === 'self'
const isGroupMsg = currentConversation.is_group && !isSelf
return (
<div key={msg.id} className={`flex ${isSelf ? 'justify-end' : 'justify-start'}`}
onContextMenu={(e) => handleContextMenu(e, msg.id)}>
<div className={`max-w-[70%] ${isSelf ? 'order-2' : ''}`}>
{isGroupMsg && (
<span className={`text-xs ml-3 mb-1 block ${isDark ? 'text-purple-400' : 'text-purple-600'}`}>
{getSenderName(msg.sender_id)}
</span>
)}
<div className={`rounded-2xl px-4 py-2.5 shadow-sm ${
isSelf
? 'bg-gradient-to-r from-green-500 to-emerald-500 text-white rounded-br-md'
: isDark
? 'bg-white/10 text-white rounded-bl-md'
: 'bg-white text-slate-900 rounded-bl-md shadow-lg'
}`}>
<p className="whitespace-pre-wrap break-words">{msg.content}</p>
{msg.reactions && msg.reactions.length > 0 && (
<div className="flex gap-1 mt-2">
{msg.reactions.map((r, i) => (
<span key={i} className={`text-sm px-1.5 py-0.5 rounded-full ${isDark ? 'bg-white/20' : 'bg-slate-100'}`}>
{r.emoji}
</span>
))}
</div>
)}
<div className={`flex items-center gap-2 mt-1 ${
isSelf ? 'text-white/70 justify-end' : isDark ? 'text-white/40' : 'text-slate-400'
}`}>
<span className="text-xs">
{new Date(msg.timestamp).toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' })}
</span>
{isSelf && (
<>
{msg.delivered && (
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
)}
{msg.email_sent && <span className="text-xs"></span>}
</>
)}
</div>
</div>
</div>
</div>
)
})}
</div>
</div>
)
}
function MessageInput({ isDark, messageInput, sendWithEmail, isSending, showEmojiPicker, showTemplates, templates, inputRef,
setMessageInput, setSendWithEmail, setShowEmojiPicker, setShowTemplates,
handleSendMessage, handleEmojiSelect, handleKeyDown }: {
isDark: boolean; messageInput: string; sendWithEmail: boolean; isSending: boolean
showEmojiPicker: boolean; showTemplates: boolean
templates: { id: string; name: string; content: string }[]
inputRef: React.RefObject<HTMLTextAreaElement | null>
setMessageInput: (val: string) => void; setSendWithEmail: (val: boolean) => void
setShowEmojiPicker: (val: boolean) => void; setShowTemplates: (val: boolean) => void
handleSendMessage: () => void; handleEmojiSelect: (emoji: string) => void
handleKeyDown: (e: React.KeyboardEvent) => void
}) {
return (
<div className={`p-4 border-t ${isDark ? 'border-white/10' : 'border-slate-200'}`}>
<div className="flex items-center gap-2 mb-3">
<button onClick={() => setSendWithEmail(!sendWithEmail)}
className={`flex items-center gap-2 px-3 py-1.5 rounded-xl text-sm transition-all ${
sendWithEmail
? isDark ? 'bg-blue-500/20 text-blue-300' : 'bg-blue-100 text-blue-700'
: isDark ? 'bg-white/5 text-white/40 hover:bg-white/10' : 'bg-slate-100 text-slate-400 hover:bg-slate-200'
}`}>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" />
</svg>
E-Mail
</button>
</div>
<div className="flex items-end gap-2">
<div className="relative">
<button onClick={() => { setShowEmojiPicker(!showEmojiPicker); setShowTemplates(false) }}
className={`p-3 rounded-2xl transition-all ${
showEmojiPicker
? isDark ? 'bg-green-500/20 text-green-300' : 'bg-green-100 text-green-700'
: isDark ? 'bg-white/5 text-white/60 hover:bg-white/10' : 'bg-slate-100 text-slate-500 hover:bg-slate-200'
}`}>
<span className="text-xl">😊</span>
</button>
{showEmojiPicker && (
<EmojiPicker onSelect={handleEmojiSelect} onClose={() => setShowEmojiPicker(false)} isDark={isDark} />
)}
</div>
<div className="relative">
<button onClick={() => { setShowTemplates(!showTemplates); setShowEmojiPicker(false) }}
className={`p-3 rounded-2xl transition-all ${
showTemplates
? isDark ? 'bg-purple-500/20 text-purple-300' : 'bg-purple-100 text-purple-700'
: isDark ? 'bg-white/5 text-white/60 hover:bg-white/10' : 'bg-slate-100 text-slate-500 hover:bg-slate-200'
}`}>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} 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>
</button>
{showTemplates && (
<TemplatesDropdown templates={templates}
onSelect={(content) => { setMessageInput(content); setShowTemplates(false); inputRef.current?.focus() }}
isDark={isDark} />
)}
</div>
<textarea ref={inputRef} value={messageInput}
onChange={(e) => setMessageInput(e.target.value)} onKeyDown={handleKeyDown}
placeholder="Nachricht schreiben..." rows={1}
className={`flex-1 px-4 py-3 rounded-2xl border resize-none transition-all ${
isDark
? 'bg-white/5 border-white/10 text-white placeholder:text-white/40 focus:border-green-500/50'
: 'bg-white border-slate-200 text-slate-900 placeholder:text-slate-400 focus:border-green-500'
} focus:outline-none focus:ring-2 focus:ring-green-500/20`}
style={{ maxHeight: '120px' }} />
<button onClick={handleSendMessage} disabled={!messageInput.trim() || isSending}
className={`p-3 rounded-2xl transition-all ${
messageInput.trim() && !isSending
? 'bg-gradient-to-r from-green-500 to-emerald-500 text-white shadow-lg hover:shadow-green-500/30'
: isDark
? 'bg-white/5 text-white/30 cursor-not-allowed'
: 'bg-slate-100 text-slate-300 cursor-not-allowed'
}`}>
{isSending ? (
<svg className="w-6 h-6 animate-spin" 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 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" />
</svg>
) : (
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 19l9 2-9-18-9 18 9-2zm0 0v-8" />
</svg>
)}
</button>
</div>
</div>
)
}
function EmptyState({ isDark, setShowNewConversation }: { isDark: boolean; setShowNewConversation: (val: boolean) => void }) {
return (
<div className="flex-1 flex items-center justify-center">
<div className={`text-center max-w-md px-8 ${isDark ? 'text-white/40' : 'text-slate-400'}`}>
<div className="w-24 h-24 mx-auto mb-6 rounded-full flex items-center justify-center text-5xl bg-gradient-to-br from-green-500/20 to-emerald-500/20">
💬
</div>
<h3 className={`text-2xl font-bold mb-3 ${isDark ? 'text-white' : 'text-slate-900'}`}>
BreakPilot Messenger
</h3>
<p className="text-sm leading-relaxed">
Kommunizieren Sie sicher mit Eltern und Kollegen.
Waehlen Sie eine Konversation aus der Liste oder starten Sie eine neue Unterhaltung.
</p>
<button onClick={() => setShowNewConversation(true)}
className="mt-6 px-6 py-3 bg-gradient-to-r from-green-500 to-emerald-500 text-white rounded-2xl font-medium shadow-lg hover:shadow-green-500/30 transition-all">
Neue Nachricht
</button>
</div>
</div>
)
}

View File

@@ -0,0 +1,113 @@
'use client'
import {
getContactInitials,
getRoleLabel,
getRoleColor,
type Conversation,
type Contact,
} from '@/lib/MessagesContext'
interface ContactInfoPanelProps {
contact: Contact | undefined
conversation: Conversation
onClose: () => void
isDark: boolean
}
export function ContactInfoPanel({ contact, conversation, onClose, isDark }: ContactInfoPanelProps) {
return (
<div className={`w-80 backdrop-blur-2xl border-l flex flex-col ${
isDark
? 'bg-white/5 border-white/10'
: 'bg-white/90 border-slate-200'
}`}>
{/* Header */}
<div className={`p-4 border-b ${isDark ? 'border-white/10' : 'border-slate-200'}`}>
<div className="flex items-center justify-between">
<span className={`font-semibold ${isDark ? 'text-white' : 'text-slate-900'}`}>Info</span>
<button
onClick={onClose}
className={`p-2 rounded-xl transition-colors ${
isDark ? 'hover:bg-white/10 text-white/60' : 'hover:bg-slate-100 text-slate-400'
}`}
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
</div>
{/* Content */}
<div className="flex-1 overflow-y-auto p-4">
{/* Avatar & Name */}
<div className="text-center mb-6">
<div className={`w-20 h-20 mx-auto rounded-full flex items-center justify-center text-2xl font-bold mb-3 ${
isDark
? 'bg-gradient-to-br from-green-500/30 to-emerald-500/30 text-green-300'
: 'bg-gradient-to-br from-green-100 to-emerald-100 text-green-700'
}`}>
{conversation.title ? getContactInitials(conversation.title) : '?'}
</div>
<h3 className={`text-lg font-semibold ${isDark ? 'text-white' : 'text-slate-900'}`}>
{conversation.title}
</h3>
{contact && (
<span className={`text-sm px-2 py-1 rounded-full ${getRoleColor(contact.role, isDark)}`}>
{getRoleLabel(contact.role)}
</span>
)}
</div>
{/* Contact Details */}
{contact && (
<div className="space-y-4">
{contact.email && (
<InfoField isDark={isDark} label="E-Mail" value={contact.email} />
)}
{contact.phone && (
<InfoField isDark={isDark} label="Telefon" value={contact.phone} />
)}
{contact.student_name && (
<InfoField isDark={isDark} label="Schueler/in"
value={`${contact.student_name} (${contact.class_name})`} />
)}
{contact.tags.length > 0 && (
<div className={`p-3 rounded-xl ${isDark ? 'bg-white/5' : 'bg-slate-50'}`}>
<span className={`text-xs block mb-2 ${isDark ? 'text-white/40' : 'text-slate-400'}`}>Tags</span>
<div className="flex flex-wrap gap-1">
{contact.tags.map(tag => (
<span key={tag} className={`text-xs px-2 py-1 rounded-full ${
isDark ? 'bg-white/10 text-white/80' : 'bg-slate-200 text-slate-700'
}`}>
{tag}
</span>
))}
</div>
</div>
)}
</div>
)}
{/* Group Members */}
{conversation.is_group && (
<div className={`p-3 rounded-xl ${isDark ? 'bg-white/5' : 'bg-slate-50'}`}>
<span className={`text-xs block mb-2 ${isDark ? 'text-white/40' : 'text-slate-400'}`}>
{conversation.participant_ids.length} Mitglieder
</span>
</div>
)}
</div>
</div>
)
}
function InfoField({ isDark, label, value }: { isDark: boolean; label: string; value: string }) {
return (
<div className={`p-3 rounded-xl ${isDark ? 'bg-white/5' : 'bg-slate-50'}`}>
<span className={`text-xs block mb-1 ${isDark ? 'text-white/40' : 'text-slate-400'}`}>{label}</span>
<span className={`text-sm ${isDark ? 'text-white' : 'text-slate-900'}`}>{value}</span>
</div>
)
}

View File

@@ -0,0 +1,47 @@
'use client'
interface ContextMenuProps {
isDark: boolean
contextMenu: { x: number; y: number; messageId: string }
currentConversationId: string | null
deleteMessage: (convId: string, msgId: string) => void
onClose: () => void
}
export function ContextMenu({ isDark, contextMenu, currentConversationId, deleteMessage, onClose }: ContextMenuProps) {
return (
<div
className={`fixed rounded-xl border shadow-xl overflow-hidden z-50 ${
isDark
? 'bg-slate-900 border-white/20'
: 'bg-white border-slate-200'
}`}
style={{ top: contextMenu.y, left: contextMenu.x }}
>
<button
onClick={() => {
// Show quick reactions
onClose()
}}
className={`w-full px-4 py-2 text-left text-sm transition-colors ${
isDark ? 'hover:bg-white/10 text-white' : 'hover:bg-slate-100 text-slate-700'
}`}
>
Reagieren
</button>
<button
onClick={() => {
if (currentConversationId) {
deleteMessage(currentConversationId, contextMenu.messageId)
}
onClose()
}}
className={`w-full px-4 py-2 text-left text-sm transition-colors ${
isDark ? 'hover:bg-red-500/20 text-red-400' : 'hover:bg-red-50 text-red-600'
}`}
>
Loeschen
</button>
</div>
)
}

View File

@@ -0,0 +1,189 @@
'use client'
import {
formatMessageTime,
getContactInitials,
type Conversation,
type Contact,
} from '@/lib/MessagesContext'
interface ConversationListProps {
isDark: boolean
filteredConversations: Conversation[]
currentConversationId: string | null
unreadCount: number
searchQuery: string
setSearchQuery: (q: string) => void
setShowNewConversation: (show: boolean) => void
selectConversation: (conv: Conversation) => void
getConversationContact: (conv: Conversation) => Contact | undefined
}
export function ConversationList({
isDark, filteredConversations, currentConversationId, unreadCount,
searchQuery, setSearchQuery, setShowNewConversation,
selectConversation, getConversationContact,
}: ConversationListProps) {
return (
<div className={`w-96 backdrop-blur-xl border rounded-3xl flex flex-col overflow-hidden ${
isDark
? 'bg-white/10 border-white/20'
: 'bg-white/70 border-black/10 shadow-xl'
}`}>
{/* Header */}
<div className={`p-4 border-b ${isDark ? 'border-white/10' : 'border-slate-200'}`}>
<div className="flex items-center justify-between mb-4">
<div>
<h2 className={`text-xl font-bold ${isDark ? 'text-white' : 'text-slate-900'}`}>
Nachrichten
</h2>
{unreadCount > 0 && (
<span className={`text-sm ${isDark ? 'text-green-400' : 'text-green-600'}`}>
{unreadCount} ungelesen
</span>
)}
</div>
<button
onClick={() => setShowNewConversation(true)}
className="p-3 rounded-2xl transition-all shadow-lg bg-gradient-to-r from-green-500 to-emerald-500 text-white hover:shadow-green-500/30"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
</svg>
</button>
</div>
{/* Search */}
<div className="relative">
<input
type="text"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder="Suchen..."
className={`w-full px-4 py-3 pl-10 rounded-2xl border transition-all ${
isDark
? 'bg-white/5 border-white/10 text-white placeholder:text-white/40 focus:border-green-500/50'
: 'bg-white border-slate-200 text-slate-900 placeholder:text-slate-400 focus:border-green-500'
} focus:outline-none focus:ring-2 focus:ring-green-500/20`}
/>
<svg className={`absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 ${
isDark ? 'text-white/40' : 'text-slate-400'
}`} fill="none" stroke="currentColor" viewBox="0 0 24 24">
<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>
{/* Conversation List */}
<div className="flex-1 overflow-y-auto">
{filteredConversations.length === 0 ? (
<div className={`p-8 text-center ${isDark ? 'text-white/40' : 'text-slate-400'}`}>
<div className="w-16 h-16 mx-auto mb-4 rounded-full flex items-center justify-center text-3xl bg-gradient-to-br from-green-500/20 to-emerald-500/20">
💬
</div>
<p className="font-medium">Keine Konversationen</p>
<p className="text-sm mt-1">Starten Sie eine neue Unterhaltung!</p>
</div>
) : (
<div className="divide-y divide-white/5">
{filteredConversations.map((conv) => (
<ConversationItem key={conv.id} conv={conv} isDark={isDark}
isActive={currentConversationId === conv.id}
contact={getConversationContact(conv)}
onClick={() => selectConversation(conv)} />
))}
</div>
)}
</div>
</div>
)
}
function ConversationItem({ conv, isDark, isActive, contact, onClick }: {
conv: Conversation; isDark: boolean; isActive: boolean
contact: Contact | undefined; onClick: () => void
}) {
return (
<button
onClick={onClick}
className={`w-full p-4 text-left transition-all ${
isActive
? isDark
? 'bg-gradient-to-r from-green-500/20 to-emerald-500/20'
: 'bg-gradient-to-r from-green-100 to-emerald-100'
: isDark
? 'hover:bg-white/5'
: 'hover:bg-slate-50'
}`}
>
<div className="flex items-start gap-3">
{/* Avatar */}
<div className="relative">
<div className={`w-12 h-12 rounded-full flex items-center justify-center text-lg font-semibold ${
conv.is_group
? isDark
? 'bg-gradient-to-br from-purple-500/30 to-pink-500/30 text-purple-300'
: 'bg-gradient-to-br from-purple-100 to-pink-100 text-purple-700'
: contact?.online
? isDark
? 'bg-gradient-to-br from-green-500/30 to-emerald-500/30 text-green-300'
: 'bg-gradient-to-br from-green-100 to-emerald-100 text-green-700'
: isDark
? 'bg-slate-700 text-slate-300'
: 'bg-slate-200 text-slate-600'
}`}>
{conv.title ? getContactInitials(conv.title) : '?'}
</div>
{!conv.is_group && contact?.online && (
<span className="absolute bottom-0 right-0 w-3 h-3 bg-green-500 rounded-full border-2 border-white dark:border-slate-900" />
)}
</div>
{/* Content */}
<div className="flex-1 min-w-0">
<div className="flex items-center justify-between mb-1">
<div className="flex items-center gap-2">
{conv.pinned && <span className="text-xs">📌</span>}
<span className={`font-semibold truncate ${isDark ? 'text-white' : 'text-slate-900'}`}>
{conv.title || 'Unbenannt'}
</span>
{conv.muted && <span className={isDark ? 'text-white/40' : 'text-slate-400'}>🔕</span>}
</div>
{conv.last_message_time && (
<span className={`text-xs flex-shrink-0 ${
conv.unread_count > 0
? isDark ? 'text-green-400' : 'text-green-600'
: isDark ? 'text-white/40' : 'text-slate-400'
}`}>
{formatMessageTime(conv.last_message_time)}
</span>
)}
</div>
<div className="flex items-center justify-between">
<div className="flex items-center gap-1 flex-1 min-w-0">
{conv.typing ? (
<span className={`text-sm italic ${isDark ? 'text-green-400' : 'text-green-600'}`}>
schreibt...
</span>
) : (
<p className={`text-sm truncate ${
conv.unread_count > 0
? isDark ? 'text-white font-medium' : 'text-slate-900 font-medium'
: isDark ? 'text-white/60' : 'text-slate-500'
}`}>
{conv.last_message}
</p>
)}
</div>
{conv.unread_count > 0 && (
<span className="ml-2 min-w-[20px] h-5 px-1.5 rounded-full bg-green-500 text-white text-xs flex items-center justify-center font-medium">
{conv.unread_count > 9 ? '9+' : conv.unread_count}
</span>
)}
</div>
</div>
</div>
</button>
)
}

View File

@@ -0,0 +1,81 @@
'use client'
import { useState } from 'react'
import { emojiCategories } from '@/lib/MessagesContext'
interface EmojiPickerProps {
onSelect: (emoji: string) => void
onClose: () => void
isDark: boolean
}
export function EmojiPicker({ onSelect, onClose, isDark }: EmojiPickerProps) {
const [activeCategory, setActiveCategory] = useState('Häufig')
return (
<div className={`absolute bottom-full left-0 mb-2 w-80 rounded-2xl border shadow-2xl overflow-hidden z-50 ${
isDark
? 'bg-slate-900 border-white/20'
: 'bg-white border-slate-200'
}`}>
{/* Header */}
<div className={`flex items-center justify-between p-3 border-b ${
isDark ? 'border-white/10' : 'border-slate-100'
}`}>
<span className={`text-sm font-medium ${isDark ? 'text-white' : 'text-slate-900'}`}>
Emoji
</span>
<button
onClick={onClose}
className={`p-1 rounded-lg transition-colors ${
isDark ? 'hover:bg-white/10 text-white/60' : 'hover:bg-slate-100 text-slate-400'
}`}
>
<svg className="w-4 h-4" 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>
{/* Category Tabs */}
<div className={`flex overflow-x-auto border-b scrollbar-hide ${
isDark ? 'border-white/10' : 'border-slate-100'
}`}>
{Object.keys(emojiCategories).map(cat => (
<button
key={cat}
onClick={() => setActiveCategory(cat)}
className={`px-3 py-2 text-xs font-medium whitespace-nowrap transition-colors ${
activeCategory === cat
? isDark
? 'text-green-400 border-b-2 border-green-400'
: 'text-green-600 border-b-2 border-green-600'
: isDark
? 'text-white/60 hover:text-white'
: 'text-slate-500 hover:text-slate-900'
}`}
>
{cat}
</button>
))}
</div>
{/* Emoji Grid */}
<div className="p-3 max-h-48 overflow-y-auto">
<div className="grid grid-cols-8 gap-1">
{emojiCategories[activeCategory as keyof typeof emojiCategories].map((emoji, i) => (
<button
key={i}
onClick={() => onSelect(emoji)}
className={`w-8 h-8 flex items-center justify-center text-xl rounded-lg transition-colors ${
isDark ? 'hover:bg-white/10' : 'hover:bg-slate-100'
}`}
>
{emoji}
</button>
))}
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,106 @@
'use client'
import {
getContactInitials,
getRoleLabel,
getRoleColor,
type Contact,
} from '@/lib/MessagesContext'
interface NewConversationModalProps {
isDark: boolean
contacts: Contact[]
onStartConversation: (contact: Contact) => void
onClose: () => void
}
export function NewConversationModal({ isDark, contacts, onStartConversation, onClose }: NewConversationModalProps) {
return (
<div className="fixed inset-0 bg-black/50 backdrop-blur-sm flex items-center justify-center z-50 p-4">
<div className={`w-full max-w-md backdrop-blur-2xl border rounded-3xl overflow-hidden shadow-2xl ${
isDark
? 'bg-slate-900/90 border-white/10'
: 'bg-white border-slate-200'
}`}>
{/* Header */}
<div className={`p-6 border-b ${isDark ? 'border-white/10' : 'border-slate-200'}`}>
<div className="flex items-center justify-between">
<h3 className={`text-xl font-bold ${isDark ? 'text-white' : 'text-slate-900'}`}>
Neue Nachricht
</h3>
<button
onClick={onClose}
className={`p-2 rounded-xl transition-all ${
isDark ? 'hover:bg-white/10 text-white/60' : 'hover:bg-slate-100 text-slate-400'
}`}
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
</div>
{/* Contact List */}
<div className="max-h-96 overflow-y-auto">
{contacts.length === 0 ? (
<div className={`p-8 text-center ${isDark ? 'text-white/40' : 'text-slate-400'}`}>
<p>Keine Kontakte vorhanden</p>
</div>
) : (
<div>
{contacts.map((contact) => (
<button
key={contact.id}
onClick={() => onStartConversation(contact)}
className={`w-full p-4 text-left transition-all flex items-center gap-3 ${
isDark
? 'hover:bg-white/5 border-b border-white/5'
: 'hover:bg-slate-50 border-b border-slate-100'
}`}
>
<div className="relative">
<div className={`w-12 h-12 rounded-full flex items-center justify-center text-lg font-semibold ${
contact.online
? isDark
? 'bg-gradient-to-br from-green-500/30 to-emerald-500/30 text-green-300'
: 'bg-gradient-to-br from-green-100 to-emerald-100 text-green-700'
: isDark
? 'bg-slate-700 text-slate-300'
: 'bg-slate-200 text-slate-600'
}`}>
{getContactInitials(contact.name)}
</div>
{contact.online && (
<span className="absolute bottom-0 right-0 w-3 h-3 bg-green-500 rounded-full border-2 border-white dark:border-slate-900" />
)}
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-0.5">
<span className={`font-semibold ${isDark ? 'text-white' : 'text-slate-900'}`}>
{contact.name}
</span>
<span className={`text-xs px-2 py-0.5 rounded-full ${getRoleColor(contact.role, isDark)}`}>
{getRoleLabel(contact.role)}
</span>
</div>
{contact.student_name && (
<p className={`text-sm ${isDark ? 'text-white/60' : 'text-slate-500'}`}>
{contact.student_name} ({contact.class_name})
</p>
)}
{contact.email && (
<p className={`text-xs ${isDark ? 'text-white/40' : 'text-slate-400'}`}>
{contact.email}
</p>
)}
</div>
</button>
))}
</div>
)}
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,43 @@
'use client'
interface TemplatesDropdownProps {
templates: { id: string; name: string; content: string }[]
onSelect: (content: string) => void
isDark: boolean
}
export function TemplatesDropdown({ templates, onSelect, isDark }: TemplatesDropdownProps) {
return (
<div className={`absolute bottom-full left-0 mb-2 w-64 rounded-2xl border shadow-2xl overflow-hidden z-50 ${
isDark
? 'bg-slate-900 border-white/20'
: 'bg-white border-slate-200'
}`}>
<div className={`p-3 border-b ${isDark ? 'border-white/10' : 'border-slate-100'}`}>
<span className={`text-sm font-medium ${isDark ? 'text-white' : 'text-slate-900'}`}>
Vorlagen
</span>
</div>
<div className="max-h-64 overflow-y-auto">
{templates.map(tpl => (
<button
key={tpl.id}
onClick={() => onSelect(tpl.content)}
className={`w-full text-left p-3 transition-colors ${
isDark
? 'hover:bg-white/5 border-b border-white/5'
: 'hover:bg-slate-50 border-b border-slate-100'
}`}
>
<span className={`text-sm font-medium block ${isDark ? 'text-white' : 'text-slate-900'}`}>
{tpl.name}
</span>
<span className={`text-xs line-clamp-2 ${isDark ? 'text-white/50' : 'text-slate-500'}`}>
{tpl.content}
</span>
</button>
))}
</div>
</div>
)
}

View File

@@ -0,0 +1,166 @@
'use client'
import { useState, useEffect, useMemo } from 'react'
import {
useMessages,
formatMessageDate,
type Conversation,
type Message,
type Contact,
} from '@/lib/MessagesContext'
export function useMessagesPage() {
const messagesCtx = useMessages()
const {
contacts,
conversations,
messages,
templates,
unreadCount,
recentConversations,
sendMessage,
markAsRead,
createConversation,
addReaction,
deleteMessage,
pinConversation,
muteConversation,
currentConversationId,
setCurrentConversationId,
} = messagesCtx
const [messageInput, setMessageInput] = useState('')
const [sendWithEmail, setSendWithEmail] = useState(false)
const [isSending, setIsSending] = useState(false)
const [showNewConversation, setShowNewConversation] = useState(false)
const [showEmojiPicker, setShowEmojiPicker] = useState(false)
const [showTemplates, setShowTemplates] = useState(false)
const [showContactInfo, setShowContactInfo] = useState(false)
const [searchQuery, setSearchQuery] = useState('')
const [contextMenu, setContextMenu] = useState<{ x: number; y: number; messageId: string } | null>(null)
// Current conversation data
const currentConversation = conversations.find(c => c.id === currentConversationId)
const currentMessages = currentConversationId ? (messages[currentConversationId] || []) : []
// Find contact for conversation
const getConversationContact = (conv: Conversation): Contact | undefined => {
if (conv.is_group) return undefined
return contacts.find(c => conv.participant_ids.includes(c.id))
}
// Get sender name for group messages
const getSenderName = (senderId: string): string => {
if (senderId === 'self') return 'Du'
const contact = contacts.find(c => c.id === senderId)
return contact?.name?.split(' ')[0] || 'Unbekannt'
}
// Filter conversations by search
const filteredConversations = useMemo(() => {
if (!searchQuery) return recentConversations
const q = searchQuery.toLowerCase()
return recentConversations.filter(c =>
c.title?.toLowerCase().includes(q) ||
c.last_message?.toLowerCase().includes(q)
)
}, [recentConversations, searchQuery])
// Group messages by date
const groupedMessages = useMemo(() => {
const groups: { date: string; messages: Message[] }[] = []
let currentDate = ''
for (const msg of currentMessages) {
const msgDate = formatMessageDate(msg.timestamp)
if (msgDate !== currentDate) {
currentDate = msgDate
groups.push({ date: msgDate, messages: [] })
}
groups[groups.length - 1].messages.push(msg)
}
return groups
}, [currentMessages])
// Select conversation
const selectConversation = async (conv: Conversation) => {
setCurrentConversationId(conv.id)
if (conv.unread_count > 0) {
await markAsRead(conv.id)
}
setShowContactInfo(false)
}
// Send message
const handleSendMessage = async () => {
if (!messageInput.trim() || !currentConversationId) return
setIsSending(true)
await sendMessage(currentConversationId, messageInput.trim(), sendWithEmail)
setMessageInput('')
setIsSending(false)
setShowEmojiPicker(false)
setShowTemplates(false)
}
// Insert emoji
const handleEmojiSelect = (emoji: string) => {
setMessageInput(prev => prev + emoji)
}
// Start new conversation
const handleStartConversation = async (contact: Contact) => {
const conv = await createConversation(contact.id)
if (conv) {
setCurrentConversationId(conv.id)
setShowNewConversation(false)
}
}
// Handle context menu
const handleContextMenu = (e: React.MouseEvent, messageId: string) => {
e.preventDefault()
setContextMenu({ x: e.clientX, y: e.clientY, messageId })
}
// Close context menu on click outside
useEffect(() => {
const handleClick = () => setContextMenu(null)
if (contextMenu) {
document.addEventListener('click', handleClick)
return () => document.removeEventListener('click', handleClick)
}
}, [contextMenu])
const currentContact = currentConversation ? getConversationContact(currentConversation) : undefined
return {
// Context data
contacts, templates, unreadCount,
currentConversationId, currentConversation, currentContact,
filteredConversations, groupedMessages,
// UI state
messageInput, setMessageInput,
sendWithEmail, setSendWithEmail,
isSending,
showNewConversation, setShowNewConversation,
showEmojiPicker, setShowEmojiPicker,
showTemplates, setShowTemplates,
showContactInfo, setShowContactInfo,
searchQuery, setSearchQuery,
contextMenu, setContextMenu,
// Actions
selectConversation,
handleSendMessage,
handleEmojiSelect,
handleStartConversation,
handleContextMenu,
getSenderName,
getConversationContact,
// From context
pinConversation,
muteConversation,
deleteMessage,
}
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,149 @@
import type { B2BHit, B2BTopic, ImportanceLabel, DecisionLabel } from './types'
/**
* Process email content into a B2BHit (Manual Import for Testing).
* Pure function — no state mutations.
*/
export function processEmailToHit(
emailContent: string,
tenantId: string,
defaultTopicId: string,
emailSubject?: string
): B2BHit {
// Parse email content to extract useful information
const lines = emailContent.split('\n').filter(l => l.trim())
const title = emailSubject || lines[0]?.slice(0, 100) || 'Manuell eingefuegter Alert'
// Try to extract URLs from content
const urlRegex = /(https?:\/\/[^\s<>"]+)/g
const urls = emailContent.match(urlRegex) || []
const firstUrl = urls[0] || 'https://example.com/manual-import'
// Extract snippet (first meaningful paragraph)
const snippet = lines.slice(0, 3).join(' ').slice(0, 300) || emailContent.slice(0, 300)
// Simulate AI analysis - look for procurement signals
const procurementSignals = ['ausschreibung', 'tender', 'vergabe', 'beschaffung', 'auftrag',
'angebot', 'submission', 'procurement', 'rfp', 'rfq', 'bid']
const productSignals = ['parking', 'parkschein', 'ladesäule', 'ev charging', 'ladestation',
'tankstelle', 'fuel', 'bezahlterminal', 'payment']
const buyerSignals = ['stadt', 'kommune', 'gemeinde', 'city', 'municipality', 'council',
'stadtwerke', 'öffentlich', 'public']
const negativeSignals = ['stellenangebot', 'job', 'karriere', 'news', 'blog', 'press release']
const lowerContent = emailContent.toLowerCase()
const foundProcurement = procurementSignals.filter(s => lowerContent.includes(s))
const foundProducts = productSignals.filter(s => lowerContent.includes(s))
const foundBuyers = buyerSignals.filter(s => lowerContent.includes(s))
const foundNegatives = negativeSignals.filter(s => lowerContent.includes(s))
// Calculate importance score (0-100)
let score = 30 // base score
score += foundProcurement.length * 15
score += foundProducts.length * 10
score += foundBuyers.length * 12
score -= foundNegatives.length * 20
score = Math.max(0, Math.min(100, score))
// Determine importance label
let importanceLabel: ImportanceLabel = 'INFO'
if (score >= 80) importanceLabel = 'KRITISCH'
else if (score >= 65) importanceLabel = 'DRINGEND'
else if (score >= 50) importanceLabel = 'WICHTIG'
else if (score >= 30) importanceLabel = 'PRUEFEN'
// Determine decision label
let decisionLabel: DecisionLabel = 'irrelevant'
let decisionConfidence = 0.5
if (foundNegatives.length > 1) {
decisionLabel = 'irrelevant'
decisionConfidence = 0.8
} else if (foundProcurement.length >= 2 && foundProducts.length >= 1) {
decisionLabel = 'relevant'
decisionConfidence = 0.85
} else if (foundProcurement.length >= 1 || foundProducts.length >= 1) {
decisionLabel = 'needs_review'
decisionConfidence = 0.6
}
// Try to guess buyer and country
let buyerGuess: string | undefined
let countryGuess: string | undefined
const countryPatterns = [
{ pattern: /deutschland|germany|german/i, country: 'DE' },
{ pattern: /österreich|austria|austrian/i, country: 'AT' },
{ pattern: /schweiz|switzerland|swiss/i, country: 'CH' },
{ pattern: /frankreich|france|french/i, country: 'FR' },
{ pattern: /niederlande|netherlands|dutch/i, country: 'NL' },
]
for (const { pattern, country } of countryPatterns) {
if (pattern.test(emailContent)) {
countryGuess = country
break
}
}
// Extract potential buyer name (look for "Stadt X" or "Kommune Y")
const buyerMatch = emailContent.match(/(?:stadt|kommune|gemeinde|city of|municipality of)\s+([A-Za-zäöüß]+)/i)
if (buyerMatch) {
buyerGuess = buyerMatch[0]
}
// Try to find deadline
let deadlineGuess: string | undefined
const dateMatch = emailContent.match(/(\d{1,2})[.\/](\d{1,2})[.\/](\d{2,4})/)
if (dateMatch) {
const day = parseInt(dateMatch[1])
const month = parseInt(dateMatch[2])
const year = dateMatch[3].length === 2 ? 2000 + parseInt(dateMatch[3]) : parseInt(dateMatch[3])
const date = new Date(year, month - 1, day)
if (date > new Date()) {
deadlineGuess = date.toISOString()
}
}
// Create new hit
const newHit: B2BHit = {
id: `manual_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`,
tenantId,
topicId: defaultTopicId,
sourceType: 'email',
sourceRef: 'manual_import',
originalUrl: firstUrl,
canonicalUrl: firstUrl,
title,
snippet,
fullText: emailContent,
foundAt: new Date(),
language: 'de',
countryGuess,
buyerGuess,
deadlineGuess,
importanceScore: score,
importanceLabel,
decisionLabel,
decisionConfidence,
decisionTrace: {
rulesTriggered: [
...(foundProcurement.length > 0 ? ['procurement_signal_detected'] : []),
...(foundProducts.length > 0 ? ['product_match_found'] : []),
...(foundBuyers.length > 0 ? ['public_buyer_signal'] : []),
...(foundNegatives.length > 0 ? ['negative_signal_detected'] : []),
],
llmUsed: true,
llmConfidence: decisionConfidence,
signals: {
procurementSignalsFound: foundProcurement,
publicBuyerSignalsFound: foundBuyers,
productSignalsFound: foundProducts,
negativesFound: foundNegatives
}
},
isRead: false,
createdAt: new Date()
}
return newHit
}

View File

@@ -0,0 +1,59 @@
import type { ImportanceLabel, DecisionLabel, Package } from './types'
// ============================================
// HELPER FUNCTIONS
// ============================================
export function getImportanceLabelColor(label: ImportanceLabel, isDark: boolean): string {
const colors = {
'KRITISCH': isDark ? 'bg-red-500/20 text-red-300 border-red-500/30' : 'bg-red-100 text-red-700 border-red-200',
'DRINGEND': isDark ? 'bg-orange-500/20 text-orange-300 border-orange-500/30' : 'bg-orange-100 text-orange-700 border-orange-200',
'WICHTIG': isDark ? 'bg-yellow-500/20 text-yellow-300 border-yellow-500/30' : 'bg-yellow-100 text-yellow-700 border-yellow-200',
'PRUEFEN': isDark ? 'bg-blue-500/20 text-blue-300 border-blue-500/30' : 'bg-blue-100 text-blue-700 border-blue-200',
'INFO': isDark ? 'bg-slate-500/20 text-slate-300 border-slate-500/30' : 'bg-slate-100 text-slate-600 border-slate-200',
}
return colors[label]
}
export function getDecisionLabelColor(label: DecisionLabel, isDark: boolean): string {
const colors = {
'relevant': isDark ? 'bg-green-500/20 text-green-300' : 'bg-green-100 text-green-700',
'irrelevant': isDark ? 'bg-slate-500/20 text-slate-400' : 'bg-slate-100 text-slate-500',
'needs_review': isDark ? 'bg-amber-500/20 text-amber-300' : 'bg-amber-100 text-amber-700',
}
return colors[label]
}
export function formatDeadline(dateStr: string | null | undefined): string {
if (!dateStr) return 'Keine Frist'
const date = new Date(dateStr)
const now = new Date()
const diffDays = Math.ceil((date.getTime() - now.getTime()) / (1000 * 60 * 60 * 24))
if (diffDays < 0) return 'Abgelaufen'
if (diffDays === 0) return 'Heute!'
if (diffDays === 1) return 'Morgen'
if (diffDays <= 7) return `${diffDays} Tage`
if (diffDays <= 30) return `${Math.ceil(diffDays / 7)} Wochen`
return date.toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit', year: 'numeric' })
}
export function getPackageIcon(pkg: Package): string {
const icons = {
'PARKING': '🅿️',
'EV_CHARGING': '⚡',
'FUEL': '⛽',
'TANK_MONITORING': '📊'
}
return icons[pkg]
}
export function getPackageLabel(pkg: Package): string {
const labels = {
'PARKING': 'Parking',
'EV_CHARGING': 'EV Charging',
'FUEL': 'Fuel',
'TANK_MONITORING': 'Tank Monitoring'
}
return labels[pkg]
}

View File

@@ -0,0 +1,427 @@
import type { B2BTemplate, B2BHit, B2BSettings, B2BTenant } from './types'
// ============================================
// HECTRONIC TEMPLATE (Real Example)
// ============================================
export const hectronicTemplate: B2BTemplate = {
templateId: 'tpl_hectronic_public_procurement_v1',
templateName: 'Hectronic Kommunale Ausschreibungen (Parking • EV Charging • Fuel)',
templateDescription: 'Findet weltweit kommunale Ausschreibungen für Parkscheinautomaten/Parkraummanagement, Payment an Ladepunkten und Fuel-Terminals. Reduziert News/Jobs/Zubehör-Rauschen stark.',
targetRoles: ['sales_ops', 'bid_management', 'product_marketing', 'regional_sales'],
industry: 'Payment & Infrastructure',
companyExample: 'Hectronic GmbH',
guidedConfig: {
regionSelector: {
type: 'multi_select',
default: ['EUROPE'],
options: ['EUROPE', 'DACH', 'NORTH_AMERICA', 'MIDDLE_EAST', 'APAC', 'GLOBAL']
},
languageSelector: {
type: 'multi_select',
default: ['de', 'en'],
options: ['de', 'en', 'fr', 'es', 'it', 'nl', 'pl', 'sv', 'da', 'no', 'fi', 'pt']
},
packageSelector: {
type: 'multi_select',
default: ['PARKING', 'EV_CHARGING'],
options: ['PARKING', 'EV_CHARGING', 'FUEL', 'TANK_MONITORING']
},
noiseMode: {
type: 'single_select',
default: 'STRICT',
options: ['STRICT', 'BALANCED', 'BROAD']
}
},
topics: [
{
name: 'Parking Parkscheinautomaten & Parkraummanagement (Kommunen)',
package: 'PARKING',
intent: {
goalStatement: 'Finde kommunale Ausschreibungen/Procurements für Parkscheinautomaten, Pay Stations, Parkraummanagement-Software und zugehörige Services.',
mustHaveAnyN: 2,
mustHave: [
{ type: 'keyword', value: 'parking meter' },
{ type: 'keyword', value: 'pay and display' },
{ type: 'keyword', value: 'parking pay station' },
{ type: 'keyword', value: 'Parkscheinautomat' },
{ type: 'keyword', value: 'Parkautomat' },
{ type: 'keyword', value: 'Parkraumbewirtschaftung' },
{ type: 'keyword', value: 'backoffice' },
{ type: 'keyword', value: 'management software' }
],
publicBuyerSignalsAny: [
'municipality', 'city', 'city council', 'local authority', 'public tender',
'Kommune', 'Stadt', 'Gemeinde', 'Landkreis', 'öffentliche Ausschreibung', 'Vergabe'
],
procurementSignalsAny: [
'tender', 'procurement', 'RFP', 'RFQ', 'invitation to tender', 'contract notice',
'Ausschreibung', 'Vergabe', 'EU-weite Ausschreibung', 'Bekanntmachung'
]
},
filters: {
hardExcludesAny: [
'keyboard', 'mouse', 'headset', 'toner', 'office supplies',
'Tastatur', 'Maus', 'Headset', 'Büromaterial',
'job', 'hiring', 'stellenanzeige', 'bewerbung',
'parking fine', 'parking ticket', 'Strafzettel', 'Knöllchen'
],
softExcludesAny: [
'press release', 'marketing', 'blog', 'opinion',
'pressemitteilung', 'werbung', 'meinung',
'maintenance only', 'consulting only', 'support only'
]
},
decisionPolicy: {
rulesFirst: true,
llmMode: 'GRAYZONE_ONLY',
autoRelevantMinConf: 0.80,
needsReviewRange: [0.50, 0.79],
autoIrrelevantMaxConf: 0.49
},
importanceModelId: 'imp_hectronic_v1',
status: 'active'
},
{
name: 'EV Charging Payment/Terminals in kommunaler Ladeinfrastruktur',
package: 'EV_CHARGING',
intent: {
goalStatement: 'Finde kommunale Ausschreibungen für Ladeinfrastruktur mit Payment-Terminals, Ad-hoc-Kartenzahlung, Backend/Management.',
mustHaveAnyN: 2,
mustHave: [
{ type: 'keyword', value: 'EV charging' },
{ type: 'keyword', value: 'charging station' },
{ type: 'keyword', value: 'charge point' },
{ type: 'keyword', value: 'Ladesäule' },
{ type: 'keyword', value: 'Ladeinfrastruktur' },
{ type: 'keyword', value: 'payment terminal' },
{ type: 'keyword', value: 'card payment' },
{ type: 'keyword', value: 'contactless' },
{ type: 'keyword', value: 'Bezahlsystem' },
{ type: 'keyword', value: 'Kartenzahlung' }
],
publicBuyerSignalsAny: [
'municipality', 'city', 'public works', 'local authority',
'Kommune', 'Stadt', 'Gemeinde', 'Kommunal'
],
procurementSignalsAny: [
'tender', 'procurement', 'RFP', 'RFQ', 'contract notice',
'Ausschreibung', 'Vergabe', 'Bekanntmachung'
]
},
filters: {
hardExcludesAny: [
'stock', 'investor', 'funding round', 'press release',
'Aktie', 'Investor', 'Finanzierung', 'Pressemitteilung',
'job', 'hiring', 'stellenanzeige',
'private home charger', 'wallbox'
],
softExcludesAny: [
'funding program', 'grant', 'Förderprogramm', 'Zuschuss',
'pilot project', 'research project', 'Modellprojekt'
]
},
decisionPolicy: {
rulesFirst: true,
llmMode: 'GRAYZONE_ONLY',
autoRelevantMinConf: 0.80,
needsReviewRange: [0.50, 0.79],
autoIrrelevantMaxConf: 0.49
},
importanceModelId: 'imp_hectronic_v1',
status: 'active'
},
{
name: 'Fuel Fleet/Public Fuel Terminals',
package: 'FUEL',
intent: {
goalStatement: 'Finde Ausschreibungen für (un)bemannte Tankautomaten/Fuel-Terminals, Fuel-Management, Flottenbetankung.',
mustHaveAnyN: 2,
mustHave: [
{ type: 'keyword', value: 'fuel terminal' },
{ type: 'keyword', value: 'fuel management' },
{ type: 'keyword', value: 'unmanned fuel station' },
{ type: 'keyword', value: 'fleet fueling' },
{ type: 'keyword', value: 'Tankautomat' },
{ type: 'keyword', value: 'Tankterminal' },
{ type: 'keyword', value: 'Flottenbetankung' },
{ type: 'keyword', value: 'Betriebstankstelle' }
],
publicBuyerSignalsAny: ['municipality', 'utilities', 'public works', 'Stadtwerke', 'Kommunal'],
procurementSignalsAny: ['tender', 'procurement', 'RFP', 'Ausschreibung', 'Vergabe']
},
filters: {
hardExcludesAny: ['fuel price', 'oil price', 'Spritpreise', 'Ölpreis', 'job', 'hiring', 'stellenanzeige'],
softExcludesAny: ['market news', 'commodity', 'Börse', 'Pressemitteilung']
},
decisionPolicy: {
rulesFirst: true,
llmMode: 'GRAYZONE_ONLY',
autoRelevantMinConf: 0.80,
needsReviewRange: [0.50, 0.79],
autoIrrelevantMaxConf: 0.49
},
importanceModelId: 'imp_hectronic_v1',
status: 'active'
}
],
importanceModel: {
modelId: 'imp_hectronic_v1',
scoreRange: [0, 100],
weights: {
deadlineProximity: 28,
procurementIntentStrength: 22,
publicBuyerStrength: 18,
productMatchStrength: 18,
sourceTrust: 8,
novelty: 6
},
thresholds: {
infoMax: 29,
reviewMin: 30,
importantMin: 50,
urgentMin: 70,
criticalMin: 85
},
topNCaps: {
dailyDigestMax: 10,
weeklyDigestMax: 20,
perTopicDailyMax: 6
}
},
notificationPreset: {
presetId: 'notif_hectronic_digest_v1',
channels: ['email'],
cadence: 'DAILY',
timeLocal: '08:00',
includeStatuses: ['relevant', 'needs_review'],
maxItems: 10
}
}
// ============================================
// MOCK DATA (Demo Hits for Hectronic)
// ============================================
export const mockHectronicHits: B2BHit[] = [
{
id: 'hit-hec-001',
tenantId: 'hectronic-demo',
topicId: 'hec_parking_v1',
sourceType: 'email',
sourceRef: 'ga-msg-001',
originalUrl: 'https://www.evergabe.de/auftraege/parkscheinautomaten-stadt-muenchen',
canonicalUrl: 'https://evergabe.de/auftraege/parkscheinautomaten-muenchen',
title: 'Stadt München: Lieferung und Installation von 150 Parkscheinautomaten',
snippet: 'Die Landeshauptstadt München schreibt die Beschaffung von 150 modernen Parkscheinautomaten inkl. Backoffice-Software für das Stadtgebiet aus. Frist: 15.03.2026.',
foundAt: new Date(Date.now() - 2 * 60 * 60 * 1000),
language: 'de',
countryGuess: 'Germany',
buyerGuess: 'Landeshauptstadt München',
deadlineGuess: '2026-03-15',
importanceScore: 92,
importanceLabel: 'KRITISCH',
decisionLabel: 'relevant',
decisionConfidence: 0.95,
decisionTrace: {
rulesTriggered: ['procurement_signal_match', 'public_buyer_match', 'product_match'],
llmUsed: false,
signals: {
procurementSignalsFound: ['Ausschreibung', 'Beschaffung', 'Vergabe'],
publicBuyerSignalsFound: ['Landeshauptstadt', 'Stadt München'],
productSignalsFound: ['Parkscheinautomaten', 'Backoffice-Software'],
negativesFound: []
}
},
isRead: false,
createdAt: new Date(Date.now() - 2 * 60 * 60 * 1000)
},
{
id: 'hit-hec-002',
tenantId: 'hectronic-demo',
topicId: 'hec_ev_charging_v1',
sourceType: 'rss',
sourceRef: 'rss-item-002',
originalUrl: 'https://ted.europa.eu/notice/2026-12345',
canonicalUrl: 'https://ted.europa.eu/notice/2026-12345',
title: 'EU Tender: Supply of EV Charging Infrastructure with Payment Terminals - City of Amsterdam',
snippet: 'Contract notice for the supply, installation and maintenance of 200 public EV charging points with integrated contactless payment terminals. Deadline: 28.02.2026.',
foundAt: new Date(Date.now() - 5 * 60 * 60 * 1000),
language: 'en',
countryGuess: 'Netherlands',
buyerGuess: 'City of Amsterdam',
deadlineGuess: '2026-02-28',
importanceScore: 88,
importanceLabel: 'KRITISCH',
decisionLabel: 'relevant',
decisionConfidence: 0.92,
decisionTrace: {
rulesTriggered: ['procurement_signal_match', 'public_buyer_match', 'product_match'],
llmUsed: false,
signals: {
procurementSignalsFound: ['Contract notice', 'tender', 'supply'],
publicBuyerSignalsFound: ['City of Amsterdam', 'public'],
productSignalsFound: ['EV charging', 'charging points', 'payment terminals', 'contactless'],
negativesFound: []
}
},
isRead: false,
createdAt: new Date(Date.now() - 5 * 60 * 60 * 1000)
},
{
id: 'hit-hec-003',
tenantId: 'hectronic-demo',
topicId: 'hec_parking_v1',
sourceType: 'email',
sourceRef: 'ga-msg-003',
originalUrl: 'https://vergabe.niedersachsen.de/NetServer/notice/8765432',
canonicalUrl: 'https://vergabe.niedersachsen.de/notice/8765432',
title: 'Gemeinde Seevetal: Erneuerung Parkraumbewirtschaftungssystem',
snippet: 'Öffentliche Ausschreibung zur Erneuerung des Parkraumbewirtschaftungssystems mit 25 Pay-Stations und zentraler Management-Software.',
foundAt: new Date(Date.now() - 8 * 60 * 60 * 1000),
language: 'de',
countryGuess: 'Germany',
buyerGuess: 'Gemeinde Seevetal',
deadlineGuess: '2026-04-01',
importanceScore: 78,
importanceLabel: 'DRINGEND',
decisionLabel: 'relevant',
decisionConfidence: 0.88,
decisionTrace: {
rulesTriggered: ['procurement_signal_match', 'public_buyer_match', 'product_match'],
llmUsed: false,
signals: {
procurementSignalsFound: ['Öffentliche Ausschreibung', 'Erneuerung'],
publicBuyerSignalsFound: ['Gemeinde'],
productSignalsFound: ['Parkraumbewirtschaftungssystem', 'Pay-Stations', 'Management-Software'],
negativesFound: []
}
},
isRead: false,
createdAt: new Date(Date.now() - 8 * 60 * 60 * 1000)
},
{
id: 'hit-hec-004',
tenantId: 'hectronic-demo',
topicId: 'hec_parking_v1',
sourceType: 'email',
sourceRef: 'ga-msg-004',
originalUrl: 'https://news.example.com/parking-industry-trends-2026',
canonicalUrl: 'https://news.example.com/parking-trends-2026',
title: 'Parking Industry Trends 2026: Smart Cities investieren in digitale Lösungen',
snippet: 'Branchenanalyse zeigt wachsende Nachfrage nach vernetzten Parkscheinautomaten. Experten erwarten 15% Marktwachstum.',
foundAt: new Date(Date.now() - 12 * 60 * 60 * 1000),
language: 'de',
countryGuess: 'Germany',
buyerGuess: undefined,
deadlineGuess: undefined,
importanceScore: 15,
importanceLabel: 'INFO',
decisionLabel: 'irrelevant',
decisionConfidence: 0.91,
decisionTrace: {
rulesTriggered: ['soft_exclude_triggered'],
llmUsed: false,
signals: {
procurementSignalsFound: [],
publicBuyerSignalsFound: [],
productSignalsFound: ['Parkscheinautomaten'],
negativesFound: ['Branchenanalyse', 'Experten', 'Marktwachstum']
}
},
isRead: true,
createdAt: new Date(Date.now() - 12 * 60 * 60 * 1000)
},
{
id: 'hit-hec-005',
tenantId: 'hectronic-demo',
topicId: 'hec_ev_charging_v1',
sourceType: 'rss',
sourceRef: 'rss-item-005',
originalUrl: 'https://ausschreibungen.at/ladeinfrastruktur-wien',
canonicalUrl: 'https://ausschreibungen.at/ladeinfrastruktur-wien',
title: 'Stadt Wien: Rahmenvertrag Ladeinfrastruktur öffentlicher Raum',
snippet: 'Vergabeverfahren für Rahmenvertrag über Lieferung und Betrieb von Ladeinfrastruktur im öffentlichen Raum. Details hinter Login.',
foundAt: new Date(Date.now() - 18 * 60 * 60 * 1000),
language: 'de',
countryGuess: 'Austria',
buyerGuess: 'Stadt Wien',
deadlineGuess: undefined,
importanceScore: 55,
importanceLabel: 'WICHTIG',
decisionLabel: 'needs_review',
decisionConfidence: 0.62,
decisionTrace: {
rulesTriggered: ['procurement_signal_match', 'public_buyer_match'],
llmUsed: true,
llmConfidence: 0.62,
signals: {
procurementSignalsFound: ['Vergabeverfahren', 'Rahmenvertrag'],
publicBuyerSignalsFound: ['Stadt Wien', 'öffentlicher Raum'],
productSignalsFound: ['Ladeinfrastruktur'],
negativesFound: ['Details hinter Login']
}
},
isRead: false,
createdAt: new Date(Date.now() - 18 * 60 * 60 * 1000)
},
{
id: 'hit-hec-006',
tenantId: 'hectronic-demo',
topicId: 'hec_fuel_v1',
sourceType: 'email',
sourceRef: 'ga-msg-006',
originalUrl: 'https://vergabe.bund.de/tankautomat-bundeswehr',
canonicalUrl: 'https://vergabe.bund.de/tankautomat-bw',
title: 'Bundesamt für Ausrüstung: Tankautomaten für Liegenschaften',
snippet: 'Beschaffung von unbemannten Tankautomaten für Flottenbetankung an 12 Bundeswehr-Standorten. EU-weite Ausschreibung.',
foundAt: new Date(Date.now() - 24 * 60 * 60 * 1000),
language: 'de',
countryGuess: 'Germany',
buyerGuess: 'Bundesamt für Ausrüstung',
deadlineGuess: '2026-05-15',
importanceScore: 72,
importanceLabel: 'DRINGEND',
decisionLabel: 'relevant',
decisionConfidence: 0.86,
decisionTrace: {
rulesTriggered: ['procurement_signal_match', 'public_buyer_match', 'product_match'],
llmUsed: false,
signals: {
procurementSignalsFound: ['Beschaffung', 'EU-weite Ausschreibung'],
publicBuyerSignalsFound: ['Bundesamt'],
productSignalsFound: ['Tankautomaten', 'Flottenbetankung', 'unbemannt'],
negativesFound: []
}
},
isRead: true,
createdAt: new Date(Date.now() - 24 * 60 * 60 * 1000)
}
]
// ============================================
// DEFAULT SETTINGS
// ============================================
export const defaultB2BSettings: B2BSettings = {
migrationCompleted: false,
wizardCompleted: false,
selectedRegions: ['EUROPE'],
selectedLanguages: ['de', 'en'],
selectedPackages: ['PARKING', 'EV_CHARGING'],
noiseMode: 'STRICT',
notificationCadence: 'DAILY',
notificationTime: '08:00',
maxDigestItems: 10
}
export const defaultTenant: B2BTenant = {
id: 'demo-tenant',
name: 'Demo Account',
companyName: 'Meine Firma GmbH',
industry: 'Payment & Infrastructure',
plan: 'trial',
inboundEmailDomain: 'alerts.breakpilot.de',
createdAt: new Date(),
settings: defaultB2BSettings
}

View File

@@ -0,0 +1,230 @@
// ============================================
// TYPES - B2B Alerts Domain Types
// ============================================
export type ImportanceLabel = 'KRITISCH' | 'DRINGEND' | 'WICHTIG' | 'PRUEFEN' | 'INFO'
export type DecisionLabel = 'relevant' | 'irrelevant' | 'needs_review'
export type SourceType = 'email' | 'rss'
export type NoiseMode = 'STRICT' | 'BALANCED' | 'BROAD'
export type Package = 'PARKING' | 'EV_CHARGING' | 'FUEL' | 'TANK_MONITORING'
export interface AlertSource {
id: string
tenantId: string
type: SourceType
inboundAddress?: string
rssUrl?: string
label: string
active: boolean
createdAt: Date
}
export interface B2BHit {
id: string
tenantId: string
topicId: string
sourceType: SourceType
sourceRef: string
originalUrl: string
canonicalUrl: string
title: string
snippet: string
fullText?: string
foundAt: Date
language?: string
countryGuess?: string
buyerGuess?: string
deadlineGuess?: string
importanceScore: number
importanceLabel: ImportanceLabel
decisionLabel: DecisionLabel
decisionConfidence: number
decisionTrace?: DecisionTrace
isRead: boolean
userFeedback?: 'relevant' | 'irrelevant'
createdAt: Date
}
export interface DecisionTrace {
rulesTriggered: string[]
llmUsed: boolean
llmConfidence?: number
signals: {
procurementSignalsFound: string[]
publicBuyerSignalsFound: string[]
productSignalsFound: string[]
negativesFound: string[]
}
}
export interface B2BTopic {
id: string
tenantId: string
name: string
package: Package
intent: TopicIntent
filters: TopicFilters
decisionPolicy: DecisionPolicy
importanceModelId: string
status: 'active' | 'paused'
createdAt: Date
}
export interface TopicIntent {
goalStatement: string
mustHaveAnyN: number
mustHave: { type: 'keyword'; value: string }[]
publicBuyerSignalsAny: string[]
procurementSignalsAny: string[]
}
export interface TopicFilters {
hardExcludesAny: string[]
softExcludesAny: string[]
}
export interface DecisionPolicy {
rulesFirst: boolean
llmMode: 'ALWAYS' | 'GRAYZONE_ONLY' | 'NEVER'
autoRelevantMinConf: number
needsReviewRange: [number, number]
autoIrrelevantMaxConf: number
}
export interface ImportanceModel {
modelId: string
scoreRange: [number, number]
weights: {
deadlineProximity: number
procurementIntentStrength: number
publicBuyerStrength: number
productMatchStrength: number
sourceTrust: number
novelty: number
}
thresholds: {
infoMax: number
reviewMin: number
importantMin: number
urgentMin: number
criticalMin: number
}
topNCaps: {
dailyDigestMax: number
weeklyDigestMax: number
perTopicDailyMax: number
}
}
export interface B2BTemplate {
templateId: string
templateName: string
templateDescription: string
targetRoles: string[]
industry?: string
companyExample?: string
topics: Omit<B2BTopic, 'id' | 'tenantId' | 'createdAt'>[]
importanceModel: ImportanceModel
guidedConfig: GuidedConfig
notificationPreset: NotificationPreset
}
export interface GuidedConfig {
regionSelector: {
type: 'multi_select'
default: string[]
options: string[]
}
languageSelector: {
type: 'multi_select'
default: string[]
options: string[]
}
packageSelector: {
type: 'multi_select'
default: Package[]
options: Package[]
}
noiseMode: {
type: 'single_select'
default: NoiseMode
options: NoiseMode[]
}
}
export interface NotificationPreset {
presetId: string
channels: string[]
cadence: 'REALTIME' | 'HOURLY' | 'DAILY' | 'WEEKLY'
timeLocal: string
includeStatuses: DecisionLabel[]
maxItems: number
}
export interface B2BTenant {
id: string
name: string
companyName: string
industry: string
plan: 'trial' | 'starter' | 'professional' | 'enterprise'
inboundEmailDomain: string
createdAt: Date
settings: B2BSettings
}
export interface B2BSettings {
migrationCompleted: boolean
wizardCompleted: boolean
selectedTemplateId?: string
selectedRegions: string[]
selectedLanguages: string[]
selectedPackages: Package[]
noiseMode: NoiseMode
notificationCadence: 'REALTIME' | 'HOURLY' | 'DAILY' | 'WEEKLY'
notificationTime: string
maxDigestItems: number
}
export interface AlertsB2BContextType {
// Tenant
tenant: B2BTenant
updateTenant: (updates: Partial<B2BTenant>) => void
// Settings
settings: B2BSettings
updateSettings: (updates: Partial<B2BSettings>) => void
// Templates
availableTemplates: B2BTemplate[]
selectedTemplate: B2BTemplate | null
selectTemplate: (templateId: string) => void
// Sources
sources: AlertSource[]
addSource: (source: Omit<AlertSource, 'id' | 'createdAt'>) => void
removeSource: (id: string) => void
generateInboundEmail: () => string
// Topics
topics: B2BTopic[]
addTopic: (topic: Omit<B2BTopic, 'id' | 'createdAt'>) => void
updateTopic: (id: string, updates: Partial<B2BTopic>) => void
removeTopic: (id: string) => void
// Hits
hits: B2BHit[]
unreadCount: number
relevantCount: number
needsReviewCount: number
markAsRead: (id: string) => void
markAllAsRead: () => void
submitFeedback: (id: string, feedback: 'relevant' | 'irrelevant') => void
processEmailContent: (emailContent: string, emailSubject?: string) => B2BHit
// Digest
getDigest: (maxItems?: number) => B2BHit[]
// Loading/Error
isLoading: boolean
error: string | null
}

View File

@@ -0,0 +1,80 @@
'use client'
import type { AlertItem, Topic, Stats } from './types'
import { getScoreBadge } from './useAlertsData'
interface DashboardTabProps {
stats: Stats | null
topics: Topic[]
alerts: AlertItem[]
error: string | null
}
export default function DashboardTab({ stats, topics, alerts, error }: DashboardTabProps) {
return (
<div className="space-y-6">
{/* Stats Grid */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<div className="bg-white rounded-xl border border-slate-200 p-4">
<div className="text-3xl font-bold text-slate-900">{stats?.total_alerts || 0}</div>
<div className="text-sm text-slate-500">Alerts gesamt</div>
</div>
<div className="bg-white rounded-xl border border-slate-200 p-4">
<div className="text-3xl font-bold text-blue-600">{stats?.new_alerts || 0}</div>
<div className="text-sm text-slate-500">Neue Alerts</div>
</div>
<div className="bg-white rounded-xl border border-slate-200 p-4">
<div className="text-3xl font-bold text-green-600">{stats?.kept_alerts || 0}</div>
<div className="text-sm text-slate-500">Relevant</div>
</div>
<div className="bg-white rounded-xl border border-slate-200 p-4">
<div className="text-3xl font-bold text-amber-600">{stats?.review_alerts || 0}</div>
<div className="text-sm text-slate-500">Zur Pruefung</div>
</div>
</div>
{/* Quick Actions */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div className="bg-white rounded-xl border border-slate-200 p-6">
<h3 className="font-semibold text-slate-900 mb-4">Aktive Topics</h3>
<div className="space-y-3">
{topics.slice(0, 5).map((topic) => (
<div key={topic.id} className="flex items-center justify-between p-3 bg-slate-50 rounded-lg">
<div>
<div className="font-medium text-slate-900">{topic.name}</div>
<div className="text-xs text-slate-500">{topic.alert_count} Alerts</div>
</div>
<span className={`px-2 py-1 rounded text-xs font-semibold ${topic.is_active ? 'bg-green-100 text-green-800' : 'bg-slate-100 text-slate-600'}`}>
{topic.is_active ? 'Aktiv' : 'Pausiert'}
</span>
</div>
))}
</div>
</div>
<div className="bg-white rounded-xl border border-slate-200 p-6">
<h3 className="font-semibold text-slate-900 mb-4">Letzte Alerts</h3>
<div className="space-y-3">
{alerts.slice(0, 5).map((alert) => (
<div key={alert.id} className="p-3 bg-slate-50 rounded-lg">
<div className="font-medium text-slate-900 text-sm truncate">{alert.title}</div>
<div className="flex items-center gap-2 mt-1">
<span className="text-xs text-slate-500">{alert.topic_name}</span>
{getScoreBadge(alert.relevance_score)}
</div>
</div>
))}
</div>
</div>
</div>
{error && (
<div className="bg-amber-50 border border-amber-200 rounded-lg p-4">
<p className="text-sm text-amber-800">
<strong>Hinweis:</strong> API nicht erreichbar. Demo-Daten werden angezeigt.
</p>
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,430 @@
'use client'
export default function DocumentationTab() {
return (
<div className="bg-white rounded-xl border border-slate-200 p-6 overflow-auto max-h-[calc(100vh-200px)]">
<div className="prose prose-slate max-w-none prose-headings:font-semibold prose-h1:text-2xl prose-h2:text-xl prose-h3:text-lg prose-pre:bg-slate-900 prose-pre:text-slate-100">
{/* Header */}
<div className="not-prose mb-8 pb-6 border-b border-slate-200">
<h1 className="text-2xl font-bold text-slate-900">BreakPilot Alerts Agent</h1>
<p className="text-sm text-slate-500 mt-1">Version: 1.0.0 | Stand: Januar 2026 | Autor: BreakPilot Development Team</p>
</div>
{/* Audit Box */}
<div className="not-prose bg-blue-50 border border-blue-200 rounded-lg p-4 mb-6">
<h3 className="font-semibold text-blue-900 mb-2">Audit-Relevante Informationen</h3>
<p className="text-sm text-blue-800">
Dieses Dokument dient als technische Dokumentation fuer das Alert-Monitoring-System der BreakPilot Plattform.
Es ist fuer Audits durch Bildungstraeger und Datenschutzbeauftragte konzipiert.
</p>
</div>
{/* Ziel des Systems */}
<h2>Ziel des Alert-Systems</h2>
<p>Das System ermoeglicht automatisierte Ueberwachung von Bildungsthemen mit:</p>
<ul>
<li><strong>Google Alerts Integration</strong>: RSS-Feeds von Google Alerts automatisch abrufen</li>
<li><strong>RSS/Atom Feeds</strong>: Beliebige Nachrichtenquellen einbinden</li>
<li><strong>KI-Relevanzpruefung</strong>: Automatische Bewertung der Relevanz durch LLM</li>
<li><strong>Regelbasierte Filterung</strong>: Flexible Regeln fuer automatische Sortierung</li>
<li><strong>Multi-Channel Actions</strong>: E-Mail, Webhook, Slack Benachrichtigungen</li>
<li><strong>Few-Shot Learning</strong>: Profil verbessert sich durch Nutzerfeedback</li>
</ul>
{/* Datenschutz Compliance */}
<h2>Datenschutz-Compliance</h2>
<DocTable
headers={['Massnahme', 'Umsetzung', 'Wirkung']}
rows={[
['100% Self-Hosted', 'Alle Dienste auf eigenen Servern', 'Keine Cloud-Abhaengigkeit'],
['Lokale KI', 'Ollama/vLLM on-premise', 'Keine Daten an OpenAI etc.'],
['URL-Deduplizierung', 'SHA256-Hash, Tracking entfernt', 'Minimale Datenspeicherung'],
['Soft-Delete', 'Archivierung statt Loeschung', 'Audit-Trail erhalten'],
['RBAC', 'Rollenbasierte Zugriffskontrolle', 'Nur autorisierter Zugriff'],
]}
/>
{/* Architektur */}
<h2>1. Systemarchitektur</h2>
<h3>Gesamtarchitektur</h3>
<pre className="text-xs leading-tight overflow-x-auto">{ARCHITECTURE_DIAGRAM}</pre>
{/* Datenfluss */}
<h3>Datenfluss bei Alert-Verarbeitung</h3>
<pre className="text-xs leading-tight overflow-x-auto">{DATAFLOW_DIAGRAM}</pre>
{/* Feed Ingestion */}
<h2>2. Feed Ingestion</h2>
<h3>RSS Fetcher</h3>
<DocTable
headers={['Eigenschaft', 'Wert', 'Beschreibung']}
rows={[
['Parser', 'feedparser 6.x', 'Standard RSS/Atom Parser'],
['HTTP Client', 'httpx (async)', 'Non-blocking Fetches'],
['Timeout', '30 Sekunden', 'Konfigurierbar'],
['Parallelitaet', 'Ja (asyncio.gather)', 'Mehrere Feeds gleichzeitig'],
]}
/>
<h3>Deduplizierung</h3>
<p>Die Deduplizierung verhindert doppelte Alerts durch:</p>
<ol>
<li><strong>URL-Normalisierung</strong>: Tracking-Parameter entfernen (utm_*, fbclid, gclid), Hostname lowercase, Trailing Slash entfernen</li>
<li><strong>URL-Hash</strong>: SHA256 der normalisierten URL, erste 16 Zeichen als Index</li>
</ol>
{/* Rule Engine */}
<h2>3. Rule Engine</h2>
<h3>Unterstuetzte Operatoren</h3>
<DocTable
headers={['Operator', 'Beschreibung', 'Beispiel']}
rows={[
['contains', 'Text enthaelt', 'title contains "Inklusion"'],
['not_contains', 'Text enthaelt nicht', 'title not_contains "Werbung"'],
['equals', 'Exakte Uebereinstimmung', 'status equals "new"'],
['regex', 'Regulaerer Ausdruck', 'title regex "\\d{4}"'],
['gt / lt', 'Groesser/Kleiner', 'relevance_score gt 0.8'],
['in', 'In Liste enthalten', 'title in ["KI", "AI"]'],
]}
monoFirstCol
/>
<h3>Verfuegbare Felder</h3>
<DocTable
headers={['Feld', 'Typ', 'Beschreibung']}
rows={[
['title', 'String', 'Alert-Titel'],
['snippet', 'String', 'Textausschnitt'],
['url', 'String', 'Artikel-URL'],
['source', 'Enum', 'google_alerts_rss, rss_feed, manual'],
['relevance_score', 'Float', '0.0 - 1.0'],
['relevance_decision', 'Enum', 'KEEP, DROP, REVIEW'],
]}
monoFirstCol
/>
<h3>Aktionen</h3>
<DocTable
headers={['Aktion', 'Beschreibung', 'Konfiguration']}
rows={[
['keep', 'Als relevant markieren', '-'],
['drop', 'Archivieren', '-'],
['tag', 'Tags hinzufuegen', '{"tags": ["wichtig"]}'],
['email', 'E-Mail senden', '{"to": "x@y.de"}'],
['webhook', 'HTTP POST', '{"url": "https://..."}'],
['slack', 'Slack-Nachricht', '{"webhook_url": "..."}'],
]}
monoFirstCol
/>
{/* KI Relevanzpruefung */}
<h2>4. KI-Relevanzpruefung</h2>
<h3>LLM Scorer</h3>
<DocTable
headers={['Eigenschaft', 'Wert', 'Beschreibung']}
rows={[
['Gateway URL', 'http://localhost:8000/llm', 'LLM Gateway Endpoint'],
['Modell', 'breakpilot-teacher-8b', 'Fein-getuntes Llama 3.1'],
['Temperatur', '0.3', 'Niedrig fuer Konsistenz'],
['Max Tokens', '500', 'Response-Limit'],
]}
/>
<h3>Bewertungskriterien</h3>
<div className="not-prose overflow-x-auto">
<table className="min-w-full text-sm">
<thead className="bg-slate-50">
<tr>
<th className="px-4 py-2 text-left font-semibold">Entscheidung</th>
<th className="px-4 py-2 text-left font-semibold">Score-Bereich</th>
<th className="px-4 py-2 text-left font-semibold">Bedeutung</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-200">
<tr className="bg-green-50"><td className="px-4 py-2 font-semibold text-green-800">KEEP</td><td className="px-4 py-2">0.7 - 1.0</td><td className="px-4 py-2">Klar relevant, in Inbox anzeigen</td></tr>
<tr className="bg-amber-50"><td className="px-4 py-2 font-semibold text-amber-800">REVIEW</td><td className="px-4 py-2">0.4 - 0.7</td><td className="px-4 py-2">Unsicher, Nutzer entscheidet</td></tr>
<tr className="bg-red-50"><td className="px-4 py-2 font-semibold text-red-800">DROP</td><td className="px-4 py-2">0.0 - 0.4</td><td className="px-4 py-2">Irrelevant, automatisch archivieren</td></tr>
</tbody>
</table>
</div>
<h3>Few-Shot Learning</h3>
<p>Das Profil verbessert sich durch Nutzerfeedback:</p>
<ol>
<li>Nutzer markiert Alert als relevant/irrelevant</li>
<li>Alert wird als positives/negatives Beispiel gespeichert</li>
<li>Beispiele werden in den Prompt eingefuegt (max. 5 pro Kategorie)</li>
<li>LLM lernt aus konkreten Beispielen des Nutzers</li>
</ol>
{/* Relevanz Profile */}
<h2>5. Relevanz-Profile</h2>
<h3>Standard-Bildungsprofil</h3>
<DocTable
headers={['Prioritaet', 'Gewicht', 'Keywords']}
rows={[
['Inklusion', '0.9', 'inklusiv, Foerderbedarf, Behinderung'],
['Datenschutz Schule', '0.85', 'DSGVO, Schuelerfotos, Einwilligung'],
['Schulrecht Bayern', '0.8', 'BayEUG, Schulordnung, KM'],
['Digitalisierung Schule', '0.7', 'DigitalPakt, Tablet-Klasse'],
]}
/>
<h3>Standard-Ausschluesse</h3>
<ul>
<li>Stellenanzeige</li>
<li>Praktikum gesucht</li>
<li>Werbung</li>
<li>Pressemitteilung</li>
</ul>
{/* API Endpoints */}
<h2>6. API Endpoints</h2>
<h3>Alerts API</h3>
<DocTable
headers={['Endpoint', 'Methode', 'Beschreibung']}
rows={[
['/api/alerts/inbox', 'GET', 'Inbox Items abrufen'],
['/api/alerts/ingest', 'POST', 'Manuell Alert importieren'],
['/api/alerts/run', 'POST', 'Scoring-Pipeline starten'],
['/api/alerts/feedback', 'POST', 'Relevanz-Feedback geben'],
['/api/alerts/stats', 'GET', 'Statistiken abrufen'],
]}
monoFirstCol
/>
<h3>Topics API</h3>
<DocTable
headers={['Endpoint', 'Methode', 'Beschreibung']}
rows={[
['/api/alerts/topics', 'GET', 'Topics auflisten'],
['/api/alerts/topics', 'POST', 'Neues Topic erstellen'],
['/api/alerts/topics/{id}', 'PUT', 'Topic aktualisieren'],
['/api/alerts/topics/{id}', 'DELETE', 'Topic loeschen (CASCADE)'],
['/api/alerts/topics/{id}/fetch', 'POST', 'Manueller Feed-Abruf'],
]}
monoFirstCol
/>
<h3>Rules & Profile API</h3>
<DocTable
headers={['Endpoint', 'Methode', 'Beschreibung']}
rows={[
['/api/alerts/rules', 'GET/POST', 'Regeln verwalten'],
['/api/alerts/rules/{id}', 'PUT/DELETE', 'Regel bearbeiten/loeschen'],
['/api/alerts/profile', 'GET', 'Profil abrufen'],
['/api/alerts/profile', 'PUT', 'Profil aktualisieren'],
['/api/alerts/scheduler/status', 'GET', 'Scheduler-Status'],
]}
monoFirstCol
/>
{/* Datenbank Schema */}
<h2>7. Datenbank-Schema</h2>
<h3>Tabellen</h3>
<DocTable
headers={['Tabelle', 'Beschreibung', 'Wichtige Felder']}
rows={[
['alert_topics', 'Feed-Quellen', 'name, feed_url, feed_type, is_active, fetch_interval'],
['alert_items', 'Einzelne Alerts', 'title, url, url_hash, relevance_score, relevance_decision'],
['alert_rules', 'Filterregeln', 'name, conditions (JSON), action_type, priority'],
['alert_profiles', 'Nutzer-Profile', 'priorities, exclusions, positive/negative_examples'],
]}
monoFirstCol
/>
{/* DSGVO */}
<h2>8. DSGVO-Konformitaet</h2>
<h3>Rechtsgrundlage (Art. 6 DSGVO)</h3>
<DocTable
headers={['Verarbeitung', 'Rechtsgrundlage', 'Umsetzung']}
rows={[
['Feed-Abruf', 'Art. 6(1)(f) - Berechtigtes Interesse', 'Informationsbeschaffung'],
['Alert-Speicherung', 'Art. 6(1)(f) - Berechtigtes Interesse', 'Nur oeffentliche Informationen'],
['LLM-Scoring', 'Art. 6(1)(f) - Berechtigtes Interesse', 'On-Premise, keine PII'],
['Profil-Learning', 'Art. 6(1)(a) - Einwilligung', 'Opt-in durch Nutzung'],
]}
/>
<h3>Technische Datenschutz-Massnahmen</h3>
<ul>
<li><strong>Datenminimierung</strong>: Nur Titel, URL, Snippet - keine personenbezogenen Daten</li>
<li><strong>Lokale Verarbeitung</strong>: Ollama/vLLM on-premise - kein Datenabfluss an Cloud</li>
<li><strong>Pseudonymisierung</strong>: UUIDs statt Namen</li>
<li><strong>Automatische Loeschung</strong>: 90 Tage Retention fuer archivierte Alerts</li>
<li><strong>Audit-Logging</strong>: Stats und Match-Counts fuer Nachvollziehbarkeit</li>
</ul>
{/* Open Source */}
<h2>9. Open Source Lizenzen (SBOM)</h2>
<h3>Python Dependencies</h3>
<DocTable
headers={['Komponente', 'Lizenz', 'Kommerziell']}
rows={[
['FastAPI', 'MIT', 'Ja'],
['SQLAlchemy', 'MIT', 'Ja'],
['httpx', 'BSD-3-Clause', 'Ja'],
['feedparser', 'BSD-2-Clause', 'Ja'],
['APScheduler', 'MIT', 'Ja'],
]}
greenLastCol
/>
<h3>KI-Komponenten</h3>
<DocTable
headers={['Komponente', 'Lizenz', 'Kommerziell']}
rows={[
['Ollama', 'MIT', 'Ja'],
['Llama 3.1', 'Meta Llama 3', 'Ja*'],
['vLLM', 'Apache-2.0', 'Ja'],
]}
greenLastCol
/>
{/* Kontakt */}
<h2>10. Kontakt & Support</h2>
<DocTable
headers={['Kontakt', 'Adresse']}
rows={[
['Technischer Support', 'support@breakpilot.de'],
['Datenschutzbeauftragter', 'dsb@breakpilot.de'],
['Dokumentation', 'docs.breakpilot.de'],
['GitHub', 'github.com/breakpilot'],
]}
/>
{/* Footer */}
<div className="not-prose mt-8 pt-6 border-t border-slate-200 text-sm text-slate-500">
<p>Dokumentation erstellt: Januar 2026 | Version: 1.0.0</p>
</div>
</div>
</div>
)
}
/* ------------------------------------------------------------------ */
/* Helper component for repetitive doc tables */
/* ------------------------------------------------------------------ */
interface DocTableProps {
headers: string[]
rows: string[][]
monoFirstCol?: boolean
greenLastCol?: boolean
}
function DocTable({ headers, rows, monoFirstCol, greenLastCol }: DocTableProps) {
return (
<div className="not-prose overflow-x-auto">
<table className="min-w-full text-sm">
<thead className="bg-slate-50">
<tr>
{headers.map((h) => (
<th key={h} className="px-4 py-2 text-left font-semibold">{h}</th>
))}
</tr>
</thead>
<tbody className="divide-y divide-slate-200">
{rows.map((row, ri) => (
<tr key={ri}>
{row.map((cell, ci) => (
<td
key={ci}
className={`px-4 py-2${ci === 0 && monoFirstCol ? ' font-mono text-xs' : ''}${ci === row.length - 1 && greenLastCol ? ' text-green-600' : ''}`}
>
{cell}
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
)
}
/* ------------------------------------------------------------------ */
/* ASCII diagrams kept as constants to avoid JSX noise */
/* ------------------------------------------------------------------ */
const ARCHITECTURE_DIAGRAM = `\u250c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2510
\u2502 BreakPilot Alerts Frontend \u2502
\u2502 \u250c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2510 \u250c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2510 \u250c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2510 \u250c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2510 \u2502
\u2502 \u2502 Dashboard \u2502 \u2502 Inbox \u2502 \u2502 Topics \u2502 \u2502 Profile \u2502 \u2502
\u2502 \u2502 (Stats) \u2502 \u2502 (Review) \u2502 \u2502 (Feeds) \u2502 \u2502 (Learning) \u2502 \u2502
\u2502 \u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518 \u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518 \u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518 \u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518 \u2502
\u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2534\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518
\u2502
v
\u250c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2510
\u2502 Ingestion Layer \u2502
\u2502 \u250c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2510 \u250c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2510 \u250c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2510 \u2502
\u2502 \u2502 RSS Fetcher \u2502 \u2502 Email Parser \u2502 \u2502 APScheduler \u2502 \u2502
\u2502 \u2502 (feedparser) \u2502 \u2502 (geplant) \u2502 \u2502 (AsyncIO) \u2502 \u2502
\u2502 \u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2534\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518 \u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2534\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518 \u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2534\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518 \u2502
\u2502 \u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2524\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518 \u2502
\u2502 \u2502 \u2502
\u2502 \u250c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2510 \u2502
\u2502 \u2502 Deduplication (URL-Hash + SimHash) \u2502 \u2502
\u2502 \u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518 \u2502
\u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518
\u2502
v
\u250c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2510
\u2502 Processing Layer \u2502
\u2502 \u250c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2510 \u2502
\u2502 \u2502 Rule Engine \u2502 \u2502
\u2502 \u2502 Operatoren: contains, regex, gt/lt, in, starts_with \u2502 \u2502
\u2502 \u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518 \u2502
\u2502 \u2502 \u2502
\u2502 \u250c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2510 \u2502
\u2502 \u2502 LLM Relevance Scorer \u2502 \u2502
\u2502 \u2502 Output: { score, decision: KEEP/DROP/REVIEW, summary } \u2502 \u2502
\u2502 \u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518 \u2502
\u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518
\u2502
v
\u250c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2510
\u2502 Action Layer \u2502
\u2502 \u250c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2510 \u250c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2510 \u250c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2510 \u2502
\u2502 \u2502 Email Action \u2502 \u2502 Webhook Action \u2502 \u2502 Slack Action \u2502 \u2502
\u2502 \u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518 \u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518 \u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518 \u2502
\u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518
\u2502
v
\u250c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2510
\u2502 Storage Layer \u2502
\u2502 \u250c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2510 \u250c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2510 \u250c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2510 \u2502
\u2502 \u2502 PostgreSQL \u2502 \u2502 Valkey \u2502 \u2502 LLM Gateway \u2502 \u2502
\u2502 \u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518 \u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518 \u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518 \u2502
\u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518`
const DATAFLOW_DIAGRAM = `1. APScheduler triggert Fetch (alle 60 Min. default)
\u2502
v
2. RSS Fetcher holt Feed von Google Alerts
\u2502
v
3. Deduplizierung prueft URL-Hash
\u2502
\u251c\u2500\u2500 URL bekannt \u2500\u2500> Uebersprungen
\u2514\u2500\u2500 URL neu \u2500\u2500> Weiter
\u2502
v
4. Alert in Datenbank gespeichert (Status: NEW)
\u2502
v
5. Rule Engine evaluiert aktive Regeln
\u2502
\u251c\u2500\u2500 Regel matcht \u2500\u2500> Aktion ausfuehren
\u2514\u2500\u2500 Keine Regel \u2500\u2500> LLM Scoring
\u2502
v
6. LLM Relevance Scorer
\u2502
\u251c\u2500\u2500 KEEP (>= 0.7) \u2500\u2500> Inbox
\u251c\u2500\u2500 REVIEW (0.4-0.7) \u2500\u2500> Inbox (Pruefung)
\u2514\u2500\u2500 DROP (< 0.4) \u2500\u2500> Archiviert
\u2502
v
7. Nutzer-Feedback \u2500\u2500> Profile aktualisieren`

View File

@@ -0,0 +1,67 @@
'use client'
import type { AlertItem } from './types'
import { formatTimeAgo, getScoreBadge, getDecisionBadge } from './useAlertsData'
interface InboxTabProps {
inboxFilter: string
setInboxFilter: (filter: string) => void
filteredAlerts: AlertItem[]
}
export default function InboxTab({ inboxFilter, setInboxFilter, filteredAlerts }: InboxTabProps) {
return (
<div className="space-y-4">
{/* Filters */}
<div className="flex gap-2 flex-wrap">
{['all', 'new', 'keep', 'review'].map((filter) => (
<button
key={filter}
onClick={() => setInboxFilter(filter)}
className={`px-4 py-2 rounded-full text-sm font-medium transition-colors ${
inboxFilter === filter
? 'bg-primary-600 text-white'
: 'bg-slate-100 text-slate-600 hover:bg-slate-200'
}`}
>
{filter === 'all' && 'Alle'}
{filter === 'new' && 'Neu'}
{filter === 'keep' && 'Relevant'}
{filter === 'review' && 'Pruefung'}
</button>
))}
</div>
{/* Alerts Table */}
<div className="bg-white rounded-xl border border-slate-200 overflow-hidden">
<table className="w-full">
<thead className="bg-slate-50 border-b border-slate-200">
<tr>
<th className="text-left p-4 text-xs font-semibold text-slate-500 uppercase">Alert</th>
<th className="text-left p-4 text-xs font-semibold text-slate-500 uppercase">Topic</th>
<th className="text-left p-4 text-xs font-semibold text-slate-500 uppercase">Score</th>
<th className="text-left p-4 text-xs font-semibold text-slate-500 uppercase">Decision</th>
<th className="text-left p-4 text-xs font-semibold text-slate-500 uppercase">Zeit</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-100">
{filteredAlerts.map((alert) => (
<tr key={alert.id} className="hover:bg-slate-50">
<td className="p-4">
<a href={alert.url} target="_blank" rel="noopener noreferrer" className="font-medium text-slate-900 hover:text-primary-600">
{alert.title}
</a>
<p className="text-sm text-slate-500 truncate max-w-md">{alert.snippet}</p>
</td>
<td className="p-4 text-sm text-slate-600">{alert.topic_name}</td>
<td className="p-4">{getScoreBadge(alert.relevance_score)}</td>
<td className="p-4">{getDecisionBadge(alert.relevance_decision)}</td>
<td className="p-4 text-sm text-slate-500">{formatTimeAgo(alert.fetched_at)}</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)
}

View File

@@ -0,0 +1,78 @@
'use client'
import type { Profile } from './types'
interface ProfileTabProps {
profile: Profile | null
}
export default function ProfileTab({ profile }: ProfileTabProps) {
return (
<div className="max-w-2xl space-y-6">
<div className="bg-white rounded-xl border border-slate-200 p-6">
<h3 className="font-semibold text-slate-900 mb-4">Relevanzprofil</h3>
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-slate-700 mb-2">
Prioritaeten (wichtige Themen)
</label>
<textarea
className="w-full p-3 border border-slate-200 rounded-lg text-sm"
rows={4}
defaultValue={profile?.priorities?.join('\n') || ''}
placeholder="Ein Thema pro Zeile..."
/>
<p className="text-xs text-slate-500 mt-1">Alerts zu diesen Themen werden hoeher bewertet.</p>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-2">
Ausschluesse (unerwuenschte Themen)
</label>
<textarea
className="w-full p-3 border border-slate-200 rounded-lg text-sm"
rows={4}
defaultValue={profile?.exclusions?.join('\n') || ''}
placeholder="Ein Thema pro Zeile..."
/>
<p className="text-xs text-slate-500 mt-1">Alerts zu diesen Themen werden niedriger bewertet.</p>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-slate-700 mb-2">
Schwellenwert KEEP
</label>
<select
className="w-full p-3 border border-slate-200 rounded-lg text-sm"
defaultValue={profile?.policies?.keep_threshold || 0.7}
>
<option value={0.8}>80% (sehr streng)</option>
<option value={0.7}>70% (empfohlen)</option>
<option value={0.6}>60% (weniger streng)</option>
</select>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-2">
Schwellenwert DROP
</label>
<select
className="w-full p-3 border border-slate-200 rounded-lg text-sm"
defaultValue={profile?.policies?.drop_threshold || 0.3}
>
<option value={0.4}>40% (strenger)</option>
<option value={0.3}>30% (empfohlen)</option>
<option value={0.2}>20% (lockerer)</option>
</select>
</div>
</div>
<button className="px-4 py-2 bg-primary-600 text-white rounded-lg text-sm font-medium hover:bg-primary-700">
Profil speichern
</button>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,57 @@
'use client'
import type { Rule } from './types'
interface RulesTabProps {
rules: Rule[]
}
export default function RulesTab({ rules }: RulesTabProps) {
return (
<div className="space-y-4">
<div className="flex justify-between items-center">
<h3 className="font-semibold text-slate-900">Filterregeln</h3>
<button className="px-4 py-2 bg-primary-600 text-white rounded-lg text-sm font-medium hover:bg-primary-700">
+ Regel erstellen
</button>
</div>
<div className="bg-white rounded-xl border border-slate-200 divide-y divide-slate-100">
{rules.map((rule) => (
<div key={rule.id} className="p-4 flex items-center gap-4">
<div className="text-slate-400 cursor-grab">
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 6h16M4 12h16M4 18h16" />
</svg>
</div>
<div className="flex-1">
<div className="font-medium text-slate-900">{rule.name}</div>
<div className="text-sm text-slate-500">
Wenn: {rule.conditions[0]?.field} {rule.conditions[0]?.operator} &quot;{rule.conditions[0]?.value}&quot;
</div>
</div>
<span className={`px-3 py-1 rounded text-xs font-semibold uppercase ${
rule.action_type === 'keep' ? 'bg-green-100 text-green-800' :
rule.action_type === 'drop' ? 'bg-red-100 text-red-800' :
rule.action_type === 'email' ? 'bg-blue-100 text-blue-800' :
'bg-purple-100 text-purple-800'
}`}>
{rule.action_type}
</span>
<div
className={`w-12 h-6 rounded-full relative cursor-pointer transition-colors ${
rule.is_active ? 'bg-green-500' : 'bg-slate-300'
}`}
>
<div
className={`absolute w-5 h-5 bg-white rounded-full top-0.5 transition-all ${
rule.is_active ? 'left-6' : 'left-0.5'
}`}
/>
</div>
</div>
))}
</div>
</div>
)
}

View File

@@ -0,0 +1,49 @@
'use client'
import type { Topic } from './types'
import { formatTimeAgo } from './useAlertsData'
interface TopicsTabProps {
topics: Topic[]
}
export default function TopicsTab({ topics }: TopicsTabProps) {
return (
<div className="space-y-4">
<div className="flex justify-between items-center">
<h3 className="font-semibold text-slate-900">Feed Topics</h3>
<button className="px-4 py-2 bg-primary-600 text-white rounded-lg text-sm font-medium hover:bg-primary-700">
+ Topic hinzufuegen
</button>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{topics.map((topic) => (
<div key={topic.id} className="bg-white rounded-xl border border-slate-200 p-4">
<div className="flex justify-between items-start mb-3">
<div className="w-10 h-10 bg-primary-100 rounded-lg flex items-center justify-center">
<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="M6 5c7.18 0 13 5.82 13 13M6 11a7 7 0 017 7m-6 0a1 1 0 11-2 0 1 1 0 012 0z" />
</svg>
</div>
<span className={`px-2 py-1 rounded text-xs font-semibold ${topic.is_active ? 'bg-green-100 text-green-800' : 'bg-slate-100 text-slate-600'}`}>
{topic.is_active ? 'Aktiv' : 'Pausiert'}
</span>
</div>
<h4 className="font-semibold text-slate-900">{topic.name}</h4>
<p className="text-sm text-slate-500 truncate">{topic.feed_url}</p>
<div className="flex justify-between items-center mt-4 pt-4 border-t border-slate-100">
<div className="text-sm">
<span className="font-semibold text-slate-900">{topic.alert_count}</span>
<span className="text-slate-500"> Alerts</span>
</div>
<div className="text-xs text-slate-500">
{formatTimeAgo(topic.last_fetched_at)}
</div>
</div>
</div>
))}
</div>
</div>
)
}

View File

@@ -0,0 +1,133 @@
/**
* System Info configuration for Audit tab
*/
export const alertsSystemConfig = {
title: 'Alerts Agent System',
description: 'Google Alerts & Feed-Ueberwachung mit KI-gestuetzter Relevanzpruefung',
version: '1.0.0',
architecture: {
layers: [
{
title: 'Ingestion Layer',
components: ['RSS Fetcher', 'Email Parser', 'Webhook Receiver', 'APScheduler'],
color: '#3b82f6',
},
{
title: 'Processing Layer',
components: ['Deduplication', 'Rule Engine', 'LLM Relevance Scorer'],
color: '#8b5cf6',
},
{
title: 'Action Layer',
components: ['Email Actions', 'Webhook Actions', 'Slack Actions'],
color: '#22c55e',
},
{
title: 'Storage Layer',
components: ['PostgreSQL', 'Valkey Cache'],
color: '#f59e0b',
},
],
},
features: [
{ name: 'RSS Feed Parsing', status: 'active' as const, description: 'Google Alerts und andere RSS/Atom Feeds' },
{ name: 'LLM Relevance Scoring', status: 'active' as const, description: 'KI-basierte Relevanzpruefung mit Few-Shot Learning' },
{ name: 'Rule Engine', status: 'active' as const, description: 'Regelbasierte Filterung mit Conditions' },
{ name: 'Email Actions', status: 'active' as const, description: 'E-Mail-Benachrichtigungen bei Matches' },
{ name: 'Webhook Actions', status: 'active' as const, description: 'HTTP Webhooks fuer Integrationen' },
{ name: 'Slack Actions', status: 'active' as const, description: 'Slack Block Kit Nachrichten' },
{ name: 'Email Parsing', status: 'planned' as const, description: 'Google Alerts per E-Mail empfangen' },
{ name: 'Microsoft Teams', status: 'planned' as const, description: 'Teams Adaptive Cards' },
],
roadmap: [
{
phase: 'Phase 1 (Completed)',
priority: 'high' as const,
items: ['PostgreSQL Persistenz', 'Repository Pattern', 'Alembic Migrations'],
},
{
phase: 'Phase 2 (Completed)',
priority: 'high' as const,
items: ['Topic CRUD API', 'APScheduler Integration', 'Email Parser'],
},
{
phase: 'Phase 3 (Completed)',
priority: 'medium' as const,
items: ['Rule Engine', 'Condition Operators', 'Rule API'],
},
{
phase: 'Phase 4 (Completed)',
priority: 'medium' as const,
items: ['Action Dispatcher', 'Email/Webhook/Slack Actions'],
},
{
phase: 'Phase 5 (Current)',
priority: 'high' as const,
items: ['Studio Frontend', 'Admin Frontend', 'Audit & Documentation'],
},
],
technicalDetails: [
{ component: 'Backend', technology: 'FastAPI', version: '0.100+', description: 'Async REST API' },
{ component: 'ORM', technology: 'SQLAlchemy', version: '2.0', description: 'Async ORM mit PostgreSQL' },
{ component: 'Scheduler', technology: 'APScheduler', version: '3.x', description: 'AsyncIO Scheduler' },
{ component: 'HTTP Client', technology: 'httpx', description: 'Async HTTP fuer Webhooks' },
{ component: 'Feed Parser', technology: 'feedparser', version: '6.x', description: 'RSS/Atom Parsing' },
{ component: 'LLM Gateway', technology: 'Ollama/vLLM/Claude', description: 'Multi-Provider LLM' },
],
privacyNotes: [
'Alle Daten werden in Deutschland gespeichert (PostgreSQL)',
'Keine personenbezogenen Daten in Alerts (nur URLs und Snippets)',
'LLM-Verarbeitung kann on-premise mit Ollama/vLLM erfolgen',
'DSGVO-konforme Datenverarbeitung',
],
auditInfo: [
{
category: 'Datenbank',
items: [
{ label: 'Tabellen', value: '4 (topics, items, rules, profiles)', status: 'ok' as const },
{ label: 'Indizes', value: 'URL-Hash, Topic-ID, Status', status: 'ok' as const },
{ label: 'Backups', value: 'PostgreSQL pg_dump', status: 'ok' as const },
],
},
{
category: 'API Sicherheit',
items: [
{ label: 'Authentifizierung', value: 'Bearer Token (geplant)', status: 'warning' as const },
{ label: 'Rate Limiting', value: 'Nicht implementiert', status: 'warning' as const },
{ label: 'Input Validation', value: 'Pydantic Models', status: 'ok' as const },
],
},
{
category: 'Logging & Monitoring',
items: [
{ label: 'Structured Logging', value: 'Python logging', status: 'ok' as const },
{ label: 'Metriken', value: 'Stats Endpoint', status: 'ok' as const },
{ label: 'Health Checks', value: '/api/alerts/health', status: 'ok' as const },
],
},
],
fullDocumentation: `
<h3>Alerts Agent - Entwicklerdokumentation</h3>
<h4>API Endpoints</h4>
<ul>
<li><code>GET /api/alerts/inbox</code> - Alerts auflisten</li>
<li><code>POST /api/alerts/ingest</code> - Alert hinzufuegen</li>
<li><code>GET /api/alerts/topics</code> - Topics auflisten</li>
<li><code>POST /api/alerts/topics</code> - Topic erstellen</li>
<li><code>GET /api/alerts/rules</code> - Regeln auflisten</li>
<li><code>POST /api/alerts/rules</code> - Regel erstellen</li>
<li><code>GET /api/alerts/profile</code> - Profil abrufen</li>
<li><code>PUT /api/alerts/profile</code> - Profil aktualisieren</li>
</ul>
<h4>Architektur</h4>
<p>Der Alerts Agent verwendet ein Pipeline-basiertes Design:</p>
<ol>
<li><strong>Ingestion</strong>: RSS Feeds werden periodisch abgerufen</li>
<li><strong>Deduplication</strong>: SimHash-basierte Duplikaterkennung</li>
<li><strong>Scoring</strong>: LLM-basierte Relevanzpruefung</li>
<li><strong>Rules</strong>: Regelbasierte Filterung und Aktionen</li>
<li><strong>Actions</strong>: Email/Webhook/Slack Benachrichtigungen</li>
</ol>
`,
}

View File

@@ -0,0 +1,68 @@
/**
* Types for Alerts Monitoring Admin Page
*/
export interface AlertItem {
id: string
title: string
url: string
snippet: string
topic_name: string
relevance_score: number | null
relevance_decision: string | null
status: string
fetched_at: string
published_at: string | null
matched_rule: string | null
tags: string[]
}
export interface Topic {
id: string
name: string
feed_url: string
feed_type: string
is_active: boolean
fetch_interval_minutes: number
last_fetched_at: string | null
alert_count: number
}
export interface Rule {
id: string
name: string
topic_id: string | null
conditions: Array<{
field: string
operator: string
value: string | number
}>
action_type: string
action_config: Record<string, unknown>
priority: number
is_active: boolean
}
export interface Profile {
priorities: string[]
exclusions: string[]
positive_examples: Array<{ title: string; url: string }>
negative_examples: Array<{ title: string; url: string }>
policies: {
keep_threshold: number
drop_threshold: number
}
}
export interface Stats {
total_alerts: number
new_alerts: number
kept_alerts: number
review_alerts: number
dropped_alerts: number
total_topics: number
active_topics: number
total_rules: number
}
export type TabId = 'dashboard' | 'inbox' | 'topics' | 'rules' | 'profile' | 'audit' | 'documentation'

View File

@@ -0,0 +1,193 @@
'use client'
import { useEffect, useState, useCallback } from 'react'
import type { AlertItem, Topic, Rule, Profile, Stats } from './types'
const API_BASE = '/api/alerts'
export function useAlertsData() {
const [stats, setStats] = useState<Stats | null>(null)
const [alerts, setAlerts] = useState<AlertItem[]>([])
const [topics, setTopics] = useState<Topic[]>([])
const [rules, setRules] = useState<Rule[]>([])
const [profile, setProfile] = useState<Profile | null>(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [inboxFilter, setInboxFilter] = useState<string>('all')
const fetchData = useCallback(async () => {
try {
const [statsRes, alertsRes, topicsRes, rulesRes, profileRes] = await Promise.all([
fetch(`${API_BASE}/stats`),
fetch(`${API_BASE}/inbox?limit=50`),
fetch(`${API_BASE}/topics`),
fetch(`${API_BASE}/rules`),
fetch(`${API_BASE}/profile`),
])
if (statsRes.ok) setStats(await statsRes.json())
if (alertsRes.ok) {
const data = await alertsRes.json()
setAlerts(data.items || [])
}
if (topicsRes.ok) {
const data = await topicsRes.json()
setTopics(data.items || data || [])
}
if (rulesRes.ok) {
const data = await rulesRes.json()
setRules(data.items || data || [])
}
if (profileRes.ok) setProfile(await profileRes.json())
setError(null)
} catch (err) {
setError(err instanceof Error ? err.message : 'Verbindungsfehler')
setStats({
total_alerts: 147,
new_alerts: 23,
kept_alerts: 89,
review_alerts: 12,
dropped_alerts: 23,
total_topics: 5,
active_topics: 4,
total_rules: 8,
})
setAlerts([
{
id: 'demo_1',
title: 'Neue Studie zur digitalen Bildung an Schulen',
url: 'https://example.com/artikel1',
snippet: 'Eine aktuelle Studie zeigt, dass digitale Lernmittel den Lernerfolg steigern koennen...',
topic_name: 'Digitale Bildung',
relevance_score: 0.85,
relevance_decision: 'KEEP',
status: 'new',
fetched_at: new Date().toISOString(),
published_at: null,
matched_rule: null,
tags: ['bildung', 'digital'],
},
{
id: 'demo_2',
title: 'Inklusion: Fortbildungen fuer Lehrkraefte',
url: 'https://example.com/artikel2',
snippet: 'Das Kultusministerium bietet neue Fortbildungsangebote zum Thema Inklusion an...',
topic_name: 'Inklusion',
relevance_score: 0.72,
relevance_decision: 'KEEP',
status: 'new',
fetched_at: new Date(Date.now() - 3600000).toISOString(),
published_at: null,
matched_rule: null,
tags: ['inklusion'],
},
])
setTopics([
{
id: 'topic_1',
name: 'Digitale Bildung',
feed_url: 'https://google.com/alerts/feeds/123',
feed_type: 'rss',
is_active: true,
fetch_interval_minutes: 60,
last_fetched_at: new Date().toISOString(),
alert_count: 47,
},
{
id: 'topic_2',
name: 'Inklusion',
feed_url: 'https://google.com/alerts/feeds/456',
feed_type: 'rss',
is_active: true,
fetch_interval_minutes: 60,
last_fetched_at: new Date(Date.now() - 1800000).toISOString(),
alert_count: 32,
},
])
setRules([
{
id: 'rule_1',
name: 'Stellenanzeigen ausschliessen',
topic_id: null,
conditions: [{ field: 'title', operator: 'contains', value: 'Stellenangebot' }],
action_type: 'drop',
action_config: {},
priority: 10,
is_active: true,
},
])
setProfile({
priorities: ['Inklusion', 'digitale Bildung'],
exclusions: ['Stellenanzeigen', 'Werbung'],
positive_examples: [],
negative_examples: [],
policies: { keep_threshold: 0.7, drop_threshold: 0.3 },
})
} finally {
setLoading(false)
}
}, [])
useEffect(() => {
fetchData()
}, [fetchData])
const filteredAlerts = alerts.filter((alert) => {
if (inboxFilter === 'all') return true
if (inboxFilter === 'new') return alert.status === 'new'
if (inboxFilter === 'keep') return alert.relevance_decision === 'KEEP'
if (inboxFilter === 'review') return alert.relevance_decision === 'REVIEW'
return true
})
return {
stats,
alerts,
topics,
rules,
profile,
loading,
error,
inboxFilter,
setInboxFilter,
filteredAlerts,
}
}
export function formatTimeAgo(dateStr: string | null): string {
if (!dateStr) return '-'
const date = new Date(dateStr)
const now = new Date()
const diffMs = now.getTime() - date.getTime()
const diffMins = Math.floor(diffMs / 60000)
if (diffMins < 1) return 'gerade eben'
if (diffMins < 60) return `vor ${diffMins} Min.`
if (diffMins < 1440) return `vor ${Math.floor(diffMins / 60)} Std.`
return `vor ${Math.floor(diffMins / 1440)} Tagen`
}
export function getScoreBadge(score: number | null) {
if (score === null) return null
const pct = Math.round(score * 100)
let cls = 'bg-slate-100 text-slate-600'
if (pct >= 70) cls = 'bg-green-100 text-green-800'
else if (pct >= 40) cls = 'bg-amber-100 text-amber-800'
else cls = 'bg-red-100 text-red-800'
return <span className={`px-2 py-0.5 rounded text-xs font-semibold ${cls}`}>{pct}%</span>
}
export function getDecisionBadge(decision: string | null) {
if (!decision) return null
const styles: Record<string, string> = {
KEEP: 'bg-green-100 text-green-800',
REVIEW: 'bg-amber-100 text-amber-800',
DROP: 'bg-red-100 text-red-800',
}
return (
<span className={`px-2 py-0.5 rounded text-xs font-semibold uppercase ${styles[decision] || 'bg-slate-100'}`}>
{decision}
</span>
)
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,133 @@
'use client'
import type { BacklogItem, BacklogCategory } from './types'
import { statusLabels, priorityLabels } from './types'
export function BacklogItemCard({
item,
category,
isExpanded,
onToggleExpand,
onUpdateStatus,
onToggleSubtask,
}: {
item: BacklogItem
category: BacklogCategory | undefined
isExpanded: boolean
onToggleExpand: () => void
onUpdateStatus: (status: BacklogItem['status']) => void
onToggleSubtask: (subtaskId: string) => void
}) {
const completedSubtasks = item.subtasks?.filter((st) => st.completed).length || 0
const totalSubtasks = item.subtasks?.length || 0
return (
<div className="bg-white rounded-xl border border-slate-200 overflow-hidden">
<div
className="p-4 cursor-pointer hover:bg-slate-50 transition-colors"
onClick={onToggleExpand}
>
<div className="flex items-start gap-4">
{/* Expand Icon */}
<button className="mt-1 text-slate-400">
<svg
className={`w-5 h-5 transition-transform ${isExpanded ? 'rotate-90' : ''}`}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M9 5l7 7-7 7"
/>
</svg>
</button>
{/* Content */}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-3 mb-1">
<h3 className="font-semibold text-slate-900 truncate">{item.title}</h3>
<span
className={`px-2 py-0.5 rounded text-xs font-medium ${
priorityLabels[item.priority].color
}`}
>
{priorityLabels[item.priority].label}
</span>
</div>
<p className="text-sm text-slate-500 mb-2">{item.description}</p>
<div className="flex items-center gap-4 text-xs">
<span className={`px-2 py-1 rounded border ${category?.color}`}>
{category?.name}
</span>
{totalSubtasks > 0 && (
<span className="text-slate-500">
{completedSubtasks}/{totalSubtasks} Teilaufgaben
</span>
)}
</div>
</div>
{/* Status */}
<select
value={item.status}
onChange={(e) => {
e.stopPropagation()
onUpdateStatus(e.target.value as BacklogItem['status'])
}}
onClick={(e) => e.stopPropagation()}
className={`px-3 py-1.5 rounded-lg text-sm font-medium border-0 cursor-pointer ${
statusLabels[item.status].color
}`}
>
{Object.entries(statusLabels).map(([value, { label }]) => (
<option key={value} value={value}>
{label}
</option>
))}
</select>
</div>
{/* Progress Bar for subtasks */}
{totalSubtasks > 0 && (
<div className="mt-3 ml-9">
<div className="w-full bg-slate-200 rounded-full h-1.5">
<div
className="bg-green-500 h-1.5 rounded-full transition-all"
style={{ width: `${(completedSubtasks / totalSubtasks) * 100}%` }}
/>
</div>
</div>
)}
</div>
{/* Expanded Subtasks */}
{isExpanded && item.subtasks && item.subtasks.length > 0 && (
<div className="border-t border-slate-200 bg-slate-50 p-4 pl-14">
<h4 className="text-sm font-medium text-slate-700 mb-3">Teilaufgaben</h4>
<ul className="space-y-2">
{item.subtasks.map((subtask) => (
<li key={subtask.id} className="flex items-center gap-3">
<input
type="checkbox"
checked={subtask.completed}
onChange={() => onToggleSubtask(subtask.id)}
className="w-4 h-4 rounded border-slate-300 text-primary-600 focus:ring-primary-500"
/>
<span
className={`text-sm ${
subtask.completed ? 'text-slate-400 line-through' : 'text-slate-700'
}`}
>
{subtask.title}
</span>
</li>
))}
</ul>
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,205 @@
import type { BacklogItem } from './types'
export const initialBacklogItems: BacklogItem[] = [
// ==================== MODULE PROGRESS ====================
{
id: 'mod-1',
title: 'Consent Service (Go) - 85% fertig',
description: 'DSGVO Consent Management Microservice - Near Production Ready',
category: 'modules',
priority: 'high',
status: 'in_progress',
notes: 'Port 8081. 19 Test-Dateien vorhanden. JWT Auth, OAuth 2.0, TOTP 2FA, DSR Workflow, Matrix/Jitsi Integration.',
subtasks: [
{ id: 'mod-1-1', title: 'Core Consent API (CRUD, Versioning)', completed: true },
{ id: 'mod-1-2', title: 'Authentication (JWT, OAuth 2.0, TOTP)', completed: true },
{ id: 'mod-1-3', title: 'DSR Workflow (Art. 15-21)', completed: true },
{ id: 'mod-1-4', title: 'Email Templates & Notifications', completed: true },
{ id: 'mod-1-5', title: 'Matrix/Jitsi Integration', completed: true },
{ id: 'mod-1-6', title: 'Performance Tests (High-Load)', completed: false },
{ id: 'mod-1-7', title: 'E2E API Contract Tests', completed: false },
],
},
{
id: 'mod-2',
title: 'School Service (Go) - 75% fertig',
description: 'Klassen, Noten, Zeugnisse Microservice',
category: 'modules',
priority: 'high',
status: 'in_progress',
notes: 'Port 8084. 6 Test-Dateien. Zeugnis-Workflow mit RBAC (Fachlehrer, Klassenlehrer, Schulleitung).',
subtasks: [
{ id: 'mod-2-1', title: 'Klassen & Schueler Management', completed: true },
{ id: 'mod-2-2', title: 'Noten-System mit Statistik', completed: true },
{ id: 'mod-2-3', title: 'Zeugnis-Workflow & Rollen', completed: true },
{ id: 'mod-2-4', title: 'Exam Variant Generation (LLM)', completed: true },
{ id: 'mod-2-5', title: 'Seed Data Generator', completed: true },
{ id: 'mod-2-6', title: 'Integration Tests zwischen Services', completed: false },
{ id: 'mod-2-7', title: 'Performance Tests (grosse Klassen)', completed: false },
{ id: 'mod-2-8', title: 'Trend-Analyse & Comparative Analytics', completed: false },
],
},
{
id: 'mod-3',
title: 'Billing Service (Go) - 80% fertig',
description: 'Stripe Integration & Task-Based Billing',
category: 'modules',
priority: 'high',
status: 'in_progress',
notes: 'Port 8083. 5 Test-Dateien. Task-Konsum mit 5-Monats-Carryover, Fair Use Mode.',
subtasks: [
{ id: 'mod-3-1', title: 'Subscription Lifecycle', completed: true },
{ id: 'mod-3-2', title: 'Stripe Checkout & Webhooks', completed: true },
{ id: 'mod-3-3', title: 'Task-Based Consumption Tracking', completed: true },
{ id: 'mod-3-4', title: 'Feature Gating / Entitlements', completed: true },
{ id: 'mod-3-5', title: 'Customer Portal Integration', completed: true },
{ id: 'mod-3-6', title: 'Refund & Chargeback Handling', completed: false },
{ id: 'mod-3-7', title: 'Advanced Analytics & Reporting', completed: false },
],
},
{
id: 'mod-4',
title: 'Klausur Service (Python) - 70% fertig',
description: 'BYOEH Abitur-Klausurkorrektur System',
category: 'modules',
priority: 'critical',
status: 'in_progress',
notes: 'Port 8086. 2 Test-Dateien. BYOEH mit AES-256-GCM Encryption, Qdrant Vector DB, Key-Sharing.',
subtasks: [
{ id: 'mod-4-1', title: 'BYOEH Upload & Encryption', completed: true },
{ id: 'mod-4-2', title: 'Key-Sharing zwischen Pruefern', completed: true },
{ id: 'mod-4-3', title: 'Qdrant RAG Integration', completed: true },
{ id: 'mod-4-4', title: 'RBAC fuer Pruefer-Rollen', completed: true },
{ id: 'mod-4-5', title: 'OCR Pipeline Implementation', completed: false },
{ id: 'mod-4-6', title: 'KI-gestuetzte Korrektur API', completed: false },
{ id: 'mod-4-7', title: 'Gutachten-Generierung', completed: false },
{ id: 'mod-4-8', title: 'Frontend: KorrekturPage fertigstellen', completed: false },
],
},
{
id: 'mod-5',
title: 'Admin Frontend (Next.js) - 60% fertig',
description: 'Next.js 15 Admin Dashboard',
category: 'modules',
priority: 'high',
status: 'in_progress',
notes: 'Port 3000. Nur 1 Test-Datei! Viele Admin-Seiten sind nur Skelett-Implementierungen.',
subtasks: [
{ id: 'mod-5-1', title: 'AdminLayout & Navigation', completed: true },
{ id: 'mod-5-2', title: 'SBOM & Architecture Pages', completed: true },
{ id: 'mod-5-3', title: 'Security Dashboard', completed: true },
{ id: 'mod-5-4', title: 'Backlog Page', completed: true },
{ id: 'mod-5-5', title: 'Consent Management Page', completed: true },
{ id: 'mod-5-6', title: 'Edu-Search Implementation', completed: false },
{ id: 'mod-5-7', title: 'DSMS Page Implementation', completed: false },
{ id: 'mod-5-8', title: 'Component Unit Tests', completed: false },
{ id: 'mod-5-9', title: 'E2E Tests mit Playwright', completed: false },
{ id: 'mod-5-10', title: 'Authentication Implementation', completed: false },
],
},
{
id: 'mod-6',
title: 'Backend Studio (Python) - 65% fertig',
description: 'Lehrer-Frontend Module (FastAPI)',
category: 'modules',
priority: 'medium',
status: 'in_progress',
notes: 'Port 8000. Keine dedizierten Tests! Integration mit allen APIs.',
subtasks: [
{ id: 'mod-6-1', title: 'Studio Router & Module Loading', completed: true },
{ id: 'mod-6-2', title: 'School Module UI', completed: true },
{ id: 'mod-6-3', title: 'Meetings/Jitsi Integration', completed: true },
{ id: 'mod-6-4', title: 'Customer Portal (Slim)', completed: true },
{ id: 'mod-6-5', title: 'Dev Admin Panel', completed: true },
{ id: 'mod-6-6', title: 'Component Unit Tests', completed: false },
{ id: 'mod-6-7', title: 'UI Component Dokumentation', completed: false },
{ id: 'mod-6-8', title: 'Accessibility Compliance', completed: false },
],
},
{
id: 'mod-7',
title: 'DSMS/IPFS Service - 40% fertig',
description: 'Dezentrales Speichersystem',
category: 'modules',
priority: 'low',
status: 'not_started',
notes: 'Port 8082. Keine Tests! Grundstruktur vorhanden aber Core-Logic fehlt.',
subtasks: [
{ id: 'mod-7-1', title: 'Service Struktur', completed: true },
{ id: 'mod-7-2', title: 'IPFS Configuration', completed: true },
{ id: 'mod-7-3', title: 'Upload/Download Handlers', completed: false },
{ id: 'mod-7-4', title: 'Encryption/Decryption Layer', completed: false },
{ id: 'mod-7-5', title: 'Pinning Strategy', completed: false },
{ id: 'mod-7-6', title: 'Replication Logic', completed: false },
{ id: 'mod-7-7', title: 'Tests & Dokumentation', completed: false },
],
},
{
id: 'mod-8',
title: 'Security Module (Python) - 75% fertig',
description: 'DevSecOps Dashboard & Scans',
category: 'modules',
priority: 'medium',
status: 'in_progress',
notes: 'Teil von Backend. Keine dedizierten Tests! Gitleaks, Semgrep, Bandit, Trivy Integration.',
subtasks: [
{ id: 'mod-8-1', title: 'Security Tool Status API', completed: true },
{ id: 'mod-8-2', title: 'Findings Aggregation', completed: true },
{ id: 'mod-8-3', title: 'Report Parsing (alle Tools)', completed: true },
{ id: 'mod-8-4', title: 'SBOM Generation (CycloneDX)', completed: true },
{ id: 'mod-8-5', title: 'Scan Triggering', completed: true },
{ id: 'mod-8-6', title: 'Unit Tests fuer Parser', completed: false },
{ id: 'mod-8-7', title: 'Vulnerability Tracking', completed: false },
{ id: 'mod-8-8', title: 'Compliance Reporting (SOC2)', completed: false },
],
},
// ==================== TESTING & QUALITY ====================
{ id: 'test-1', title: 'Test Coverage Erhöhen - Consent Service', description: 'Integration & E2E Tests fuer produktionskritischen Service', category: 'testing', priority: 'high', status: 'not_started', subtasks: [{ id: 'test-1-1', title: 'E2E API Tests mit echter DB', completed: false }, { id: 'test-1-2', title: 'Load Testing mit k6 oder vegeta', completed: false }, { id: 'test-1-3', title: 'Edge Cases in DSR Workflow', completed: false }] },
{ id: 'test-2', title: 'Admin Frontend Test Suite', description: 'Component & E2E Tests fuer Next.js Admin', category: 'testing', priority: 'critical', status: 'not_started', notes: 'Aktuell nur 1 Test-Datei! Kritischer Mangel.', subtasks: [{ id: 'test-2-1', title: 'Jest/Vitest fuer Component Tests', completed: false }, { id: 'test-2-2', title: 'Playwright E2E Setup', completed: false }, { id: 'test-2-3', title: 'API Mock Layer', completed: false }, { id: 'test-2-4', title: 'Visual Regression Tests', completed: false }] },
{ id: 'test-3', title: 'Klausur Service Tests erweitern', description: 'OCR & KI-Korrektur Pipeline testen', category: 'testing', priority: 'high', status: 'not_started', subtasks: [{ id: 'test-3-1', title: 'BYOEH Encryption Tests', completed: true }, { id: 'test-3-2', title: 'Key-Sharing Tests', completed: true }, { id: 'test-3-3', title: 'OCR Pipeline Integration Tests', completed: false }, { id: 'test-3-4', title: 'Large File Upload Tests', completed: false }] },
{ id: 'test-4', title: 'Security Module Unit Tests', description: 'Parser-Funktionen und Findings-Aggregation testen', category: 'testing', priority: 'medium', status: 'not_started', subtasks: [{ id: 'test-4-1', title: 'Gitleaks Parser Tests', completed: false }, { id: 'test-4-2', title: 'Trivy Parser Tests', completed: false }, { id: 'test-4-3', title: 'SBOM Generation Tests', completed: false }, { id: 'test-4-4', title: 'Mock Security Reports', completed: false }] },
// ==================== CI/CD PIPELINES ====================
{ id: 'cicd-1', title: 'GitHub Actions Workflow Setup', description: 'Basis CI/CD Pipeline mit GitHub Actions aufsetzen', category: 'cicd', priority: 'critical', status: 'completed', notes: 'Implementiert in .github/workflows/ci.yml', subtasks: [{ id: 'cicd-1-1', title: 'Build-Job fuer alle Services', completed: true }, { id: 'cicd-1-2', title: 'Test-Job mit Coverage Report', completed: true }, { id: 'cicd-1-3', title: 'Docker Image Build & Push', completed: true }, { id: 'cicd-1-4', title: 'Deploy to Staging Environment', completed: true }] },
{ id: 'cicd-2', title: 'Staging Environment einrichten', description: 'Separate Staging-Umgebung fuer Pre-Production Tests', category: 'cicd', priority: 'high', status: 'not_started', subtasks: [{ id: 'cicd-2-1', title: 'Staging Server/Cluster provisionieren', completed: false }, { id: 'cicd-2-2', title: 'Staging Datenbank mit anonymisierten Daten', completed: false }, { id: 'cicd-2-3', title: 'Automatisches Deployment bei Merge to main', completed: false }] },
{ id: 'cicd-3', title: 'Production Deployment Pipeline', description: 'Kontrolliertes Deployment in Production mit Rollback', category: 'cicd', priority: 'critical', status: 'not_started', subtasks: [{ id: 'cicd-3-1', title: 'Blue-Green oder Canary Deployment Strategy', completed: false }, { id: 'cicd-3-2', title: 'Automatischer Rollback bei Fehlern', completed: false }, { id: 'cicd-3-3', title: 'Health Check nach Deployment', completed: false }, { id: 'cicd-3-4', title: 'Deployment Notifications (Slack/Email)', completed: false }] },
// Security & Vulnerability
{ id: 'sec-1', title: 'Dependency Vulnerability Scanning', description: 'Automatische Pruefung auf bekannte Schwachstellen', category: 'security', priority: 'critical', status: 'completed', notes: 'Implementiert in .github/dependabot.yml', subtasks: [{ id: 'sec-1-1', title: 'Dependabot fuer Go aktivieren', completed: true }, { id: 'sec-1-2', title: 'Dependabot fuer Python aktivieren', completed: true }, { id: 'sec-1-3', title: 'Dependabot fuer npm aktivieren', completed: true }, { id: 'sec-1-4', title: 'CI-Job: Block Merge bei kritischen Vulnerabilities', completed: true }] },
{ id: 'sec-2', title: 'Container Image Scanning', description: 'Sicherheitspruefung aller Docker Images', category: 'security', priority: 'high', status: 'completed', notes: 'Implementiert in .github/workflows/security.yml', subtasks: [{ id: 'sec-2-1', title: 'Trivy oder Snyk in CI integrieren', completed: true }, { id: 'sec-2-2', title: 'Base Image Policy definieren', completed: true }, { id: 'sec-2-3', title: 'Scan Report bei jedem Build', completed: true }] },
{ id: 'sec-3', title: 'SAST (Static Application Security Testing)', description: 'Code-Analyse auf Sicherheitsluecken', category: 'security', priority: 'high', status: 'completed', notes: 'Implementiert in .github/workflows/security.yml', subtasks: [{ id: 'sec-3-1', title: 'CodeQL fuer Go/Python aktivieren', completed: true }, { id: 'sec-3-2', title: 'Semgrep Regeln konfigurieren', completed: false }, { id: 'sec-3-3', title: 'OWASP Top 10 Checks integrieren', completed: true }] },
{ id: 'sec-4', title: 'Secret Scanning', description: 'Verhindern, dass Secrets in Git landen', category: 'security', priority: 'critical', status: 'completed', notes: 'Implementiert in .github/workflows/security.yml', subtasks: [{ id: 'sec-4-1', title: 'GitHub Secret Scanning aktivieren', completed: true }, { id: 'sec-4-2', title: 'Pre-commit Hook mit gitleaks', completed: true }, { id: 'sec-4-3', title: 'Historische Commits scannen', completed: true }] },
// RBAC & Access Control
{ id: 'rbac-1', title: 'GitHub Team & Repository Permissions', description: 'Team-basierte Zugriffsrechte auf Repository', category: 'rbac', priority: 'high', status: 'not_started', subtasks: [{ id: 'rbac-1-1', title: 'Team "Maintainers" erstellen', completed: false }, { id: 'rbac-1-2', title: 'Team "Developers" erstellen', completed: false }, { id: 'rbac-1-3', title: 'Team "Reviewers" erstellen', completed: false }, { id: 'rbac-1-4', title: 'External Collaborators Policy', completed: false }] },
{ id: 'rbac-2', title: 'Infrastructure Access Control', description: 'Zugriffsrechte auf Server und Cloud-Ressourcen', category: 'rbac', priority: 'critical', status: 'not_started', subtasks: [{ id: 'rbac-2-1', title: 'SSH Key Management Policy', completed: false }, { id: 'rbac-2-2', title: 'Production Server Access Audit Log', completed: false }, { id: 'rbac-2-3', title: 'Database Access nur ueber Jump Host', completed: false }, { id: 'rbac-2-4', title: 'Secrets Management (Vault/AWS Secrets)', completed: false }] },
{ id: 'rbac-3', title: 'Admin Panel Access Control', description: 'Rollenbasierte Zugriffsrechte im Admin Frontend', category: 'rbac', priority: 'medium', status: 'not_started', subtasks: [{ id: 'rbac-3-1', title: 'Admin Authentication implementieren', completed: false }, { id: 'rbac-3-2', title: 'Role-based Views (Admin vs. Support)', completed: false }, { id: 'rbac-3-3', title: 'Audit Log fuer Admin-Aktionen', completed: false }] },
// Git & Branch Protection
{ id: 'git-1', title: 'Protected Branches Setup', description: 'Schutz fuer main/develop Branches', category: 'git', priority: 'critical', status: 'not_started', subtasks: [{ id: 'git-1-1', title: 'main Branch: No direct push', completed: false }, { id: 'git-1-2', title: 'Require PR with min. 1 Approval', completed: false }, { id: 'git-1-3', title: 'Require Status Checks (CI muss gruen sein)', completed: false }, { id: 'git-1-4', title: 'Require Signed Commits', completed: false }] },
{ id: 'git-2', title: 'Pull Request Template', description: 'Standardisierte PR-Beschreibung mit Checkliste', category: 'git', priority: 'medium', status: 'not_started', subtasks: [{ id: 'git-2-1', title: 'PR Template mit Description, Testing, Checklist', completed: false }, { id: 'git-2-2', title: 'Issue Template fuer Bugs und Features', completed: false }, { id: 'git-2-3', title: 'Automatische Labels basierend auf Aenderungen', completed: false }] },
{ id: 'git-3', title: 'Code Review Guidelines', description: 'Dokumentierte Richtlinien fuer Code Reviews', category: 'git', priority: 'medium', status: 'not_started', subtasks: [{ id: 'git-3-1', title: 'Code Review Checklist erstellen', completed: false }, { id: 'git-3-2', title: 'Review Turnaround Time Policy', completed: false }, { id: 'git-3-3', title: 'CODEOWNERS Datei pflegen', completed: false }] },
// Release Management
{ id: 'rel-1', title: 'Semantic Versioning Setup', description: 'Automatische Versionierung nach SemVer', category: 'release', priority: 'high', status: 'not_started', subtasks: [{ id: 'rel-1-1', title: 'Conventional Commits erzwingen', completed: false }, { id: 'rel-1-2', title: 'semantic-release oder release-please einrichten', completed: false }, { id: 'rel-1-3', title: 'Automatische Git Tags', completed: false }] },
{ id: 'rel-2', title: 'Changelog Automation', description: 'Automatisch generierte Release Notes', category: 'release', priority: 'medium', status: 'not_started', subtasks: [{ id: 'rel-2-1', title: 'CHANGELOG.md automatisch generieren', completed: false }, { id: 'rel-2-2', title: 'GitHub Release mit Notes erstellen', completed: false }, { id: 'rel-2-3', title: 'Breaking Changes hervorheben', completed: false }] },
{ id: 'rel-3', title: 'Release Branches Strategy', description: 'Branching-Modell fuer Releases definieren', category: 'release', priority: 'medium', status: 'not_started', subtasks: [{ id: 'rel-3-1', title: 'Git Flow oder GitHub Flow definieren', completed: false }, { id: 'rel-3-2', title: 'Hotfix Branch Process', completed: false }, { id: 'rel-3-3', title: 'Release Branch Protection', completed: false }] },
// Data Protection
{ id: 'data-1', title: 'Database Backup Strategy', description: 'Automatische Backups mit Retention Policy', category: 'data', priority: 'critical', status: 'not_started', subtasks: [{ id: 'data-1-1', title: 'Taegliche automatische Backups', completed: false }, { id: 'data-1-2', title: 'Point-in-Time Recovery aktivieren', completed: false }, { id: 'data-1-3', title: 'Backup Encryption at Rest', completed: false }, { id: 'data-1-4', title: 'Backup Restore Test dokumentieren', completed: false }] },
{ id: 'data-2', title: 'Customer Data Protection', description: 'Schutz von Stammdaten, Consent & Dokumenten', category: 'data', priority: 'critical', status: 'not_started', subtasks: [{ id: 'data-2-1', title: 'Stammdaten: Encryption at Rest', completed: false }, { id: 'data-2-2', title: 'Consent-Daten: Audit Log fuer Aenderungen', completed: false }, { id: 'data-2-3', title: 'Dokumente: Secure Storage (S3 mit Encryption)', completed: false }, { id: 'data-2-4', title: 'PII Data Masking in Logs', completed: false }] },
{ id: 'data-3', title: 'Migration Safety', description: 'Sichere Datenbank-Migrationen ohne Datenverlust', category: 'data', priority: 'high', status: 'not_started', subtasks: [{ id: 'data-3-1', title: 'Migration Pre-Check Script', completed: false }, { id: 'data-3-2', title: 'Rollback-faehige Migrationen', completed: false }, { id: 'data-3-3', title: 'Staging Migration Test vor Production', completed: false }] },
{ id: 'data-4', title: 'Data Anonymization', description: 'Anonymisierte Daten fuer Staging/Test', category: 'data', priority: 'medium', status: 'not_started', subtasks: [{ id: 'data-4-1', title: 'Anonymisierungs-Script fuer Staging DB', completed: false }, { id: 'data-4-2', title: 'Test-Datengenerator', completed: false }, { id: 'data-4-3', title: 'DSGVO-konforme Loeschung in Test-Systemen', completed: false }] },
// Compliance & SBOM
{ id: 'sbom-1', title: 'Software Bill of Materials (SBOM) erstellen', description: 'Vollstaendige Liste aller Open Source Komponenten', category: 'compliance', priority: 'high', status: 'completed', notes: 'Implementiert in /admin/sbom', subtasks: [{ id: 'sbom-1-1', title: 'Go Dependencies auflisten', completed: true }, { id: 'sbom-1-2', title: 'Python Dependencies auflisten', completed: true }, { id: 'sbom-1-3', title: 'npm Dependencies auflisten', completed: true }, { id: 'sbom-1-4', title: 'Docker Base Images dokumentieren', completed: true }, { id: 'sbom-1-5', title: 'SBOM in CycloneDX/SPDX Format exportieren', completed: true }] },
{ id: 'sbom-2', title: 'Open Source Lizenz-Compliance', description: 'Pruefung aller Lizenzen auf Kompatibilitaet', category: 'compliance', priority: 'high', status: 'in_progress', subtasks: [{ id: 'sbom-2-1', title: 'Alle Lizenzen identifizieren', completed: true }, { id: 'sbom-2-2', title: 'Lizenz-Kompatibilitaet pruefen', completed: false }, { id: 'sbom-2-3', title: 'LICENSES.md Datei erstellen', completed: false }, { id: 'sbom-2-4', title: 'Third-Party Notices generieren', completed: false }] },
{ id: 'sbom-3', title: 'Open Source Policy', description: 'Richtlinien fuer Verwendung von Open Source', category: 'compliance', priority: 'medium', status: 'not_started', subtasks: [{ id: 'sbom-3-1', title: 'Erlaubte Lizenzen definieren', completed: false }, { id: 'sbom-3-2', title: 'Genehmigungsprozess fuer neue Dependencies', completed: false }, { id: 'sbom-3-3', title: 'Automatische Lizenz-Checks in CI', completed: false }] },
{ id: 'sbom-4', title: 'Aktuelle Open Source Komponenten dokumentieren', description: 'BreakPilot Open Source Stack dokumentieren', category: 'compliance', priority: 'medium', status: 'completed', notes: 'Implementiert in /admin/sbom und /admin/architecture', subtasks: [{ id: 'sbom-4-1', title: 'Backend: FastAPI, Pydantic, httpx, anthropic', completed: true }, { id: 'sbom-4-2', title: 'Go Services: Gin, GORM, goquery', completed: true }, { id: 'sbom-4-3', title: 'Frontend: Next.js, React, Tailwind', completed: true }, { id: 'sbom-4-4', title: 'Infrastructure: PostgreSQL, OpenSearch, Ollama', completed: true }] },
// Approval Workflow
{ id: 'appr-1', title: 'Release Approval Gates', description: 'Mehrstufige Freigabe vor Production Deploy', category: 'approval', priority: 'critical', status: 'not_started', subtasks: [{ id: 'appr-1-1', title: 'QA Sign-off erforderlich', completed: false }, { id: 'appr-1-2', title: 'Security Review fuer kritische Aenderungen', completed: false }, { id: 'appr-1-3', title: 'Product Owner Freigabe', completed: false }, { id: 'appr-1-4', title: 'GitHub Environments mit Required Reviewers', completed: false }] },
{ id: 'appr-2', title: 'Deployment Windows', description: 'Definierte Zeitfenster fuer Production Deployments', category: 'approval', priority: 'medium', status: 'not_started', subtasks: [{ id: 'appr-2-1', title: 'Deployment-Kalender definieren', completed: false }, { id: 'appr-2-2', title: 'Freeze Periods (z.B. vor Feiertagen)', completed: false }, { id: 'appr-2-3', title: 'Emergency Hotfix Process', completed: false }] },
{ id: 'appr-3', title: 'Post-Deployment Verification', description: 'Checks nach erfolgreichem Deployment', category: 'approval', priority: 'high', status: 'not_started', subtasks: [{ id: 'appr-3-1', title: 'Smoke Tests automatisieren', completed: false }, { id: 'appr-3-2', title: 'Error Rate Monitoring (erste 30 Min)', completed: false }, { id: 'appr-3-3', title: 'Rollback-Kriterien definieren', completed: false }] },
]

View File

@@ -0,0 +1,114 @@
import type { BacklogCategory } from './types'
export const categories: BacklogCategory[] = [
{
id: 'modules',
name: 'Module Progress',
icon: (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10" />
</svg>
),
color: 'bg-violet-100 text-violet-700 border-violet-300',
description: 'Fertigstellungsgrad aller Services & Module',
},
{
id: 'cicd',
name: 'CI/CD Pipelines',
icon: (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
</svg>
),
color: 'bg-blue-100 text-blue-700 border-blue-300',
description: 'Build, Test & Deployment Automation',
},
{
id: 'security',
name: 'Security & Vulnerability',
icon: (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z" />
</svg>
),
color: 'bg-red-100 text-red-700 border-red-300',
description: 'Security Scans, Dependency Checks & Penetration Testing',
},
{
id: 'testing',
name: 'Testing & Quality',
icon: (
<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 2m-6 9l2 2 4-4" />
</svg>
),
color: 'bg-emerald-100 text-emerald-700 border-emerald-300',
description: 'Unit Tests, Integration Tests & E2E Testing',
},
{
id: 'rbac',
name: 'RBAC & Access Control',
icon: (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197M13 7a4 4 0 11-8 0 4 4 0 018 0z" />
</svg>
),
color: 'bg-purple-100 text-purple-700 border-purple-300',
description: 'Developer Roles, Permissions & Team Management',
},
{
id: 'git',
name: 'Git & Branch Protection',
icon: (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13.828 10.172a4 4 0 00-5.656 0l-4 4a4 4 0 105.656 5.656l1.102-1.101m-.758-4.899a4 4 0 005.656 0l4-4a4 4 0 00-5.656-5.656l-1.1 1.1" />
</svg>
),
color: 'bg-orange-100 text-orange-700 border-orange-300',
description: 'Protected Branches, Merge Requests & Code Reviews',
},
{
id: 'release',
name: 'Release Management',
icon: (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M7 7h.01M7 3h5c.512 0 1.024.195 1.414.586l7 7a2 2 0 010 2.828l-7 7a2 2 0 01-2.828 0l-7-7A1.994 1.994 0 013 12V7a4 4 0 014-4z" />
</svg>
),
color: 'bg-green-100 text-green-700 border-green-300',
description: 'Versioning, Changelog & Release Notes',
},
{
id: 'data',
name: 'Data Protection',
icon: (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 7v10c0 2.21 3.582 4 8 4s8-1.79 8-4V7M4 7c0 2.21 3.582 4 8 4s8-1.79 8-4M4 7c0-2.21 3.582-4 8-4s8 1.79 8 4m0 5c0 2.21-3.582 4-8 4s-8-1.79-8-4" />
</svg>
),
color: 'bg-cyan-100 text-cyan-700 border-cyan-300',
description: 'Backup, Migration & Customer Data Safety',
},
{
id: 'compliance',
name: 'Compliance & SBOM',
icon: (
<svg className="w-5 h-5" 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>
),
color: 'bg-teal-100 text-teal-700 border-teal-300',
description: 'SBOM, Lizenzen & Open Source Compliance',
},
{
id: 'approval',
name: 'Approval Workflow',
icon: (
<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 2m-6 9l2 2 4-4" />
</svg>
),
color: 'bg-indigo-100 text-indigo-700 border-indigo-300',
description: 'Developer Approval, QA Sign-off & Release Gates',
},
]

View File

@@ -0,0 +1,35 @@
export interface BacklogItem {
id: string
title: string
description: string
category: string
priority: 'critical' | 'high' | 'medium' | 'low'
status: 'not_started' | 'in_progress' | 'review' | 'completed' | 'blocked'
assignee?: string
dueDate?: string
notes?: string
subtasks?: { id: string; title: string; completed: boolean }[]
}
export interface BacklogCategory {
id: string
name: string
icon: React.ReactNode
color: string
description: string
}
export const statusLabels: Record<BacklogItem['status'], { label: string; color: string }> = {
not_started: { label: 'Nicht begonnen', color: 'bg-slate-100 text-slate-600' },
in_progress: { label: 'In Arbeit', color: 'bg-blue-100 text-blue-700' },
review: { label: 'In Review', color: 'bg-yellow-100 text-yellow-700' },
completed: { label: 'Abgeschlossen', color: 'bg-green-100 text-green-700' },
blocked: { label: 'Blockiert', color: 'bg-red-100 text-red-700' },
}
export const priorityLabels: Record<BacklogItem['priority'], { label: string; color: string }> = {
critical: { label: 'Kritisch', color: 'bg-red-500 text-white' },
high: { label: 'Hoch', color: 'bg-orange-500 text-white' },
medium: { label: 'Mittel', color: 'bg-yellow-500 text-white' },
low: { label: 'Niedrig', color: 'bg-slate-500 text-white' },
}

View File

@@ -0,0 +1,110 @@
'use client'
import { useState, useEffect } from 'react'
import type { BacklogItem } from './types'
import { initialBacklogItems } from './backlogData'
import { categories } from './categories'
export function useBacklog() {
const [items, setItems] = useState<BacklogItem[]>(initialBacklogItems)
const [selectedCategory, setSelectedCategory] = useState<string | null>(null)
const [selectedPriority, setSelectedPriority] = useState<string | null>(null)
const [expandedItems, setExpandedItems] = useState<Set<string>>(new Set())
const [searchQuery, setSearchQuery] = useState('')
// Load saved state from localStorage
useEffect(() => {
const saved = localStorage.getItem('backlogItems')
if (saved) {
try {
setItems(JSON.parse(saved))
} catch (e) {
console.error('Failed to load backlog items:', e)
}
}
}, [])
// Save state to localStorage
useEffect(() => {
localStorage.setItem('backlogItems', JSON.stringify(items))
}, [items])
const filteredItems = items.filter((item) => {
if (selectedCategory && item.category !== selectedCategory) return false
if (selectedPriority && item.priority !== selectedPriority) return false
if (searchQuery) {
const query = searchQuery.toLowerCase()
return (
item.title.toLowerCase().includes(query) ||
item.description.toLowerCase().includes(query) ||
item.subtasks?.some((st) => st.title.toLowerCase().includes(query))
)
}
return true
})
const toggleExpand = (id: string) => {
const newExpanded = new Set(expandedItems)
if (newExpanded.has(id)) {
newExpanded.delete(id)
} else {
newExpanded.add(id)
}
setExpandedItems(newExpanded)
}
const updateItemStatus = (id: string, status: BacklogItem['status']) => {
setItems(items.map((item) => (item.id === id ? { ...item, status } : item)))
}
const toggleSubtask = (itemId: string, subtaskId: string) => {
setItems(
items.map((item) => {
if (item.id !== itemId) return item
return {
...item,
subtasks: item.subtasks?.map((st) =>
st.id === subtaskId ? { ...st, completed: !st.completed } : st
),
}
})
)
}
const getProgress = () => {
const total = items.length
const completed = items.filter((i) => i.status === 'completed').length
return { total, completed, percentage: Math.round((completed / total) * 100) }
}
const getCategoryProgress = (categoryId: string) => {
const categoryItems = items.filter((i) => i.category === categoryId)
const completed = categoryItems.filter((i) => i.status === 'completed').length
return { total: categoryItems.length, completed }
}
const clearFilters = () => {
setSelectedCategory(null)
setSelectedPriority(null)
setSearchQuery('')
}
return {
items,
filteredItems,
selectedCategory,
setSelectedCategory,
selectedPriority,
setSelectedPriority,
expandedItems,
searchQuery,
setSearchQuery,
toggleExpand,
updateItemStatus,
toggleSubtask,
getProgress,
getCategoryProgress,
clearFilters,
categories,
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,98 @@
export default function ArchitekturTab() {
return (
<div className="space-y-6">
<div className="bg-white rounded-xl shadow-sm border p-6">
<h2 className="text-xl font-semibold text-slate-900 mb-4">Systemarchitektur</h2>
<p className="text-slate-600 mb-6">
Das Compliance & Audit Framework ist modular aufgebaut und integriert sich nahtlos in die bestehende Breakpilot-Infrastruktur.
</p>
{/* Architecture Diagram */}
<div className="bg-slate-50 rounded-lg p-6 mb-6">
<pre className="text-sm text-slate-700 font-mono whitespace-pre overflow-x-auto">{`
┌─────────────────────────────────────────────────────────────────────────┐
│ COMPLIANCE FRAMEWORK │
├─────────────────────────────────────────────────────────────────────────┤
│ │
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │
│ │ Next.js │ │ FastAPI │ │ PostgreSQL │ │
│ │ Frontend │───▶│ Backend │───▶│ Database │ │
│ │ (Port 3000)│ │ (Port 8000)│ │ (Port 5432)│ │
│ └─────────────┘ └─────────────┘ └─────────────┘ │
│ │ │ │ │
│ │ │ │ │
│ ┌─────▼─────┐ ┌─────▼─────┐ ┌─────▼─────┐ │
│ │ Admin UI │ │ Compliance │ │ 7 Tables │ │
│ │ /admin/ │ │ Module │ │ compliance_│ │
│ │compliance/│ │ /backend/ │ │ regulations│ │
│ └───────────┘ │compliance/ │ │ _controls │ │
│ └───────────┘ │ _evidence │ │
│ │ _risks │ │
│ │ ... │ │
│ └───────────┘ │
└─────────────────────────────────────────────────────────────────────────┘
`}</pre>
</div>
{/* Component Details */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
<div className="border rounded-lg p-4">
<h3 className="font-semibold text-slate-900 mb-2">Frontend (Next.js)</h3>
<ul className="text-sm text-slate-600 space-y-1">
<li>- Dashboard mit Compliance Score</li>
<li>- Control Catalogue mit Filtern</li>
<li>- Evidence Upload & Management</li>
<li>- Risk Matrix Visualisierung</li>
<li>- Audit Export Wizard</li>
</ul>
</div>
<div className="border rounded-lg p-4">
<h3 className="font-semibold text-slate-900 mb-2">Backend (FastAPI)</h3>
<ul className="text-sm text-slate-600 space-y-1">
<li>- REST API Endpoints</li>
<li>- Repository Pattern</li>
<li>- Pydantic Schemas</li>
<li>- Seeder Service</li>
<li>- Export Generator</li>
</ul>
</div>
<div className="border rounded-lg p-4">
<h3 className="font-semibold text-slate-900 mb-2">Datenbank (PostgreSQL)</h3>
<ul className="text-sm text-slate-600 space-y-1">
<li>- compliance_regulations</li>
<li>- compliance_requirements</li>
<li>- compliance_controls</li>
<li>- compliance_control_mappings</li>
<li>- compliance_evidence</li>
<li>- compliance_risks</li>
<li>- compliance_audit_exports</li>
</ul>
</div>
</div>
</div>
{/* Data Flow */}
<div className="bg-white rounded-xl shadow-sm border p-6">
<h3 className="text-lg font-semibold text-slate-900 mb-4">Datenfluss</h3>
<div className="space-y-4">
{[
{ num: 1, bg: 'bg-blue-50', numBg: 'bg-blue-500', title: 'Regulations & Requirements', desc: 'EU-Verordnungen, BSI-Standards werden als Seed-Daten geladen' },
{ num: 2, bg: 'bg-green-50', numBg: 'bg-green-500', title: 'Controls & Mappings', desc: 'Technische Controls werden Requirements zugeordnet' },
{ num: 3, bg: 'bg-purple-50', numBg: 'bg-purple-500', title: 'Evidence Collection', desc: 'Nachweise werden manuell oder automatisiert erfasst' },
{ num: 4, bg: 'bg-orange-50', numBg: 'bg-orange-500', title: 'Audit Export', desc: 'ZIP-Pakete fuer externe Pruefer generieren' },
].map((step) => (
<div key={step.num} className={`flex items-center gap-4 p-4 ${step.bg} rounded-lg`}>
<div className={`w-8 h-8 ${step.numBg} text-white rounded-full flex items-center justify-center font-bold`}>{step.num}</div>
<div>
<p className="font-medium text-slate-900">{step.title}</p>
<p className="text-sm text-slate-600">{step.desc}</p>
</div>
</div>
))}
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,81 @@
import Link from 'next/link'
export default function AuditTab() {
return (
<div className="space-y-6">
<div className="bg-white rounded-xl shadow-sm border p-6">
<div className="flex items-center justify-between mb-4">
<h3 className="text-lg font-semibold text-slate-900">Audit Export</h3>
<Link
href="/admin/compliance/export"
className="px-4 py-2 bg-primary-600 text-white rounded-lg hover:bg-primary-700 text-sm"
>
Export Wizard oeffnen
</Link>
</div>
<p className="text-slate-600 mb-6">
Erstellen Sie ZIP-Pakete mit allen relevanten Compliance-Daten fuer externe Pruefer.
</p>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div className="border rounded-lg p-4">
<h4 className="font-semibold text-slate-900 mb-2">Vollstaendiger Export</h4>
<p className="text-sm text-slate-600">Alle Daten inkl. Regulations, Controls, Evidence, Risks</p>
</div>
<div className="border rounded-lg p-4">
<h4 className="font-semibold text-slate-900 mb-2">Nur Controls</h4>
<p className="text-sm text-slate-600">Control Catalogue mit Mappings</p>
</div>
<div className="border rounded-lg p-4">
<h4 className="font-semibold text-slate-900 mb-2">Nur Evidence</h4>
<p className="text-sm text-slate-600">Evidence-Dateien und Metadaten</p>
</div>
</div>
</div>
{/* Export Format */}
<div className="bg-white rounded-xl shadow-sm border p-6">
<h3 className="text-lg font-semibold text-slate-900 mb-4">Export Format</h3>
<div className="bg-slate-50 rounded-lg p-4 font-mono text-sm">
<pre className="whitespace-pre overflow-x-auto">{`
audit_export_2026-01-16/
├── index.html # Navigations-Uebersicht
├── summary.json # Maschinenlesbare Zusammenfassung
├── regulations/
│ ├── gdpr.json
│ ├── aiact.json
│ └── ...
├── controls/
│ ├── control_catalogue.json
│ └── control_catalogue.xlsx
├── evidence/
│ ├── scan_reports/
│ ├── policies/
│ └── configs/
├── risks/
│ └── risk_register.json
└── README.md # Erklaerung fuer Pruefer
`}</pre>
</div>
</div>
{/* Audit Quick Links */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<Link
href="/admin/compliance/controls"
className="bg-white rounded-xl shadow-sm border p-6 hover:border-primary-500 transition-colors"
>
<h4 className="font-semibold text-slate-900 mb-2">Control Reviews</h4>
<p className="text-sm text-slate-600">Controls ueberpruefen und Status aktualisieren</p>
</Link>
<Link
href="/admin/compliance/evidence"
className="bg-white rounded-xl shadow-sm border p-6 hover:border-primary-500 transition-colors"
>
<h4 className="font-semibold text-slate-900 mb-2">Evidence Management</h4>
<p className="text-sm text-slate-600">Nachweise hochladen und verwalten</p>
</Link>
</div>
</div>
)
}

View File

@@ -0,0 +1,97 @@
import { DOMAIN_LABELS } from '../types'
export default function DokumentationTab() {
return (
<div className="space-y-6">
{/* Quick Start Guide */}
<div className="bg-white rounded-xl shadow-sm border p-6">
<h3 className="text-lg font-semibold text-slate-900 mb-4">Quick Start Guide</h3>
<div className="space-y-4">
{[
{ num: 1, title: 'Datenbank initialisieren', desc: 'Klicken Sie auf "Datenbank initialisieren" im Dashboard, um die Seed-Daten zu laden.' },
{ num: 2, title: 'Controls reviewen', desc: 'Gehen Sie zum Control Catalogue und bewerten Sie den Status jedes Controls.' },
{ num: 3, title: 'Evidence hochladen', desc: 'Laden Sie Nachweise (Scan-Reports, Policies, Screenshots) fuer Ihre Controls hoch.' },
{ num: 4, title: 'Risiken bewerten', desc: 'Dokumentieren Sie identifizierte Risiken in der Risk Matrix.' },
{ num: 5, title: 'Audit Export', desc: 'Generieren Sie ein ZIP-Paket fuer externe Pruefer.' },
].map((step) => (
<div key={step.num} className="flex gap-4">
<div className="w-8 h-8 bg-primary-100 text-primary-600 rounded-full flex items-center justify-center font-bold flex-shrink-0">{step.num}</div>
<div>
<p className="font-medium text-slate-900">{step.title}</p>
<p className="text-sm text-slate-600">{step.desc}</p>
</div>
</div>
))}
</div>
</div>
{/* Regulatory Framework */}
<div className="bg-white rounded-xl shadow-sm border p-6">
<h3 className="text-lg font-semibold text-slate-900 mb-4">Abgedeckte Verordnungen</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div>
<h4 className="font-medium text-slate-900 mb-2">EU-Verordnungen & Richtlinien</h4>
<ul className="text-sm text-slate-600 space-y-1">
<li>- DSGVO (Datenschutz-Grundverordnung)</li>
<li>- AI Act (KI-Verordnung)</li>
<li>- CRA (Cyber Resilience Act)</li>
<li>- NIS2 (Netzwerk- und Informationssicherheit)</li>
<li>- DSA (Digital Services Act)</li>
<li>- Data Act (Datenverordnung)</li>
<li>- DGA (Data Governance Act)</li>
<li>- ePrivacy-Richtlinie</li>
</ul>
</div>
<div>
<h4 className="font-medium text-slate-900 mb-2">Deutsche Standards</h4>
<ul className="text-sm text-slate-600 space-y-1">
<li>- BSI-TR-03161-1 (Mobile Anwendungen Teil 1)</li>
<li>- BSI-TR-03161-2 (Mobile Anwendungen Teil 2)</li>
<li>- BSI-TR-03161-3 (Mobile Anwendungen Teil 3)</li>
<li>- TDDDG (Telekommunikation-Digitale-Dienste-Datenschutz)</li>
</ul>
</div>
</div>
</div>
{/* Control Domains */}
<div className="bg-white rounded-xl shadow-sm border p-6">
<h3 className="text-lg font-semibold text-slate-900 mb-4">Control Domains</h3>
<div className="grid grid-cols-2 md:grid-cols-3 gap-4">
{Object.entries(DOMAIN_LABELS).map(([key, label]) => (
<div key={key} className="border rounded-lg p-4">
<span className="font-mono text-xs text-primary-600 bg-primary-50 px-2 py-0.5 rounded">{key}</span>
<p className="font-medium text-slate-900 mt-2">{label}</p>
</div>
))}
</div>
</div>
{/* External Links */}
<div className="bg-white rounded-xl shadow-sm border p-6">
<h3 className="text-lg font-semibold text-slate-900 mb-4">Externe Ressourcen</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{[
{ href: 'https://eur-lex.europa.eu/eli/reg/2016/679/oj/eng', label: 'DSGVO - EUR-Lex' },
{ href: 'https://eur-lex.europa.eu/eli/reg/2024/1689/oj/eng', label: 'AI Act - EUR-Lex' },
{ href: 'https://eur-lex.europa.eu/eli/reg/2024/2847/oj/eng', label: 'CRA - EUR-Lex' },
{ href: 'https://www.bsi.bund.de/DE/Themen/Unternehmen-und-Organisationen/Standards-und-Zertifizierung/Technische-Richtlinien/TR-nach-Thema-sortiert/tr03161/tr-03161.html', label: 'BSI-TR-03161 - BSI' },
].map((link) => (
<a
key={link.href}
href={link.href}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-2 p-3 border rounded-lg hover:bg-slate-50"
>
<svg className="w-5 h-5 text-slate-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
</svg>
<span className="text-sm text-slate-700">{link.label}</span>
</a>
))}
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,303 @@
'use client'
import { useState, useEffect } from 'react'
import dynamic from 'next/dynamic'
import { ExecutiveDashboardData } from '../types'
const ComplianceTrendChart = dynamic(
() => import('@/components/compliance/charts/ComplianceTrendChart'),
{ ssr: false, loading: () => <div className="h-48 bg-slate-100 animate-pulse rounded" /> }
)
interface ExecutiveTabProps {
loading: boolean
onRefresh: () => void
}
export default function ExecutiveTab({ loading, onRefresh }: ExecutiveTabProps) {
const [executiveData, setExecutiveData] = useState<ExecutiveDashboardData | null>(null)
const [execLoading, setExecLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const BACKEND_URL = process.env.NEXT_PUBLIC_BACKEND_URL || 'http://localhost:8000'
useEffect(() => {
loadExecutiveData()
}, [])
const loadExecutiveData = async () => {
setExecLoading(true)
setError(null)
try {
const res = await fetch(`${BACKEND_URL}/api/v1/compliance/dashboard/executive`)
if (res.ok) {
setExecutiveData(await res.json())
} else {
setError('Executive Dashboard konnte nicht geladen werden')
}
} catch (err) {
console.error('Failed to load executive dashboard:', err)
setError('Verbindung zum Backend fehlgeschlagen')
} finally {
setExecLoading(false)
}
}
if (execLoading) {
return (
<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>
)
}
if (error || !executiveData) {
return (
<div className="bg-red-50 border border-red-200 rounded-lg p-6 text-center">
<p className="text-red-700">{error || 'Keine Daten verfuegbar'}</p>
<button
onClick={loadExecutiveData}
className="mt-4 px-4 py-2 bg-red-600 text-white rounded hover:bg-red-700"
>
Erneut versuchen
</button>
</div>
)
}
const { traffic_light_status, overall_score, score_trend, score_change, top_risks, upcoming_deadlines, team_workload } = executiveData
const trafficLightColors = {
green: { bg: 'bg-green-500', ring: 'ring-green-200', text: 'text-green-700', label: 'Gut' },
yellow: { bg: 'bg-yellow-500', ring: 'ring-yellow-200', text: 'text-yellow-700', label: 'Achtung' },
red: { bg: 'bg-red-500', ring: 'ring-red-200', text: 'text-red-700', label: 'Kritisch' },
}
const tlConfig = trafficLightColors[traffic_light_status]
return (
<div className="space-y-6">
{/* Header Row: Traffic Light + Key Metrics */}
<div className="grid grid-cols-1 lg:grid-cols-4 gap-6">
<TrafficLightCard
tlConfig={tlConfig}
overall_score={overall_score}
score_change={score_change}
/>
<MetricCard
label="Verordnungen"
value={executiveData.total_regulations}
detail={`${executiveData.total_requirements} Anforderungen`}
iconBg="bg-blue-100"
iconColor="text-blue-600"
iconPath="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"
/>
<MetricCard
label="Massnahmen"
value={executiveData.total_controls}
detail="Technische Controls"
iconBg="bg-green-100"
iconColor="text-green-600"
iconPath="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"
/>
<MetricCard
label="Offene Risiken"
value={executiveData.open_risks}
detail="Unmitigiert"
iconBg="bg-red-100"
iconColor="text-red-600"
iconPath="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"
/>
</div>
{/* Charts Row */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
<TrendChartCard score_trend={score_trend} onRefresh={loadExecutiveData} />
<TopRisksCard top_risks={top_risks} />
</div>
{/* Bottom Row: Deadlines + Workload */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
<DeadlinesCard upcoming_deadlines={upcoming_deadlines} />
<WorkloadCard team_workload={team_workload} />
</div>
{/* Last Updated */}
<div className="text-right text-sm text-slate-400">
Zuletzt aktualisiert: {new Date(executiveData.last_updated).toLocaleString('de-DE')}
</div>
</div>
)
}
// ============================================================================
// Sub-components
// ============================================================================
function TrafficLightCard({ tlConfig, overall_score, score_change }: {
tlConfig: { bg: string; ring: string; text: string; label: string }
overall_score: number
score_change: number | null
}) {
return (
<div className="bg-white rounded-xl shadow-sm border p-6 flex flex-col items-center justify-center">
<div
className={`w-28 h-28 rounded-full flex items-center justify-center ${tlConfig.bg} ring-8 ${tlConfig.ring} shadow-lg mb-4`}
>
<span className="text-4xl font-bold text-white">{overall_score.toFixed(0)}%</span>
</div>
<p className={`text-lg font-semibold ${tlConfig.text}`}>{tlConfig.label}</p>
<p className="text-sm text-slate-500 mt-1">Erfuellungsgrad</p>
{score_change !== null && (
<p className={`text-sm mt-2 ${score_change >= 0 ? 'text-green-600' : 'text-red-600'}`}>
{score_change >= 0 ? '\u2191' : '\u2193'} {Math.abs(score_change).toFixed(1)}% zum Vormonat
</p>
)}
</div>
)
}
function MetricCard({ label, value, detail, iconBg, iconColor, iconPath }: {
label: string
value: number
detail: string
iconBg: string
iconColor: string
iconPath: string
}) {
return (
<div className="bg-white rounded-xl shadow-sm border p-6">
<div className="flex items-center justify-between mb-2">
<p className="text-sm text-slate-500">{label}</p>
<span className={`w-8 h-8 ${iconBg} rounded-lg flex items-center justify-center`}>
<svg className={`w-4 h-4 ${iconColor}`} fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d={iconPath} />
</svg>
</span>
</div>
<p className="text-3xl font-bold text-slate-900">{value}</p>
<p className="text-sm text-slate-500 mt-1">{detail}</p>
</div>
)
}
function TrendChartCard({ score_trend, onRefresh }: {
score_trend: { date: string; score: number; label: string }[]
onRefresh: () => void
}) {
return (
<div className="bg-white rounded-xl shadow-sm border p-6">
<div className="flex items-center justify-between mb-4">
<h3 className="text-lg font-semibold text-slate-900">Compliance-Trend (12 Monate)</h3>
<button onClick={onRefresh} className="text-sm text-primary-600 hover:text-primary-700">
Aktualisieren
</button>
</div>
<div className="h-48">
<ComplianceTrendChart data={score_trend} lang="de" height={180} />
</div>
</div>
)
}
function TopRisksCard({ top_risks }: { top_risks: ExecutiveDashboardData['top_risks'] }) {
const riskColors: Record<string, string> = {
critical: 'bg-red-100 text-red-700 border-red-200',
high: 'bg-orange-100 text-orange-700 border-orange-200',
medium: 'bg-yellow-100 text-yellow-700 border-yellow-200',
low: 'bg-green-100 text-green-700 border-green-200',
}
return (
<div className="bg-white rounded-xl shadow-sm border p-6">
<h3 className="text-lg font-semibold text-slate-900 mb-4">Top 5 Risiken</h3>
{top_risks.length === 0 ? (
<p className="text-slate-500 text-center py-8">Keine offenen Risiken</p>
) : (
<div className="space-y-3">
{top_risks.map((risk) => (
<div key={risk.id} className="flex items-center gap-3 p-3 bg-slate-50 rounded-lg">
<span className={`px-2 py-1 text-xs font-medium rounded border ${riskColors[risk.risk_level] || riskColors.medium}`}>
{risk.risk_level.toUpperCase()}
</span>
<div className="flex-1 min-w-0">
<p className="font-medium text-slate-900 truncate">{risk.title}</p>
<p className="text-xs text-slate-500">{risk.owner || 'Kein Owner'}</p>
</div>
<span className="text-xs text-slate-400">{risk.risk_id}</span>
</div>
))}
</div>
)}
</div>
)
}
function DeadlinesCard({ upcoming_deadlines }: { upcoming_deadlines: ExecutiveDashboardData['upcoming_deadlines'] }) {
const statusColors: Record<string, string> = {
overdue: 'bg-red-100 text-red-700',
at_risk: 'bg-yellow-100 text-yellow-700',
on_track: 'bg-green-100 text-green-700',
}
return (
<div className="bg-white rounded-xl shadow-sm border p-6">
<h3 className="text-lg font-semibold text-slate-900 mb-4">Naechste Fristen</h3>
{upcoming_deadlines.length === 0 ? (
<p className="text-slate-500 text-center py-8">Keine anstehenden Fristen</p>
) : (
<div className="space-y-2">
{upcoming_deadlines.slice(0, 5).map((deadline) => (
<div key={deadline.id} className="flex items-center gap-3 p-3 bg-slate-50 rounded-lg">
<span className={`px-2 py-1 text-xs font-medium rounded ${statusColors[deadline.status] || statusColors.on_track}`}>
{deadline.days_remaining < 0
? `${Math.abs(deadline.days_remaining)}d ueberfaellig`
: deadline.days_remaining === 0
? 'Heute'
: `${deadline.days_remaining}d`}
</span>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-slate-900 truncate">{deadline.title}</p>
<p className="text-xs text-slate-500">{new Date(deadline.deadline).toLocaleDateString('de-DE')}</p>
</div>
</div>
))}
</div>
)}
</div>
)
}
function WorkloadCard({ team_workload }: { team_workload: ExecutiveDashboardData['team_workload'] }) {
return (
<div className="bg-white rounded-xl shadow-sm border p-6">
<h3 className="text-lg font-semibold text-slate-900 mb-4">Team-Auslastung</h3>
{team_workload.length === 0 ? (
<p className="text-slate-500 text-center py-8">Keine Daten verfuegbar</p>
) : (
<div className="space-y-3">
{team_workload.slice(0, 5).map((member) => (
<div key={member.name}>
<div className="flex justify-between text-sm mb-1">
<span className="font-medium text-slate-700">{member.name}</span>
<span className="text-slate-500">
{member.completed_tasks}/{member.total_tasks} ({member.completion_rate.toFixed(0)}%)
</span>
</div>
<div className="h-2 bg-slate-200 rounded-full overflow-hidden flex">
<div
className="bg-green-500 h-full"
style={{ width: `${(member.completed_tasks / member.total_tasks) * 100}%` }}
/>
<div
className="bg-yellow-500 h-full"
style={{ width: `${(member.in_progress_tasks / member.total_tasks) * 100}%` }}
/>
</div>
</div>
))}
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,105 @@
import { BACKLOG_ITEMS } from '../types'
export default function RoadmapTab() {
const completedCount = BACKLOG_ITEMS.filter(i => i.status === 'completed').length
const inProgressCount = BACKLOG_ITEMS.filter(i => i.status === 'in_progress').length
const plannedCount = BACKLOG_ITEMS.filter(i => i.status === 'planned').length
return (
<div className="space-y-6">
{/* Progress Overview */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div className="bg-green-50 border border-green-200 rounded-xl p-6">
<p className="text-sm text-green-600 font-medium">Abgeschlossen</p>
<p className="text-3xl font-bold text-green-700">{completedCount}</p>
</div>
<div className="bg-yellow-50 border border-yellow-200 rounded-xl p-6">
<p className="text-sm text-yellow-600 font-medium">In Bearbeitung</p>
<p className="text-3xl font-bold text-yellow-700">{inProgressCount}</p>
</div>
<div className="bg-slate-50 border border-slate-200 rounded-xl p-6">
<p className="text-sm text-slate-600 font-medium">Geplant</p>
<p className="text-3xl font-bold text-slate-700">{plannedCount}</p>
</div>
</div>
{/* Implemented Features */}
<div className="bg-white rounded-xl shadow-sm border p-6">
<h3 className="text-lg font-semibold text-slate-900 mb-4">Implementierte Features (v2.0 - Stand: 2026-01-17)</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{[
'558 Requirements aus 19 Regulations extrahiert',
'44 Controls in 9 Domains mit Auto-Mapping',
'474 Control-Mappings automatisch generiert',
'30 Service-Module in Registry kartiert',
'AI-Interpretation fuer alle Requirements',
'EU-Lex Scraper fuer Live-Regulation-Fetch',
'BSI-TR-03161 PDF Parser (alle 3 Teile)',
'Evidence Management mit File Upload',
'Risk Matrix (5x5 Likelihood x Impact)',
'Audit Export Wizard (ZIP Generator)',
'Compliance Score Berechnung',
'Dashboard mit Echtzeit-Statistiken',
].map((feature, idx) => (
<div key={idx} className="flex items-center gap-2">
<svg className="w-5 h-5 text-green-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
<span className="text-slate-700">{feature}</span>
</div>
))}
</div>
</div>
{/* Backlog */}
<div className="bg-white rounded-xl shadow-sm border overflow-hidden">
<div className="p-4 border-b">
<h3 className="text-lg font-semibold text-slate-900">Backlog</h3>
</div>
<div className="overflow-x-auto">
<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">Titel</th>
<th className="px-4 py-3 text-left 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">Prioritaet</th>
<th className="px-4 py-3 text-center text-xs font-medium text-slate-500 uppercase">Status</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-200">
{BACKLOG_ITEMS.map((item) => (
<tr key={item.id} className="hover:bg-slate-50">
<td className="px-4 py-3">
<span className="font-medium text-slate-900">{item.title}</span>
</td>
<td className="px-4 py-3">
<span className="text-sm text-slate-600">{item.category}</span>
</td>
<td className="px-4 py-3 text-center">
<span className={`px-2 py-1 text-xs rounded-full ${
item.priority === 'high' ? 'bg-red-100 text-red-700' :
item.priority === 'medium' ? 'bg-yellow-100 text-yellow-700' :
'bg-slate-100 text-slate-700'
}`}>
{item.priority}
</span>
</td>
<td className="px-4 py-3 text-center">
<span className={`px-2 py-1 text-xs rounded-full ${
item.status === 'completed' ? 'bg-green-100 text-green-700' :
item.status === 'in_progress' ? 'bg-blue-100 text-blue-700' :
'bg-slate-100 text-slate-700'
}`}>
{item.status === 'completed' ? 'Fertig' :
item.status === 'in_progress' ? 'In Arbeit' : 'Geplant'}
</span>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,88 @@
export default function TechnischTab() {
return (
<div className="space-y-6">
{/* API Endpoints */}
<div className="bg-white rounded-xl shadow-sm border p-6">
<h3 className="text-lg font-semibold text-slate-900 mb-4">API Endpoints</h3>
<div className="space-y-3">
{[
{ method: 'GET', path: '/api/v1/compliance/regulations', desc: 'Liste aller Verordnungen' },
{ method: 'GET', path: '/api/v1/compliance/controls', desc: 'Control Catalogue' },
{ method: 'PUT', path: '/api/v1/compliance/controls/{id}/review', desc: 'Control Review' },
{ method: 'GET', path: '/api/v1/compliance/evidence', desc: 'Evidence Liste' },
{ method: 'POST', path: '/api/v1/compliance/evidence/upload', desc: 'Evidence Upload' },
{ method: 'GET', path: '/api/v1/compliance/risks', desc: 'Risk Register' },
{ method: 'GET', path: '/api/v1/compliance/risks/matrix', desc: 'Risk Matrix' },
{ method: 'GET', path: '/api/v1/compliance/dashboard', desc: 'Dashboard Stats' },
{ method: 'POST', path: '/api/v1/compliance/export', desc: 'Audit Export erstellen' },
{ method: 'POST', path: '/api/v1/compliance/seed', desc: 'Datenbank seeden' },
].map((ep, idx) => (
<div key={idx} className="flex items-center gap-4 p-3 bg-slate-50 rounded-lg font-mono text-sm">
<span className={`px-2 py-1 rounded text-xs font-bold ${
ep.method === 'GET' ? 'bg-green-100 text-green-700' :
ep.method === 'POST' ? 'bg-blue-100 text-blue-700' :
'bg-yellow-100 text-yellow-700'
}`}>
{ep.method}
</span>
<span className="text-slate-700 flex-1">{ep.path}</span>
<span className="text-slate-500 text-xs">{ep.desc}</span>
</div>
))}
</div>
</div>
{/* Database Schema */}
<div className="bg-white rounded-xl shadow-sm border p-6">
<h3 className="text-lg font-semibold text-slate-900 mb-4">Datenmodell</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{[
{ table: 'compliance_regulations', fields: 'id, code, name, regulation_type, source_url, effective_date' },
{ table: 'compliance_requirements', fields: 'id, regulation_id, article, title, description, is_applicable' },
{ table: 'compliance_controls', fields: 'id, control_id, domain, title, status, is_automated, owner' },
{ table: 'compliance_control_mappings', fields: 'id, requirement_id, control_id, coverage_level' },
{ table: 'compliance_evidence', fields: 'id, control_id, evidence_type, title, artifact_path, status' },
{ table: 'compliance_risks', fields: 'id, risk_id, title, likelihood, impact, inherent_risk, status' },
{ table: 'compliance_audit_exports', fields: 'id, export_type, status, file_path, file_hash' },
].map((t, idx) => (
<div key={idx} className="border rounded-lg p-4">
<h4 className="font-mono font-semibold text-primary-600 mb-2">{t.table}</h4>
<p className="text-xs text-slate-500 font-mono">{t.fields}</p>
</div>
))}
</div>
</div>
{/* Enums */}
<div className="bg-white rounded-xl shadow-sm border p-6">
<h3 className="text-lg font-semibold text-slate-900 mb-4">Enums</h3>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div className="border rounded-lg p-4">
<h4 className="font-semibold text-slate-900 mb-2">ControlDomainEnum</h4>
<div className="flex flex-wrap gap-1">
{['gov', 'priv', 'iam', 'crypto', 'sdlc', 'ops', 'ai', 'cra', 'aud'].map((d) => (
<span key={d} className="px-2 py-0.5 bg-slate-100 text-slate-700 text-xs rounded">{d}</span>
))}
</div>
</div>
<div className="border rounded-lg p-4">
<h4 className="font-semibold text-slate-900 mb-2">ControlStatusEnum</h4>
<div className="flex flex-wrap gap-1">
{['pass', 'partial', 'fail', 'planned', 'n/a'].map((s) => (
<span key={s} className="px-2 py-0.5 bg-slate-100 text-slate-700 text-xs rounded">{s}</span>
))}
</div>
</div>
<div className="border rounded-lg p-4">
<h4 className="font-semibold text-slate-900 mb-2">RiskLevelEnum</h4>
<div className="flex flex-wrap gap-1">
{['low', 'medium', 'high', 'critical'].map((l) => (
<span key={l} className="px-2 py-0.5 bg-slate-100 text-slate-700 text-xs rounded">{l}</span>
))}
</div>
</div>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,293 @@
'use client'
import Link from 'next/link'
import { DashboardData, Regulation, AIStatus, DOMAIN_LABELS } from '../types'
interface UebersichtTabProps {
dashboard: DashboardData | null
regulations: Regulation[]
aiStatus: AIStatus | null
loading: boolean
onRefresh: () => void
}
export default function UebersichtTab({
dashboard,
regulations,
aiStatus,
loading,
onRefresh,
}: UebersichtTabProps) {
if (loading) {
return (
<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>
)
}
const score = dashboard?.compliance_score || 0
const scoreColor = score >= 80 ? 'text-green-600' : score >= 60 ? 'text-yellow-600' : 'text-red-600'
return (
<div className="space-y-6">
{/* AI Status Banner */}
<AIStatusBanner aiStatus={aiStatus} />
{/* Score and Stats Row */}
<div className="grid grid-cols-1 lg:grid-cols-5 gap-6">
<ScoreCard score={score} scoreColor={scoreColor} dashboard={dashboard} />
<StatCard
label="Verordnungen"
value={dashboard?.total_regulations || 0}
detail={`${dashboard?.total_requirements || 0} Anforderungen`}
iconBg="bg-blue-100"
iconColor="text-blue-600"
iconPath="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"
/>
<StatCard
label="Controls"
value={dashboard?.total_controls || 0}
detail={`${dashboard?.controls_by_status?.pass || 0} bestanden`}
iconBg="bg-green-100"
iconColor="text-green-600"
iconPath="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"
/>
<StatCard
label="Nachweise"
value={dashboard?.total_evidence || 0}
detail={`${dashboard?.evidence_by_status?.valid || 0} aktiv`}
iconBg="bg-purple-100"
iconColor="text-purple-600"
iconPath="M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z"
/>
<StatCard
label="Risiken"
value={dashboard?.total_risks || 0}
detail={`${(dashboard?.risks_by_level?.high || 0) + (dashboard?.risks_by_level?.critical || 0)} kritisch`}
iconBg="bg-red-100"
iconColor="text-red-600"
iconPath="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"
/>
</div>
{/* Domain Chart and Quick Actions */}
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
<DomainChart dashboard={dashboard} />
<QuickActions />
</div>
{/* Regulations Table */}
<RegulationsTable regulations={regulations} onRefresh={onRefresh} />
</div>
)
}
// ============================================================================
// Sub-components
// ============================================================================
function AIStatusBanner({ aiStatus }: { aiStatus: AIStatus | null }) {
if (!aiStatus) return null
return (
<div className={`rounded-lg p-4 flex items-center justify-between ${
aiStatus.is_available && !aiStatus.is_mock
? 'bg-gradient-to-r from-purple-50 to-pink-50 border border-purple-200'
: aiStatus.is_mock
? 'bg-yellow-50 border border-yellow-200'
: 'bg-red-50 border border-red-200'
}`}>
<div className="flex items-center gap-3">
<span className="text-2xl">🤖</span>
<div>
<div className="font-medium text-slate-900">
AI-Compliance-Assistent {aiStatus.is_available ? 'aktiv' : 'nicht verfuegbar'}
</div>
<div className="text-sm text-slate-600">
{aiStatus.is_mock ? (
<span className="text-yellow-700">Mock-Modus (kein API-Key konfiguriert)</span>
) : (
<>Provider: <span className="font-mono">{aiStatus.provider}</span> | Modell: <span className="font-mono">{aiStatus.model}</span></>
)}
</div>
</div>
</div>
<div className={`px-3 py-1 rounded-full text-sm font-medium ${
aiStatus.is_available && !aiStatus.is_mock
? 'bg-green-100 text-green-700'
: aiStatus.is_mock
? 'bg-yellow-100 text-yellow-700'
: 'bg-red-100 text-red-700'
}`}>
{aiStatus.is_available && !aiStatus.is_mock ? 'Online' : aiStatus.is_mock ? 'Mock' : 'Offline'}
</div>
</div>
)
}
function ScoreCard({ score, scoreColor, dashboard }: { score: number; scoreColor: string; dashboard: DashboardData | null }) {
return (
<div className="bg-white rounded-xl shadow-sm border p-6">
<h3 className="text-sm font-medium text-slate-500 mb-4">Compliance Score</h3>
<div className={`text-5xl font-bold ${scoreColor}`}>
{score.toFixed(0)}%
</div>
<div className="mt-4 h-2 bg-slate-200 rounded-full overflow-hidden">
<div
className={`h-full transition-all duration-500 ${score >= 80 ? 'bg-green-500' : score >= 60 ? 'bg-yellow-500' : 'bg-red-500'}`}
style={{ width: `${score}%` }}
/>
</div>
<p className="mt-2 text-sm text-slate-500">
{dashboard?.controls_by_status?.pass || 0} von {dashboard?.total_controls || 0} Controls bestanden
</p>
</div>
)
}
function StatCard({ label, value, detail, iconBg, iconColor, iconPath }: {
label: string
value: number
detail: string
iconBg: string
iconColor: string
iconPath: string
}) {
return (
<div className="bg-white rounded-xl shadow-sm border p-6">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-slate-500">{label}</p>
<p className="text-2xl font-bold text-slate-900">{value}</p>
</div>
<div className={`w-10 h-10 ${iconBg} rounded-lg flex items-center justify-center`}>
<svg className={`w-5 h-5 ${iconColor}`} fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d={iconPath} />
</svg>
</div>
</div>
<p className="mt-2 text-sm text-slate-500">{detail}</p>
</div>
)
}
function DomainChart({ dashboard }: { dashboard: DashboardData | null }) {
return (
<div className="lg:col-span-2 bg-white rounded-xl shadow-sm border p-6">
<h3 className="text-lg font-semibold text-slate-900 mb-4">Controls nach Domain</h3>
<div className="space-y-4">
{Object.entries(dashboard?.controls_by_domain || {}).map(([domain, stats]) => {
const total = stats.total || 0
const pass = stats.pass || 0
const partial = stats.partial || 0
const passPercent = total > 0 ? ((pass + partial * 0.5) / total) * 100 : 0
return (
<div key={domain}>
<div className="flex justify-between text-sm mb-1">
<span className="font-medium text-slate-700">
{DOMAIN_LABELS[domain] || domain.toUpperCase()}
</span>
<span className="text-slate-500">
{pass}/{total} ({passPercent.toFixed(0)}%)
</span>
</div>
<div className="h-2 bg-slate-200 rounded-full overflow-hidden flex">
<div className="bg-green-500 h-full" style={{ width: `${(pass / total) * 100}%` }} />
<div className="bg-yellow-500 h-full" style={{ width: `${(partial / total) * 100}%` }} />
</div>
</div>
)
})}
</div>
</div>
)
}
function QuickActions() {
const actions = [
{ href: '/admin/compliance/controls', label: 'Controls', color: 'primary', hoverBorder: 'hover:border-primary-500', hoverBg: 'hover:bg-primary-50', iconColor: 'text-primary-600', iconPath: 'M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z' },
{ href: '/admin/compliance/evidence', label: 'Evidence', color: 'purple', hoverBorder: 'hover:border-primary-500', hoverBg: 'hover:bg-primary-50', iconColor: 'text-purple-600', iconPath: 'M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z' },
{ href: '/admin/compliance/risks', label: 'Risiken', color: 'red', hoverBorder: 'hover:border-primary-500', hoverBg: 'hover:bg-primary-50', iconColor: 'text-red-600', iconPath: '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' },
{ href: '/admin/compliance/scraper', label: 'Scraper', color: 'orange', hoverBorder: 'hover:border-orange-500', hoverBg: 'hover:bg-orange-50', iconColor: 'text-orange-600', iconPath: 'M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15' },
{ href: '/admin/compliance/export', label: 'Export', color: 'green', hoverBorder: 'hover:border-primary-500', hoverBg: 'hover:bg-primary-50', iconColor: 'text-green-600', iconPath: 'M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4' },
{ href: '/admin/compliance/audit-workspace', label: 'Audit Workspace', color: 'blue', hoverBorder: 'hover:border-blue-500', hoverBg: 'hover:bg-blue-50', iconColor: 'text-blue-600', iconPath: 'M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-3 7h3m-3 4h3m-6-4h.01M9 16h.01' },
{ href: '/admin/compliance/modules', label: 'Service Module Registry', color: 'pink', hoverBorder: 'hover:border-pink-500', hoverBg: 'hover:bg-pink-50', iconColor: 'text-pink-600', iconPath: 'M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10' },
]
return (
<div className="bg-white rounded-xl shadow-sm border p-6">
<h3 className="text-lg font-semibold text-slate-900 mb-4">Schnellaktionen</h3>
<div className="grid grid-cols-2 gap-3">
{actions.map((action) => (
<Link
key={action.href}
href={action.href}
className={`p-4 rounded-lg border border-slate-200 ${action.hoverBorder} ${action.hoverBg} transition-colors`}
>
<div className={`${action.iconColor} mb-2`}>
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d={action.iconPath} />
</svg>
</div>
<p className="font-medium text-slate-900 text-sm">{action.label}</p>
</Link>
))}
</div>
</div>
)
}
function RegulationsTable({ regulations, onRefresh }: { regulations: Regulation[]; onRefresh: () => void }) {
return (
<div className="bg-white rounded-xl shadow-sm border overflow-hidden">
<div className="p-4 border-b flex items-center justify-between">
<h3 className="text-lg font-semibold text-slate-900">Verordnungen & Standards</h3>
<button onClick={onRefresh} className="text-sm text-primary-600 hover:text-primary-700">
Aktualisieren
</button>
</div>
<div className="overflow-x-auto">
<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">Code</th>
<th className="px-4 py-3 text-left text-xs font-medium text-slate-500 uppercase">Name</th>
<th className="px-4 py-3 text-left text-xs font-medium text-slate-500 uppercase">Typ</th>
<th className="px-4 py-3 text-center text-xs font-medium text-slate-500 uppercase">Anforderungen</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-200">
{regulations.slice(0, 10).map((reg) => (
<tr key={reg.id} className="hover:bg-slate-50">
<td className="px-4 py-3">
<span className="font-mono font-medium text-primary-600">{reg.code}</span>
</td>
<td className="px-4 py-3">
<p className="font-medium text-slate-900">{reg.name}</p>
</td>
<td className="px-4 py-3">
<span className={`px-2 py-1 text-xs rounded-full ${
reg.regulation_type === 'eu_regulation' ? 'bg-blue-100 text-blue-700' :
reg.regulation_type === 'eu_directive' ? 'bg-purple-100 text-purple-700' :
reg.regulation_type === 'bsi_standard' ? 'bg-green-100 text-green-700' :
'bg-slate-100 text-slate-700'
}`}>
{reg.regulation_type === 'eu_regulation' ? 'EU-VO' :
reg.regulation_type === 'eu_directive' ? 'EU-RL' :
reg.regulation_type === 'bsi_standard' ? 'BSI' :
reg.regulation_type === 'de_law' ? 'DE' : reg.regulation_type}
</span>
</td>
<td className="px-4 py-3 text-center">
<span className="font-medium">{reg.requirement_count}</span>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,191 @@
/**
* Types and constants for the Compliance & Audit Framework Dashboard
*/
// ============================================================================
// Data Types
// ============================================================================
export interface DashboardData {
compliance_score: number
total_regulations: number
total_requirements: number
total_controls: number
controls_by_status: Record<string, number>
controls_by_domain: Record<string, Record<string, number>>
total_evidence: number
evidence_by_status: Record<string, number>
total_risks: number
risks_by_level: Record<string, number>
}
export interface AIStatus {
provider: string
model: string
is_available: boolean
is_mock: boolean
}
export interface Regulation {
id: string
code: string
name: string
full_name: string
regulation_type: string
effective_date: string | null
description: string
requirement_count: number
}
export interface ExecutiveDashboardData {
traffic_light_status: 'green' | 'yellow' | 'red'
overall_score: number
score_trend: { date: string; score: number; label: string }[]
previous_score: number | null
score_change: number | null
total_regulations: number
total_requirements: number
total_controls: number
open_risks: number
top_risks: {
id: string
risk_id: string
title: string
risk_level: string
owner: string | null
status: string
category: string
impact: number
likelihood: number
}[]
upcoming_deadlines: {
id: string
title: string
deadline: string
days_remaining: number
type: string
status: string
owner: string | null
}[]
team_workload: {
name: string
pending_tasks: number
in_progress_tasks: number
completed_tasks: number
total_tasks: number
completion_rate: number
}[]
last_updated: string
}
// ============================================================================
// Tab Definitions
// ============================================================================
export type TabId = 'executive' | 'uebersicht' | 'architektur' | 'roadmap' | 'technisch' | 'audit' | 'dokumentation'
export const tabs: { id: TabId; name: string; icon: JSX.Element }[] = [
{
id: 'executive',
name: 'Executive',
icon: (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z" />
</svg>
),
},
{
id: 'uebersicht',
name: 'Uebersicht',
icon: (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 6a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2V6zM14 6a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2V6zM4 16a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2v-2zM14 16a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2v-2z" />
</svg>
),
},
{
id: 'architektur',
name: 'Architektur',
icon: (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10" />
</svg>
),
},
{
id: 'roadmap',
name: 'Roadmap',
icon: (
<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 2m-3 7h3m-3 4h3m-6-4h.01M9 16h.01" />
</svg>
),
},
{
id: 'technisch',
name: 'Technisch',
icon: (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 20l4-16m4 4l4 4-4 4M6 16l-4-4 4-4" />
</svg>
),
},
{
id: 'audit',
name: 'Audit',
icon: (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z" />
</svg>
),
},
{
id: 'dokumentation',
name: 'Dokumentation',
icon: (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253" />
</svg>
),
},
]
// ============================================================================
// Constants
// ============================================================================
export const DOMAIN_LABELS: Record<string, string> = {
gov: 'Governance',
priv: 'Datenschutz',
iam: 'Identity & Access',
crypto: 'Kryptografie',
sdlc: 'Secure Dev',
ops: 'Operations',
ai: 'KI-spezifisch',
cra: 'Supply Chain',
aud: 'Audit',
}
export const STATUS_COLORS: Record<string, string> = {
pass: 'bg-green-500',
partial: 'bg-yellow-500',
fail: 'bg-red-500',
planned: 'bg-slate-400',
'n/a': 'bg-slate-300',
}
export const BACKLOG_ITEMS = [
{ id: 1, title: 'EU-Lex Live-Fetch (19 Regulations)', priority: 'high', status: 'completed', category: 'Integration' },
{ id: 2, title: 'BSI-TR-03161 PDF Parser (3 PDFs)', priority: 'high', status: 'completed', category: 'Data Import' },
{ id: 3, title: 'AI-Interpretation fuer Requirements', priority: 'high', status: 'completed', category: 'AI' },
{ id: 4, title: 'Auto-Mapping Controls zu Requirements (474)', priority: 'high', status: 'completed', category: 'Automation' },
{ id: 5, title: 'Service-Modul-Registry (30 Module)', priority: 'high', status: 'completed', category: 'Architecture' },
{ id: 6, title: 'Audit Trail fuer alle Aenderungen', priority: 'high', status: 'completed', category: 'Audit' },
{ id: 7, title: 'Automatische Evidence-Sammlung aus CI/CD', priority: 'high', status: 'planned', category: 'Automation' },
{ id: 8, title: 'Control-Review Workflow mit Benachrichtigungen', priority: 'medium', status: 'planned', category: 'Workflow' },
{ id: 9, title: 'Risk Treatment Plan Tracking', priority: 'medium', status: 'planned', category: 'Risk Management' },
{ id: 10, title: 'Compliance Score Trend-Analyse', priority: 'low', status: 'planned', category: 'Analytics' },
{ id: 11, title: 'SBOM Integration fuer CRA Compliance', priority: 'medium', status: 'planned', category: 'Integration' },
{ id: 12, title: 'Multi-Mandanten Compliance Trennung', priority: 'medium', status: 'planned', category: 'Architecture' },
{ id: 13, title: 'Compliance Report PDF Generator', priority: 'medium', status: 'planned', category: 'Export' },
]

View File

@@ -0,0 +1,67 @@
'use client'
import { useState } from 'react'
import { services } from '../data'
import { getServiceTypeColor, getMethodColor } from '../helpers'
export default function ApiReferenceTab() {
const [copiedEndpoint, setCopiedEndpoint] = useState<string | null>(null)
const copyToClipboard = (text: string, id: string) => {
navigator.clipboard.writeText(text)
setCopiedEndpoint(id)
setTimeout(() => setCopiedEndpoint(null), 2000)
}
return (
<div className="space-y-6">
{services.filter(s => s.endpoints.length > 0).map((service) => (
<div key={service.id} className="bg-white rounded-xl border border-slate-200 overflow-hidden">
<div className="px-6 py-4 bg-slate-50 border-b border-slate-200">
<div className="flex items-center justify-between">
<div>
<h3 className="font-semibold text-slate-900">{service.name}</h3>
<div className="text-sm text-slate-500">Base URL: http://localhost:{service.port}</div>
</div>
<span className={`text-xs px-2 py-0.5 rounded-full ${getServiceTypeColor(service.type)}`}>
{service.type}
</span>
</div>
</div>
<div className="divide-y divide-slate-100">
{service.endpoints.map((endpoint, idx) => {
const endpointId = `${service.id}-${idx}`
const curlCommand = `curl -X ${endpoint.method} http://localhost:${service.port}${endpoint.path}`
return (
<div key={idx} className="px-6 py-3 hover:bg-slate-50 transition-colors">
<div className="flex items-center gap-3">
<span className={`text-xs font-mono font-semibold px-2 py-1 rounded ${getMethodColor(endpoint.method)}`}>
{endpoint.method}
</span>
<code className="text-sm font-mono text-slate-700 flex-1">{endpoint.path}</code>
<button
onClick={() => copyToClipboard(curlCommand, endpointId)}
className="text-xs text-slate-400 hover:text-slate-600 transition-colors"
title="Copy curl command"
>
{copiedEndpoint === endpointId ? (
<span className="text-green-600">Copied!</span>
) : (
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z" />
</svg>
)}
</button>
</div>
<div className="text-sm text-slate-500 mt-1 ml-14">{endpoint.description}</div>
</div>
)
})}
</div>
</div>
))}
</div>
)
}

View File

@@ -0,0 +1,116 @@
'use client'
import { useState } from 'react'
import { services } from '../data'
import { getServiceTypeColor } from '../helpers'
export default function DockerTab() {
const [copiedEndpoint, setCopiedEndpoint] = useState<string | null>(null)
const copyToClipboard = (text: string, id: string) => {
navigator.clipboard.writeText(text)
setCopiedEndpoint(id)
setTimeout(() => setCopiedEndpoint(null), 2000)
}
const commonCommands = [
{ label: 'Alle Services starten', cmd: 'docker compose up -d' },
{ label: 'Logs anzeigen', cmd: 'docker compose logs -f [service]' },
{ label: 'Service neu bauen', cmd: 'docker compose build [service] --no-cache' },
{ label: 'Container Status', cmd: 'docker ps --format "table {{.Names}}\\t{{.Status}}\\t{{.Ports}}"' },
{ label: 'In Container einloggen', cmd: 'docker exec -it [container] /bin/sh' },
]
return (
<div className="space-y-6">
<div className="bg-white rounded-xl border border-slate-200 p-6">
<h2 className="text-lg font-semibold text-slate-900 mb-4">Docker Compose Services</h2>
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-slate-200">
<th className="text-left py-3 px-4 font-medium text-slate-700">Container</th>
<th className="text-left py-3 px-4 font-medium text-slate-700">Port</th>
<th className="text-left py-3 px-4 font-medium text-slate-700">Type</th>
<th className="text-left py-3 px-4 font-medium text-slate-700">Health Check</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-100">
{services.map((service) => (
<tr key={service.id} className="hover:bg-slate-50">
<td className="py-3 px-4">
<code className="text-sm bg-slate-100 px-2 py-0.5 rounded">{service.container}</code>
</td>
<td className="py-3 px-4 font-mono">{service.port}</td>
<td className="py-3 px-4">
<span className={`text-xs px-2 py-0.5 rounded-full ${getServiceTypeColor(service.type)}`}>
{service.type}
</span>
</td>
<td className="py-3 px-4">
{service.healthEndpoint ? (
<code className="text-xs bg-green-50 text-green-700 px-2 py-0.5 rounded">
{service.healthEndpoint}
</code>
) : (
<span className="text-slate-400">-</span>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
{/* Common Commands */}
<div className="bg-white rounded-xl border border-slate-200 p-6">
<h2 className="text-lg font-semibold text-slate-900 mb-4">Haeufige Befehle</h2>
<div className="space-y-4">
{commonCommands.map((item, idx) => (
<div key={idx} className="flex items-center gap-4 p-3 bg-slate-50 rounded-lg">
<div className="text-sm text-slate-600 w-40">{item.label}</div>
<code className="flex-1 text-sm font-mono bg-slate-900 text-green-400 px-3 py-2 rounded">
{item.cmd}
</code>
<button
onClick={() => copyToClipboard(item.cmd, `cmd-${idx}`)}
className="text-slate-400 hover:text-slate-600"
>
{copiedEndpoint === `cmd-${idx}` ? (
<span className="text-xs text-green-600">Copied!</span>
) : (
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z" />
</svg>
)}
</button>
</div>
))}
</div>
</div>
{/* Environment Variables */}
<div className="bg-white rounded-xl border border-slate-200 p-6">
<h2 className="text-lg font-semibold text-slate-900 mb-4">Wichtige Umgebungsvariablen</h2>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{services.filter(s => s.envVars.length > 0).map((service) => (
<div key={service.id} className="p-4 bg-slate-50 rounded-lg">
<h4 className="font-medium text-slate-900 mb-2">{service.name}</h4>
<div className="flex flex-wrap gap-2">
{service.envVars.map((env) => (
<code key={env} className="text-xs bg-slate-200 text-slate-700 px-2 py-1 rounded">
{env}
</code>
))}
</div>
</div>
))}
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,229 @@
'use client'
import { useState } from 'react'
import type { ServiceNode } from '../types'
import { ARCHITECTURE_SERVICES, LAYERS, DATAFLOW_DIAGRAM } from '../data'
import { getArchTypeColor, getArchTypeLabel } from '../helpers'
import ServiceDetailPanel from './ServiceDetailPanel'
export default function OverviewTab() {
const [selectedArchService, setSelectedArchService] = useState<ServiceNode | null>(null)
const [activeLayer, setActiveLayer] = useState<string>('all')
const getServicesForLayer = (layer: typeof LAYERS[0]) => {
return ARCHITECTURE_SERVICES.filter(s => layer.types.includes(s.type))
}
const archStats = {
total: ARCHITECTURE_SERVICES.length,
frontends: ARCHITECTURE_SERVICES.filter(s => s.type === 'frontend').length,
backends: ARCHITECTURE_SERVICES.filter(s => s.type === 'backend').length,
databases: ARCHITECTURE_SERVICES.filter(s => s.type === 'database').length,
infrastructure: ARCHITECTURE_SERVICES.filter(s => ['cache', 'search', 'storage', 'security', 'communication', 'ai', 'erp'].includes(s.type)).length,
}
return (
<div className="space-y-6">
{/* Stats */}
<div className="grid grid-cols-2 md:grid-cols-5 gap-4">
<div className="bg-white rounded-lg shadow p-4">
<div className="text-3xl font-bold text-slate-800">{archStats.total}</div>
<div className="text-sm text-slate-500">Services Total</div>
</div>
<div className="bg-white rounded-lg shadow p-4">
<div className="text-3xl font-bold text-blue-600">{archStats.frontends}</div>
<div className="text-sm text-slate-500">Frontends</div>
</div>
<div className="bg-white rounded-lg shadow p-4">
<div className="text-3xl font-bold text-green-600">{archStats.backends}</div>
<div className="text-sm text-slate-500">Backends</div>
</div>
<div className="bg-white rounded-lg shadow p-4">
<div className="text-3xl font-bold text-purple-600">{archStats.databases}</div>
<div className="text-sm text-slate-500">Datenbanken</div>
</div>
<div className="bg-white rounded-lg shadow p-4">
<div className="text-3xl font-bold text-orange-600">{archStats.infrastructure}</div>
<div className="text-sm text-slate-500">Infrastruktur</div>
</div>
</div>
{/* ASCII Architecture Diagram with Arrows */}
<div className="bg-white rounded-lg shadow p-6">
<h2 className="text-xl font-bold text-slate-800 mb-4">Datenfluss-Diagramm</h2>
<div className="bg-slate-900 rounded-lg p-6 overflow-x-auto">
<pre className="text-green-400 font-mono text-xs whitespace-pre">{DATAFLOW_DIAGRAM}</pre>
</div>
</div>
{/* Layer Filter */}
<div className="bg-white rounded-lg shadow p-4">
<div className="flex flex-wrap gap-2">
<button
onClick={() => setActiveLayer('all')}
className={`px-4 py-2 rounded-lg text-sm font-medium transition-colors ${
activeLayer === 'all'
? 'bg-primary-600 text-white'
: 'bg-gray-100 text-gray-700 hover:bg-gray-200'
}`}
>
Alle Layer
</button>
{LAYERS.map((layer) => (
<button
key={layer.id}
onClick={() => setActiveLayer(layer.id)}
className={`px-4 py-2 rounded-lg text-sm font-medium transition-colors ${
activeLayer === layer.id
? 'bg-primary-600 text-white'
: 'bg-gray-100 text-gray-700 hover:bg-gray-200'
}`}
>
{layer.name}
</button>
))}
</div>
</div>
{/* Architecture Diagram - Layered View */}
<div className="bg-white rounded-lg shadow p-6">
<h2 className="text-xl font-bold text-slate-800 mb-6">System-Architektur Diagramm</h2>
<div className="space-y-6">
{LAYERS.map((layer) => {
const layerServices = getServicesForLayer(layer)
if (activeLayer !== 'all' && activeLayer !== layer.id) return null
return (
<div key={layer.id} className="border-2 border-dashed border-slate-200 rounded-xl p-4">
<div className="flex items-center gap-3 mb-4">
<h3 className="text-lg font-semibold text-slate-700">{layer.name}</h3>
<span className="text-sm text-slate-500">- {layer.description}</span>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-3">
{layerServices.map((service) => {
const colors = getArchTypeColor(service.type)
const isSelected = selectedArchService?.id === service.id
return (
<div
key={service.id}
onClick={() => setSelectedArchService(isSelected ? null : service)}
className={`cursor-pointer rounded-lg border-2 p-3 transition-all ${
isSelected
? `${colors.border} ${colors.light} shadow-lg scale-105`
: 'border-slate-200 bg-white hover:border-slate-300 hover:shadow'
}`}
>
<div className="flex items-start justify-between mb-2">
<div className={`w-3 h-3 rounded-full ${colors.bg}`}></div>
{service.port && service.port !== '-' && (
<span className="text-xs font-mono text-slate-500">:{service.port}</span>
)}
</div>
<h4 className="font-medium text-sm text-slate-800">{service.name}</h4>
<p className="text-xs text-slate-500">{service.technology}</p>
<p className="text-xs text-slate-600 mt-2 line-clamp-2">{service.description}</p>
{service.connections && service.connections.length > 0 && (
<div className="mt-2 flex items-center gap-1 text-xs text-slate-400">
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 10V3L4 14h7v7l9-11h-7z" />
</svg>
{service.connections.length} Verbindung{service.connections.length > 1 ? 'en' : ''}
</div>
)}
</div>
)
})}
</div>
</div>
)
})}
</div>
</div>
{/* Service Detail Panel */}
{selectedArchService && (
<ServiceDetailPanel
service={selectedArchService}
onClose={() => setSelectedArchService(null)}
onSelectService={setSelectedArchService}
/>
)}
{/* Legend */}
<div className="bg-white rounded-lg shadow p-4">
<h3 className="text-sm font-medium text-slate-700 mb-3">Legende</h3>
<div className="flex flex-wrap gap-4">
{(['frontend', 'backend', 'database', 'cache', 'search', 'storage', 'security', 'communication', 'ai', 'erp'] as const).map((type) => {
const colors = getArchTypeColor(type)
return (
<div key={type} className="flex items-center gap-2">
<div className={`w-3 h-3 rounded-full ${colors.bg}`}></div>
<span className="text-sm text-slate-600">{getArchTypeLabel(type)}</span>
</div>
)
})}
</div>
</div>
{/* Technical Details Section */}
<div className="bg-white rounded-lg shadow p-6">
<h2 className="text-xl font-bold text-slate-800 mb-4">Technische Details</h2>
<div className="grid md:grid-cols-2 gap-6">
{/* Data Flow */}
<div>
<h3 className="text-lg font-semibold text-slate-700 mb-3">Datenfluss</h3>
<div className="space-y-2 text-sm text-slate-600">
<p><strong>1. Request:</strong> Browser &rarr; Next.js/FastAPI Frontend</p>
<p><strong>2. API:</strong> Frontend &rarr; Python Backend / Go Microservices</p>
<p><strong>3. Auth:</strong> Keycloak/Vault fuer SSO & Secrets</p>
<p><strong>4. Data:</strong> PostgreSQL (ACID) / Redis (Cache)</p>
<p><strong>5. Search:</strong> Qdrant (Vector) / Meilisearch (Fulltext)</p>
<p><strong>6. Storage:</strong> MinIO (Files) / IPFS (Dezentral)</p>
</div>
</div>
{/* Security */}
<div>
<h3 className="text-lg font-semibold text-slate-700 mb-3">Sicherheit</h3>
<div className="space-y-2 text-sm text-slate-600">
<p><strong>Auth:</strong> JWT + Keycloak OIDC</p>
<p><strong>Secrets:</strong> HashiCorp Vault (encrypted)</p>
<p><strong>Communication:</strong> Matrix E2EE, TLS everywhere</p>
<p><strong>DSGVO:</strong> Consent Service fuer Einwilligungen</p>
<p><strong>DevSecOps:</strong> Trivy, Gitleaks, Semgrep, Bandit</p>
<p><strong>SBOM:</strong> CycloneDX fuer alle Komponenten</p>
</div>
</div>
{/* Languages */}
<div>
<h3 className="text-lg font-semibold text-slate-700 mb-3">Programmiersprachen</h3>
<div className="flex flex-wrap gap-2">
<span className="px-3 py-1 bg-emerald-100 text-emerald-700 rounded-full text-sm">Python 3.12</span>
<span className="px-3 py-1 bg-sky-100 text-sky-700 rounded-full text-sm">Go 1.21</span>
<span className="px-3 py-1 bg-yellow-100 text-yellow-700 rounded-full text-sm">TypeScript 5.x</span>
<span className="px-3 py-1 bg-lime-100 text-lime-700 rounded-full text-sm">JavaScript ES2022</span>
</div>
</div>
{/* Frameworks */}
<div>
<h3 className="text-lg font-semibold text-slate-700 mb-3">Frameworks</h3>
<div className="flex flex-wrap gap-2">
<span className="px-3 py-1 bg-blue-100 text-blue-700 rounded-full text-sm">Next.js 15</span>
<span className="px-3 py-1 bg-green-100 text-green-700 rounded-full text-sm">FastAPI</span>
<span className="px-3 py-1 bg-cyan-100 text-cyan-700 rounded-full text-sm">Gin (Go)</span>
<span className="px-3 py-1 bg-purple-100 text-purple-700 rounded-full text-sm">Vue 3</span>
<span className="px-3 py-1 bg-red-100 text-red-700 rounded-full text-sm">Angular 17</span>
<span className="px-3 py-1 bg-pink-100 text-pink-700 rounded-full text-sm">NestJS</span>
</div>
</div>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,79 @@
'use client'
import type { ServiceNode } from '../types'
import { ARCHITECTURE_SERVICES } from '../data'
import { getArchTypeColor, getArchTypeLabel } from '../helpers'
interface ServiceDetailPanelProps {
service: ServiceNode
onClose: () => void
onSelectService: (service: ServiceNode) => void
}
export default function ServiceDetailPanel({ service, onClose, onSelectService }: ServiceDetailPanelProps) {
return (
<div className="bg-white rounded-lg shadow p-6">
<div className="flex items-start justify-between mb-4">
<div>
<h2 className="text-xl font-bold text-slate-800">{service.name}</h2>
<p className="text-slate-600">{service.description}</p>
</div>
<button
onClick={onClose}
className="p-2 hover: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="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<div className="bg-slate-50 rounded-lg p-3">
<div className="text-xs text-slate-500 uppercase">Typ</div>
<div className={`text-sm font-medium ${getArchTypeColor(service.type).text}`}>
{getArchTypeLabel(service.type)}
</div>
</div>
<div className="bg-slate-50 rounded-lg p-3">
<div className="text-xs text-slate-500 uppercase">Technologie</div>
<div className="text-sm font-medium text-slate-800">{service.technology}</div>
</div>
<div className="bg-slate-50 rounded-lg p-3">
<div className="text-xs text-slate-500 uppercase">Port</div>
<div className="text-sm font-mono font-medium text-slate-800">
{service.port || '-'}
</div>
</div>
<div className="bg-slate-50 rounded-lg p-3">
<div className="text-xs text-slate-500 uppercase">Verbindungen</div>
<div className="text-sm font-medium text-slate-800">
{service.connections?.length || 0} Services
</div>
</div>
</div>
{service.connections && service.connections.length > 0 && (
<div className="mt-4">
<h4 className="text-sm font-medium text-slate-700 mb-2">Verbunden mit:</h4>
<div className="flex flex-wrap gap-2">
{service.connections.map((connId) => {
const connService = ARCHITECTURE_SERVICES.find(s => s.id === connId)
if (!connService) return null
const colors = getArchTypeColor(connService.type)
return (
<button
key={connId}
onClick={() => onSelectService(connService)}
className={`px-3 py-1 rounded-full text-sm ${colors.light} ${colors.text} hover:opacity-80`}
>
{connService.name}
</button>
)
})}
</div>
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,78 @@
'use client'
import { useState } from 'react'
import { services, docPaths, PROJECT_BASE_PATH } from '../data'
import { getServiceTypeColor } from '../helpers'
export default function ServicesTab() {
const [selectedService, setSelectedService] = useState<string | null>(null)
return (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{services.map((service) => (
<div
key={service.id}
className={`bg-white rounded-xl border border-slate-200 p-5 cursor-pointer transition-all hover:shadow-md ${
selectedService === service.id ? 'ring-2 ring-primary-600' : ''
}`}
onClick={() => setSelectedService(selectedService === service.id ? null : service.id)}
>
<div className="flex items-start justify-between mb-3">
<div>
<h3 className="font-semibold text-slate-900">{service.name}</h3>
<span className={`text-xs px-2 py-0.5 rounded-full ${getServiceTypeColor(service.type)}`}>
{service.type}
</span>
</div>
<div className="text-right">
<div className="text-sm font-mono text-slate-600">:{service.port}</div>
</div>
</div>
<p className="text-sm text-slate-600 mb-3">{service.description}</p>
<div className="flex flex-wrap gap-1 mb-3">
{service.tech.map((t) => (
<span key={t} className="text-xs bg-slate-100 text-slate-600 px-2 py-0.5 rounded">
{t}
</span>
))}
</div>
{selectedService === service.id && (
<div className="mt-4 pt-4 border-t border-slate-200 space-y-2">
{/* Purpose/Warum dieser Service */}
<div className="bg-primary-50 border border-primary-200 rounded-lg p-3 mb-3">
<div className="text-xs font-medium text-primary-700 mb-1">Warum dieser Service?</div>
<div className="text-sm text-primary-900">{service.purpose}</div>
</div>
<div className="text-xs text-slate-500">Container: {service.container}</div>
{service.healthEndpoint && (
<div className="text-xs text-slate-500">
Health: <code className="bg-slate-100 px-1 rounded">localhost:{service.port}{service.healthEndpoint}</code>
</div>
)}
<div className="text-xs text-slate-500">
Endpoints: {service.endpoints.length}
</div>
{/* VS Code Link */}
{docPaths[service.id] && (
<a
href={`vscode://file/${PROJECT_BASE_PATH}/${docPaths[service.id]}`}
onClick={(e) => e.stopPropagation()}
className="mt-3 flex items-center gap-2 text-xs bg-blue-50 text-blue-700 px-3 py-2 rounded-lg hover:bg-blue-100 transition-colors"
>
<svg className="w-4 h-4" viewBox="0 0 24 24" fill="currentColor">
<path d="M23.15 2.587L18.21.21a1.494 1.494 0 0 0-1.705.29l-9.46 8.63-4.12-3.128a.999.999 0 0 0-1.276.057L.327 7.261A1 1 0 0 0 .326 8.74L3.899 12 .326 15.26a1 1 0 0 0 .001 1.479L1.65 17.94a.999.999 0 0 0 1.276.057l4.12-3.128 9.46 8.63a1.492 1.492 0 0 0 1.704.29l4.942-2.377A1.5 1.5 0 0 0 24 20.06V3.939a1.5 1.5 0 0 0-.85-1.352zm-5.146 14.861L10.826 12l7.178-5.448v10.896z"/>
</svg>
In VS Code oeffnen
</a>
)}
</div>
)}
</div>
))}
</div>
)
}

View File

@@ -0,0 +1,31 @@
'use client'
import type { TabType } from '../types'
import { TAB_DEFINITIONS } from '../data'
interface TabNavigationProps {
activeTab: TabType
onTabChange: (tab: TabType) => void
}
export default function TabNavigation({ activeTab, onTabChange }: TabNavigationProps) {
return (
<div className="border-b border-slate-200 mb-6">
<nav className="flex gap-6">
{TAB_DEFINITIONS.map((tab) => (
<button
key={tab.id}
onClick={() => onTabChange(tab.id as TabType)}
className={`pb-3 px-1 text-sm font-medium border-b-2 transition-colors ${
activeTab === tab.id
? 'border-primary-600 text-primary-600'
: 'border-transparent text-slate-500 hover:text-slate-700'
}`}
>
{tab.label}
</button>
))}
</nav>
</div>
)
}

View File

@@ -0,0 +1,171 @@
'use client'
import { useState } from 'react'
import { PROJECT_BASE_PATH } from '../data'
const testDocLinks = [
{ file: 'docs/testing/README.md', label: 'Test-Uebersicht', desc: 'Teststrategie & Coverage-Ziele' },
{ file: 'docs/testing/QUICKSTART.md', label: 'Quickstart', desc: 'Schnellstart fuer Tests' },
{ file: 'docs/testing/INTEGRATION_TESTS.md', label: 'Integrationstests', desc: 'API & DB Tests' },
]
const coverageTargets = [
{ component: 'Go Consent Service', target: '80%', current: '~75%', color: 'green' },
{ component: 'Python Backend', target: '70%', current: '~65%', color: 'yellow' },
{ component: 'Critical Paths (Auth, OAuth)', target: '95%', current: '~90%', color: 'green' },
]
const testCommands = [
{ label: 'Go Tests (alle)', cmd: 'cd consent-service && go test -v ./...', lang: 'Go' },
{ label: 'Go Tests mit Coverage', cmd: 'cd consent-service && go test -cover ./...', lang: 'Go' },
{ label: 'Python Tests (alle)', cmd: 'cd backend && source venv/bin/activate && pytest -v', lang: 'Python' },
{ label: 'Python Tests mit Coverage', cmd: 'cd backend && pytest --cov=. --cov-report=html', lang: 'Python' },
]
export default function TestingTab() {
const [copiedEndpoint, setCopiedEndpoint] = useState<string | null>(null)
const copyToClipboard = (text: string, id: string) => {
navigator.clipboard.writeText(text)
setCopiedEndpoint(id)
setTimeout(() => setCopiedEndpoint(null), 2000)
}
return (
<div className="space-y-6">
{/* Quick Links to Docs */}
<div className="bg-white rounded-xl border border-slate-200 p-6">
<div className="flex items-center justify-between mb-4">
<h2 className="text-lg font-semibold text-slate-900">Test-Dokumentation</h2>
<a
href={`vscode://file/${PROJECT_BASE_PATH}/docs/testing/README.md`}
className="flex items-center gap-2 text-sm bg-blue-50 text-blue-700 px-3 py-2 rounded-lg hover:bg-blue-100 transition-colors"
>
<svg className="w-4 h-4" viewBox="0 0 24 24" fill="currentColor">
<path d="M23.15 2.587L18.21.21a1.494 1.494 0 0 0-1.705.29l-9.46 8.63-4.12-3.128a.999.999 0 0 0-1.276.057L.327 7.261A1 1 0 0 0 .326 8.74L3.899 12 .326 15.26a1 1 0 0 0 .001 1.479L1.65 17.94a.999.999 0 0 0 1.276.057l4.12-3.128 9.46 8.63a1.492 1.492 0 0 0 1.704.29l4.942-2.377A1.5 1.5 0 0 0 24 20.06V3.939a1.5 1.5 0 0 0-.85-1.352zm-5.146 14.861L10.826 12l7.178-5.448v10.896z"/>
</svg>
Vollstaendige Docs in VS Code
</a>
</div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
{testDocLinks.map((doc) => (
<a
key={doc.file}
href={`vscode://file/${PROJECT_BASE_PATH}/${doc.file}`}
className="p-4 bg-slate-50 rounded-lg hover:bg-slate-100 transition-colors"
>
<div className="font-medium text-slate-900">{doc.label}</div>
<div className="text-sm text-slate-500">{doc.desc}</div>
</a>
))}
</div>
</div>
{/* Test Pyramid */}
<div className="bg-white rounded-xl border border-slate-200 p-6">
<h2 className="text-lg font-semibold text-slate-900 mb-4">Test-Pyramide</h2>
<div className="bg-slate-900 rounded-lg p-6 text-center">
<pre className="text-green-400 font-mono text-sm whitespace-pre inline-block text-left">{` /\\
/ \\ E2E (10%)
/----\\
/ \\ Integration (20%)
/--------\\
/ \\ Unit Tests (70%)
/--------------\\`}</pre>
</div>
</div>
{/* Coverage Ziele */}
<div className="bg-white rounded-xl border border-slate-200 p-6">
<h2 className="text-lg font-semibold text-slate-900 mb-4">Coverage-Ziele</h2>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
{coverageTargets.map((item) => (
<div key={item.component} className="p-4 bg-slate-50 rounded-lg">
<div className="font-medium text-slate-900">{item.component}</div>
<div className="flex items-center gap-2 mt-2">
<div className="flex-1 h-2 bg-slate-200 rounded-full overflow-hidden">
<div
className={`h-full ${item.color === 'green' ? 'bg-green-500' : 'bg-yellow-500'}`}
style={{ width: item.current.replace('~', '') }}
/>
</div>
<span className="text-sm text-slate-600">{item.current}</span>
</div>
<div className="text-xs text-slate-500 mt-1">Ziel: {item.target}</div>
</div>
))}
</div>
</div>
{/* Test Commands */}
<div className="bg-white rounded-xl border border-slate-200 p-6">
<h2 className="text-lg font-semibold text-slate-900 mb-4">Test-Befehle</h2>
<div className="space-y-4">
{testCommands.map((item, idx) => (
<div key={idx} className="flex items-center gap-4 p-3 bg-slate-50 rounded-lg">
<span className={`text-xs px-2 py-0.5 rounded-full ${item.lang === 'Go' ? 'bg-cyan-100 text-cyan-700' : 'bg-yellow-100 text-yellow-700'}`}>
{item.lang}
</span>
<div className="text-sm text-slate-600 w-40">{item.label}</div>
<code className="flex-1 text-sm font-mono bg-slate-900 text-green-400 px-3 py-2 rounded">
{item.cmd}
</code>
<button
onClick={() => copyToClipboard(item.cmd, `test-${idx}`)}
className="text-slate-400 hover:text-slate-600"
>
{copiedEndpoint === `test-${idx}` ? (
<span className="text-xs text-green-600">Copied!</span>
) : (
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z" />
</svg>
)}
</button>
</div>
))}
</div>
</div>
{/* Test-Struktur */}
<div className="bg-white rounded-xl border border-slate-200 p-6">
<h2 className="text-lg font-semibold text-slate-900 mb-4">Test-Struktur</h2>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{/* Go Tests */}
<div>
<h3 className="font-medium text-slate-900 mb-2 flex items-center gap-2">
<span className="text-xs px-2 py-0.5 rounded-full bg-cyan-100 text-cyan-700">Go</span>
Consent Service
</h3>
<div className="bg-slate-900 rounded-lg p-4 font-mono text-sm text-green-400">
<div>consent-service/</div>
<div className="ml-4">internal/</div>
<div className="ml-8">handlers/handlers_test.go</div>
<div className="ml-8">services/auth_service_test.go</div>
<div className="ml-8">services/oauth_service_test.go</div>
<div className="ml-8">services/totp_service_test.go</div>
<div className="ml-8">middleware/middleware_test.go</div>
</div>
</div>
{/* Python Tests */}
<div>
<h3 className="font-medium text-slate-900 mb-2 flex items-center gap-2">
<span className="text-xs px-2 py-0.5 rounded-full bg-yellow-100 text-yellow-700">Python</span>
Backend
</h3>
<div className="bg-slate-900 rounded-lg p-4 font-mono text-sm text-green-400">
<div>backend/</div>
<div className="ml-4">tests/</div>
<div className="ml-8">test_consent_client.py</div>
<div className="ml-8">test_gdpr_api.py</div>
<div className="ml-8">test_dsms_webui.py</div>
<div className="ml-4">conftest.py</div>
</div>
</div>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,37 @@
'use client'
/**
* Sticky header bar with document title, TOC toggle, and print button.
*/
interface AuditHeaderProps {
showToc: boolean
onToggleToc: () => void
}
export function AuditHeader({ showToc, onToggleToc }: AuditHeaderProps) {
return (
<div className="bg-white border-b border-slate-200 px-8 py-4 sticky top-16 z-10">
<div className="flex items-center justify-between max-w-4xl">
<div>
<h1 className="text-xl font-bold text-slate-900">DSGVO-Audit-Dokumentation</h1>
<p className="text-sm text-slate-500">OCR-Labeling-System | Version 1.0.0 | 21. Januar 2026</p>
</div>
<div className="flex items-center gap-3">
<button
onClick={onToggleToc}
className="px-3 py-1.5 text-sm text-slate-600 hover:text-slate-900 border border-slate-300 rounded-lg hover:bg-slate-50"
>
{showToc ? 'TOC ausblenden' : 'TOC anzeigen'}
</button>
<button
onClick={() => window.print()}
className="px-3 py-1.5 text-sm font-medium text-white bg-primary-600 rounded-lg hover:bg-primary-700"
>
PDF drucken
</button>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,19 @@
/**
* Document title block with version, date, classification, and review date.
*/
export function AuditTitleBlock() {
return (
<div className="mb-8 pb-6 border-b-2 border-slate-200">
<h1 className="text-3xl font-bold text-slate-900 mb-4">
DSGVO-Audit-Dokumentation: OCR-Labeling-System für Handschrifterkennung
</h1>
<div className="grid grid-cols-2 gap-4 text-sm">
<div><span className="font-semibold">Dokumentversion:</span> 1.0.0</div>
<div><span className="font-semibold">Datum:</span> 21. Januar 2026</div>
<div><span className="font-semibold">Klassifizierung:</span> Vertraulich - Nur für internen Gebrauch und Auditoren</div>
<div><span className="font-semibold">Nächste Überprüfung:</span> 21. Januar 2027</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,12 @@
/**
* Code block component for rendering preformatted text
* in the audit documentation (diagrams, config examples).
*/
export function CodeBlock({ children }: { children: string }) {
return (
<pre className="bg-slate-900 text-slate-100 p-4 rounded-lg overflow-x-auto text-sm font-mono my-4 whitespace-pre">
{children}
</pre>
)
}

View File

@@ -0,0 +1,33 @@
/**
* Reusable table component for the audit documentation.
* Renders a striped HTML table with headers and rows.
*/
export function Table({ headers, rows }: { headers: string[]; rows: string[][] }) {
return (
<div className="overflow-x-auto my-4">
<table className="min-w-full border-collapse border border-slate-300">
<thead>
<tr className="bg-slate-100">
{headers.map((header, i) => (
<th key={i} className="border border-slate-300 px-4 py-2 text-left text-sm font-semibold text-slate-700">
{header}
</th>
))}
</tr>
</thead>
<tbody>
{rows.map((row, i) => (
<tr key={i} className={i % 2 === 0 ? 'bg-white' : 'bg-slate-50'}>
{row.map((cell, j) => (
<td key={j} className="border border-slate-300 px-4 py-2 text-sm text-slate-600">
{cell}
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
)
}

View File

@@ -0,0 +1,41 @@
'use client'
/**
* Sidebar table of contents for navigating audit documentation sections.
*/
import { SECTIONS } from '../constants'
interface TableOfContentsProps {
activeSection: string
onScrollToSection: (sectionId: string) => void
}
export function TableOfContents({ activeSection, onScrollToSection }: TableOfContentsProps) {
return (
<aside className="w-64 flex-shrink-0 border-r border-slate-200 bg-slate-50 overflow-y-auto fixed left-64 top-16 bottom-0 z-20">
<div className="p-4">
<h2 className="text-sm font-semibold text-slate-900 uppercase tracking-wider mb-4">
Inhaltsverzeichnis
</h2>
<nav className="space-y-0.5">
{SECTIONS.map((section) => (
<button
key={section.id}
onClick={() => onScrollToSection(section.id)}
className={`w-full text-left px-2 py-1.5 text-sm rounded transition-colors ${
section.level === 2 ? 'font-medium' : 'ml-3 text-xs'
} ${
activeSection === section.id
? 'bg-primary-100 text-primary-700'
: 'text-slate-600 hover:bg-slate-200 hover:text-slate-900'
}`}
>
{section.id.includes('-') ? '' : `${section.id}. `}{section.title}
</button>
))}
</nav>
</div>
</aside>
)
}

View File

@@ -0,0 +1,64 @@
/**
* Anhaenge (Appendices): TOM-Checkliste, Vendor-Dokumentation, Voice Service TOM
* Plus document footer.
*/
import { Table } from '../Table'
export function Anhaenge() {
return (
<>
<section className="mb-10">
<h2 className="text-2xl font-bold text-slate-800 mb-6 pb-2 border-b border-slate-200">
Anhänge
</h2>
<h3 className="text-xl font-semibold text-slate-700 mb-3">Anhang B: TOM-Checkliste</h3>
<Table
headers={['Kategorie', 'Maßnahme', 'Status']}
rows={[
['Zutrittskontrolle', 'Serverraum verschlossen', '✓'],
['Zugangskontrolle', 'Passwort-Policy', '✓'],
['Zugriffskontrolle', 'RBAC implementiert', '✓'],
['Weitergabekontrolle', 'Netzwerkisolation', '✓'],
['Eingabekontrolle', 'Audit-Logging', '✓'],
['Verfügbarkeit', 'Backup + USV', '✓'],
['Trennungskontrolle', 'Mandantentrennung', '✓'],
['Verschlüsselung', 'FileVault + TLS', '✓'],
]}
/>
<h3 className="text-xl font-semibold text-slate-700 mt-6 mb-3">Anhang E: Vendor-Dokumentation</h3>
<ul className="list-disc list-inside text-slate-600 space-y-1 ml-4">
<li><strong>llama3.2-vision:</strong> https://llama.meta.com/</li>
<li><strong>TrOCR:</strong> https://github.com/microsoft/unilm/tree/master/trocr</li>
<li><strong>Ollama:</strong> https://ollama.ai/</li>
<li><strong>PersonaPlex-7B:</strong> https://developer.nvidia.com (MIT + NVIDIA Open Model License)</li>
<li><strong>TaskOrchestrator:</strong> Proprietary - Agent-Orchestrierung</li>
<li><strong>Mimi Codec:</strong> MIT License - 24kHz Audio, 80ms Frames</li>
</ul>
<h3 className="text-xl font-semibold text-slate-700 mt-6 mb-3">Anhang F: Voice Service TOM</h3>
<Table
headers={['Maßnahme', 'Umsetzung', 'Status']}
rows={[
['Audio-Persistenz verboten', 'AUDIO_PERSISTENCE=false (zwingend)', '✓'],
['Client-side Encryption', 'AES-256-GCM vor Übertragung', '✓'],
['Namespace-Isolation', 'Pro-Lehrer-Schlüssel', '✓'],
['TTL-basierte Löschung', 'Valkey mit automatischem Expire', '✓'],
['Transport-Verschlüsselung', 'TLS 1.3 + WSS', '✓'],
['Audit ohne PII', 'Nur Metadaten protokolliert', '✓'],
['Key-Hash statt Klartext', 'SHA-256 Hash zum Server', '✓'],
]}
/>
</section>
{/* Footer */}
<div className="border-t-2 border-slate-200 pt-6 mt-10 text-center text-slate-500 text-sm">
<p><strong>Dokumentende</strong></p>
<p className="mt-2">Diese Dokumentation wird jährlich oder bei wesentlichen Änderungen aktualisiert.</p>
<p className="mt-1">Letzte Aktualisierung: 26. Januar 2026</p>
</div>
</>
)
}

View File

@@ -0,0 +1,129 @@
/**
* Section 18: BQAS Lokaler Scheduler (QA-System)
* Subsections: 18.1-18.5
*/
import { Table } from '../Table'
import { CodeBlock } from '../CodeBlock'
export function BQASScheduler() {
return (
<section id="section-18" className="mb-10 scroll-mt-32">
<h2 className="text-2xl font-bold text-slate-800 mb-6 pb-2 border-b border-slate-200">
18. BQAS Lokaler Scheduler (QA-System)
</h2>
<div id="section-18-1" className="mb-6 scroll-mt-32">
<h3 className="text-xl font-semibold text-slate-700 mb-3">18.1 GitHub Actions Alternative</h3>
<p className="text-slate-600 mb-4">
Das BQAS (Breakpilot Quality Assurance System) nutzt einen <strong>lokalen Scheduler</strong> anstelle von GitHub Actions.
Dies gewährleistet, dass <strong>keine Testdaten oder Ergebnisse</strong> an externe Cloud-Dienste übertragen werden.
</p>
<Table
headers={['Feature', 'GitHub Actions', 'Lokaler Scheduler', 'DSGVO-Relevanz']}
rows={[
['Tägliche Tests', 'schedule: cron', 'macOS launchd', 'Keine Datenübertragung'],
['Push-Tests', 'on: push (Cloud)', 'Git post-commit Hook (lokal)', 'Keine Datenübertragung'],
['PR-Tests', 'on: pull_request', 'Nicht verfügbar', '-'],
['Benachrichtigungen', 'GitHub Issues (US)', 'Desktop/Slack/Email', 'Konfigurierbar'],
['Datenverarbeitung', 'GitHub Server (US)', '100% lokal', 'DSGVO-konform'],
]}
/>
</div>
<div id="section-18-2" className="mb-6 scroll-mt-32">
<h3 className="text-xl font-semibold text-slate-700 mb-3">18.2 Datenschutz-Vorteile</h3>
<div className="bg-green-50 border border-green-200 rounded-lg p-4 mb-4">
<p className="text-green-800 font-medium">
<strong>DSGVO-Konformität:</strong> Der lokale Scheduler verarbeitet alle Testdaten ausschließlich auf dem schuleigenen Mac Mini.
Es erfolgt keine Übertragung von Schülerdaten, Testergebnissen oder Modell-Outputs an externe Server.
</p>
</div>
<Table
headers={['Aspekt', 'Umsetzung']}
rows={[
['Verarbeitungsort', '100% auf lokalem Mac Mini'],
['Drittlandübermittlung', 'Keine'],
['Cloud-Abhängigkeit', 'Keine - vollständig offline-fähig'],
['Testdaten', 'Verbleiben lokal, keine Synchronisation'],
['Logs', '/var/log/bqas/ - lokal, ohne PII'],
]}
/>
</div>
<div id="section-18-3" className="mb-6 scroll-mt-32">
<h3 className="text-xl font-semibold text-slate-700 mb-3">18.3 Komponenten</h3>
<p className="text-slate-600 mb-4">Der lokale Scheduler besteht aus folgenden Komponenten:</p>
<Table
headers={['Komponente', 'Beschreibung', 'Datenschutz']}
rows={[
['run_bqas.sh', 'Hauptscript für Test-Ausführung', 'Keine Netzwerk-Calls außer lokalem API'],
['launchd Job', 'macOS-nativer Scheduler (07:00 täglich)', 'System-Level, keine Cloud'],
['Git Hook', 'post-commit für automatische Quick-Tests', 'Rein lokal'],
['Notifier', 'Benachrichtigungsmodul (Python)', 'Desktop lokal, Slack/Email optional'],
['LLM Judge', 'Qwen2.5-32B via lokalem Ollama', 'Keine externe API'],
['RAG Judge', 'Korrektur-Evaluierung lokal', 'Keine externe API'],
]}
/>
<CodeBlock>{`# Dateistruktur
voice-service/
├── scripts/
│ ├── run_bqas.sh # Haupt-Runner
│ ├── install_bqas_scheduler.sh # Installation
│ ├── com.breakpilot.bqas.plist # launchd Template
│ └── post-commit.hook # Git Hook
└── bqas/
├── judge.py # LLM Judge
├── rag_judge.py # RAG Judge
├── notifier.py # Benachrichtigungen
└── regression_tracker.py # Score-Historie`}</CodeBlock>
</div>
<div id="section-18-4" className="mb-6 scroll-mt-32">
<h3 className="text-xl font-semibold text-slate-700 mb-3">18.4 Datenverarbeitung</h3>
<p className="text-slate-600 mb-4">Folgende Daten werden während der Test-Ausführung verarbeitet:</p>
<Table
headers={['Datentyp', 'Verarbeitung', 'Speicherung', 'Löschung']}
rows={[
['Test-Inputs (Golden Suite)', 'Lokal via pytest', 'Im Speicher während Test', 'Nach Test-Ende'],
['LLM-Antworten', 'Lokales Ollama', 'Temporär im Speicher', 'Nach Bewertung'],
['Test-Ergebnisse', 'SQLite DB', 'bqas_history.db (lokal)', 'Nach Konfiguration'],
['Logs', 'Dateisystem', '/var/log/bqas/', 'Manuelle Rotation'],
['Benachrichtigungen', 'Log + Optional Slack/Email', 'notifications.log', 'Manuelle Rotation'],
]}
/>
<div className="bg-amber-50 border border-amber-200 rounded-lg p-4 mt-4">
<p className="text-amber-800">
<strong>Wichtig:</strong> Die Test-Inputs (Golden Suite YAML-Dateien) enthalten <strong>keine echten Schülerdaten</strong>,
sondern ausschließlich synthetische Beispiele zur Qualitätssicherung der Intent-Erkennung und RAG-Funktionalität.
</p>
</div>
</div>
<div id="section-18-5" className="mb-6 scroll-mt-32">
<h3 className="text-xl font-semibold text-slate-700 mb-3">18.5 Benachrichtigungen</h3>
<p className="text-slate-600 mb-4">Das Notifier-Modul unterstützt verschiedene Benachrichtigungskanäle:</p>
<Table
headers={['Kanal', 'Standard', 'Konfiguration', 'Datenschutz-Hinweis']}
rows={[
['Desktop (macOS)', 'Aktiviert', 'BQAS_NOTIFY_DESKTOP=true', 'Rein lokal, keine Übertragung'],
['Log-Datei', 'Immer', '/var/log/bqas/notifications.log', 'Lokal, nur Metadaten'],
['Slack Webhook', 'Deaktiviert', 'BQAS_NOTIFY_SLACK=true', 'Externe Übertragung - nur Status, keine PII'],
['E-Mail', 'Deaktiviert', 'BQAS_NOTIFY_EMAIL=true', 'Via lokalen Mailserver möglich'],
]}
/>
<p className="text-slate-600 mt-4 mb-2"><strong>Empfohlene Konfiguration für maximale Datenschutz-Konformität:</strong></p>
<CodeBlock>{`# Nur lokale Benachrichtigungen (Standard)
BQAS_NOTIFY_DESKTOP=true
BQAS_NOTIFY_SLACK=false
BQAS_NOTIFY_EMAIL=false
# Benachrichtigungs-Inhalt (ohne PII):
# - Status: success/failure/warning
# - Anzahl bestandener/fehlgeschlagener Tests
# - Test-IDs (keine Schülernamen oder Inhalte)`}</CodeBlock>
</div>
</section>
)
}

View File

@@ -0,0 +1,97 @@
/**
* Section 9: BSI-Anforderungen und Sicherheitsrichtlinien
* Section 10: EU AI Act Compliance (KI-Verordnung)
*/
import { Table } from '../Table'
export function BSIAndEUAIAct() {
return (
<>
{/* Section 9 */}
<section id="section-9" className="mb-10 scroll-mt-32">
<h2 className="text-2xl font-bold text-slate-800 mb-6 pb-2 border-b border-slate-200">
9. BSI-Anforderungen und Sicherheitsrichtlinien
</h2>
<h3 className="text-xl font-semibold text-slate-700 mb-3">9.1 Angewandte BSI-Publikationen</h3>
<Table
headers={['Publikation', 'Relevanz', 'Umsetzung']}
rows={[
['IT-Grundschutz-Kompendium', 'Basis-Absicherung', 'TOM nach Abschnitt 8'],
['BSI TR-03116-4', 'Kryptographische Verfahren', 'AES-256, TLS 1.3'],
['Kriterienkatalog KI (Juni 2025)', 'KI-Sicherheit', 'Siehe 9.2'],
['QUAIDAL (Juli 2025)', 'Trainingsdaten-Qualität', 'Siehe 9.3'],
]}
/>
<h3 className="text-xl font-semibold text-slate-700 mt-6 mb-3">9.2 KI-Sicherheitsanforderungen (BSI Kriterienkatalog)</h3>
<Table
headers={['Kriterium', 'Anforderung', 'Umsetzung']}
rows={[
['Modellintegrität', 'Schutz vor Manipulation', 'Lokale Modelle, keine Updates ohne Review'],
['Eingabevalidierung', 'Schutz vor Adversarial Attacks', 'Bildformat-Prüfung, Größenlimits'],
['Ausgabevalidierung', 'Plausibilitätsprüfung', 'Konfidenz-Schwellwerte'],
['Protokollierung', 'Nachvollziehbarkeit', 'Vollständiges Audit-Log'],
['Incident Response', 'Reaktion auf Fehlfunktionen', 'Eskalationsprozess definiert'],
]}
/>
<h3 className="text-xl font-semibold text-slate-700 mt-6 mb-3">9.3 Trainingsdaten-Qualität (QUAIDAL)</h3>
<Table
headers={['Qualitätskriterium', 'Umsetzung']}
rows={[
['Herkunftsdokumentation', 'Alle Trainingsdaten aus eigenem Labeling-Prozess'],
['Repräsentativität', 'Diverse Handschriften aus verschiedenen Klassenstufen'],
['Qualitätskontrolle', 'Lehrkraft-Verifikation jedes Samples'],
['Bias-Prüfung', 'Regelmäßige Stichproben-Analyse'],
['Versionierung', 'Git-basierte Versionskontrolle für Datasets'],
]}
/>
</section>
{/* Section 10 */}
<section id="section-10" className="mb-10 scroll-mt-32">
<h2 className="text-2xl font-bold text-slate-800 mb-6 pb-2 border-b border-slate-200">
10. EU AI Act Compliance (KI-Verordnung)
</h2>
<h3 className="text-xl font-semibold text-slate-700 mb-3">10.1 Risikoklassifizierung</h3>
<p className="text-slate-600 mb-4"><strong>Prüfung nach Anhang III der KI-Verordnung:</strong></p>
<Table
headers={['Hochrisiko-Kategorie', 'Anwendbar', 'Begründung']}
rows={[
['3(a) Biometrische Identifizierung', 'Nein', 'Keine biometrische Verarbeitung'],
['3(b) Kritische Infrastruktur', 'Nein', 'Keine kritische Infrastruktur'],
['3(c) Allgemeine/berufliche Bildung', 'Prüfen', 'Bildungsbereich'],
['3(d) Beschäftigung', 'Nein', 'Nicht anwendbar'],
]}
/>
<p className="text-slate-600 mt-4 mb-2">Das System wird <strong>nicht</strong> für folgende Hochrisiko-Anwendungen genutzt:</p>
<ul className="list-disc list-inside text-slate-600 space-y-1 ml-4 mb-4">
<li>Entscheidung über Zugang zu Bildungseinrichtungen</li>
<li>Zuweisung zu Bildungseinrichtungen oder -programmen</li>
<li>Bewertung von Lernergebnissen (nur Unterstützung, keine automatische Bewertung)</li>
<li>Überwachung von Prüfungen</li>
</ul>
<div className="bg-green-50 border border-green-200 rounded-lg p-4">
<p className="text-green-800 font-medium">
<strong>Ergebnis:</strong> Kein Hochrisiko-KI-System nach aktuellem Stand.
</p>
</div>
<h3 className="text-xl font-semibold text-slate-700 mt-6 mb-3">10.2 Verbotsprüfung (Art. 5)</h3>
<Table
headers={['Verbotene Praxis', 'Geprüft', 'Ergebnis']}
rows={[
['Unterschwellige Manipulation', '✓', 'Nicht vorhanden'],
['Ausnutzung von Schwächen', '✓', 'Nicht vorhanden'],
['Social Scoring', '✓', 'Nicht vorhanden'],
['Echtzeit-Biometrie', '✓', 'Nicht vorhanden'],
['Emotionserkennung in Bildung', '✓', 'Nicht vorhanden'],
]}
/>
</section>
</>
)
}

View File

@@ -0,0 +1,131 @@
/**
* Section 4: Datenschutz-Folgenabschaetzung (Art. 35 DSGVO)
* Subsections: 4.1-4.5
*/
import { Table } from '../Table'
import { CodeBlock } from '../CodeBlock'
export function DatenschutzFolgen() {
return (
<section id="section-4" className="mb-10 scroll-mt-32">
<h2 className="text-2xl font-bold text-slate-800 mb-6 pb-2 border-b border-slate-200">
4. Datenschutz-Folgenabschätzung (Art. 35 DSGVO)
</h2>
<div id="section-4-1" className="mb-6 scroll-mt-32">
<h3 className="text-xl font-semibold text-slate-700 mb-3">4.1 Schwellwertanalyse - Erforderlichkeit der DSFA</h3>
<Table
headers={['Kriterium', 'Erfüllt', 'Begründung']}
rows={[
['Neue Technologien (KI/ML)', '✓', 'Vision-LLM für OCR'],
['Umfangreiche Verarbeitung', '✗', 'Begrenzt auf einzelne Schule'],
['Daten von Minderjährigen', '✓', 'Schülerarbeiten'],
['Systematische Überwachung', '✗', 'Keine Überwachung'],
['Scoring/Profiling', '✗', 'Keine automatische Bewertung'],
]}
/>
<div className="bg-amber-50 border border-amber-200 rounded-lg p-4 mt-4">
<p className="text-amber-800 font-medium">
<strong>Ergebnis:</strong> DSFA erforderlich aufgrund KI-Einsatz und Verarbeitung von Daten Minderjähriger.
</p>
</div>
</div>
<div id="section-4-2" className="mb-6 scroll-mt-32">
<h3 className="text-xl font-semibold text-slate-700 mb-3">4.2 Systematische Beschreibung der Verarbeitung</h3>
<h4 className="text-lg font-medium text-slate-700 mt-4 mb-2">Datenfluss-Diagramm</h4>
<CodeBlock>{`┌─────────────────────────────────────────────────────────────────────────────────┐
│ OCR-LABELING DATENFLUSS │
├─────────────────────────────────────────────────────────────────────────────────┤
│ │
│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │
│ │ 1. SCAN │───►│ 2. UPLOAD │───►│ 3. OCR │───►│ 4. LABELING │ │
│ │ (Lehrkraft) │ │ (MinIO) │ │ (Ollama) │ │ (Lehrkraft) │ │
│ └──────────────┘ └──────────────┘ └──────────────┘ └──────────────┘ │
│ │ │ │ │ │
│ ▼ ▼ ▼ ▼ │
│ Papierdokument Verschlüsselte Lokale LLM- Bestätigung/ │
│ → digitaler Scan Bildspeicherung Verarbeitung Korrektur │
│ │
│ ┌──────────────────────────────────────────────────────────────────────────┐ │
│ │ SPEICHERUNG (PostgreSQL) │ │
│ │ • Session-ID (UUID) • Status (pending/confirmed/corrected) │ │
│ │ • Bild-Hash (SHA256) • Ground Truth (korrigierter Text) │ │
│ │ • OCR-Text • Zeitstempel │ │
│ └──────────────────────────────────────────────────────────────────────────┘ │
│ │
│ ┌──────────────┐ │
│ │ 5. EXPORT │ Pseudonymisierte Trainingsdaten (JSONL) │
│ │ (Optional) │ → Lokal gespeichert für Fine-Tuning │
│ └──────────────┘ │
└─────────────────────────────────────────────────────────────────────────────────┘`}</CodeBlock>
<h4 className="text-lg font-medium text-slate-700 mt-6 mb-2">Verarbeitungsschritte im Detail</h4>
<Table
headers={['Schritt', 'Beschreibung', 'Datenschutzmaßnahme']}
rows={[
['1. Scan', 'Lehrkraft scannt Papierklausur', 'Physischer Zugang nur für Lehrkräfte'],
['2. Upload', 'Bild wird in lokales MinIO hochgeladen', 'SHA256-Deduplizierung, verschlüsselte Speicherung'],
['3. OCR', 'llama3.2-vision erkennt Text', '100% lokal, kein Internet'],
['4. Labeling', 'Lehrkraft prüft/korrigiert OCR-Ergebnis', 'Protokollierung aller Aktionen'],
['5. Export', 'Optional: Pseudonymisierte Trainingsdaten', 'Entfernung direkter Identifikatoren'],
]}
/>
</div>
<div id="section-4-3" className="mb-6 scroll-mt-32">
<h3 className="text-xl font-semibold text-slate-700 mb-3">4.3 Notwendigkeit und Verhältnismäßigkeit</h3>
<h4 className="text-lg font-medium text-slate-700 mt-4 mb-2">Prüfung der Erforderlichkeit</h4>
<Table
headers={['Prinzip', 'Umsetzung']}
rows={[
['Zweckbindung', 'Ausschließlich für schulische Leistungsbewertung und Modelltraining'],
['Datenminimierung', 'Nur Bildausschnitte mit Text, keine vollständigen Klausuren nötig'],
['Speicherbegrenzung', 'Automatische Löschung nach definierter Aufbewahrungsfrist'],
]}
/>
<h4 className="text-lg font-medium text-slate-700 mt-6 mb-2">Alternativenprüfung</h4>
<Table
headers={['Alternative', 'Bewertung']}
rows={[
['Manuelle Transkription', 'Zeitaufwändig, fehleranfällig, nicht praktikabel'],
['Cloud-OCR (Google, Azure)', 'Datenschutzrisiken durch Drittlandübermittlung'],
['Kommerzielles lokales OCR', 'Hohe Kosten, Lizenzabhängigkeit'],
['Gewählte Lösung', 'Open-Source lokal - optimale Balance'],
]}
/>
</div>
<div id="section-4-4" className="mb-6 scroll-mt-32">
<h3 className="text-xl font-semibold text-slate-700 mb-3">4.4 Risikobewertung</h3>
<h4 className="text-lg font-medium text-slate-700 mt-4 mb-2">Identifizierte Risiken</h4>
<Table
headers={['Risiko', 'Eintrittswahrscheinlichkeit', 'Schwere', 'Risikostufe', 'Mitigationsmaßnahme']}
rows={[
['R1: Unbefugter Zugriff auf Schülerdaten', 'Gering', 'Hoch', 'Mittel', 'Rollenbasierte Zugriffskontrolle, MFA'],
['R2: Datenleck durch Systemkompromittierung', 'Gering', 'Hoch', 'Mittel', 'Verschlüsselung, Netzwerkisolation'],
['R3: Fehlerhaftes OCR beeinflusst Bewertung', 'Mittel', 'Mittel', 'Mittel', 'Pflicht-Review durch Lehrkraft'],
['R4: Re-Identifizierung aus Handschrift', 'Gering', 'Mittel', 'Gering', 'Pseudonymisierung, keine Handschriftanalyse'],
['R5: Bias im OCR-Modell', 'Mittel', 'Mittel', 'Mittel', 'Regelmäßige Qualitätsprüfung'],
]}
/>
</div>
<div id="section-4-5" className="mb-6 scroll-mt-32">
<h3 className="text-xl font-semibold text-slate-700 mb-3">4.5 Maßnahmen zur Risikominderung</h3>
<Table
headers={['Risiko', 'Maßnahme', 'Umsetzungsstatus']}
rows={[
['R1', 'RBAC, MFA, Audit-Logging', '✓ Implementiert'],
['R2', 'FileVault-Verschlüsselung, lokales Netz', '✓ Implementiert'],
['R3', 'Pflicht-Bestätigung durch Lehrkraft', '✓ Implementiert'],
['R4', 'Pseudonymisierung bei Export', '✓ Implementiert'],
['R5', 'Diverse Trainingssamples, manuelle Reviews', '○ In Entwicklung'],
]}
/>
</div>
</section>
)
}

View File

@@ -0,0 +1,89 @@
/**
* Section 5: Informationspflichten (Art. 13/14 DSGVO)
* Section 6: Automatisierte Entscheidungsfindung (Art. 22 DSGVO)
*/
import { Table } from '../Table'
import { CodeBlock } from '../CodeBlock'
export function InformationspflichtenAndArt22() {
return (
<>
{/* Section 5 */}
<section id="section-5" className="mb-10 scroll-mt-32">
<h2 className="text-2xl font-bold text-slate-800 mb-6 pb-2 border-b border-slate-200">
5. Informationspflichten (Art. 13/14 DSGVO)
</h2>
<h3 className="text-xl font-semibold text-slate-700 mb-3">5.1 Pflichtangaben nach Art. 13 DSGVO</h3>
<Table
headers={['Information', 'Bereitstellung']}
rows={[
['Identität des Verantwortlichen', 'Schulwebsite, Datenschutzerklärung'],
['Kontakt DSB', 'Schulwebsite, Aushang'],
['Verarbeitungszwecke', 'Datenschutzinformation bei Einschulung'],
['Rechtsgrundlage', 'Datenschutzinformation'],
['Empfänger/Kategorien', 'Datenschutzinformation'],
['Speicherdauer', 'Datenschutzinformation'],
['Betroffenenrechte', 'Datenschutzinformation, auf Anfrage'],
['Beschwerderecht', 'Datenschutzinformation'],
]}
/>
<h3 className="text-xl font-semibold text-slate-700 mt-6 mb-3">5.2 KI-spezifische Transparenz</h3>
<Table
headers={['Information', 'Inhalt']}
rows={[
['Art der KI', 'Vision-LLM für Texterkennung, kein automatisches Bewerten'],
['Menschliche Aufsicht', 'Jedes OCR-Ergebnis wird von Lehrkraft geprüft'],
['Keine automatische Entscheidung', 'System macht Vorschläge, Lehrkraft entscheidet'],
['Widerspruchsrecht', 'Opt-out von Training-Verwendung möglich'],
]}
/>
</section>
{/* Section 6 */}
<section id="section-6" className="mb-10 scroll-mt-32">
<h2 className="text-2xl font-bold text-slate-800 mb-6 pb-2 border-b border-slate-200">
6. Automatisierte Entscheidungsfindung (Art. 22 DSGVO)
</h2>
<h3 className="text-xl font-semibold text-slate-700 mb-3">6.1 Anwendbarkeitsprüfung</h3>
<Table
headers={['Merkmal', 'Erfüllt', 'Begründung']}
rows={[
['Automatisierte Verarbeitung', 'Ja', 'KI-gestützte Texterkennung'],
['Entscheidung', 'Nein', 'OCR liefert nur Vorschlag'],
['Rechtliche Wirkung/erhebliche Beeinträchtigung', 'Nein', 'Lehrkraft trifft finale Bewertungsentscheidung'],
]}
/>
<div className="bg-green-50 border border-green-200 rounded-lg p-4 mt-4">
<p className="text-green-800 font-medium">
<strong>Ergebnis:</strong> Art. 22 DSGVO ist <strong>nicht anwendbar</strong>, da keine automatisierte Entscheidung mit rechtlicher Wirkung erfolgt.
</p>
</div>
<h3 className="text-xl font-semibold text-slate-700 mt-6 mb-3">6.2 Teacher-in-the-Loop Garantie</h3>
<p className="text-slate-600 mb-4">Das System implementiert obligatorische menschliche Aufsicht:</p>
<CodeBlock>{`┌──────────────┐ ┌──────────────┐ ┌──────────────┐
│ OCR-System │────►│ Lehrkraft │────►│ Bewertung │
│ (Vorschlag) │ │ (Prüfung) │ │ (Final) │
└──────────────┘ └──────────────┘ └──────────────┘
│ │ │
│ ▼ │
│ ┌──────────────┐ │
└───────────►│ Korrektur │◄───────────┘
│ (Optional) │
└──────────────┘`}</CodeBlock>
<p className="text-slate-600 mt-4 mb-2"><strong>Workflow-Garantien:</strong></p>
<ol className="list-decimal list-inside text-slate-600 space-y-1 ml-4">
<li>Kein OCR-Ergebnis wird automatisch als korrekt übernommen</li>
<li>Lehrkraft muss explizit bestätigen ODER korrigieren</li>
<li>Bewertungsentscheidung liegt ausschließlich bei der Lehrkraft</li>
<li>System gibt keine Notenvorschläge</li>
</ol>
</section>
</>
)
}

View File

@@ -0,0 +1,164 @@
/**
* Section 16: Kontakte
* Section 17: Voice Service DSGVO-Compliance
*/
import { Table } from '../Table'
import { CodeBlock } from '../CodeBlock'
export function KontakteAndVoice() {
return (
<>
{/* Section 16 */}
<section id="section-16" className="mb-10 scroll-mt-32">
<h2 className="text-2xl font-bold text-slate-800 mb-6 pb-2 border-b border-slate-200">
16. Kontakte
</h2>
<h3 className="text-xl font-semibold text-slate-700 mb-3">16.1 Interne Kontakte</h3>
<Table
headers={['Rolle', 'Name', 'Kontakt']}
rows={[
['Schulleitung', '[Name]', '[E-Mail]'],
['IT-Administrator', '[Name]', '[E-Mail]'],
['Datenschutzbeauftragter', '[Name]', '[E-Mail]'],
]}
/>
<h3 className="text-xl font-semibold text-slate-700 mt-6 mb-3">16.2 Externe Kontakte</h3>
<Table
headers={['Institution', 'Kontakt']}
rows={[
['LfD Niedersachsen', 'poststelle@lfd.niedersachsen.de'],
['BSI', 'bsi@bsi.bund.de'],
]}
/>
</section>
{/* Section 17 */}
<section id="section-17" className="mb-10 scroll-mt-32">
<h2 className="text-2xl font-bold text-slate-800 mb-6 pb-2 border-b border-slate-200">
17. Voice Service DSGVO-Compliance
</h2>
<div className="bg-teal-50 border border-teal-200 rounded-lg p-4 mb-6">
<p className="text-teal-800">
<strong>NEU:</strong> Das Voice Service implementiert eine Voice-First Schnittstelle fuer Lehrkraefte mit
PersonaPlex-7B (Full-Duplex Speech-to-Speech) und TaskOrchestrator (Agent-Orchestrierung).
<strong> Alle Audiodaten werden ausschliesslich transient im RAM verarbeitet und niemals persistiert.</strong>
</p>
</div>
<div id="section-17-1" className="mb-6 scroll-mt-32">
<h3 className="text-xl font-semibold text-slate-700 mb-3">17.1 Architektur & Datenfluss</h3>
<CodeBlock>{`┌──────────────────────────────────────────────────────────────────┐
│ LEHRERGERÄT (PWA / App) │
│ ┌────────────────────────────────────────────────────────────┐ │
│ │ VoiceCapture.tsx │ voice-encryption.ts │ voice-api.ts │ │
│ │ Mikrofon │ AES-256-GCM │ WebSocket Client │ │
│ └────────────────────────────────────────────────────────────┘ │
│ Namespace-Key: NIEMALS verlässt dieses Gerät │
└───────────────────────────┬──────────────────────────────────────┘
│ WebSocket (wss://) - verschlüsselt
┌──────────────────────────────────────────────────────────────────┐
│ VOICE SERVICE (Port 8091) │
│ ┌────────────────────────────────────────────────────────────┐ │
│ │ TRANSIENT ONLY: Audio nur im RAM, nie persistiert! │ │
│ │ Kein Klartext-PII: Nur Pseudonyme serverseitig erlaubt │ │
│ └────────────────────────────────────────────────────────────┘ │
└───────────────────────────┬──────────────────────────────────────┘
┌──────────────────┼──────────────────┐
▼ ▼ ▼
┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐
│ PersonaPlex-7B │ │ Ollama Fallback │ │ Valkey Cache │
│ (A100 GPU) │ │ (Mac Mini) │ │ (nur Session- │
│ Full-Duplex │ │ Text-only │ │ Metadaten) │
└─────────────────┘ └─────────────────┘ └─────────────────┘`}</CodeBlock>
</div>
<div id="section-17-2" className="mb-6 scroll-mt-32">
<h3 className="text-xl font-semibold text-slate-700 mb-3">17.2 Datenklassifizierung</h3>
<Table
headers={['Datenklasse', 'Verarbeitung', 'Speicherort', 'Beispiele']}
rows={[
['PII (Personenbezogen)', 'NUR auf Lehrergerät', 'Client-side IndexedDB', 'Schülernamen, Noten, Vorfälle'],
['Pseudonyme', 'Server erlaubt', 'Valkey Cache', 'student_ref, class_ref'],
['Content (Transkripte)', 'NUR verschlüsselt', 'Valkey (TTL 7d)', 'Voice-Transkripte'],
['Audio-Daten', 'NIEMALS persistiert', 'NUR RAM (transient)', 'Sprachaufnahmen'],
]}
/>
<div className="bg-red-50 border border-red-200 rounded-lg p-4 mt-4">
<p className="text-red-800 font-medium">
<strong>KRITISCH:</strong> Audio-Daten dürfen unter keinen Umständen persistiert werden (AUDIO_PERSISTENCE=false).
Dies ist eine harte DSGVO-Anforderung zum Schutz der Privatsphäre.
</p>
</div>
</div>
<div id="section-17-3" className="mb-6 scroll-mt-32">
<h3 className="text-xl font-semibold text-slate-700 mb-3">17.3 Verschlüsselung</h3>
<Table
headers={['Bereich', 'Verfahren', 'Key-Management']}
rows={[
['Client-side Encryption', 'AES-256-GCM', 'Master-Key in IndexedDB (nie Server)'],
['Key-Identifikation', 'SHA-256 Hash', 'Nur Hash wird zum Server gesendet'],
['Transport', 'TLS 1.3 + WSS', 'Standard-Zertifikate'],
['Namespace-Isolation', 'Pro-Lehrer-Namespace', 'Schlüssel verlässt nie das Gerät'],
]}
/>
<p className="text-slate-600 mt-4">
<strong>Wichtig:</strong> Der Server erhält niemals den Klartext-Schlüssel. Es wird nur ein SHA-256 Hash
zur Verifizierung übermittelt. Alle sensiblen Daten werden <em>vor</em> der Übertragung client-seitig verschlüsselt.
</p>
</div>
<div id="section-17-4" className="mb-6 scroll-mt-32">
<h3 className="text-xl font-semibold text-slate-700 mb-3">17.4 TTL & Automatische Löschung</h3>
<Table
headers={['Datentyp', 'TTL', 'Löschung', 'Beschreibung']}
rows={[
['Audio-Frames', '0 (keine Speicherung)', 'Sofort nach Verarbeitung', 'Nur transient im RAM'],
['Voice-Transkripte', '7 Tage', 'Automatisch', 'Verschlüsselte Transkripte in Valkey'],
['Task State', '30 Tage', 'Automatisch', 'Workflow-Daten (Draft, Queued, etc.)'],
['Audit Logs', '90 Tage', 'Automatisch', 'Compliance-Nachweise (ohne PII)'],
]}
/>
<div className="bg-green-50 border border-green-200 rounded-lg p-4 mt-4">
<p className="text-green-800 font-medium">
<strong>Compliance:</strong> Die TTL-basierte Auto-Löschung ist durch Valkey-Mechanismen sichergestellt und
erfordert keine manuelle Intervention.
</p>
</div>
</div>
<div id="section-17-5" className="mb-6 scroll-mt-32">
<h3 className="text-xl font-semibold text-slate-700 mb-3">17.5 Audit-Logs (ohne PII)</h3>
<p className="text-slate-600 mb-4">Audit-Logs enthalten ausschließlich nicht-personenbezogene Metadaten:</p>
<Table
headers={['Erlaubt', 'Verboten']}
rows={[
['ref_id (truncated hash)', 'user_name'],
['content_type', 'content / transcript'],
['size_bytes', 'email'],
['ttl_hours', 'student_name'],
['timestamp', 'Klartext-Audio'],
]}
/>
<CodeBlock>{`// Beispiel: Erlaubter Audit-Log-Eintrag
{
"ref_id": "abc123...", // truncated
"content_type": "transcript",
"size_bytes": 1234,
"ttl_hours": 168, // 7 Tage
"timestamp": "2026-01-26T10:30:00Z"
}
// VERBOTEN:
// user_name, content, transcript, email, student_name, audio_data`}</CodeBlock>
</div>
</section>
</>
)
}

View File

@@ -0,0 +1,106 @@
/**
* Section 11: ML/AI Training Dokumentation
* Section 12: Betroffenenrechte
*/
import { Table } from '../Table'
import { CodeBlock } from '../CodeBlock'
export function MLTrainingAndRechte() {
return (
<>
{/* Section 11 */}
<section id="section-11" className="mb-10 scroll-mt-32">
<h2 className="text-2xl font-bold text-slate-800 mb-6 pb-2 border-b border-slate-200">
11. ML/AI Training Dokumentation
</h2>
<h3 className="text-xl font-semibold text-slate-700 mb-3">11.1 Trainingsdaten-Quellen</h3>
<Table
headers={['Datensatz', 'Quelle', 'Rechtsgrundlage', 'Volumen']}
rows={[
['Klausur-Scans', 'Schulinterne Prüfungen', 'Art. 6(1)(e) + Einwilligung', 'Variabel'],
['Lehrer-Korrekturen', 'Labeling-System', 'Art. 6(1)(e)', 'Variabel'],
]}
/>
<h3 className="text-xl font-semibold text-slate-700 mt-6 mb-3">11.2 Datenqualitätsmaßnahmen</h3>
<Table
headers={['Maßnahme', 'Beschreibung']}
rows={[
['Deduplizierung', 'SHA256-Hash zur Vermeidung von Duplikaten'],
['Qualitätskontrolle', 'Jedes Sample von Lehrkraft geprüft'],
['Repräsentativität', 'Samples aus verschiedenen Fächern/Klassenstufen'],
['Dokumentation', 'Metadaten zu jedem Sample'],
]}
/>
<h3 className="text-xl font-semibold text-slate-700 mt-6 mb-3">11.3 Labeling-Prozess</h3>
<CodeBlock>{`┌─────────────────────────────────────────────────────────────────────┐
│ LABELING WORKFLOW │
├─────────────────────────────────────────────────────────────────────┤
│ │
│ 1. Bild-Upload 2. OCR-Vorschlag 3. Review │
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │
│ │ Scan │─────────►│ LLM-OCR │─────────►│ Lehrkraft │ │
│ │ Upload │ │ (lokal) │ │ prüft │ │
│ └─────────────┘ └─────────────┘ └──────┬──────┘ │
│ │ │
│ ┌──────────────────────┴─────┐ │
│ ▼ ▼ │
│ ┌─────────────┐ ┌─────────┐ │
│ │ Bestätigt │ │Korrigiert│ │
│ │ (korrekt) │ │(manuell) │ │
│ └─────────────┘ └─────────┘ │
│ │ │ │
│ └──────────┬─────────────────┘ │
│ ▼ │
│ ┌─────────────────┐ │
│ │ Ground Truth │ │
│ │ (verifiziert) │ │
│ └─────────────────┘ │
└─────────────────────────────────────────────────────────────────────┘`}</CodeBlock>
<h3 className="text-xl font-semibold text-slate-700 mt-6 mb-3">11.4 Export-Prozeduren</h3>
<Table
headers={['Schritt', 'Beschreibung', 'Datenschutzmaßnahme']}
rows={[
['1. Auswahl', 'Sessions/Items für Export wählen', 'Nur bestätigte/korrigierte Items'],
['2. Pseudonymisierung', 'Entfernung direkter Identifikatoren', 'UUID statt Schüler-ID'],
['3. Format-Konvertierung', 'TrOCR/Llama/Generic Format', 'Nur notwendige Felder'],
['4. Speicherung', 'Lokal in /app/ocr-exports/', 'Verschlüsselt, zugriffsbeschränkt'],
]}
/>
</section>
{/* Section 12 */}
<section id="section-12" className="mb-10 scroll-mt-32">
<h2 className="text-2xl font-bold text-slate-800 mb-6 pb-2 border-b border-slate-200">
12. Betroffenenrechte
</h2>
<h3 className="text-xl font-semibold text-slate-700 mb-3">12.1 Implementierte Rechte</h3>
<Table
headers={['Recht', 'Art. DSGVO', 'Umsetzung']}
rows={[
['Auskunft', '15', 'Schriftliche Anfrage an DSB'],
['Berichtigung', '16', 'Korrektur falscher OCR-Ergebnisse'],
['Löschung', '17', 'Nach Aufbewahrungsfrist oder auf Antrag'],
['Einschränkung', '18', 'Sperrung der Verarbeitung auf Antrag'],
['Datenportabilität', '20', 'Export eigener Daten in JSON'],
['Widerspruch', '21', 'Opt-out von Training-Verwendung'],
]}
/>
<h3 className="text-xl font-semibold text-slate-700 mt-6 mb-3">12.2 Sonderrechte bei KI-Training</h3>
<Table
headers={['Recht', 'Umsetzung']}
rows={[
['Widerspruch gegen Training', 'Daten werden nicht für Fine-Tuning verwendet'],
['Löschung aus Trainingsset', '"Machine Unlearning" durch Re-Training ohne betroffene Daten'],
]}
/>
</section>
</>
)
}

View File

@@ -0,0 +1,53 @@
/**
* Section 1: Management Summary
* Subsections: 1.1 Systemuebersicht, 1.2 Datenschutz-Garantien, 1.3 Compliance-Status
*/
import { Table } from '../Table'
export function ManagementSummary() {
return (
<section id="section-1" className="mb-10 scroll-mt-32">
<h2 className="text-2xl font-bold text-slate-800 mb-6 pb-2 border-b border-slate-200">
1. Management Summary
</h2>
<div id="section-1-1" className="mb-6 scroll-mt-32">
<h3 className="text-xl font-semibold text-slate-700 mb-3">1.1 Systemübersicht</h3>
<p className="text-slate-600 mb-4">
Das OCR-Labeling-System ist eine <strong>vollständig lokal betriebene</strong> Lösung zur Digitalisierung und Auswertung handschriftlicher Schülerarbeiten (Klausuren, Aufsätze). Das System nutzt:
</p>
<ul className="list-disc list-inside text-slate-600 space-y-1 ml-4">
<li><strong>llama3.2-vision:11b</strong> - Open-Source Vision-Language-Modell für OCR (lokal via Ollama)</li>
<li><strong>TrOCR</strong> - Microsoft Transformer OCR für Handschrifterkennung (lokal)</li>
<li><strong>qwen2.5:14b</strong> - Open-Source LLM für Korrekturassistenz (lokal via Ollama)</li>
</ul>
</div>
<div id="section-1-2" className="mb-6 scroll-mt-32">
<h3 className="text-xl font-semibold text-slate-700 mb-3">1.2 Datenschutz-Garantien</h3>
<Table
headers={['Merkmal', 'Umsetzung']}
rows={[
['Verarbeitungsort', '100% lokal auf schuleigenem Mac Mini'],
['Cloud-Dienste', 'Keine - vollständig offline-fähig'],
['Datenübertragung', 'Keine Übertragung an externe Server'],
['KI-Modelle', 'Open-Source, lokal ausgeführt, keine Telemetrie'],
['Speicherung', 'Lokale PostgreSQL-Datenbank, MinIO Object Storage'],
]}
/>
</div>
<div id="section-1-3" className="mb-6 scroll-mt-32">
<h3 className="text-xl font-semibold text-slate-700 mb-3">1.3 Compliance-Status</h3>
<p className="text-slate-600 mb-2">Das System erfüllt die Anforderungen der:</p>
<ul className="list-disc list-inside text-slate-600 space-y-1 ml-4">
<li>DSGVO (Verordnung (EU) 2016/679)</li>
<li>BDSG (Bundesdatenschutzgesetz)</li>
<li>Niedersächsisches Schulgesetz (NSchG) §31</li>
<li>EU AI Act (Verordnung (EU) 2024/1689)</li>
</ul>
</div>
</section>
)
}

View File

@@ -0,0 +1,117 @@
/**
* Section 7: Privacy by Design und Default (Art. 25 DSGVO)
* Section 8: Technisch-Organisatorische Massnahmen (Art. 32 DSGVO)
*/
import { Table } from '../Table'
export function PrivacyByDesignAndTOM() {
return (
<>
{/* Section 7 */}
<section id="section-7" className="mb-10 scroll-mt-32">
<h2 className="text-2xl font-bold text-slate-800 mb-6 pb-2 border-b border-slate-200">
7. Privacy by Design und Default (Art. 25 DSGVO)
</h2>
<h3 className="text-xl font-semibold text-slate-700 mb-3">7.1 Design-Prinzipien</h3>
<Table
headers={['Prinzip', 'Implementierung']}
rows={[
['Proaktive Maßnahmen', 'Datenschutz von Anfang an im System-Design berücksichtigt'],
['Standard-Datenschutz', 'Minimale Datenerhebung als Default'],
['Eingebetteter Datenschutz', 'Technische Maßnahmen nicht umgehbar'],
['Volle Funktionalität', 'Kein Trade-off Datenschutz vs. Funktionalität'],
['End-to-End Sicherheit', 'Verschlüsselung vom Upload bis zur Löschung'],
['Sichtbarkeit/Transparenz', 'Alle Verarbeitungen protokolliert und nachvollziehbar'],
['Nutzerzentrierung', 'Betroffenenrechte einfach ausübbar'],
]}
/>
<h3 className="text-xl font-semibold text-slate-700 mt-6 mb-3">7.2 Vendor-Auswahl</h3>
<p className="text-slate-600 mb-4">Die verwendeten KI-Modelle wurden nach Datenschutzkriterien ausgewählt:</p>
<Table
headers={['Modell', 'Anbieter', 'Lizenz', 'Lokale Ausführung', 'Telemetrie']}
rows={[
['llama3.2-vision:11b', 'Meta', 'Llama 3.2 Community', '✓', 'Keine'],
['qwen2.5:14b', 'Alibaba', 'Apache 2.0', '✓', 'Keine'],
['TrOCR', 'Microsoft', 'MIT', '✓', 'Keine'],
]}
/>
</section>
{/* Section 8 */}
<section id="section-8" className="mb-10 scroll-mt-32">
<h2 className="text-2xl font-bold text-slate-800 mb-6 pb-2 border-b border-slate-200">
8. Technisch-Organisatorische Maßnahmen (Art. 32 DSGVO)
</h2>
<h3 className="text-xl font-semibold text-slate-700 mb-3">8.1 Vertraulichkeit</h3>
<h4 className="text-lg font-medium text-slate-700 mt-4 mb-2">8.1.1 Zutrittskontrolle</h4>
<Table
headers={['Maßnahme', 'Umsetzung']}
rows={[
['Physische Sicherung', 'Server in abgeschlossenem Raum'],
['Zugangsprotokoll', 'Elektronisches Schloss mit Protokollierung'],
['Berechtigte Personen', 'IT-Administrator, Schulleitung'],
]}
/>
<h4 className="text-lg font-medium text-slate-700 mt-6 mb-2">8.1.2 Zugangskontrolle</h4>
<Table
headers={['Maßnahme', 'Umsetzung']}
rows={[
['Authentifizierung', 'Benutzername + Passwort'],
['Passwort-Policy', 'Min. 12 Zeichen, Komplexitätsanforderungen'],
['Session-Timeout', '30 Minuten Inaktivität'],
['Fehlversuche', 'Account-Sperrung nach 5 Fehlversuchen'],
]}
/>
<h4 className="text-lg font-medium text-slate-700 mt-6 mb-2">8.1.3 Zugriffskontrolle (RBAC)</h4>
<Table
headers={['Rolle', 'Berechtigungen']}
rows={[
['Admin', 'Vollzugriff, Benutzerverwaltung'],
['Lehrkraft', 'Eigene Sessions, Labeling, Export'],
['Viewer', 'Nur Lesezugriff auf Statistiken'],
]}
/>
<h4 className="text-lg font-medium text-slate-700 mt-6 mb-2">8.1.4 Verschlüsselung</h4>
<Table
headers={['Bereich', 'Maßnahme']}
rows={[
['Festplatte', 'FileVault 2 (AES-256)'],
['Datenbank', 'Transparent Data Encryption'],
['MinIO Storage', 'Server-Side Encryption (SSE)'],
['Netzwerk', 'TLS 1.3 für lokale Verbindungen'],
]}
/>
<h3 className="text-xl font-semibold text-slate-700 mt-8 mb-3">8.2 Integrität</h3>
<Table
headers={['Maßnahme', 'Umsetzung']}
rows={[
['Audit-Log', 'Alle Aktionen mit Timestamp und User-ID'],
['Unveränderlichkeit', 'Append-only Logging'],
['Log-Retention', '1 Jahr'],
['Netzwerkisolation', 'Lokales Netz, keine Internet-Verbindung erforderlich'],
]}
/>
<h3 className="text-xl font-semibold text-slate-700 mt-8 mb-3">8.3 Verfügbarkeit</h3>
<Table
headers={['Maßnahme', 'Umsetzung']}
rows={[
['Backup', 'Tägliches inkrementelles Backup'],
['USV', 'Unterbrechungsfreie Stromversorgung'],
['RAID', 'RAID 1 Spiegelung für Datenträger'],
['Recovery-Test', 'Halbjährlich'],
]}
/>
</section>
</>
)
}

View File

@@ -0,0 +1,67 @@
/**
* Section 3: Rechtsgrundlagen (Art. 6 DSGVO)
* Subsections: 3.1 Primaere, 3.2 Landesrecht, 3.3 Besondere Kategorien
*/
import { Table } from '../Table'
export function Rechtsgrundlagen() {
return (
<section id="section-3" className="mb-10 scroll-mt-32">
<h2 className="text-2xl font-bold text-slate-800 mb-6 pb-2 border-b border-slate-200">
3. Rechtsgrundlagen (Art. 6 DSGVO)
</h2>
<div id="section-3-1" className="mb-6 scroll-mt-32">
<h3 className="text-xl font-semibold text-slate-700 mb-3">3.1 Primäre Rechtsgrundlagen</h3>
<Table
headers={['Verarbeitungsschritt', 'Rechtsgrundlage', 'Begründung']}
rows={[
['Scan von Klausuren', 'Art. 6 Abs. 1 lit. e DSGVO', 'Öffentliche Aufgabe der schulischen Leistungsbewertung'],
['OCR-Verarbeitung', 'Art. 6 Abs. 1 lit. e DSGVO', 'Teil der Bewertungsaufgabe, Effizienzsteigerung'],
['Lehrerkorrektur', 'Art. 6 Abs. 1 lit. e DSGVO', 'Kernaufgabe der Leistungsbewertung'],
['Export für Training', 'Art. 6 Abs. 1 lit. f DSGVO', 'Berechtigtes Interesse an Modellverbesserung'],
]}
/>
</div>
<div id="section-3-2" className="mb-6 scroll-mt-32">
<h3 className="text-xl font-semibold text-slate-700 mb-3">3.2 Landesrechtliche Grundlagen</h3>
<p className="text-slate-600 mb-2"><strong>Niedersachsen:</strong></p>
<ul className="list-disc list-inside text-slate-600 space-y-1 ml-4 mb-4">
<li>§31 NSchG: Erhebung, Verarbeitung und Nutzung personenbezogener Daten</li>
<li>Ergänzende Bestimmungen zur VO-DV I</li>
</ul>
<p className="text-slate-600 mb-2"><strong>Interesse-Abwägung für Training (Art. 6 Abs. 1 lit. f):</strong></p>
<Table
headers={['Aspekt', 'Bewertung']}
rows={[
['Interesse des Verantwortlichen', 'Verbesserung der OCR-Qualität für effizientere Klausurkorrektur'],
['Erwartung der Betroffenen', 'Schüler erwarten, dass Prüfungsarbeiten für schulische Zwecke verarbeitet werden'],
['Auswirkung auf Betroffene', 'Minimal - Daten werden pseudonymisiert, rein lokale Verarbeitung'],
['Schutzmaßnahmen', 'Pseudonymisierung, keine Weitergabe, lokale Verarbeitung'],
['Ergebnis', 'Berechtigtes Interesse überwiegt'],
]}
/>
</div>
<div id="section-3-3" className="mb-6 scroll-mt-32">
<h3 className="text-xl font-semibold text-slate-700 mb-3">3.3 Besondere Kategorien (Art. 9 DSGVO)</h3>
<p className="text-slate-600 mb-2"><strong>Prüfung auf besondere Kategorien:</strong></p>
<p className="text-slate-600 mb-4">
Handschriftproben könnten theoretisch Rückschlüsse auf Gesundheitszustände ermöglichen (z.B. Tremor). Dies wird wie folgt adressiert:
</p>
<ul className="list-disc list-inside text-slate-600 space-y-1 ml-4 mb-4">
<li>OCR-Modelle analysieren ausschließlich Textinhalt, nicht Handschriftcharakteristiken</li>
<li>Keine Speicherung von Handschriftanalysen</li>
<li>Bei Training werden nur Textinhalte verwendet, keine biometrischen Merkmale</li>
</ul>
<div className="bg-green-50 border border-green-200 rounded-lg p-4">
<p className="text-green-800 font-medium">
<strong>Ergebnis:</strong> Art. 9 ist nicht anwendbar, da keine Verarbeitung besonderer Kategorien erfolgt.
</p>
</div>
</div>
</section>
)
}

View File

@@ -0,0 +1,98 @@
/**
* Section 13: Schulung und Awareness
* Section 14: Review und Audit
* Section 15: Vorfallmanagement
*/
import { Table } from '../Table'
import { CodeBlock } from '../CodeBlock'
export function SchulungReviewVorfall() {
return (
<>
{/* Section 13 */}
<section id="section-13" className="mb-10 scroll-mt-32">
<h2 className="text-2xl font-bold text-slate-800 mb-6 pb-2 border-b border-slate-200">
13. Schulung und Awareness
</h2>
<h3 className="text-xl font-semibold text-slate-700 mb-3">13.1 Schulungskonzept</h3>
<Table
headers={['Schulung', 'Zielgruppe', 'Frequenz', 'Dokumentation']}
rows={[
['DSGVO-Grundlagen', 'Alle Lehrkräfte', 'Jährlich', 'Teilnehmerliste'],
['OCR-System-Nutzung', 'Nutzende Lehrkräfte', 'Bei Einführung', 'Zertifikat'],
['KI-Kompetenz (AI Act Art. 4)', 'Alle Nutzenden', 'Jährlich', 'Nachweis'],
]}
/>
</section>
{/* Section 14 */}
<section id="section-14" className="mb-10 scroll-mt-32">
<h2 className="text-2xl font-bold text-slate-800 mb-6 pb-2 border-b border-slate-200">
14. Review und Audit
</h2>
<h3 className="text-xl font-semibold text-slate-700 mb-3">14.1 Regelmäßige Überprüfungen</h3>
<Table
headers={['Prüfung', 'Frequenz', 'Verantwortlich']}
rows={[
['DSFA-Review', 'Jährlich', 'DSB'],
['TOM-Wirksamkeit', 'Jährlich', 'IT-Administrator'],
['Zugriffsrechte', 'Halbjährlich', 'IT-Administrator'],
['Backup-Test', 'Halbjährlich', 'IT-Administrator'],
['Modell-Bias-Prüfung', 'Jährlich', 'IT + Lehrkräfte'],
]}
/>
<h3 className="text-xl font-semibold text-slate-700 mt-6 mb-3">14.2 Audit-Trail</h3>
<Table
headers={['Protokollierte Daten', 'Aufbewahrung', 'Format']}
rows={[
['Benutzeraktionen', '1 Jahr', 'PostgreSQL'],
['Systemereignisse', '1 Jahr', 'Syslog'],
['Sicherheitsvorfälle', '3 Jahre', 'Incident-Dokumentation'],
]}
/>
</section>
{/* Section 15 */}
<section id="section-15" className="mb-10 scroll-mt-32">
<h2 className="text-2xl font-bold text-slate-800 mb-6 pb-2 border-b border-slate-200">
15. Vorfallmanagement
</h2>
<h3 className="text-xl font-semibold text-slate-700 mb-3">15.1 Datenpannen-Prozess</h3>
<CodeBlock>{`┌─────────────────────────────────────────────────────────────────────┐
│ INCIDENT RESPONSE │
├─────────────────────────────────────────────────────────────────────┤
│ │
│ Erkennung ──► Bewertung ──► Meldung ──► Eindämmung ──► Behebung │
│ │ │ │ │ │ │
│ ▼ ▼ ▼ ▼ ▼ │
│ Monitoring Risiko- 72h an LfD Isolation Ursachen- │
│ Audit-Log einschätzung (Art.33) Forensik analyse │
└─────────────────────────────────────────────────────────────────────┘`}</CodeBlock>
<h3 className="text-xl font-semibold text-slate-700 mt-6 mb-3">15.2 Meldepflichten</h3>
<Table
headers={['Ereignis', 'Frist', 'Empfänger']}
rows={[
['Datenpanne mit Risiko', '72 Stunden', 'Landesbeauftragte/r für Datenschutz'],
['Hohes Risiko für Betroffene', 'Unverzüglich', 'Betroffene Personen'],
]}
/>
<h3 className="text-xl font-semibold text-slate-700 mt-6 mb-3">15.3 KI-spezifische Vorfälle</h3>
<Table
headers={['Vorfall', 'Reaktion']}
rows={[
['Systematisch falsche OCR-Ergebnisse', 'Modell-Rollback, Analyse'],
['Bias-Erkennung', 'Untersuchung, ggf. Re-Training'],
['Adversarial Attack', 'System-Isolierung, Forensik'],
]}
/>
</section>
</>
)
}

View File

@@ -0,0 +1,72 @@
/**
* Section 2: Verzeichnis der Verarbeitungstaetigkeiten (Art. 30 DSGVO)
* Subsections: 2.1 Verantwortlicher, 2.2 DSB, 2.3 Verarbeitungstaetigkeiten
*/
import { Table } from '../Table'
export function VerarbeitungsTaetigkeiten() {
return (
<section id="section-2" className="mb-10 scroll-mt-32">
<h2 className="text-2xl font-bold text-slate-800 mb-6 pb-2 border-b border-slate-200">
2. Verzeichnis der Verarbeitungstätigkeiten (Art. 30 DSGVO)
</h2>
<div id="section-2-1" className="mb-6 scroll-mt-32">
<h3 className="text-xl font-semibold text-slate-700 mb-3">2.1 Verantwortlicher</h3>
<Table
headers={['Feld', 'Inhalt']}
rows={[
['Verantwortlicher', '[Schulname], [Schuladresse]'],
['Vertreter', 'Schulleitung: [Name]'],
['Kontakt', '[E-Mail], [Telefon]'],
]}
/>
</div>
<div id="section-2-2" className="mb-6 scroll-mt-32">
<h3 className="text-xl font-semibold text-slate-700 mb-3">2.2 Datenschutzbeauftragter</h3>
<Table
headers={['Feld', 'Inhalt']}
rows={[
['Name', '[Name DSB]'],
['Organisation', '[Behördlicher/Externer DSB]'],
['Kontakt', '[E-Mail], [Telefon]'],
]}
/>
</div>
<div id="section-2-3" className="mb-6 scroll-mt-32">
<h3 className="text-xl font-semibold text-slate-700 mb-3">2.3 Verarbeitungstätigkeiten</h3>
<h4 className="text-lg font-medium text-slate-700 mt-4 mb-2">2.3.1 OCR-Verarbeitung von Klausuren</h4>
<Table
headers={['Attribut', 'Beschreibung']}
rows={[
['Zweck', 'Digitalisierung handschriftlicher Prüfungsantworten mittels KI-gestützter Texterkennung zur Unterstützung der Lehrkräfte bei der Korrektur'],
['Rechtsgrundlage', 'Art. 6 Abs. 1 lit. e DSGVO i.V.m. §31 NSchG (öffentliche Aufgabe der Leistungsbewertung)'],
['Betroffene Personen', 'Schülerinnen und Schüler (Prüfungsarbeiten)'],
['Datenkategorien', 'Handschriftproben, Prüfungsantworten, Schülerkennung (optional)'],
['Empfänger', 'Ausschließlich berechtigte Lehrkräfte der Schule'],
['Drittlandübermittlung', 'Keine'],
['Löschfrist', 'Gem. Aufbewahrungspflichten für Prüfungsunterlagen (i.d.R. 2-10 Jahre je nach Bundesland)'],
]}
/>
<h4 className="text-lg font-medium text-slate-700 mt-6 mb-2">2.3.2 Labeling für Modell-Training</h4>
<Table
headers={['Attribut', 'Beschreibung']}
rows={[
['Zweck', 'Erstellung von Trainingsdaten für lokales Fine-Tuning der OCR-Modelle zur Verbesserung der Handschrifterkennung'],
['Rechtsgrundlage', 'Art. 6 Abs. 1 lit. f DSGVO (berechtigtes Interesse) oder Art. 6 Abs. 1 lit. a DSGVO (Einwilligung)'],
['Betroffene Personen', 'Schülerinnen und Schüler (anonymisierte Handschriftproben)'],
['Datenkategorien', 'Anonymisierte/pseudonymisierte Handschriftbilder, korrigierter Text'],
['Empfänger', 'Lokales ML-System, keine externen Empfänger'],
['Drittlandübermittlung', 'Keine'],
['Löschfrist', 'Trainingsdaten: Nach Abschluss des Trainings oder auf Widerruf'],
]}
/>
</div>
</section>
)
}

View File

@@ -0,0 +1,12 @@
export { ManagementSummary } from './ManagementSummary'
export { VerarbeitungsTaetigkeiten } from './VerarbeitungsTaetigkeiten'
export { Rechtsgrundlagen } from './Rechtsgrundlagen'
export { DatenschutzFolgen } from './DatenschutzFolgen'
export { InformationspflichtenAndArt22 } from './InformationspflichtenAndArt22'
export { PrivacyByDesignAndTOM } from './PrivacyByDesignAndTOM'
export { BSIAndEUAIAct } from './BSIAndEUAIAct'
export { MLTrainingAndRechte } from './MLTrainingAndRechte'
export { SchulungReviewVorfall } from './SchulungReviewVorfall'
export { KontakteAndVoice } from './KontakteAndVoice'
export { BQASScheduler } from './BQASScheduler'
export { Anhaenge } from './Anhaenge'

View File

@@ -0,0 +1,54 @@
/**
* Table of contents sections for the DSGVO Audit Documentation navigation.
*/
export interface Section {
id: string
title: string
level: number
}
export const SECTIONS: Section[] = [
{ id: '1', title: 'Management Summary', level: 2 },
{ id: '1-1', title: 'Systemuebersicht', level: 3 },
{ id: '1-2', title: 'Datenschutz-Garantien', level: 3 },
{ id: '1-3', title: 'Compliance-Status', level: 3 },
{ id: '2', title: 'Verzeichnis der Verarbeitungstaetigkeiten', level: 2 },
{ id: '2-1', title: 'Verantwortlicher', level: 3 },
{ id: '2-2', title: 'Datenschutzbeauftragter', level: 3 },
{ id: '2-3', title: 'Verarbeitungstaetigkeiten', level: 3 },
{ id: '3', title: 'Rechtsgrundlagen (Art. 6)', level: 2 },
{ id: '3-1', title: 'Primaere Rechtsgrundlagen', level: 3 },
{ id: '3-2', title: 'Landesrechtliche Grundlagen', level: 3 },
{ id: '3-3', title: 'Besondere Kategorien (Art. 9)', level: 3 },
{ id: '4', title: 'Datenschutz-Folgenabschaetzung', level: 2 },
{ id: '4-1', title: 'Schwellwertanalyse', level: 3 },
{ id: '4-2', title: 'Systematische Beschreibung', level: 3 },
{ id: '4-3', title: 'Notwendigkeit und Verhaeltnismaessigkeit', level: 3 },
{ id: '4-4', title: 'Risikobewertung', level: 3 },
{ id: '4-5', title: 'Massnahmen zur Risikominderung', level: 3 },
{ id: '5', title: 'Informationspflichten', level: 2 },
{ id: '6', title: 'Automatisierte Entscheidungsfindung', level: 2 },
{ id: '7', title: 'Privacy by Design', level: 2 },
{ id: '8', title: 'Technisch-Organisatorische Massnahmen', level: 2 },
{ id: '9', title: 'BSI-Anforderungen', level: 2 },
{ id: '10', title: 'EU AI Act Compliance', level: 2 },
{ id: '11', title: 'ML/AI Training Dokumentation', level: 2 },
{ id: '12', title: 'Betroffenenrechte', level: 2 },
{ id: '13', title: 'Schulung und Awareness', level: 2 },
{ id: '14', title: 'Review und Audit', level: 2 },
{ id: '15', title: 'Vorfallmanagement', level: 2 },
{ id: '16', title: 'Kontakte', level: 2 },
{ id: '17', title: 'Voice Service DSGVO', level: 2 },
{ id: '17-1', title: 'Architektur & Datenfluss', level: 3 },
{ id: '17-2', title: 'Datenklassifizierung', level: 3 },
{ id: '17-3', title: 'Verschluesselung', level: 3 },
{ id: '17-4', title: 'TTL & Auto-Loeschung', level: 3 },
{ id: '17-5', title: 'Audit-Logs', level: 3 },
{ id: '18', title: 'BQAS Lokaler Scheduler', level: 2 },
{ id: '18-1', title: 'GitHub Actions Alternative', level: 3 },
{ id: '18-2', title: 'Datenschutz-Vorteile', level: 3 },
{ id: '18-3', title: 'Komponenten', level: 3 },
{ id: '18-4', title: 'Datenverarbeitung', level: 3 },
{ id: '18-5', title: 'Benachrichtigungen', level: 3 },
]

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,386 @@
/**
* Static data for Developer Documentation Page
*/
import type { ServiceNode, ArchitectureLayer, ServiceDefinition } from './types'
// Documentation paths for VS Code links
export const docPaths: Record<string, string> = {
'postgres': 'docs/architecture/data-model.md',
'backend': 'docs/backend/README.md',
'consent-service': 'docs/consent-service/README.md',
'billing-service': 'docs/billing/billing-service-api.md',
'edu-search-service': 'docs/api/edu-search-seeds-api.md',
'dsms-gateway': 'docs/dsms/README.md',
'pca-platform': 'docs/api/pca-platform-api.md',
'matrix-synapse': 'docs/matrix/README.md',
'jitsi': 'docs/jitsi/README.md',
'mailpit': 'docs/guides/email-and-auth-testing.md',
'llm-gateway': 'docs/llm-platform/README.md',
'website': 'docs/website/README.md',
'opensearch': 'docs/llm-platform/guides/ollama-setup.md',
'klausur': 'klausur-service/docs/RAG-Admin-Spec.md',
'qdrant': 'klausur-service/docs/RAG-Admin-Spec.md',
'minio': 'klausur-service/docs/RAG-Admin-Spec.md',
}
// Base path for project (used for VS Code links)
export const PROJECT_BASE_PATH = '/Users/benjaminadmin/Projekte/breakpilot-pwa'
// All services in the architecture (30+ services)
export const ARCHITECTURE_SERVICES: ServiceNode[] = [
// Frontends
{ id: 'website', name: 'Admin Frontend', type: 'frontend', port: '3000', technology: 'Next.js 15', description: 'Admin Dashboard, Edu-Search, DSMS, Consent', connections: ['backend', 'consent-service'] },
{ id: 'studio', name: 'Lehrer Studio', type: 'frontend', port: '8000', technology: 'FastAPI + JS', description: 'Klausur, School, Stundenplan Module', connections: ['backend'] },
{ id: 'creator', name: 'Creator Studio', type: 'frontend', port: '-', technology: 'Vue 3', description: 'Content Creation Interface', connections: ['backend'] },
{ id: 'policy-ui', name: 'Policy Vault UI', type: 'frontend', port: '4200', technology: 'Angular 17', description: 'Richtlinien-Verwaltung', connections: ['policy-api'] },
// Python Backend
{ id: 'backend', name: 'Main Backend', type: 'backend', port: '8000', technology: 'Python FastAPI', description: 'Haupt-API, DevSecOps, Studio UI', connections: ['postgres', 'vault', 'redis', 'qdrant', 'minio'] },
{ id: 'klausur', name: 'Klausur Service', type: 'backend', port: '8086', technology: 'Python FastAPI', description: 'BYOEH Abitur-Klausurkorrektur, RAG Admin, NiBiS Ingestion', connections: ['postgres', 'minio', 'qdrant'] },
// Go Microservices
{ id: 'consent-service', name: 'Consent Service', type: 'backend', port: '8081', technology: 'Go Gin', description: 'DSGVO Consent Management', connections: ['postgres'] },
{ id: 'school-service', name: 'School Service', type: 'backend', port: '8084', technology: 'Go Gin', description: 'Klausuren, Noten, Zeugnisse', connections: ['postgres'] },
{ id: 'billing-service', name: 'Billing Service', type: 'backend', port: '8083', technology: 'Go Gin', description: 'Stripe Integration', connections: ['postgres'] },
{ id: 'dsms-gateway', name: 'DSMS Gateway', type: 'backend', port: '8082', technology: 'Go', description: 'IPFS REST API', connections: ['ipfs'] },
// Node.js Services
{ id: 'h5p', name: 'H5P Service', type: 'backend', port: '8085', technology: 'Node.js', description: 'Interaktive Inhalte', connections: ['postgres', 'minio'] },
{ id: 'policy-api', name: 'Policy Vault API', type: 'backend', port: '3001', technology: 'NestJS', description: 'Richtlinien-Verwaltung API', connections: ['postgres'] },
// Databases
{ id: 'postgres', name: 'PostgreSQL', type: 'database', port: '5432', technology: 'PostgreSQL 16', description: 'Hauptdatenbank', connections: [] },
{ id: 'synapse-db', name: 'Synapse DB', type: 'database', port: '-', technology: 'PostgreSQL 16', description: 'Matrix Datenbank', connections: [] },
{ id: 'mariadb', name: 'MariaDB', type: 'database', port: '-', technology: 'MariaDB 10.6', description: 'ERPNext Datenbank', connections: [] },
{ id: 'mongodb', name: 'MongoDB', type: 'database', port: '27017', technology: 'MongoDB 7', description: 'LibreChat Datenbank', connections: [] },
// Cache & Queue
{ id: 'redis', name: 'Redis', type: 'cache', port: '6379', technology: 'Redis Alpine', description: 'Cache & Sessions', connections: [] },
// Search Engines
{ id: 'qdrant', name: 'Qdrant', type: 'search', port: '6333', technology: 'Qdrant 1.7', description: 'Vector DB - NiBiS EWH (7352 Chunks), BYOEH', connections: [] },
{ id: 'opensearch', name: 'OpenSearch', type: 'search', port: '9200', technology: 'OpenSearch 2.x', description: 'Volltext-Suche', connections: [] },
{ id: 'meilisearch', name: 'Meilisearch', type: 'search', port: '7700', technology: 'Meilisearch', description: 'Instant Search', connections: [] },
// Storage
{ id: 'minio', name: 'MinIO', type: 'storage', port: '9000/9001', technology: 'MinIO', description: 'S3-kompatibel - RAG Dokumente, Landes/Lehrer-Daten', connections: [] },
{ id: 'ipfs', name: 'IPFS (Kubo)', type: 'storage', port: '5001', technology: 'IPFS 0.24', description: 'Dezentral', connections: [] },
// Security
{ id: 'vault', name: 'Vault', type: 'security', port: '8200', technology: 'HashiCorp Vault', description: 'Secrets Management', connections: [] },
{ id: 'keycloak', name: 'Keycloak', type: 'security', port: '8180', technology: 'Keycloak 23', description: 'SSO/OIDC', connections: ['postgres'] },
// Communication
{ id: 'synapse', name: 'Matrix Synapse', type: 'communication', port: '8008', technology: 'Matrix', description: 'E2EE Messenger', connections: ['synapse-db'] },
{ id: 'jitsi', name: 'Jitsi Meet', type: 'communication', port: '8443', technology: 'Jitsi', description: 'Videokonferenz', connections: [] },
// AI/LLM
{ id: 'librechat', name: 'LibreChat', type: 'ai', port: '3080', technology: 'LibreChat', description: 'Multi-LLM Chat', connections: ['mongodb', 'qdrant'] },
{ id: 'ragflow', name: 'RAGFlow', type: 'ai', port: '9380', technology: 'RAGFlow', description: 'RAG Pipeline', connections: ['qdrant', 'opensearch'] },
// ERP
{ id: 'erpnext', name: 'ERPNext', type: 'erp', port: '8090', technology: 'ERPNext v15', description: 'Open Source ERP', connections: ['mariadb', 'redis'] },
]
// Architecture layers
export const LAYERS: ArchitectureLayer[] = [
{ id: 'presentation', name: 'Presentation Layer', description: 'User Interfaces & Frontends', types: ['frontend'] },
{ id: 'application', name: 'Application Layer', description: 'Business Logic & APIs', types: ['backend'] },
{ id: 'data', name: 'Data Layer', description: 'Databases, Cache & Search', types: ['database', 'cache', 'search'] },
{ id: 'infrastructure', name: 'Infrastructure Layer', description: 'Storage, Security & Communication', types: ['storage', 'security', 'communication', 'ai', 'erp'] },
]
// Service definitions with ports, technologies, and API info
export const services: ServiceDefinition[] = [
{
id: 'postgres',
name: 'PostgreSQL',
type: 'database',
port: 5432,
container: 'breakpilot-pwa-postgres',
description: 'Zentrale Datenbank für alle Services',
purpose: 'Persistente Datenspeicherung für Benutzer, Dokumente, Consents und alle Anwendungsdaten mit pgvector für Embedding-Suche.',
tech: ['PostgreSQL 15', 'pgvector'],
healthEndpoint: null,
endpoints: [],
envVars: ['POSTGRES_USER', 'POSTGRES_PASSWORD', 'POSTGRES_DB'],
},
{
id: 'backend',
name: 'Python Backend',
type: 'backend',
port: 8000,
container: 'breakpilot-pwa-backend',
description: 'FastAPI Backend mit AI-Integration und GDPR-Export',
purpose: 'Zentrale API-Schicht für das Studio-Frontend mit AI-gestützter Arbeitsblatt-Generierung, Multi-LLM-Integration und DSGVO-konformem Datenexport.',
tech: ['Python 3.11', 'FastAPI', 'SQLAlchemy', 'Pydantic'],
healthEndpoint: '/health',
endpoints: [
{ method: 'GET', path: '/api/v1/health', description: 'Health Check' },
{ method: 'POST', path: '/api/v1/chat', description: 'AI Chat Endpoint' },
{ method: 'GET', path: '/api/v1/gdpr/export', description: 'DSGVO Datenexport' },
{ method: 'POST', path: '/api/v1/seeds', description: 'Edu Search Seeds' },
],
envVars: ['DATABASE_URL', 'JWT_SECRET', 'OPENAI_API_KEY', 'ANTHROPIC_API_KEY'],
},
{
id: 'consent-service',
name: 'Consent Service',
type: 'backend',
port: 8081,
container: 'breakpilot-pwa-consent-service',
description: 'Go-basierter Consent-Management-Service',
purpose: 'DSGVO-konforme Einwilligungsverwaltung mit Versionierung, Audit-Trail und rechtssicherer Dokumentenspeicherung für Schulen.',
tech: ['Go 1.21', 'Gin', 'GORM', 'JWT'],
healthEndpoint: '/health',
endpoints: [
{ method: 'GET', path: '/api/v1/health', description: 'Health Check' },
{ method: 'GET', path: '/api/v1/consent/check', description: 'Consent Status pruefen' },
{ method: 'POST', path: '/api/v1/consent/grant', description: 'Consent erteilen' },
{ method: 'GET', path: '/api/v1/documents', description: 'Rechtsdokumente abrufen' },
{ method: 'GET', path: '/api/v1/communication/status', description: 'Matrix/Jitsi Status' },
{ method: 'POST', path: '/api/v1/communication/rooms', description: 'Matrix Raum erstellen' },
{ method: 'POST', path: '/api/v1/communication/meetings', description: 'Jitsi Meeting erstellen' },
],
envVars: ['DATABASE_URL', 'JWT_SECRET', 'PORT', 'MATRIX_HOMESERVER_URL', 'JITSI_BASE_URL'],
},
{
id: 'billing-service',
name: 'Billing Service',
type: 'backend',
port: 8083,
container: 'breakpilot-pwa-billing-service',
description: 'Stripe-basiertes Billing mit Trial & Subscription',
purpose: 'Monetarisierung der Plattform mit 7-Tage-Trial, gestuften Abo-Modellen (Basic/Standard/Premium) und automatischer Nutzungslimitierung.',
tech: ['Go 1.21', 'Gin', 'Stripe API', 'pgx'],
healthEndpoint: '/health',
endpoints: [
{ method: 'GET', path: '/api/v1/billing/status', description: 'Subscription Status' },
{ method: 'POST', path: '/api/v1/billing/trial/start', description: 'Trial starten' },
{ method: 'POST', path: '/api/v1/billing/webhook', description: 'Stripe Webhooks' },
],
envVars: ['DATABASE_URL', 'STRIPE_SECRET_KEY', 'STRIPE_WEBHOOK_SECRET'],
},
{
id: 'edu-search-service',
name: 'Edu Search Service',
type: 'backend',
port: 8086,
container: 'breakpilot-edu-search',
description: 'Bildungsquellen-Crawler mit OpenSearch-Integration',
purpose: 'Automatisches Crawlen und Indexieren von Bildungsressourcen (OER, Lehrpläne, Schulbücher) für RAG-gestützte Arbeitsblatterstellung.',
tech: ['Go 1.23', 'Gin', 'OpenSearch', 'Colly'],
healthEndpoint: '/v1/health',
endpoints: [
{ method: 'GET', path: '/v1/health', description: 'Health Check' },
{ method: 'GET', path: '/v1/search', description: 'Dokumentensuche' },
{ method: 'POST', path: '/v1/crawl/start', description: 'Crawler starten' },
{ method: 'GET', path: '/api/v1/staff/stats', description: 'Staff Statistiken' },
{ method: 'POST', path: '/api/v1/admin/crawl/staff', description: 'Staff Crawl starten' },
],
envVars: ['OPENSEARCH_URL', 'DB_HOST', 'DB_USER', 'DB_PASSWORD'],
},
{
id: 'dsms-gateway',
name: 'DSMS Gateway',
type: 'backend',
port: 8082,
container: 'breakpilot-pwa-dsms-gateway',
description: 'Datenschutz-Management Gateway',
purpose: 'Dezentrale Dokumentenspeicherung mit IPFS-Integration für manipulationssichere Audit-Logs und Rechtsdokumente.',
tech: ['Go 1.21', 'Gin', 'IPFS'],
healthEndpoint: '/health',
endpoints: [
{ method: 'GET', path: '/health', description: 'Health Check' },
{ method: 'POST', path: '/api/v1/documents', description: 'Dokument speichern' },
],
envVars: ['IPFS_URL', 'DATABASE_URL'],
},
{
id: 'pca-platform',
name: 'PCA Platform',
type: 'backend',
port: 8084,
container: 'breakpilot-pca-platform',
description: 'Payment Card Adapter für Taschengeld-Management',
purpose: 'Fintech-Integration für Schüler-Taschengeld mit virtuellen Karten, Spending-Limits und Echtzeit-Transaktionsverfolgung für Eltern.',
tech: ['Go 1.21', 'Gin', 'Stripe Issuing', 'pgx'],
healthEndpoint: '/health',
endpoints: [
{ method: 'GET', path: '/api/v1/health', description: 'Health Check' },
{ method: 'POST', path: '/api/v1/cards/create', description: 'Virtuelle Karte erstellen' },
{ method: 'GET', path: '/api/v1/transactions', description: 'Transaktionen abrufen' },
{ method: 'POST', path: '/api/v1/wallet/topup', description: 'Wallet aufladen' },
],
envVars: ['DATABASE_URL', 'STRIPE_SECRET_KEY', 'STRIPE_WEBHOOK_SECRET'],
},
{
id: 'matrix-synapse',
name: 'Matrix Synapse',
type: 'communication',
port: 8448,
container: 'breakpilot-synapse',
description: 'Ende-zu-Ende verschlüsselter Messenger',
purpose: 'Sichere Kommunikation zwischen Lehrern und Eltern mit E2EE, Raum-Management und DSGVO-konformer Nachrichtenspeicherung.',
tech: ['Matrix Protocol', 'Synapse', 'PostgreSQL'],
healthEndpoint: '/_matrix/client/versions',
endpoints: [
{ method: 'GET', path: '/_matrix/client/versions', description: 'Client Versions' },
{ method: 'POST', path: '/_matrix/client/v3/login', description: 'Matrix Login' },
{ method: 'POST', path: '/_matrix/client/v3/createRoom', description: 'Raum erstellen' },
],
envVars: ['SYNAPSE_SERVER_NAME', 'POSTGRES_HOST', 'SYNAPSE_REGISTRATION_SHARED_SECRET'],
},
{
id: 'jitsi',
name: 'Jitsi Meet',
type: 'communication',
port: 8443,
container: 'breakpilot-jitsi',
description: 'Videokonferenz-Plattform',
purpose: 'Virtuelle Elterngespräche und Klassenkonferenzen mit optionaler JWT-Authentifizierung und Embedded-Integration ins Studio.',
tech: ['Jitsi Meet', 'Prosody', 'JWT Auth'],
healthEndpoint: '/http-bind',
endpoints: [
{ method: 'GET', path: '/http-bind', description: 'BOSH Endpoint' },
{ method: 'GET', path: '/config.js', description: 'Jitsi Konfiguration' },
],
envVars: ['JITSI_APP_ID', 'JITSI_APP_SECRET', 'PUBLIC_URL'],
},
{
id: 'mailpit',
name: 'Mailpit (SMTP)',
type: 'infrastructure',
port: 1025,
container: 'breakpilot-pwa-mailpit',
description: 'E-Mail-Testing und Vorschau',
purpose: 'Lokaler SMTP-Server für E-Mail-Vorschau im Development mit Web-UI auf Port 8025 zur Überprüfung von Lifecycle-Emails.',
tech: ['Mailpit', 'SMTP', 'Web UI'],
healthEndpoint: null,
endpoints: [
{ method: 'GET', path: '/', description: 'Web UI (Port 8025)' },
],
envVars: ['MP_SMTP_AUTH', 'MP_SMTP_AUTH_ALLOW_INSECURE'],
},
{
id: 'llm-gateway',
name: 'LLM Gateway',
type: 'backend',
port: 8085,
container: 'breakpilot-llm-gateway',
description: 'Multi-Provider LLM Router',
purpose: 'Einheitliche API für verschiedene LLM-Anbieter (OpenAI, Anthropic, Ollama) mit Provider-Switching, Token-Tracking und Fallback-Logik.',
tech: ['Python 3.11', 'FastAPI', 'LiteLLM'],
healthEndpoint: '/health',
endpoints: [
{ method: 'GET', path: '/health', description: 'Health Check' },
{ method: 'POST', path: '/v1/chat/completions', description: 'Chat Completion (OpenAI-kompatibel)' },
{ method: 'GET', path: '/v1/models', description: 'Verfügbare Modelle' },
],
envVars: ['OPENAI_API_KEY', 'ANTHROPIC_API_KEY', 'OLLAMA_BASE_URL'],
},
{
id: 'website',
name: 'Website (Next.js)',
type: 'frontend',
port: 3000,
container: 'breakpilot-pwa-website',
description: 'Next.js 14 Frontend mit App Router',
purpose: 'Admin-Dashboard, Landing-Page und API-Routing für das Next.js Frontend mit Server Components und Edge Functions.',
tech: ['Next.js 14', 'React 18', 'TypeScript', 'Tailwind CSS'],
healthEndpoint: null,
endpoints: [
{ method: 'GET', path: '/', description: 'Landing Page' },
{ method: 'GET', path: '/admin', description: 'Admin Dashboard' },
{ method: 'GET', path: '/app', description: 'Benutzer-App (redirect to :8000)' },
],
envVars: ['NEXT_PUBLIC_API_URL', 'NEXTAUTH_SECRET'],
},
{
id: 'opensearch',
name: 'OpenSearch',
type: 'database',
port: 9200,
container: 'breakpilot-opensearch',
description: 'Volltextsuche und Vektorsuche',
purpose: 'Hochperformante Suche in Bildungsressourcen mit k-NN für semantische Ähnlichkeitssuche und BM25 für Keyword-Matching.',
tech: ['OpenSearch 2.11', 'k-NN Plugin'],
healthEndpoint: '/',
endpoints: [
{ method: 'GET', path: '/_cluster/health', description: 'Cluster Health' },
{ method: 'POST', path: '/bp_documents_v1/_search', description: 'Dokumentensuche' },
],
envVars: ['OPENSEARCH_JAVA_OPTS'],
},
]
// ASCII architecture diagram
export const DATAFLOW_DIAGRAM = `
BreakPilot Platform - Datenfluss
┌─────────────────────────────────────────────────────────────────────────────────────────────┐
│ FRONTEND LAYER │
├─────────────────────────────────────────────────────────────────────────────────────────────┤
│ ┌─────────────────────┐ ┌─────────────────────┐ ┌─────────────────────┐ │
│ │ Admin Frontend │ │ Lehrer Studio │ │ Policy Vault UI │ │
│ │ Next.js :3000 │ │ FastAPI :8000 │ │ Angular :4200 │ │
│ └──────────┬──────────┘ └──────────┬──────────┘ └──────────┬──────────┘ │
└─────────────┼───────────────────────────┼───────────────────────────┼────────────────────────┘
│ │ │
▼ ▼ ▼
┌─────────────────────────────────────────────────────────────────────────────────────────────┐
│ BACKEND LAYER │
├───────────────────┬───────────────────┬───────────────────┬─────────────────────────────────┤
│ ┌───────────────┐ │ ┌───────────────┐ │ ┌───────────────┐ │ ┌───────────────┐ │
│ │ Consent :8081 │ │ │ Billing :8083 │ │ │ School :8084 │ │ │ DSMS GW :8082 │ │
│ │ Go/Gin │ │ │ Go/Stripe │ │ │ Go/Gin │ │ │ Go/IPFS │ │
│ │ DSGVO Consent │ │ │ Subscriptions │ │ │ Noten/Zeugnis │ │ │ Audit Storage │ │
│ └───────┬───────┘ │ └───────┬───────┘ │ └───────┬───────┘ │ └───────┬───────┘ │
│ │ │ │ │ │ │ │ │
│ ▼ │ ▼ │ ▼ │ ▼ │
│ ┌───────────────┐ │ ┌───────────────┐ │ ┌───────────────┐ │ ┌───────────────┐ │
│ │ Klausur :8086 │ │ │ H5P :8085 │ │ │ Policy API │ │ │ LLM Services │ │
│ │ Python/BYOEH │ │ │ Node.js │ │ │ NestJS :3001 │ │ │ LibreChat/RAG │ │
│ │ Abiturkorrek. │ │ │ Interaktiv │ │ │ Richtlinien │ │ │ KI-Assistenz │ │
│ └───────┬───────┘ │ └───────┬───────┘ │ └───────┬───────┘ │ └───────┬───────┘ │
└─────────┼─────────┴─────────┼─────────┴─────────┼─────────┴─────────┼───────────────────────┘
│ │ │ │
▼ ▼ ▼ ▼
┌─────────────────────────────────────────────────────────────────────────────────────────────┐
│ DATA LAYER │
├───────────────────┬───────────────────┬───────────────────┬─────────────────────────────────┤
│ ┌───────────────┐ │ ┌───────────────┐ │ ┌───────────────┐ │ ┌───────────────┐ │
│ │ PostgreSQL │◄┼►│ Redis │ │ │ Qdrant │ │ │ OpenSearch │ │
│ │ :5432 │ │ │ :6379 Cache │ │ │ :6333 Vector │ │ │ :9200 Search │ │
│ │ Hauptdaten │ │ │ Sessions │ │ │ RAG Embeddings│ │ │ Volltext │ │
│ └───────────────┘ │ └───────────────┘ │ └───────────────┘ │ └───────────────┘ │
└───────────────────┴───────────────────┴───────────────────┴─────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────────────────────────────────┐
│ INFRASTRUCTURE LAYER │
├───────────────────┬───────────────────┬───────────────────┬─────────────────────────────────┤
│ ┌───────────────┐ │ ┌───────────────┐ │ ┌───────────────┐ │ ┌───────────────┐ │
│ │ Vault :8200 │ │ │ Keycloak :8180│ │ │ MinIO :9000 │ │ │ IPFS :5001 │ │
│ │ Secrets Mgmt │ │ │ SSO/OIDC Auth │ │ │ S3 Storage │ │ │ Dezentral │ │
│ └───────────────┘ │ └───────────────┘ │ └───────────────┘ │ └───────────────┘ │
│ ┌───────────────┐ │ ┌───────────────┐ │ ┌───────────────┐ │ │
│ │ Matrix :8008 │ │ │ Jitsi :8443 │ │ │ ERPNext :8090 │ │ │
│ │ E2EE Chat │ │ │ Video Calls │ │ │ Open ERP │ │ │
│ └───────────────┘ │ └───────────────┘ │ └───────────────┘ │ │
└───────────────────┴───────────────────┴───────────────────┴─────────────────────────────────┘
═══════════════════════════════════════════════════════════════════════════════════════════════
Legende: ──► Datenfluss ◄──► Bidirektional │ Layer-Grenze ┌─┐ Service-Box
═══════════════════════════════════════════════════════════════════════════════════════════════
`
// Tab definitions
export const TAB_DEFINITIONS = [
{ id: 'overview', label: 'Architektur' },
{ id: 'services', label: 'Services' },
{ id: 'api', label: 'API Reference' },
{ id: 'docker', label: 'Docker' },
{ id: 'testing', label: 'Testing' },
] as const

View File

@@ -0,0 +1,58 @@
/**
* Helper functions for Developer Documentation Page
*/
import type { ServiceNode } from './types'
export const getArchTypeColor = (type: ServiceNode['type']) => {
switch (type) {
case 'frontend': return { bg: 'bg-blue-500', border: 'border-blue-600', text: 'text-blue-800', light: 'bg-blue-50' }
case 'backend': return { bg: 'bg-green-500', border: 'border-green-600', text: 'text-green-800', light: 'bg-green-50' }
case 'database': return { bg: 'bg-purple-500', border: 'border-purple-600', text: 'text-purple-800', light: 'bg-purple-50' }
case 'cache': return { bg: 'bg-cyan-500', border: 'border-cyan-600', text: 'text-cyan-800', light: 'bg-cyan-50' }
case 'search': return { bg: 'bg-pink-500', border: 'border-pink-600', text: 'text-pink-800', light: 'bg-pink-50' }
case 'storage': return { bg: 'bg-orange-500', border: 'border-orange-600', text: 'text-orange-800', light: 'bg-orange-50' }
case 'security': return { bg: 'bg-red-500', border: 'border-red-600', text: 'text-red-800', light: 'bg-red-50' }
case 'communication': return { bg: 'bg-yellow-500', border: 'border-yellow-600', text: 'text-yellow-800', light: 'bg-yellow-50' }
case 'ai': return { bg: 'bg-violet-500', border: 'border-violet-600', text: 'text-violet-800', light: 'bg-violet-50' }
case 'erp': return { bg: 'bg-indigo-500', border: 'border-indigo-600', text: 'text-indigo-800', light: 'bg-indigo-50' }
default: return { bg: 'bg-gray-500', border: 'border-gray-600', text: 'text-gray-800', light: 'bg-gray-50' }
}
}
export const getArchTypeLabel = (type: ServiceNode['type']) => {
switch (type) {
case 'frontend': return 'Frontend'
case 'backend': return 'Backend'
case 'database': return 'Datenbank'
case 'cache': return 'Cache'
case 'search': return 'Suche'
case 'storage': return 'Speicher'
case 'security': return 'Sicherheit'
case 'communication': return 'Kommunikation'
case 'ai': return 'KI/LLM'
case 'erp': return 'ERP'
default: return type
}
}
export const getServiceTypeColor = (type: string) => {
switch (type) {
case 'frontend': return 'bg-blue-100 text-blue-800'
case 'backend': return 'bg-green-100 text-green-800'
case 'database': return 'bg-purple-100 text-purple-800'
case 'communication': return 'bg-orange-100 text-orange-800'
case 'infrastructure': return 'bg-slate-200 text-slate-700'
default: return 'bg-gray-100 text-gray-800'
}
}
export const getMethodColor = (method: string) => {
switch (method) {
case 'GET': return 'bg-emerald-100 text-emerald-700'
case 'POST': return 'bg-blue-100 text-blue-700'
case 'PUT': return 'bg-amber-100 text-amber-700'
case 'DELETE': return 'bg-red-100 text-red-700'
default: return 'bg-gray-100 text-gray-700'
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,42 @@
/**
* Types for Developer Documentation Page
*/
export interface ServiceNode {
id: string
name: string
type: 'frontend' | 'backend' | 'database' | 'cache' | 'search' | 'storage' | 'security' | 'communication' | 'ai' | 'erp'
port?: string
technology: string
description: string
connections?: string[]
}
export interface ServiceEndpoint {
method: string
path: string
description: string
}
export interface ServiceDefinition {
id: string
name: string
type: string
port: number
container: string
description: string
purpose: string
tech: string[]
healthEndpoint: string | null
endpoints: ServiceEndpoint[]
envVars: string[]
}
export interface ArchitectureLayer {
id: string
name: string
description: string
types: string[]
}
export type TabType = 'overview' | 'services' | 'api' | 'docker' | 'testing'

View File

@@ -0,0 +1,38 @@
export function FailedTestsList({ testIds, onViewDetails }: { testIds: string[]; onViewDetails?: (id: string) => void }) {
if (testIds.length === 0) {
return (
<div className="text-center py-8 text-emerald-600">
<svg className="w-12 h-12 mx-auto mb-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
Alle Tests bestanden!
</div>
)
}
return (
<div className="space-y-2 max-h-64 overflow-y-auto">
{testIds.map((testId) => (
<div
key={testId}
className="flex items-center justify-between p-3 bg-red-50 rounded-lg border border-red-100"
>
<div className="flex items-center gap-2">
<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="M6 18L18 6M6 6l12 12" />
</svg>
<span className="font-mono text-sm text-red-700">{testId}</span>
</div>
{onViewDetails && (
<button
onClick={() => onViewDetails(testId)}
className="text-xs text-red-600 hover:text-red-800"
>
Details
</button>
)}
</div>
))}
</div>
)
}

View File

@@ -0,0 +1,73 @@
import { BQASMetrics } from '../types'
import { IntentScoresChart } from './IntentScoresChart'
import { FailedTestsList } from './FailedTestsList'
export function GoldenTab({
goldenMetrics,
isRunningGolden,
runGoldenTests,
}: {
goldenMetrics: BQASMetrics | null
isRunningGolden: boolean
runGoldenTests: () => void
}) {
return (
<div className="space-y-6">
<div className="bg-white rounded-xl border border-slate-200 p-6">
<div className="flex items-center justify-between mb-6">
<div>
<h3 className="text-lg font-semibold text-slate-900">Golden Test Suite</h3>
<p className="text-sm text-slate-500">Validierte Referenz-Tests gegen definierte Erwartungen</p>
</div>
<button
onClick={runGoldenTests}
disabled={isRunningGolden}
className="px-4 py-2 bg-primary-600 text-white rounded-lg hover:bg-primary-700 disabled:bg-slate-300"
>
{isRunningGolden ? 'Laeuft...' : 'Tests starten'}
</button>
</div>
{goldenMetrics && (
<>
{/* Metrics Overview */}
<div className="grid grid-cols-2 md:grid-cols-5 gap-4 mb-6">
<div className="text-center p-4 bg-slate-50 rounded-lg">
<p className="text-2xl font-bold text-slate-900">{goldenMetrics.total_tests}</p>
<p className="text-xs text-slate-500">Tests</p>
</div>
<div className="text-center p-4 bg-emerald-50 rounded-lg">
<p className="text-2xl font-bold text-emerald-600">{goldenMetrics.passed_tests}</p>
<p className="text-xs text-slate-500">Bestanden</p>
</div>
<div className="text-center p-4 bg-red-50 rounded-lg">
<p className="text-2xl font-bold text-red-600">{goldenMetrics.failed_tests}</p>
<p className="text-xs text-slate-500">Fehlgeschlagen</p>
</div>
<div className="text-center p-4 bg-blue-50 rounded-lg">
<p className="text-2xl font-bold text-blue-600">{goldenMetrics.avg_intent_accuracy.toFixed(0)}%</p>
<p className="text-xs text-slate-500">Intent Accuracy</p>
</div>
<div className="text-center p-4 bg-purple-50 rounded-lg">
<p className="text-2xl font-bold text-purple-600">{goldenMetrics.avg_composite_score.toFixed(2)}</p>
<p className="text-xs text-slate-500">Composite Score</p>
</div>
</div>
{/* Intent Scores & Failed Tests */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
<div>
<h4 className="font-medium text-slate-900 mb-4">Scores nach Intent</h4>
<IntentScoresChart scores={goldenMetrics.scores_by_intent} />
</div>
<div>
<h4 className="font-medium text-slate-900 mb-4">Fehlgeschlagene Tests</h4>
<FailedTestsList testIds={goldenMetrics.failed_test_ids} />
</div>
</div>
</>
)}
</div>
</div>
)
}

View File

@@ -0,0 +1,38 @@
export function IntentScoresChart({ scores }: { scores: Record<string, number> }) {
const entries = Object.entries(scores).sort((a, b) => b[1] - a[1])
if (entries.length === 0) {
return (
<div className="text-center py-8 text-slate-400">
Keine Intent-Scores verfuegbar
</div>
)
}
return (
<div className="space-y-3">
{entries.map(([intent, score]) => (
<div key={intent}>
<div className="flex items-center justify-between text-sm mb-1">
<span className="text-slate-600 truncate max-w-[200px]">{intent.replace(/_/g, ' ')}</span>
<span
className={`font-medium ${
score >= 4 ? 'text-emerald-600' : score >= 3 ? 'text-amber-600' : 'text-red-600'
}`}
>
{score.toFixed(2)}
</span>
</div>
<div className="h-2 bg-slate-100 rounded-full overflow-hidden">
<div
className={`h-full rounded-full transition-all ${
score >= 4 ? 'bg-emerald-500' : score >= 3 ? 'bg-amber-500' : 'bg-red-500'
}`}
style={{ width: `${(score / 5) * 100}%` }}
/>
</div>
</div>
))}
</div>
)
}

View File

@@ -0,0 +1,52 @@
export function MetricCard({
title,
value,
subtitle,
trend,
color = 'blue',
}: {
title: string
value: string | number
subtitle?: string
trend?: 'up' | 'down' | 'stable'
color?: 'blue' | 'green' | 'red' | 'yellow' | 'purple'
}) {
const colorClasses = {
blue: 'bg-blue-50 border-blue-200',
green: 'bg-emerald-50 border-emerald-200',
red: 'bg-red-50 border-red-200',
yellow: 'bg-amber-50 border-amber-200',
purple: 'bg-purple-50 border-purple-200',
}
const trendIcons = {
up: (
<svg className="w-4 h-4 text-emerald-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 10l7-7m0 0l7 7m-7-7v18" />
</svg>
),
down: (
<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="M19 14l-7 7m0 0l-7-7m7 7V3" />
</svg>
),
stable: (
<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="M5 12h14" />
</svg>
),
}
return (
<div className={`rounded-xl border p-5 ${colorClasses[color]}`}>
<div className="flex items-start justify-between">
<div>
<p className="text-sm font-medium text-slate-600">{title}</p>
<p className="mt-1 text-2xl font-bold text-slate-900">{value}</p>
{subtitle && <p className="mt-1 text-xs text-slate-500">{subtitle}</p>}
</div>
{trend && <div className="mt-1">{trendIcons[trend]}</div>}
</div>
</div>
)
}

View File

@@ -0,0 +1,94 @@
import { BQASMetrics, TrendData, TestRun } from '../types'
import { MetricCard } from './MetricCard'
import { TrendChart } from './TrendChart'
import { TestSuiteCard } from './TestSuiteCard'
export function OverviewTab({
goldenMetrics,
ragMetrics,
syntheticMetrics,
trendData,
testRuns,
isRunningGolden,
isRunningRag,
isRunningSynthetic,
runGoldenTests,
runRagTests,
runSyntheticTests,
}: {
goldenMetrics: BQASMetrics | null
ragMetrics: BQASMetrics | null
syntheticMetrics: BQASMetrics | null
trendData: TrendData | null
testRuns: TestRun[]
isRunningGolden: boolean
isRunningRag: boolean
isRunningSynthetic: boolean
runGoldenTests: () => void
runRagTests: () => void
runSyntheticTests: () => void
}) {
return (
<div className="space-y-6">
{/* Quick Stats */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
<MetricCard
title="Golden Score"
value={goldenMetrics?.avg_composite_score.toFixed(2) || '-'}
subtitle="Durchschnitt aller Golden Tests"
trend={trendData?.trend === 'improving' ? 'up' : trendData?.trend === 'declining' ? 'down' : 'stable'}
color="blue"
/>
<MetricCard
title="Pass Rate"
value={goldenMetrics ? `${((goldenMetrics.passed_tests / goldenMetrics.total_tests) * 100).toFixed(0)}%` : '-'}
subtitle={goldenMetrics ? `${goldenMetrics.passed_tests}/${goldenMetrics.total_tests} bestanden` : undefined}
color="green"
/>
<MetricCard
title="RAG Qualitaet"
value={ragMetrics?.avg_composite_score.toFixed(2) || '-'}
subtitle="RAG Retrieval Score"
color="purple"
/>
<MetricCard
title="Test Runs"
value={testRuns.length}
subtitle="Letzte 30 Tage"
color="yellow"
/>
</div>
{/* Trend Chart */}
<div className="bg-white rounded-xl border border-slate-200 p-6">
<h3 className="text-lg font-semibold text-slate-900 mb-4">Score-Trend (30 Tage)</h3>
<TrendChart data={trendData || { dates: [], scores: [], trend: 'insufficient_data' }} />
</div>
{/* Test Suites Grid */}
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
<TestSuiteCard
title="Golden Suite"
description="97 validierte Referenz-Tests fuer Intent-Erkennung"
metrics={goldenMetrics || undefined}
onRun={runGoldenTests}
isRunning={isRunningGolden}
/>
<TestSuiteCard
title="RAG/Korrektur Tests"
description="EH-Retrieval, Operatoren-Alignment, Citation Tests"
metrics={ragMetrics || undefined}
onRun={runRagTests}
isRunning={isRunningRag}
/>
<TestSuiteCard
title="Synthetic Tests"
description="LLM-generierte Variationen fuer Robustheit"
metrics={syntheticMetrics || undefined}
onRun={runSyntheticTests}
isRunning={isRunningSynthetic}
/>
</div>
</div>
)
}

View File

@@ -0,0 +1,97 @@
import { BQASMetrics } from '../types'
import { IntentScoresChart } from './IntentScoresChart'
import { FailedTestsList } from './FailedTestsList'
export function RagTab({
ragMetrics,
isRunningRag,
runRagTests,
}: {
ragMetrics: BQASMetrics | null
isRunningRag: boolean
runRagTests: () => void
}) {
return (
<div className="space-y-6">
<div className="bg-white rounded-xl border border-slate-200 p-6">
<div className="flex items-center justify-between mb-6">
<div>
<h3 className="text-lg font-semibold text-slate-900">RAG/Korrektur Test Suite</h3>
<p className="text-sm text-slate-500">Erwartungshorizont-Retrieval, Operatoren-Alignment, Citations</p>
</div>
<button
onClick={runRagTests}
disabled={isRunningRag}
className="px-4 py-2 bg-primary-600 text-white rounded-lg hover:bg-primary-700 disabled:bg-slate-300"
>
{isRunningRag ? 'Laeuft...' : 'Tests starten'}
</button>
</div>
{ragMetrics ? (
<>
{/* RAG Metrics */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 mb-6">
<div className="text-center p-4 bg-slate-50 rounded-lg">
<p className="text-2xl font-bold text-slate-900">{ragMetrics.total_tests}</p>
<p className="text-xs text-slate-500">Tests</p>
</div>
<div className="text-center p-4 bg-purple-50 rounded-lg">
<p className="text-2xl font-bold text-purple-600">{ragMetrics.avg_faithfulness.toFixed(2)}</p>
<p className="text-xs text-slate-500">Faithfulness</p>
</div>
<div className="text-center p-4 bg-blue-50 rounded-lg">
<p className="text-2xl font-bold text-blue-600">{ragMetrics.avg_relevance.toFixed(2)}</p>
<p className="text-xs text-slate-500">Relevance</p>
</div>
<div className="text-center p-4 bg-emerald-50 rounded-lg">
<p className="text-2xl font-bold text-emerald-600">{(ragMetrics.safety_pass_rate * 100).toFixed(0)}%</p>
<p className="text-xs text-slate-500">Safety Pass</p>
</div>
</div>
{/* RAG Categories */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
<div>
<h4 className="font-medium text-slate-900 mb-4">RAG Kategorien</h4>
<IntentScoresChart scores={ragMetrics.scores_by_intent} />
</div>
<div>
<h4 className="font-medium text-slate-900 mb-4">Fehlgeschlagene Tests</h4>
<FailedTestsList testIds={ragMetrics.failed_test_ids} />
</div>
</div>
</>
) : (
<div className="text-center py-12 text-slate-400">
<svg className="w-16 h-16 mx-auto mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} 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>
<p>Noch keine RAG-Test-Ergebnisse</p>
<p className="text-sm mt-2">Klicke &quot;Tests starten&quot; um die RAG-Suite auszufuehren</p>
</div>
)}
</div>
{/* RAG Test Categories Explanation */}
<div className="bg-white rounded-xl border border-slate-200 p-6">
<h3 className="text-lg font-semibold text-slate-900 mb-4">Test-Kategorien</h3>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{[
{ name: 'EH Retrieval', desc: 'Korrektes Abrufen von Erwartungshorizont-Passagen', color: 'blue' },
{ name: 'Operator Alignment', desc: 'Passende Operatoren fuer Abitur-Aufgaben', color: 'purple' },
{ name: 'Hallucination Control', desc: 'Keine erfundenen Fakten oder Inhalte', color: 'red' },
{ name: 'Citation Enforcement', desc: 'Quellenangaben bei EH-Bezuegen', color: 'green' },
{ name: 'Privacy Compliance', desc: 'Keine PII-Leaks, DSGVO-Konformitaet', color: 'amber' },
{ name: 'Namespace Isolation', desc: 'Strikte Trennung zwischen Lehrern', color: 'slate' },
].map((cat) => (
<div key={cat.name} className={`p-4 rounded-lg border bg-${cat.color}-50 border-${cat.color}-200`}>
<h4 className="font-medium text-slate-900">{cat.name}</h4>
<p className="text-sm text-slate-600 mt-1">{cat.desc}</p>
</div>
))}
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,40 @@
export function SchedulerStatusCard({
title,
status,
description,
icon,
}: {
title: string
status: 'active' | 'inactive' | 'warning' | 'unknown'
description: string
icon: React.ReactNode
}) {
const statusColors = {
active: 'bg-emerald-100 border-emerald-200 text-emerald-700',
inactive: 'bg-slate-100 border-slate-200 text-slate-700',
warning: 'bg-amber-100 border-amber-200 text-amber-700',
unknown: 'bg-slate-100 border-slate-200 text-slate-500',
}
const statusBadges = {
active: 'bg-emerald-500',
inactive: 'bg-slate-400',
warning: 'bg-amber-500',
unknown: 'bg-slate-300',
}
return (
<div className={`rounded-xl border p-5 ${statusColors[status]}`}>
<div className="flex items-start gap-4">
<div className="flex-shrink-0">{icon}</div>
<div className="flex-1">
<div className="flex items-center gap-2">
<h4 className="font-semibold">{title}</h4>
<span className={`w-2 h-2 rounded-full ${statusBadges[status]}`} />
</div>
<p className="text-sm mt-1 opacity-80">{description}</p>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,266 @@
import { SchedulerStatusCard } from './SchedulerStatusCard'
import { SpinnerIcon } from './SpinnerIcon'
export function SchedulerTab({
isRunningGolden,
isRunningRag,
isRunningSynthetic,
runGoldenTests,
runRagTests,
runSyntheticTests,
}: {
isRunningGolden: boolean
isRunningRag: boolean
isRunningSynthetic: boolean
runGoldenTests: () => void
runRagTests: () => void
runSyntheticTests: () => void
}) {
return (
<div className="space-y-6">
{/* Status Overview */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<SchedulerStatusCard
title="launchd Job"
status="active"
description="Taeglich um 07:00 Uhr automatisch"
icon={
<svg className="w-8 h-8" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
}
/>
<SchedulerStatusCard
title="Git Hook"
status="active"
description="Quick Tests bei voice-service Aenderungen"
icon={
<svg className="w-8 h-8" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M8 9l3 3-3 3m5 0h3M5 20h14a2 2 0 002-2V6a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" />
</svg>
}
/>
<SchedulerStatusCard
title="Benachrichtigungen"
status="active"
description="Desktop-Alerts bei Fehlern aktiviert"
icon={
<svg className="w-8 h-8" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9" />
</svg>
}
/>
</div>
{/* Quick Actions */}
<div className="bg-white rounded-xl border border-slate-200 p-6">
<h3 className="text-lg font-semibold text-slate-900 mb-4">Quick Actions</h3>
<div className="flex flex-wrap gap-3">
<QuickActionButton
label="Golden Suite starten"
isRunning={isRunningGolden}
onClick={runGoldenTests}
colorClass="bg-blue-600 hover:bg-blue-700"
/>
<QuickActionButton
label="RAG Tests starten"
isRunning={isRunningRag}
onClick={runRagTests}
colorClass="bg-purple-600 hover:bg-purple-700"
/>
<QuickActionButton
label="Synthetic Tests"
isRunning={isRunningSynthetic}
onClick={runSyntheticTests}
colorClass="bg-emerald-600 hover:bg-emerald-700"
/>
</div>
</div>
{/* GitHub Actions vs Local - Comparison */}
<ComparisonTable />
{/* Configuration Details */}
<ConfigurationSection />
{/* Detailed Explanation */}
<ExplanationSection />
</div>
)
}
function QuickActionButton({
label,
isRunning,
onClick,
colorClass,
}: {
label: string
isRunning: boolean
onClick: () => void
colorClass: string
}) {
return (
<button
onClick={onClick}
disabled={isRunning}
className={`px-4 py-2 ${colorClass} text-white rounded-lg disabled:bg-slate-300 flex items-center gap-2`}
>
{isRunning ? (
<SpinnerIcon />
) : (
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M14.752 11.168l-3.197-2.132A1 1 0 0010 9.87v4.263a1 1 0 001.555.832l3.197-2.132a1 1 0 000-1.664z" />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
)}
{label}
</button>
)
}
function ComparisonTable() {
const rows = [
{ feature: 'Taegliche Tests (07:00)', gh: 'schedule: cron', local: 'macOS launchd', localColor: 'emerald' },
{ feature: 'Push-basierte Tests', gh: 'on: push', local: 'Git post-commit Hook', localColor: 'emerald' },
{ feature: 'PR-basierte Tests', gh: 'on: pull_request', ghColor: 'emerald', local: 'Nicht moeglich', localColor: 'amber' },
{ feature: 'Regression-Check', gh: 'API-Call', local: 'Identischer API-Call', localColor: 'emerald' },
{ feature: 'Benachrichtigungen', gh: 'GitHub Issues', local: 'Desktop/Slack/Email', localColor: 'emerald' },
{ feature: 'DSGVO-Konformitaet', gh: 'Daten bei GitHub (US)', ghColor: 'amber', local: '100% lokal', localColor: 'emerald' },
{ feature: 'Offline-Faehig', gh: 'Nein', ghColor: 'red', local: 'Ja', localColor: 'emerald' },
]
return (
<div className="bg-white rounded-xl border border-slate-200 p-6">
<h3 className="text-lg font-semibold text-slate-900 mb-4">GitHub Actions Alternative</h3>
<p className="text-slate-600 mb-4">
Der lokale BQAS Scheduler ersetzt GitHub Actions und bietet DSGVO-konforme, vollstaendig lokale Test-Ausfuehrung.
</p>
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-slate-200 bg-slate-50">
<th className="text-left py-3 px-4 font-medium text-slate-700">Feature</th>
<th className="text-center py-3 px-4 font-medium text-slate-700">GitHub Actions</th>
<th className="text-center py-3 px-4 font-medium text-slate-700">Lokaler Scheduler</th>
</tr>
</thead>
<tbody>
{rows.map((row) => (
<tr key={row.feature} className="border-b border-slate-100">
<td className="py-3 px-4 text-slate-600">{row.feature}</td>
<td className="py-3 px-4 text-center">
{row.ghColor ? (
<span className={`px-2 py-1 bg-${row.ghColor}-100 text-${row.ghColor}-700 rounded text-xs font-medium`}>
{row.gh}
</span>
) : (
<span className="text-slate-600">{row.gh}</span>
)}
</td>
<td className="py-3 px-4 text-center">
<span className={`px-2 py-1 bg-${row.localColor}-100 text-${row.localColor}-700 rounded text-xs font-medium`}>
{row.local}
</span>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)
}
function ConfigurationSection() {
return (
<div className="bg-white rounded-xl border border-slate-200 p-6">
<h3 className="text-lg font-semibold text-slate-900 mb-4">Konfiguration</h3>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{/* launchd Configuration */}
<div>
<h4 className="font-medium text-slate-800 mb-3">launchd Job</h4>
<div className="bg-slate-900 rounded-lg p-4 font-mono text-sm text-slate-100 overflow-x-auto">
<pre>{`# ~/Library/LaunchAgents/com.breakpilot.bqas.plist
Label: com.breakpilot.bqas
Schedule: 07:00 taeglich
Script: /voice-service/scripts/run_bqas.sh
Logs: /var/log/bqas/`}</pre>
</div>
</div>
{/* Environment Variables */}
<div>
<h4 className="font-medium text-slate-800 mb-3">Umgebungsvariablen</h4>
<div className="space-y-2 text-sm">
{[
{ key: 'BQAS_SERVICE_URL', value: 'http://localhost:8091', color: 'text-slate-900' },
{ key: 'BQAS_REGRESSION_THRESHOLD', value: '0.1', color: 'text-slate-900' },
{ key: 'BQAS_NOTIFY_DESKTOP', value: 'true', color: 'text-emerald-600 font-medium' },
{ key: 'BQAS_NOTIFY_SLACK', value: 'false', color: 'text-slate-400' },
].map((env) => (
<div key={env.key} className="flex justify-between p-2 bg-slate-50 rounded">
<span className="font-mono text-slate-600">{env.key}</span>
<span className={env.color}>{env.value}</span>
</div>
))}
</div>
</div>
</div>
</div>
)
}
function ExplanationSection() {
return (
<div className="bg-gradient-to-r from-blue-50 to-indigo-50 rounded-xl border border-blue-200 p-6">
<h3 className="text-lg font-semibold text-slate-900 mb-4 flex items-center gap-2">
<svg className="w-5 h-5 text-blue-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
Detaillierte Erklaerung
</h3>
<div className="prose prose-sm max-w-none text-slate-700">
<h4 className="text-base font-semibold mt-4 mb-2">Warum ein lokaler Scheduler?</h4>
<p className="mb-4">
Der lokale BQAS Scheduler wurde entwickelt, um die gleiche Funktionalitaet wie GitHub Actions zu bieten,
aber mit dem entscheidenden Vorteil, dass <strong>alle Daten zu 100% auf dem lokalen Mac Mini verbleiben</strong>.
Dies ist besonders wichtig fuer DSGVO-Konformitaet, da keine Schuelerdaten oder Testergebnisse an externe Server uebertragen werden.
</p>
<h4 className="text-base font-semibold mt-4 mb-2">Komponenten</h4>
<ul className="list-disc list-inside space-y-2 mb-4">
<li>
<strong>run_bqas.sh</strong> - Hauptscript das pytest ausfuehrt, Regression-Checks macht und Benachrichtigungen versendet
</li>
<li>
<strong>launchd Job</strong> - macOS-nativer Scheduler der das Script taeglich um 07:00 Uhr startet
</li>
<li>
<strong>Git Hook</strong> - post-commit Hook der bei Aenderungen im voice-service automatisch Quick-Tests startet
</li>
<li>
<strong>Notifier</strong> - Python-Modul das Desktop-, Slack- und E-Mail-Benachrichtigungen versendet
</li>
</ul>
<h4 className="text-base font-semibold mt-4 mb-2">Installation</h4>
<div className="bg-slate-900 rounded-lg p-3 font-mono text-sm text-slate-100 mb-4">
<code>./voice-service/scripts/install_bqas_scheduler.sh install</code>
</div>
<h4 className="text-base font-semibold mt-4 mb-2">Vorteile gegenueber GitHub Actions</h4>
<ul className="list-disc list-inside space-y-1">
<li>100% DSGVO-konform - alle Daten bleiben lokal</li>
<li>Keine Internet-Abhaengigkeit - funktioniert auch offline</li>
<li>Keine GitHub-Kosten fuer private Repositories</li>
<li>Schnellere Ausfuehrung ohne Cloud-Overhead</li>
<li>Volle Kontrolle ueber Scheduling und Benachrichtigungen</li>
</ul>
</div>
</div>
)
}

View File

@@ -0,0 +1,12 @@
export function SpinnerIcon() {
return (
<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>
)
}

Some files were not shown because too many files have changed in this diff Show More