[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:
120
studio-v2/app/alerts-b2b/_components/DecisionTraceModal.tsx
Normal file
120
studio-v2/app/alerts-b2b/_components/DecisionTraceModal.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
49
studio-v2/app/alerts-b2b/_components/DigestView.tsx
Normal file
49
studio-v2/app/alerts-b2b/_components/DigestView.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
163
studio-v2/app/alerts-b2b/_components/EmailImportModal.tsx
Normal file
163
studio-v2/app/alerts-b2b/_components/EmailImportModal.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
66
studio-v2/app/alerts-b2b/_components/HitCard.tsx
Normal file
66
studio-v2/app/alerts-b2b/_components/HitCard.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
148
studio-v2/app/alerts-b2b/_components/HitDetailModal.tsx
Normal file
148
studio-v2/app/alerts-b2b/_components/HitDetailModal.tsx
Normal 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
@@ -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>
|
||||
)
|
||||
}
|
||||
79
studio-v2/app/korrektur/archiv/_components/DokumentCard.tsx
Normal file
79
studio-v2/app/korrektur/archiv/_components/DokumentCard.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
49
studio-v2/app/korrektur/archiv/_components/GlassCard.tsx
Normal file
49
studio-v2/app/korrektur/archiv/_components/GlassCard.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
164
studio-v2/app/korrektur/archiv/_components/PreviewModal.tsx
Normal file
164
studio-v2/app/korrektur/archiv/_components/PreviewModal.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
183
studio-v2/app/meet/_components/BreakoutTab.tsx
Normal file
183
studio-v2/app/meet/_components/BreakoutTab.tsx
Normal 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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
254
studio-v2/app/meet/_components/DashboardTab.tsx
Normal file
254
studio-v2/app/meet/_components/DashboardTab.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
55
studio-v2/app/meet/_components/JoinMeetingModal.tsx
Normal file
55
studio-v2/app/meet/_components/JoinMeetingModal.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
131
studio-v2/app/meet/_components/NewMeetingModal.tsx
Normal file
131
studio-v2/app/meet/_components/NewMeetingModal.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
196
studio-v2/app/meet/_components/RecordingsTab.tsx
Normal file
196
studio-v2/app/meet/_components/RecordingsTab.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
93
studio-v2/app/meet/_components/TranscriptModal.tsx
Normal file
93
studio-v2/app/meet/_components/TranscriptModal.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
193
studio-v2/app/meet/_components/types.ts
Normal file
193
studio-v2/app/meet/_components/types.ts
Normal 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'
|
||||
}
|
||||
323
studio-v2/app/meet/_components/useMeetPage.ts
Normal file
323
studio-v2/app/meet/_components/useMeetPage.ts
Normal 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
425
studio-v2/app/messages/_components/ChatArea.tsx
Normal file
425
studio-v2/app/messages/_components/ChatArea.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
113
studio-v2/app/messages/_components/ContactInfoPanel.tsx
Normal file
113
studio-v2/app/messages/_components/ContactInfoPanel.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
47
studio-v2/app/messages/_components/ContextMenu.tsx
Normal file
47
studio-v2/app/messages/_components/ContextMenu.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
189
studio-v2/app/messages/_components/ConversationList.tsx
Normal file
189
studio-v2/app/messages/_components/ConversationList.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
81
studio-v2/app/messages/_components/EmojiPicker.tsx
Normal file
81
studio-v2/app/messages/_components/EmojiPicker.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
106
studio-v2/app/messages/_components/NewConversationModal.tsx
Normal file
106
studio-v2/app/messages/_components/NewConversationModal.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
43
studio-v2/app/messages/_components/TemplatesDropdown.tsx
Normal file
43
studio-v2/app/messages/_components/TemplatesDropdown.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
166
studio-v2/app/messages/_components/useMessagesPage.ts
Normal file
166
studio-v2/app/messages/_components/useMessagesPage.ts
Normal 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
149
studio-v2/lib/alerts-b2b/actions.ts
Normal file
149
studio-v2/lib/alerts-b2b/actions.ts
Normal 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
|
||||
}
|
||||
59
studio-v2/lib/alerts-b2b/helpers.ts
Normal file
59
studio-v2/lib/alerts-b2b/helpers.ts
Normal 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]
|
||||
}
|
||||
427
studio-v2/lib/alerts-b2b/template-data.ts
Normal file
427
studio-v2/lib/alerts-b2b/template-data.ts
Normal 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
|
||||
}
|
||||
230
studio-v2/lib/alerts-b2b/types.ts
Normal file
230
studio-v2/lib/alerts-b2b/types.ts
Normal 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
|
||||
}
|
||||
80
website/app/admin/alerts/_components/DashboardTab.tsx
Normal file
80
website/app/admin/alerts/_components/DashboardTab.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
430
website/app/admin/alerts/_components/DocumentationTab.tsx
Normal file
430
website/app/admin/alerts/_components/DocumentationTab.tsx
Normal 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`
|
||||
67
website/app/admin/alerts/_components/InboxTab.tsx
Normal file
67
website/app/admin/alerts/_components/InboxTab.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
78
website/app/admin/alerts/_components/ProfileTab.tsx
Normal file
78
website/app/admin/alerts/_components/ProfileTab.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
57
website/app/admin/alerts/_components/RulesTab.tsx
Normal file
57
website/app/admin/alerts/_components/RulesTab.tsx
Normal 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} "{rule.conditions[0]?.value}"
|
||||
</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>
|
||||
)
|
||||
}
|
||||
49
website/app/admin/alerts/_components/TopicsTab.tsx
Normal file
49
website/app/admin/alerts/_components/TopicsTab.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
133
website/app/admin/alerts/_components/alertsSystemConfig.ts
Normal file
133
website/app/admin/alerts/_components/alertsSystemConfig.ts
Normal 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>
|
||||
`,
|
||||
}
|
||||
68
website/app/admin/alerts/_components/types.ts
Normal file
68
website/app/admin/alerts/_components/types.ts
Normal 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'
|
||||
193
website/app/admin/alerts/_components/useAlertsData.tsx
Normal file
193
website/app/admin/alerts/_components/useAlertsData.tsx
Normal 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
133
website/app/admin/backlog/_components/BacklogItemCard.tsx
Normal file
133
website/app/admin/backlog/_components/BacklogItemCard.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
205
website/app/admin/backlog/_components/backlogData.ts
Normal file
205
website/app/admin/backlog/_components/backlogData.ts
Normal 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 }] },
|
||||
]
|
||||
114
website/app/admin/backlog/_components/categories.tsx
Normal file
114
website/app/admin/backlog/_components/categories.tsx
Normal 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',
|
||||
},
|
||||
]
|
||||
35
website/app/admin/backlog/_components/types.ts
Normal file
35
website/app/admin/backlog/_components/types.ts
Normal 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' },
|
||||
}
|
||||
110
website/app/admin/backlog/_components/useBacklog.ts
Normal file
110
website/app/admin/backlog/_components/useBacklog.ts
Normal 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
98
website/app/admin/compliance/_components/ArchitekturTab.tsx
Normal file
98
website/app/admin/compliance/_components/ArchitekturTab.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
81
website/app/admin/compliance/_components/AuditTab.tsx
Normal file
81
website/app/admin/compliance/_components/AuditTab.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
303
website/app/admin/compliance/_components/ExecutiveTab.tsx
Normal file
303
website/app/admin/compliance/_components/ExecutiveTab.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
105
website/app/admin/compliance/_components/RoadmapTab.tsx
Normal file
105
website/app/admin/compliance/_components/RoadmapTab.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
88
website/app/admin/compliance/_components/TechnischTab.tsx
Normal file
88
website/app/admin/compliance/_components/TechnischTab.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
293
website/app/admin/compliance/_components/UebersichtTab.tsx
Normal file
293
website/app/admin/compliance/_components/UebersichtTab.tsx
Normal 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
191
website/app/admin/compliance/types.ts
Normal file
191
website/app/admin/compliance/types.ts
Normal 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' },
|
||||
]
|
||||
67
website/app/admin/docs/_components/ApiReferenceTab.tsx
Normal file
67
website/app/admin/docs/_components/ApiReferenceTab.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
116
website/app/admin/docs/_components/DockerTab.tsx
Normal file
116
website/app/admin/docs/_components/DockerTab.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
229
website/app/admin/docs/_components/OverviewTab.tsx
Normal file
229
website/app/admin/docs/_components/OverviewTab.tsx
Normal 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 → Next.js/FastAPI Frontend</p>
|
||||
<p><strong>2. API:</strong> Frontend → 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>
|
||||
)
|
||||
}
|
||||
79
website/app/admin/docs/_components/ServiceDetailPanel.tsx
Normal file
79
website/app/admin/docs/_components/ServiceDetailPanel.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
78
website/app/admin/docs/_components/ServicesTab.tsx
Normal file
78
website/app/admin/docs/_components/ServicesTab.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
31
website/app/admin/docs/_components/TabNavigation.tsx
Normal file
31
website/app/admin/docs/_components/TabNavigation.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
171
website/app/admin/docs/_components/TestingTab.tsx
Normal file
171
website/app/admin/docs/_components/TestingTab.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
37
website/app/admin/docs/audit/_components/AuditHeader.tsx
Normal file
37
website/app/admin/docs/audit/_components/AuditHeader.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
19
website/app/admin/docs/audit/_components/AuditTitleBlock.tsx
Normal file
19
website/app/admin/docs/audit/_components/AuditTitleBlock.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
12
website/app/admin/docs/audit/_components/CodeBlock.tsx
Normal file
12
website/app/admin/docs/audit/_components/CodeBlock.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
33
website/app/admin/docs/audit/_components/Table.tsx
Normal file
33
website/app/admin/docs/audit/_components/Table.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
41
website/app/admin/docs/audit/_components/TableOfContents.tsx
Normal file
41
website/app/admin/docs/audit/_components/TableOfContents.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
12
website/app/admin/docs/audit/_components/sections/index.ts
Normal file
12
website/app/admin/docs/audit/_components/sections/index.ts
Normal 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'
|
||||
54
website/app/admin/docs/audit/constants.ts
Normal file
54
website/app/admin/docs/audit/constants.ts
Normal 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
386
website/app/admin/docs/data.ts
Normal file
386
website/app/admin/docs/data.ts
Normal 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
|
||||
58
website/app/admin/docs/helpers.ts
Normal file
58
website/app/admin/docs/helpers.ts
Normal 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
42
website/app/admin/docs/types.ts
Normal file
42
website/app/admin/docs/types.ts
Normal 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'
|
||||
38
website/app/admin/quality/_components/FailedTestsList.tsx
Normal file
38
website/app/admin/quality/_components/FailedTestsList.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
73
website/app/admin/quality/_components/GoldenTab.tsx
Normal file
73
website/app/admin/quality/_components/GoldenTab.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
38
website/app/admin/quality/_components/IntentScoresChart.tsx
Normal file
38
website/app/admin/quality/_components/IntentScoresChart.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
52
website/app/admin/quality/_components/MetricCard.tsx
Normal file
52
website/app/admin/quality/_components/MetricCard.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
94
website/app/admin/quality/_components/OverviewTab.tsx
Normal file
94
website/app/admin/quality/_components/OverviewTab.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
97
website/app/admin/quality/_components/RagTab.tsx
Normal file
97
website/app/admin/quality/_components/RagTab.tsx
Normal 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 "Tests starten" 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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
266
website/app/admin/quality/_components/SchedulerTab.tsx
Normal file
266
website/app/admin/quality/_components/SchedulerTab.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
12
website/app/admin/quality/_components/SpinnerIcon.tsx
Normal file
12
website/app/admin/quality/_components/SpinnerIcon.tsx
Normal 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
Reference in New Issue
Block a user