[split-required] Split 58 monoliths across Python, Go, TypeScript (Phases 1-3)

Phase 1 — Python (klausur-service): 5 monoliths → 36 files
- dsfa_corpus_ingestion.py (1,828 LOC → 5 files)
- cv_ocr_engines.py (2,102 LOC → 7 files)
- cv_layout.py (3,653 LOC → 10 files)
- vocab_worksheet_api.py (2,783 LOC → 8 files)
- grid_build_core.py (1,958 LOC → 6 files)

Phase 2 — Go (edu-search-service, school-service): 8 monoliths → 19 files
- staff_crawler.go (1,402 → 4), policy/store.go (1,168 → 3)
- policy_handlers.go (700 → 2), repository.go (684 → 2)
- search.go (592 → 2), ai_extraction_handlers.go (554 → 2)
- seed_data.go (591 → 2), grade_service.go (646 → 2)

Phase 3 — TypeScript (admin-lehrer): 45 monoliths → 220+ files
- sdk/types.ts (2,108 → 16 domain files)
- ai/rag/page.tsx (2,686 → 14 files)
- 22 page.tsx files split into _components/ + _hooks/
- 11 component files split into sub-components
- 10 SDK data catalogs added to loc-exceptions
- Deleted dead backup index_original.ts (4,899 LOC)

All original public APIs preserved via re-export facades.
Zero new errors: Python imports verified, Go builds clean,
TypeScript tsc --noEmit shows only pre-existing errors.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Benjamin Admin
2026-04-24 17:28:57 +02:00
parent 9ba420fa91
commit b681ddb131
251 changed files with 30016 additions and 25037 deletions

View File

@@ -17,6 +17,8 @@
# Pure Data Registries (keine Logik, nur Daten-Definitionen)
**/dsfa_sources_registry.py | owner=klausur | reason=Pure data registry (license + source definitions, no logic) | review=2027-01-01
**/backlog/backlog-items.ts | owner=admin-lehrer | reason=Pure data array (506 LOC, no logic, only BacklogItem[] literals) | review=2027-01-01
**/lib/module-registry-data.ts | owner=admin-lehrer | reason=Pure data array (510 LOC, no logic, only BackendModule[] literals) | review=2027-01-01
# Algorithmic monolith — detect_column_geometry() allein 411 LOC, nicht weiter teilbar
**/cv_layout_columns.py | owner=klausur | reason=detect_column_geometry ist eine einzelne 411-LOC Funktion (Whitespace-Gap-Analyse) | review=2026-10-01
@@ -24,6 +26,18 @@
# Two indivisible route handlers (~230 LOC each) that cannot be split further
**/vocab_worksheet_compare_api.py | owner=klausur | reason=compare_ocr_methods (234 LOC) + analyze_grid (255 LOC), each a single cohesive handler | review=2026-10-01
# TypeScript Data Catalogs (admin-lehrer/lib/sdk/)
# Pure exported const arrays/objects with type definitions, no business logic.
# DSGVO/GDPR compliance catalogs: risk scenarios, mitigations, legal bases, checklists, etc.
**/lib/sdk/vendor-compliance/catalog/*.ts | owner=admin-lehrer | reason=Pure data catalogs (processing-activities 813, vendor-templates 564, legal-basis 562 LOC) | review=2027-01-01
**/lib/sdk/vendor-compliance/contract-review/findings.ts | owner=admin-lehrer | reason=Pure data catalog (573 LOC, FindingTemplate[] literals) | review=2027-01-01
**/lib/sdk/vendor-compliance/contract-review/checklists.ts | owner=admin-lehrer | reason=Pure data catalog (508 LOC, ChecklistItem[] literals) | review=2027-01-01
**/lib/sdk/dsfa/mitigation-library.ts | owner=admin-lehrer | reason=Pure data catalog (694 LOC, CatalogMitigation[] literals) | review=2027-01-01
**/lib/sdk/dsfa/eu-legal-frameworks.ts | owner=admin-lehrer | reason=Pure data catalog (622 LOC, legal framework definitions) | review=2027-01-01
**/lib/sdk/dsfa/risk-catalog.ts | owner=admin-lehrer | reason=Pure data catalog (615 LOC, CatalogRisk[] literals) | review=2027-01-01
**/lib/sdk/vvt-baseline-catalog.ts | owner=admin-lehrer | reason=Pure data catalog (630 LOC, BaselineTemplate[] literals) | review=2027-01-01
**/lib/sdk/loeschfristen-baseline-catalog.ts | owner=admin-lehrer | reason=Pure data catalog (578 LOC, retention period templates) | review=2027-01-01
# Legacy — TEMPORAER bis Refactoring abgeschlossen
# Dateien hier werden Phase fuer Phase abgearbeitet und entfernt.
# KEINE neuen Ausnahmen ohne [guardrail-change] Commit-Marker!

View File

@@ -0,0 +1,58 @@
import Link from 'next/link'
import { Brain, ArrowLeft, Play, Pause, CheckCircle, XCircle } from 'lucide-react'
import type { AgentDetail } from './types'
interface AgentHeaderProps {
agent: AgentDetail
}
export function AgentHeader({ agent }: AgentHeaderProps) {
return (
<div className="flex items-center justify-between mb-6">
<div className="flex items-center gap-4">
<Link
href="/ai/agents"
className="p-2 hover:bg-gray-100 rounded-lg transition-colors"
>
<ArrowLeft className="w-5 h-5 text-gray-600" />
</Link>
<div
className="p-3 rounded-xl"
style={{ backgroundColor: `${agent.color}20` }}
>
<Brain className="w-6 h-6" style={{ color: agent.color }} />
</div>
<div>
<h1 className="text-2xl font-bold text-gray-900">{agent.name}</h1>
<p className="text-gray-500">{agent.description}</p>
</div>
</div>
<div className="flex items-center gap-3">
<div className={`flex items-center gap-2 px-3 py-1.5 rounded-full text-sm font-medium ${
agent.status === 'running' ? 'bg-green-100 text-green-700' :
agent.status === 'paused' ? 'bg-yellow-100 text-yellow-700' :
'bg-red-100 text-red-700'
}`}>
{agent.status === 'running' ? <CheckCircle className="w-4 h-4" /> :
agent.status === 'paused' ? <Pause className="w-4 h-4" /> :
<XCircle className="w-4 h-4" />}
{agent.status}
</div>
<button className="flex items-center gap-2 px-4 py-2 border border-gray-300 rounded-lg hover:bg-gray-50 transition-colors">
{agent.status === 'running' ? (
<>
<Pause className="w-4 h-4" />
Pausieren
</>
) : (
<>
<Play className="w-4 h-4" />
Starten
</>
)}
</button>
</div>
</div>
)
}

View File

@@ -0,0 +1,32 @@
import type { AgentDetail } from './types'
interface AgentStatsBarProps {
agent: AgentDetail
}
export function AgentStatsBar({ agent }: AgentStatsBarProps) {
return (
<div className="grid grid-cols-5 gap-4 mb-6">
<div className="bg-white border border-gray-200 rounded-lg p-4">
<div className="text-sm text-gray-500">Aktive Sessions</div>
<div className="text-2xl font-bold text-gray-900">{agent.activeSessions}</div>
</div>
<div className="bg-white border border-gray-200 rounded-lg p-4">
<div className="text-sm text-gray-500">Verarbeitet (24h)</div>
<div className="text-2xl font-bold text-gray-900">{agent.totalProcessed.toLocaleString()}</div>
</div>
<div className="bg-white border border-gray-200 rounded-lg p-4">
<div className="text-sm text-gray-500">Avg. Antwortzeit</div>
<div className="text-2xl font-bold text-gray-900">{agent.avgResponseTime}ms</div>
</div>
<div className="bg-white border border-gray-200 rounded-lg p-4">
<div className="text-sm text-gray-500">Fehlerrate</div>
<div className="text-2xl font-bold text-amber-600">{agent.errorRate}%</div>
</div>
<div className="bg-white border border-gray-200 rounded-lg p-4">
<div className="text-sm text-gray-500">Version</div>
<div className="text-2xl font-bold text-gray-900">{agent.version}</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,32 @@
import { History } from 'lucide-react'
import type { ChangeLog } from './types'
interface HistoryTabContentProps {
changeLogs: ChangeLog[]
}
export function HistoryTabContent({ changeLogs }: HistoryTabContentProps) {
return (
<div>
<div className="space-y-4">
{changeLogs.map((log) => (
<div key={log.id} className="flex items-start gap-4 p-4 bg-gray-50 rounded-lg">
<div className="p-2 bg-white rounded-full border border-gray-200">
<History className="w-4 h-4 text-gray-500" />
</div>
<div className="flex-1">
<div className="flex items-center justify-between">
<span className="font-medium text-gray-900">{log.action}</span>
<span className="text-sm text-gray-500">
{new Date(log.timestamp).toLocaleString('de-DE')}
</span>
</div>
<p className="text-sm text-gray-600 mt-1">{log.description}</p>
<p className="text-xs text-gray-400 mt-1">von {log.user}</p>
</div>
</div>
))}
</div>
</div>
)
}

View File

@@ -0,0 +1,102 @@
import { FileText, Clock, RotateCcw, Save, Edit3, AlertTriangle } from 'lucide-react'
import type { AgentDetail } from './types'
interface SoulTabContentProps {
agent: AgentDetail
editedContent: string
isEditing: boolean
hasChanges: boolean
saving: boolean
onContentChange: (content: string) => void
onSave: () => void
onReset: () => void
onStartEditing: () => void
}
export function SoulTabContent({
agent,
editedContent,
isEditing,
hasChanges,
saving,
onContentChange,
onSave,
onReset,
onStartEditing,
}: SoulTabContentProps) {
return (
<div>
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-2 text-sm text-gray-500">
<FileText className="w-4 h-4" />
{agent.soulFile}
<span className="text-gray-300">|</span>
<Clock className="w-4 h-4" />
Zuletzt geaendert: {new Date(agent.updatedAt).toLocaleString('de-DE')}
</div>
<div className="flex items-center gap-2">
{isEditing ? (
<>
<button
onClick={onReset}
disabled={!hasChanges}
className="flex items-center gap-2 px-4 py-2 border border-gray-300 rounded-lg hover:bg-gray-50 transition-colors disabled:opacity-50"
>
<RotateCcw className="w-4 h-4" />
Zuruecksetzen
</button>
<button
onClick={onSave}
disabled={!hasChanges || saving}
className="flex items-center gap-2 px-4 py-2 bg-teal-600 text-white rounded-lg hover:bg-teal-700 transition-colors disabled:opacity-50"
>
<Save className="w-4 h-4" />
{saving ? 'Speichert...' : 'Speichern'}
</button>
</>
) : (
<button
onClick={onStartEditing}
className="flex items-center gap-2 px-4 py-2 bg-teal-600 text-white rounded-lg hover:bg-teal-700 transition-colors"
>
<Edit3 className="w-4 h-4" />
Bearbeiten
</button>
)}
</div>
</div>
{hasChanges && (
<div className="mb-4 p-3 bg-amber-50 border border-amber-200 rounded-lg flex items-center gap-2 text-amber-700">
<AlertTriangle className="w-4 h-4" />
<span className="text-sm">Ungespeicherte Aenderungen vorhanden</span>
</div>
)}
<div className="relative">
{isEditing ? (
<textarea
value={editedContent}
onChange={(e) => onContentChange(e.target.value)}
className="w-full h-[600px] p-4 font-mono text-sm bg-gray-50 border border-gray-200 rounded-lg focus:outline-none focus:ring-2 focus:ring-teal-500 focus:border-transparent resize-none"
spellCheck={false}
/>
) : (
<div className="w-full h-[600px] p-4 font-mono text-sm bg-gray-50 border border-gray-200 rounded-lg overflow-auto whitespace-pre-wrap">
{agent.soulContent}
</div>
)}
</div>
<div className="mt-4 p-4 bg-blue-50 border border-blue-200 rounded-lg">
<h4 className="font-medium text-blue-900 mb-2">Hinweise zur SOUL-Datei</h4>
<ul className="text-sm text-blue-700 space-y-1">
<li>Die SOUL-Datei definiert die Persoenlichkeit und das Verhalten des Agents</li>
<li>Aenderungen werden nach dem Speichern sofort wirksam</li>
<li>Testen Sie Aenderungen zuerst im Staging-Modus</li>
<li>Alle Aenderungen werden in der Historie protokolliert</li>
</ul>
</div>
</div>
)
}

View File

@@ -0,0 +1,16 @@
import Link from 'next/link'
import { Activity } from 'lucide-react'
export function StatsTabContent() {
return (
<div className="space-y-6">
<div className="text-center py-12 text-gray-500">
<Activity className="w-12 h-12 mx-auto mb-4 text-gray-400" />
<p>Live-Statistiken werden in einer zukuenftigen Version verfuegbar sein.</p>
<p className="text-sm mt-2">
Besuchen Sie die <Link href="/ai/agents/statistics" className="text-teal-600 hover:underline">Statistik-Seite</Link> fuer aggregierte Daten.
</p>
</div>
</div>
)
}

View File

@@ -0,0 +1,6 @@
export { AgentHeader } from './AgentHeader'
export { AgentStatsBar } from './AgentStatsBar'
export { SoulTabContent } from './SoulTabContent'
export { StatsTabContent } from './StatsTabContent'
export { HistoryTabContent } from './HistoryTabContent'
export type { AgentDetail, ChangeLog } from './types'

View File

@@ -0,0 +1,308 @@
import type { AgentDetail, ChangeLog } from './types'
export const mockAgentDetails: Record<string, AgentDetail> = {
'tutor-agent': {
id: 'tutor-agent',
name: 'TutorAgent',
description: 'Geduldiger, ermutigender Lernbegleiter fuer Schueler',
soulFile: 'tutor-agent.soul.md',
soulContent: `# TutorAgent SOUL
## Identitaet
Du bist ein geduldiger, ermutigender Lernbegleiter fuer Schueler.
Dein Ziel ist es, Verstaendnis zu foerdern, nicht Antworten vorzugeben.
## Kernprinzipien
- **Sokratische Methode**: Stelle Fragen, die zum Nachdenken anregen
- **Positives Reinforcement**: Erkenne und feiere Lernfortschritte
- **Adaptive Kommunikation**: Passe Sprache und Komplexitaet an das Niveau an
- **Geduld**: Wiederhole Erklaerungen ohne Frustration zu zeigen
## Kommunikationsstil
- Verwende einfache, klare Sprache
- Stelle Rueckfragen, um Verstaendnis zu pruefen
- Gib Hinweise statt direkter Loesungen
- Feiere kleine Erfolge
- Nutze Analogien und Beispiele aus dem Alltag
- Strukturiere komplexe Themen in verdauliche Schritte
## Fachgebiete
- Mathematik (Grundschule bis Abitur)
- Naturwissenschaften (Physik, Chemie, Biologie)
- Sprachen (Deutsch, Englisch)
- Gesellschaftswissenschaften (Geschichte, Politik)
## Lernstrategien
1. **Konzeptbasiertes Lernen**: Erklaere das "Warum" hinter Regeln
2. **Visualisierung**: Nutze Diagramme und Skizzen wenn moeglich
3. **Verbindungen herstellen**: Verknuepfe neues Wissen mit Bekanntem
4. **Wiederholung**: Baue systematische Wiederholung ein
5. **Selbsttest**: Ermutige zur Selbstueberpruefung
## Einschraenkungen
- Gib NIEMALS vollstaendige Loesungen fuer Hausaufgaben
- Verweise bei komplexen Themen auf Lehrkraefte
- Erkenne Frustration und biete Pausen an
- Keine Unterstuetzung bei Pruefungsbetrug
- Keine medizinischen oder rechtlichen Ratschlaege
## Eskalation
- Bei wiederholtem Unverstaendnis: Schlage alternatives Erklaerformat vor
- Bei emotionaler Belastung: Empfehle Gespraech mit Vertrauensperson
- Bei technischen Problemen: Eskaliere an Support
- Bei Verdacht auf Lernschwierigkeiten: Empfehle professionelle Diagnostik
## Metrik-Ziele
- Verstaendnis-Score > 80% bei Nachfragen
- Engagement-Zeit > 5 Minuten pro Session
- Wiederbesuchs-Rate > 60%
- Frustrations-Indikatoren < 10%`,
color: '#3b82f6',
status: 'running',
activeSessions: 12,
totalProcessed: 1847,
avgResponseTime: 234,
errorRate: 0.5,
lastRestart: '2025-01-14T08:30:00Z',
version: '1.2.0',
createdAt: '2024-11-01T00:00:00Z',
updatedAt: '2025-01-14T10:15:00Z'
},
'grader-agent': {
id: 'grader-agent',
name: 'GraderAgent',
description: 'Objektiver, fairer Pruefer von Schuelerarbeiten',
soulFile: 'grader-agent.soul.md',
soulContent: `# GraderAgent SOUL
## Identitaet
Du bist ein objektiver, fairer Pruefer von Schuelerarbeiten.
Dein Ziel ist konstruktives Feedback, das zum Lernen motiviert.
## Kernprinzipien
- **Objektivitaet**: Bewerte nach festgelegten Kriterien, nicht nach Sympathie
- **Fairness**: Gleiche Massstaebe fuer alle Schueler
- **Konstruktivitaet**: Feedback soll zum Lernen anregen
- **Transparenz**: Begruende jede Bewertung nachvollziehbar
## Bewertungsprinzipien
- Bewerte nach festgelegten Kriterien (Erwartungshorizont)
- Beruecksichtige Teilleistungen
- Unterscheide zwischen Fluechtigkeitsfehlern und Verstaendnisluecken
- Formuliere Feedback lernfoerdernd
- Nutze das 15-Punkte-System korrekt (0-15 Punkte, 5 = ausreichend)
## Workflow
1. Lies die Aufgabenstellung und den Erwartungshorizont
2. Analysiere die Schuelerantwort systematisch
3. Identifiziere korrekte Elemente
4. Identifiziere Fehler mit Kategorisierung
5. Vergebe Punkte nach Kriterienkatalog
6. Formuliere konstruktives Feedback
## Fehlerkategorien
- **Rechtschreibung (R)**: Orthografische Fehler
- **Grammatik (Gr)**: Grammatikalische Fehler
- **Ausdruck (A)**: Stilistische Schwaechen
- **Inhalt (I)**: Fachliche Fehler oder Luecken
- **Struktur (St)**: Aufbau- und Gliederungsprobleme
- **Logik (L)**: Argumentationsfehler
## Qualitaetssicherung
- Bei Unsicherheit: Markiere zur manuellen Ueberpruefung
- Bei Grenzfaellen: Dokumentiere Entscheidungsgrundlage
- Konsistenz: Vergleiche mit aehnlichen Bewertungen
- Kalibrierung: Orientiere an Vergleichsarbeiten
## Eskalation
- Unleserliche Antworten: Markiere fuer manuelles Review
- Verdacht auf Plagiat: Eskaliere an Lehrkraft
- Technische Fehler: Pausiere und melde
- Unklare Aufgabenstellung: Frage nach Klarstellung`,
color: '#10b981',
status: 'running',
activeSessions: 3,
totalProcessed: 456,
avgResponseTime: 1205,
errorRate: 1.2,
lastRestart: '2025-01-13T14:00:00Z',
version: '1.1.0',
createdAt: '2024-11-01T00:00:00Z',
updatedAt: '2025-01-13T16:30:00Z'
},
'quality-judge': {
id: 'quality-judge',
name: 'QualityJudge',
description: 'Kritischer Qualitaetspruefer fuer KI-generierte Inhalte',
soulFile: 'quality-judge.soul.md',
soulContent: `# QualityJudge SOUL
## Identitaet
Du bist ein kritischer Qualitaetspruefer fuer KI-generierte Inhalte.
Dein Ziel ist die Sicherstellung hoher Qualitaetsstandards.
## Bewertungsdimensionen
### 1. Intent Accuracy (0-100)
- Wurde die Benutzerabsicht korrekt erkannt?
- Stimmt die Kategorie der Antwort?
### 2. Faithfulness (1-5)
- **5**: Vollstaendig faktisch korrekt
- **4**: Minor Ungenauigkeiten ohne Auswirkung
- **3**: Einige Ungenauigkeiten, Kernaussage korrekt
- **2**: Signifikante Fehler
- **1**: Grundlegend falsch
### 3. Relevance (1-5)
- **5**: Direkt und vollstaendig relevant
- **4**: Weitgehend relevant
- **3**: Teilweise relevant
- **2**: Geringe Relevanz
- **1**: Voellig irrelevant
### 4. Coherence (1-5)
- **5**: Perfekt strukturiert und logisch
- **4**: Gut strukturiert, kleine Luecken
- **3**: Verstaendlich, aber verbesserungsfaehig
- **2**: Schwer zu folgen
- **1**: Unverstaendlich/chaotisch
### 5. Safety ("pass"/"fail")
- Keine DSGVO-Verstoesse (keine PII)
- Keine schaedlichen Inhalte
- Keine Desinformation
- Keine Diskriminierung
- Altersgerechte Sprache
## Schwellenwerte
- **Production Ready**: composite >= 80
- **Needs Review**: 60 <= composite < 80
- **Failed**: composite < 60`,
color: '#f59e0b',
status: 'running',
activeSessions: 8,
totalProcessed: 3291,
avgResponseTime: 89,
errorRate: 0.3,
lastRestart: '2025-01-14T06:00:00Z',
version: '2.0.0',
createdAt: '2024-10-15T00:00:00Z',
updatedAt: '2025-01-14T08:00:00Z'
},
'alert-agent': {
id: 'alert-agent',
name: 'AlertAgent',
description: 'Aufmerksamer Waechter fuer das Breakpilot-System',
soulFile: 'alert-agent.soul.md',
soulContent: `# AlertAgent SOUL
## Identitaet
Du bist ein aufmerksamer Waechter fuer das Breakpilot-System.
Dein Ziel ist die rechtzeitige Erkennung und Kommunikation relevanter Ereignisse.
## Importance Levels
### KRITISCH (5)
- Systemausfaelle
- Sicherheitsvorfaelle
- DSGVO-Verstoesse
**Aktion**: Sofortige Benachrichtigung aller Admins
### DRINGEND (4)
- Performance-Probleme
- API-Ausfaelle
- Hohe Fehlerraten
**Aktion**: Benachrichtigung innerhalb 5 Minuten
### WICHTIG (3)
- Neue kritische Nachrichten
- Relevante Bildungspolitik
- Technische Warnungen
**Aktion**: Taeglicher Digest
### PRUEFEN (2)
- Interessante Entwicklungen
- Konkurrenznachrichten
**Aktion**: Woechentlicher Digest
### INFO (1)
- Allgemeine Updates
**Aktion**: Archivieren`,
color: '#ef4444',
status: 'running',
activeSessions: 1,
totalProcessed: 892,
avgResponseTime: 45,
errorRate: 0.1,
lastRestart: '2025-01-12T00:00:00Z',
version: '1.0.0',
createdAt: '2024-12-01T00:00:00Z',
updatedAt: '2025-01-12T02:00:00Z'
},
'orchestrator': {
id: 'orchestrator',
name: 'Orchestrator',
description: 'Zentraler Koordinator des Multi-Agent-Systems',
soulFile: 'orchestrator.soul.md',
soulContent: `# OrchestratorAgent SOUL
## Identitaet
Du bist der zentrale Koordinator des Breakpilot Multi-Agent-Systems.
Dein Ziel ist die effiziente Verteilung und Ueberwachung von Aufgaben.
## Kernprinzipien
- **Effizienz**: Minimale Latenz bei maximaler Qualitaet
- **Resilienz**: Graceful Degradation bei Agent-Ausfaellen
- **Fairness**: Ausgewogene Lastverteilung
- **Transparenz**: Volle Nachvollziehbarkeit aller Entscheidungen
## Verantwortlichkeiten
1. Task-Routing zu spezialisierten Agents
2. Session-Management und Recovery
3. Agent-Gesundheitsueberwachung
4. Lastverteilung
5. Fehlerbehandlung und Retry-Logik
## Task-Routing-Logik
| Intent-Kategorie | Primaerer Agent | Fallback |
|------------------|-----------------|----------|
| learning_support | TutorAgent | Manuell |
| exam_grading | GraderAgent | QualityJudge |
| quality_check | QualityJudge | Manual Review |
| system_alert | AlertAgent | E-Mail Fallback |
## Fehlerbehandlung
### Retry-Policy
- **Max Retries**: 3
- **Backoff**: Exponential (1s, 2s, 4s)
- **Keine Retries**: Validation Errors, Auth Failures
### Circuit Breaker
- **Threshold**: 5 Fehler in 60 Sekunden
- **Cooldown**: 30 Sekunden
## Metriken
- **Task Completion Rate**: > 99%
- **Average Latency**: < 2s
- **Error Rate**: < 1%`,
color: '#8b5cf6',
status: 'running',
activeSessions: 24,
totalProcessed: 8934,
avgResponseTime: 12,
errorRate: 0.2,
lastRestart: '2025-01-14T00:00:00Z',
version: '1.5.0',
createdAt: '2024-10-01T00:00:00Z',
updatedAt: '2025-01-14T00:30:00Z'
}
}
export const mockChangeLogs: ChangeLog[] = [
{ id: '1', timestamp: '2025-01-14T10:15:00Z', user: 'admin@breakpilot.de', action: 'SOUL Updated', description: 'Kommunikationsstil angepasst' },
{ id: '2', timestamp: '2025-01-13T14:30:00Z', user: 'lehrer1@schule.de', action: 'Einschraenkung hinzugefuegt', description: 'Keine Hausaufgaben-Loesungen' },
{ id: '3', timestamp: '2025-01-10T09:00:00Z', user: 'admin@breakpilot.de', action: 'Version 1.2.0', description: 'Neue Fachgebiete hinzugefuegt' },
]

View File

@@ -0,0 +1,25 @@
export interface AgentDetail {
id: string
name: string
description: string
soulFile: string
soulContent: string
color: string
status: 'running' | 'paused' | 'stopped' | 'error'
activeSessions: number
totalProcessed: number
avgResponseTime: number
errorRate: number
lastRestart: string
version: string
createdAt: string
updatedAt: string
}
export interface ChangeLog {
id: string
timestamp: string
user: string
action: string
description: string
}

View File

@@ -1,348 +1,29 @@
'use client'
import { useState, useEffect } from 'react'
import { useParams, useRouter } from 'next/navigation'
import { useParams } from 'next/navigation'
import Link from 'next/link'
import { Bot, Brain, ArrowLeft, Save, RotateCcw, Play, Pause, AlertTriangle, FileText, Settings, Activity, Clock, CheckCircle, XCircle, History, Eye, Edit3 } from 'lucide-react'
import { AlertTriangle, FileText, Activity, History } from 'lucide-react'
import { mockAgentDetails, mockChangeLogs } from './_components/mock-data'
import {
AgentHeader,
AgentStatsBar,
SoulTabContent,
StatsTabContent,
HistoryTabContent,
} from './_components'
import type { AgentDetail } from './_components'
// Types
interface AgentDetail {
id: string
name: string
description: string
soulFile: string
soulContent: string
color: string
status: 'running' | 'paused' | 'stopped' | 'error'
activeSessions: number
totalProcessed: number
avgResponseTime: number
errorRate: number
lastRestart: string
version: string
createdAt: string
updatedAt: string
}
type TabId = 'soul' | 'stats' | 'history'
interface ChangeLog {
id: string
timestamp: string
user: string
action: string
description: string
}
// Mock data
const mockAgentDetails: Record<string, AgentDetail> = {
'tutor-agent': {
id: 'tutor-agent',
name: 'TutorAgent',
description: 'Geduldiger, ermutigender Lernbegleiter fuer Schueler',
soulFile: 'tutor-agent.soul.md',
soulContent: `# TutorAgent SOUL
## Identitaet
Du bist ein geduldiger, ermutigender Lernbegleiter fuer Schueler.
Dein Ziel ist es, Verstaendnis zu foerdern, nicht Antworten vorzugeben.
## Kernprinzipien
- **Sokratische Methode**: Stelle Fragen, die zum Nachdenken anregen
- **Positives Reinforcement**: Erkenne und feiere Lernfortschritte
- **Adaptive Kommunikation**: Passe Sprache und Komplexitaet an das Niveau an
- **Geduld**: Wiederhole Erklaerungen ohne Frustration zu zeigen
## Kommunikationsstil
- Verwende einfache, klare Sprache
- Stelle Rueckfragen, um Verstaendnis zu pruefen
- Gib Hinweise statt direkter Loesungen
- Feiere kleine Erfolge
- Nutze Analogien und Beispiele aus dem Alltag
- Strukturiere komplexe Themen in verdauliche Schritte
## Fachgebiete
- Mathematik (Grundschule bis Abitur)
- Naturwissenschaften (Physik, Chemie, Biologie)
- Sprachen (Deutsch, Englisch)
- Gesellschaftswissenschaften (Geschichte, Politik)
## Lernstrategien
1. **Konzeptbasiertes Lernen**: Erklaere das "Warum" hinter Regeln
2. **Visualisierung**: Nutze Diagramme und Skizzen wenn moeglich
3. **Verbindungen herstellen**: Verknuepfe neues Wissen mit Bekanntem
4. **Wiederholung**: Baue systematische Wiederholung ein
5. **Selbsttest**: Ermutige zur Selbstueberpruefung
## Einschraenkungen
- Gib NIEMALS vollstaendige Loesungen fuer Hausaufgaben
- Verweise bei komplexen Themen auf Lehrkraefte
- Erkenne Frustration und biete Pausen an
- Keine Unterstuetzung bei Pruefungsbetrug
- Keine medizinischen oder rechtlichen Ratschlaege
## Eskalation
- Bei wiederholtem Unverstaendnis: Schlage alternatives Erklaerformat vor
- Bei emotionaler Belastung: Empfehle Gespraech mit Vertrauensperson
- Bei technischen Problemen: Eskaliere an Support
- Bei Verdacht auf Lernschwierigkeiten: Empfehle professionelle Diagnostik
## Metrik-Ziele
- Verstaendnis-Score > 80% bei Nachfragen
- Engagement-Zeit > 5 Minuten pro Session
- Wiederbesuchs-Rate > 60%
- Frustrations-Indikatoren < 10%`,
color: '#3b82f6',
status: 'running',
activeSessions: 12,
totalProcessed: 1847,
avgResponseTime: 234,
errorRate: 0.5,
lastRestart: '2025-01-14T08:30:00Z',
version: '1.2.0',
createdAt: '2024-11-01T00:00:00Z',
updatedAt: '2025-01-14T10:15:00Z'
},
'grader-agent': {
id: 'grader-agent',
name: 'GraderAgent',
description: 'Objektiver, fairer Pruefer von Schuelerarbeiten',
soulFile: 'grader-agent.soul.md',
soulContent: `# GraderAgent SOUL
## Identitaet
Du bist ein objektiver, fairer Pruefer von Schuelerarbeiten.
Dein Ziel ist konstruktives Feedback, das zum Lernen motiviert.
## Kernprinzipien
- **Objektivitaet**: Bewerte nach festgelegten Kriterien, nicht nach Sympathie
- **Fairness**: Gleiche Massstaebe fuer alle Schueler
- **Konstruktivitaet**: Feedback soll zum Lernen anregen
- **Transparenz**: Begruende jede Bewertung nachvollziehbar
## Bewertungsprinzipien
- Bewerte nach festgelegten Kriterien (Erwartungshorizont)
- Beruecksichtige Teilleistungen
- Unterscheide zwischen Fluechtigkeitsfehlern und Verstaendnisluecken
- Formuliere Feedback lernfoerdernd
- Nutze das 15-Punkte-System korrekt (0-15 Punkte, 5 = ausreichend)
## Workflow
1. Lies die Aufgabenstellung und den Erwartungshorizont
2. Analysiere die Schuelerantwort systematisch
3. Identifiziere korrekte Elemente
4. Identifiziere Fehler mit Kategorisierung
5. Vergebe Punkte nach Kriterienkatalog
6. Formuliere konstruktives Feedback
## Fehlerkategorien
- **Rechtschreibung (R)**: Orthografische Fehler
- **Grammatik (Gr)**: Grammatikalische Fehler
- **Ausdruck (A)**: Stilistische Schwaechen
- **Inhalt (I)**: Fachliche Fehler oder Luecken
- **Struktur (St)**: Aufbau- und Gliederungsprobleme
- **Logik (L)**: Argumentationsfehler
## Qualitaetssicherung
- Bei Unsicherheit: Markiere zur manuellen Ueberpruefung
- Bei Grenzfaellen: Dokumentiere Entscheidungsgrundlage
- Konsistenz: Vergleiche mit aehnlichen Bewertungen
- Kalibrierung: Orientiere an Vergleichsarbeiten
## Eskalation
- Unleserliche Antworten: Markiere fuer manuelles Review
- Verdacht auf Plagiat: Eskaliere an Lehrkraft
- Technische Fehler: Pausiere und melde
- Unklare Aufgabenstellung: Frage nach Klarstellung`,
color: '#10b981',
status: 'running',
activeSessions: 3,
totalProcessed: 456,
avgResponseTime: 1205,
errorRate: 1.2,
lastRestart: '2025-01-13T14:00:00Z',
version: '1.1.0',
createdAt: '2024-11-01T00:00:00Z',
updatedAt: '2025-01-13T16:30:00Z'
},
'quality-judge': {
id: 'quality-judge',
name: 'QualityJudge',
description: 'Kritischer Qualitaetspruefer fuer KI-generierte Inhalte',
soulFile: 'quality-judge.soul.md',
soulContent: `# QualityJudge SOUL
## Identitaet
Du bist ein kritischer Qualitaetspruefer fuer KI-generierte Inhalte.
Dein Ziel ist die Sicherstellung hoher Qualitaetsstandards.
## Bewertungsdimensionen
### 1. Intent Accuracy (0-100)
- Wurde die Benutzerabsicht korrekt erkannt?
- Stimmt die Kategorie der Antwort?
### 2. Faithfulness (1-5)
- **5**: Vollstaendig faktisch korrekt
- **4**: Minor Ungenauigkeiten ohne Auswirkung
- **3**: Einige Ungenauigkeiten, Kernaussage korrekt
- **2**: Signifikante Fehler
- **1**: Grundlegend falsch
### 3. Relevance (1-5)
- **5**: Direkt und vollstaendig relevant
- **4**: Weitgehend relevant
- **3**: Teilweise relevant
- **2**: Geringe Relevanz
- **1**: Voellig irrelevant
### 4. Coherence (1-5)
- **5**: Perfekt strukturiert und logisch
- **4**: Gut strukturiert, kleine Luecken
- **3**: Verstaendlich, aber verbesserungsfaehig
- **2**: Schwer zu folgen
- **1**: Unverstaendlich/chaotisch
### 5. Safety ("pass"/"fail")
- Keine DSGVO-Verstoesse (keine PII)
- Keine schaedlichen Inhalte
- Keine Desinformation
- Keine Diskriminierung
- Altersgerechte Sprache
## Schwellenwerte
- **Production Ready**: composite >= 80
- **Needs Review**: 60 <= composite < 80
- **Failed**: composite < 60`,
color: '#f59e0b',
status: 'running',
activeSessions: 8,
totalProcessed: 3291,
avgResponseTime: 89,
errorRate: 0.3,
lastRestart: '2025-01-14T06:00:00Z',
version: '2.0.0',
createdAt: '2024-10-15T00:00:00Z',
updatedAt: '2025-01-14T08:00:00Z'
},
'alert-agent': {
id: 'alert-agent',
name: 'AlertAgent',
description: 'Aufmerksamer Waechter fuer das Breakpilot-System',
soulFile: 'alert-agent.soul.md',
soulContent: `# AlertAgent SOUL
## Identitaet
Du bist ein aufmerksamer Waechter fuer das Breakpilot-System.
Dein Ziel ist die rechtzeitige Erkennung und Kommunikation relevanter Ereignisse.
## Importance Levels
### KRITISCH (5)
- Systemausfaelle
- Sicherheitsvorfaelle
- DSGVO-Verstoesse
**Aktion**: Sofortige Benachrichtigung aller Admins
### DRINGEND (4)
- Performance-Probleme
- API-Ausfaelle
- Hohe Fehlerraten
**Aktion**: Benachrichtigung innerhalb 5 Minuten
### WICHTIG (3)
- Neue kritische Nachrichten
- Relevante Bildungspolitik
- Technische Warnungen
**Aktion**: Taeglicher Digest
### PRUEFEN (2)
- Interessante Entwicklungen
- Konkurrenznachrichten
**Aktion**: Woechentlicher Digest
### INFO (1)
- Allgemeine Updates
**Aktion**: Archivieren`,
color: '#ef4444',
status: 'running',
activeSessions: 1,
totalProcessed: 892,
avgResponseTime: 45,
errorRate: 0.1,
lastRestart: '2025-01-12T00:00:00Z',
version: '1.0.0',
createdAt: '2024-12-01T00:00:00Z',
updatedAt: '2025-01-12T02:00:00Z'
},
'orchestrator': {
id: 'orchestrator',
name: 'Orchestrator',
description: 'Zentraler Koordinator des Multi-Agent-Systems',
soulFile: 'orchestrator.soul.md',
soulContent: `# OrchestratorAgent SOUL
## Identitaet
Du bist der zentrale Koordinator des Breakpilot Multi-Agent-Systems.
Dein Ziel ist die effiziente Verteilung und Ueberwachung von Aufgaben.
## Kernprinzipien
- **Effizienz**: Minimale Latenz bei maximaler Qualitaet
- **Resilienz**: Graceful Degradation bei Agent-Ausfaellen
- **Fairness**: Ausgewogene Lastverteilung
- **Transparenz**: Volle Nachvollziehbarkeit aller Entscheidungen
## Verantwortlichkeiten
1. Task-Routing zu spezialisierten Agents
2. Session-Management und Recovery
3. Agent-Gesundheitsueberwachung
4. Lastverteilung
5. Fehlerbehandlung und Retry-Logik
## Task-Routing-Logik
| Intent-Kategorie | Primaerer Agent | Fallback |
|------------------|-----------------|----------|
| learning_support | TutorAgent | Manuell |
| exam_grading | GraderAgent | QualityJudge |
| quality_check | QualityJudge | Manual Review |
| system_alert | AlertAgent | E-Mail Fallback |
## Fehlerbehandlung
### Retry-Policy
- **Max Retries**: 3
- **Backoff**: Exponential (1s, 2s, 4s)
- **Keine Retries**: Validation Errors, Auth Failures
### Circuit Breaker
- **Threshold**: 5 Fehler in 60 Sekunden
- **Cooldown**: 30 Sekunden
## Metriken
- **Task Completion Rate**: > 99%
- **Average Latency**: < 2s
- **Error Rate**: < 1%`,
color: '#8b5cf6',
status: 'running',
activeSessions: 24,
totalProcessed: 8934,
avgResponseTime: 12,
errorRate: 0.2,
lastRestart: '2025-01-14T00:00:00Z',
version: '1.5.0',
createdAt: '2024-10-01T00:00:00Z',
updatedAt: '2025-01-14T00:30:00Z'
}
}
const mockChangeLogs: ChangeLog[] = [
{ id: '1', timestamp: '2025-01-14T10:15:00Z', user: 'admin@breakpilot.de', action: 'SOUL Updated', description: 'Kommunikationsstil angepasst' },
{ id: '2', timestamp: '2025-01-13T14:30:00Z', user: 'lehrer1@schule.de', action: 'Einschraenkung hinzugefuegt', description: 'Keine Hausaufgaben-Loesungen' },
{ id: '3', timestamp: '2025-01-10T09:00:00Z', user: 'admin@breakpilot.de', action: 'Version 1.2.0', description: 'Neue Fachgebiete hinzugefuegt' },
const TABS: { id: TabId; label: string; icon: typeof FileText }[] = [
{ id: 'soul', label: 'SOUL-File', icon: FileText },
{ id: 'stats', label: 'Live-Statistiken', icon: Activity },
{ id: 'history', label: 'Aenderungshistorie', icon: History },
]
export default function AgentDetailPage() {
const params = useParams()
const router = useRouter()
const agentId = params.agentId as string
const [agent, setAgent] = useState<AgentDetail | null>(null)
@@ -350,10 +31,9 @@ export default function AgentDetailPage() {
const [isEditing, setIsEditing] = useState(false)
const [hasChanges, setHasChanges] = useState(false)
const [saving, setSaving] = useState(false)
const [activeTab, setActiveTab] = useState<'soul' | 'stats' | 'history'>('soul')
const [activeTab, setActiveTab] = useState<TabId>('soul')
useEffect(() => {
// Load agent data
const agentData = mockAgentDetails[agentId]
if (agentData) {
setAgent(agentData)
@@ -363,10 +43,7 @@ export default function AgentDetailPage() {
const handleSave = async () => {
setSaving(true)
// In production, save to API
// await fetch(`/api/admin/agents/${agentId}/soul`, { method: 'PUT', body: editedContent })
await new Promise(resolve => setTimeout(resolve, 1000))
if (agent) {
setAgent({ ...agent, soulContent: editedContent, updatedAt: new Date().toISOString() })
}
@@ -393,7 +70,7 @@ export default function AgentDetailPage() {
<div className="text-center py-12">
<AlertTriangle className="w-12 h-12 text-amber-500 mx-auto mb-4" />
<h2 className="text-xl font-semibold text-gray-900 mb-2">Agent nicht gefunden</h2>
<p className="text-gray-500 mb-4">Der Agent "{agentId}" existiert nicht.</p>
<p className="text-gray-500 mb-4">Der Agent &quot;{agentId}&quot; existiert nicht.</p>
<Link href="/ai/agents" className="text-teal-600 hover:text-teal-700">
&larr; Zurueck zur Uebersicht
</Link>
@@ -404,231 +81,46 @@ export default function AgentDetailPage() {
return (
<div className="p-6 max-w-7xl mx-auto">
{/* Header */}
<div className="flex items-center justify-between mb-6">
<div className="flex items-center gap-4">
<Link
href="/ai/agents"
className="p-2 hover:bg-gray-100 rounded-lg transition-colors"
>
<ArrowLeft className="w-5 h-5 text-gray-600" />
</Link>
<div
className="p-3 rounded-xl"
style={{ backgroundColor: `${agent.color}20` }}
>
<Brain className="w-6 h-6" style={{ color: agent.color }} />
</div>
<div>
<h1 className="text-2xl font-bold text-gray-900">{agent.name}</h1>
<p className="text-gray-500">{agent.description}</p>
</div>
</div>
<div className="flex items-center gap-3">
<div className={`flex items-center gap-2 px-3 py-1.5 rounded-full text-sm font-medium ${
agent.status === 'running' ? 'bg-green-100 text-green-700' :
agent.status === 'paused' ? 'bg-yellow-100 text-yellow-700' :
'bg-red-100 text-red-700'
}`}>
{agent.status === 'running' ? <CheckCircle className="w-4 h-4" /> :
agent.status === 'paused' ? <Pause className="w-4 h-4" /> :
<XCircle className="w-4 h-4" />}
{agent.status}
</div>
<button className="flex items-center gap-2 px-4 py-2 border border-gray-300 rounded-lg hover:bg-gray-50 transition-colors">
{agent.status === 'running' ? (
<>
<Pause className="w-4 h-4" />
Pausieren
</>
) : (
<>
<Play className="w-4 h-4" />
Starten
</>
)}
</button>
</div>
</div>
{/* Stats Bar */}
<div className="grid grid-cols-5 gap-4 mb-6">
<div className="bg-white border border-gray-200 rounded-lg p-4">
<div className="text-sm text-gray-500">Aktive Sessions</div>
<div className="text-2xl font-bold text-gray-900">{agent.activeSessions}</div>
</div>
<div className="bg-white border border-gray-200 rounded-lg p-4">
<div className="text-sm text-gray-500">Verarbeitet (24h)</div>
<div className="text-2xl font-bold text-gray-900">{agent.totalProcessed.toLocaleString()}</div>
</div>
<div className="bg-white border border-gray-200 rounded-lg p-4">
<div className="text-sm text-gray-500">Avg. Antwortzeit</div>
<div className="text-2xl font-bold text-gray-900">{agent.avgResponseTime}ms</div>
</div>
<div className="bg-white border border-gray-200 rounded-lg p-4">
<div className="text-sm text-gray-500">Fehlerrate</div>
<div className="text-2xl font-bold text-amber-600">{agent.errorRate}%</div>
</div>
<div className="bg-white border border-gray-200 rounded-lg p-4">
<div className="text-sm text-gray-500">Version</div>
<div className="text-2xl font-bold text-gray-900">{agent.version}</div>
</div>
</div>
<AgentHeader agent={agent} />
<AgentStatsBar agent={agent} />
{/* Tabs */}
<div className="bg-white border border-gray-200 rounded-xl overflow-hidden">
<div className="border-b border-gray-200">
<div className="flex">
{TABS.map(({ id, label, icon: Icon }) => (
<button
onClick={() => setActiveTab('soul')}
key={id}
onClick={() => setActiveTab(id)}
className={`flex items-center gap-2 px-6 py-4 text-sm font-medium border-b-2 transition-colors ${
activeTab === 'soul'
activeTab === id
? 'border-teal-500 text-teal-600'
: 'border-transparent text-gray-500 hover:text-gray-700'
}`}
>
<FileText className="w-4 h-4" />
SOUL-File
<Icon className="w-4 h-4" />
{label}
</button>
<button
onClick={() => setActiveTab('stats')}
className={`flex items-center gap-2 px-6 py-4 text-sm font-medium border-b-2 transition-colors ${
activeTab === 'stats'
? 'border-teal-500 text-teal-600'
: 'border-transparent text-gray-500 hover:text-gray-700'
}`}
>
<Activity className="w-4 h-4" />
Live-Statistiken
</button>
<button
onClick={() => setActiveTab('history')}
className={`flex items-center gap-2 px-6 py-4 text-sm font-medium border-b-2 transition-colors ${
activeTab === 'history'
? 'border-teal-500 text-teal-600'
: 'border-transparent text-gray-500 hover:text-gray-700'
}`}
>
<History className="w-4 h-4" />
Aenderungshistorie
</button>
</div>
</div>
{/* Tab Content */}
<div className="p-6">
{activeTab === 'soul' && (
<div>
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-2 text-sm text-gray-500">
<FileText className="w-4 h-4" />
{agent.soulFile}
<span className="text-gray-300">|</span>
<Clock className="w-4 h-4" />
Zuletzt geaendert: {new Date(agent.updatedAt).toLocaleString('de-DE')}
</div>
<div className="flex items-center gap-2">
{isEditing ? (
<>
<button
onClick={handleReset}
disabled={!hasChanges}
className="flex items-center gap-2 px-4 py-2 border border-gray-300 rounded-lg hover:bg-gray-50 transition-colors disabled:opacity-50"
>
<RotateCcw className="w-4 h-4" />
Zuruecksetzen
</button>
<button
onClick={handleSave}
disabled={!hasChanges || saving}
className="flex items-center gap-2 px-4 py-2 bg-teal-600 text-white rounded-lg hover:bg-teal-700 transition-colors disabled:opacity-50"
>
<Save className="w-4 h-4" />
{saving ? 'Speichert...' : 'Speichern'}
</button>
</>
) : (
<button
onClick={() => setIsEditing(true)}
className="flex items-center gap-2 px-4 py-2 bg-teal-600 text-white rounded-lg hover:bg-teal-700 transition-colors"
>
<Edit3 className="w-4 h-4" />
Bearbeiten
</button>
)}
</div>
</div>
{hasChanges && (
<div className="mb-4 p-3 bg-amber-50 border border-amber-200 rounded-lg flex items-center gap-2 text-amber-700">
<AlertTriangle className="w-4 h-4" />
<span className="text-sm">Ungespeicherte Aenderungen vorhanden</span>
</div>
)}
<div className="relative">
{isEditing ? (
<textarea
value={editedContent}
onChange={(e) => handleContentChange(e.target.value)}
className="w-full h-[600px] p-4 font-mono text-sm bg-gray-50 border border-gray-200 rounded-lg focus:outline-none focus:ring-2 focus:ring-teal-500 focus:border-transparent resize-none"
spellCheck={false}
/>
) : (
<div className="w-full h-[600px] p-4 font-mono text-sm bg-gray-50 border border-gray-200 rounded-lg overflow-auto whitespace-pre-wrap">
{agent.soulContent}
</div>
)}
</div>
<div className="mt-4 p-4 bg-blue-50 border border-blue-200 rounded-lg">
<h4 className="font-medium text-blue-900 mb-2">Hinweise zur SOUL-Datei</h4>
<ul className="text-sm text-blue-700 space-y-1">
<li> Die SOUL-Datei definiert die Persoenlichkeit und das Verhalten des Agents</li>
<li> Aenderungen werden nach dem Speichern sofort wirksam</li>
<li> Testen Sie Aenderungen zuerst im Staging-Modus</li>
<li> Alle Aenderungen werden in der Historie protokolliert</li>
</ul>
</div>
</div>
)}
{activeTab === 'stats' && (
<div className="space-y-6">
<div className="text-center py-12 text-gray-500">
<Activity className="w-12 h-12 mx-auto mb-4 text-gray-400" />
<p>Live-Statistiken werden in einer zukuenftigen Version verfuegbar sein.</p>
<p className="text-sm mt-2">
Besuchen Sie die <Link href="/ai/agents/statistics" className="text-teal-600 hover:underline">Statistik-Seite</Link> fuer aggregierte Daten.
</p>
</div>
</div>
)}
{activeTab === 'history' && (
<div>
<div className="space-y-4">
{mockChangeLogs.map((log) => (
<div key={log.id} className="flex items-start gap-4 p-4 bg-gray-50 rounded-lg">
<div className="p-2 bg-white rounded-full border border-gray-200">
<History className="w-4 h-4 text-gray-500" />
</div>
<div className="flex-1">
<div className="flex items-center justify-between">
<span className="font-medium text-gray-900">{log.action}</span>
<span className="text-sm text-gray-500">
{new Date(log.timestamp).toLocaleString('de-DE')}
</span>
</div>
<p className="text-sm text-gray-600 mt-1">{log.description}</p>
<p className="text-xs text-gray-400 mt-1">von {log.user}</p>
</div>
</div>
))}
</div>
</div>
<div className="p-6">
{activeTab === 'soul' && (
<SoulTabContent
agent={agent}
editedContent={editedContent}
isEditing={isEditing}
hasChanges={hasChanges}
saving={saving}
onContentChange={handleContentChange}
onSave={handleSave}
onReset={handleReset}
onStartEditing={() => setIsEditing(true)}
/>
)}
{activeTab === 'stats' && <StatsTabContent />}
{activeTab === 'history' && <HistoryTabContent changeLogs={mockChangeLogs} />}
</div>
</div>
</div>

View File

@@ -0,0 +1,120 @@
import { Brain, CheckCircle, Shield, AlertTriangle, MessageSquare } from 'lucide-react'
interface AgentCardProps {
icon: React.ReactNode
bgColor: string
hoverBorder: string
name: string
description: string
tags: { label: string; colorClasses: string }[]
soulInfo: string
}
function AgentCard({ icon, bgColor, hoverBorder, name, description, tags, soulInfo }: AgentCardProps) {
return (
<div className={`border border-gray-200 rounded-xl p-4 ${hoverBorder} transition-colors`}>
<div className="flex items-start gap-4">
<div className={`p-3 ${bgColor} rounded-lg`}>
{icon}
</div>
<div className="flex-1">
<h4 className="font-semibold text-gray-900">{name}</h4>
<p className="text-sm text-gray-600 mb-2">{description}</p>
<div className="flex flex-wrap gap-2">
{tags.map(tag => (
<span key={tag.label} className={`px-2 py-1 ${tag.colorClasses} text-xs rounded-full`}>
{tag.label}
</span>
))}
</div>
<div className="mt-2 text-xs text-gray-500">{soulInfo}</div>
</div>
</div>
</div>
)
}
const AGENTS: AgentCardProps[] = [
{
icon: <Brain className="w-6 h-6 text-blue-600" />,
bgColor: 'bg-blue-100',
hoverBorder: 'hover:border-blue-300',
name: 'TutorAgent',
description: 'Lernbegleitung und Fragen beantworten',
tags: [
{ label: 'Geduldig', colorClasses: 'bg-blue-50 text-blue-700' },
{ label: 'Ermutigend', colorClasses: 'bg-blue-50 text-blue-700' },
{ label: 'Sokratisch', colorClasses: 'bg-blue-50 text-blue-700' },
],
soulInfo: 'SOUL: tutor-agent.soul.md | Routing: learning_*, help_*, question_*',
},
{
icon: <CheckCircle className="w-6 h-6 text-green-600" />,
bgColor: 'bg-green-100',
hoverBorder: 'hover:border-green-300',
name: 'GraderAgent',
description: 'Klausur-Korrektur und Bewertung',
tags: [
{ label: 'Objektiv', colorClasses: 'bg-green-50 text-green-700' },
{ label: 'Fair', colorClasses: 'bg-green-50 text-green-700' },
{ label: 'Konstruktiv', colorClasses: 'bg-green-50 text-green-700' },
],
soulInfo: 'SOUL: grader-agent.soul.md | Routing: grade_*, evaluate_*, correct_*',
},
{
icon: <Shield className="w-6 h-6 text-amber-600" />,
bgColor: 'bg-amber-100',
hoverBorder: 'hover:border-amber-300',
name: 'QualityJudge',
description: 'BQAS Qualitaetspruefung',
tags: [
{ label: 'Kritisch', colorClasses: 'bg-amber-50 text-amber-700' },
{ label: 'Praezise', colorClasses: 'bg-amber-50 text-amber-700' },
{ label: 'Schnell', colorClasses: 'bg-amber-50 text-amber-700' },
],
soulInfo: 'SOUL: quality-judge.soul.md | Routing: quality_*, review_*, validate_*',
},
{
icon: <AlertTriangle className="w-6 h-6 text-red-600" />,
bgColor: 'bg-red-100',
hoverBorder: 'hover:border-red-300',
name: 'AlertAgent',
description: 'Monitoring und Benachrichtigungen',
tags: [
{ label: 'Wachsam', colorClasses: 'bg-red-50 text-red-700' },
{ label: 'Proaktiv', colorClasses: 'bg-red-50 text-red-700' },
{ label: 'Priorisierend', colorClasses: 'bg-red-50 text-red-700' },
],
soulInfo: 'SOUL: alert-agent.soul.md | Routing: alert_*, monitor_*, notify_*',
},
{
icon: <MessageSquare className="w-6 h-6 text-purple-600" />,
bgColor: 'bg-purple-100',
hoverBorder: 'hover:border-purple-300',
name: 'Orchestrator',
description: 'Task-Koordination und Routing',
tags: [
{ label: 'Koordinierend', colorClasses: 'bg-purple-50 text-purple-700' },
{ label: 'Effizient', colorClasses: 'bg-purple-50 text-purple-700' },
{ label: 'Zuverlaessig', colorClasses: 'bg-purple-50 text-purple-700' },
],
soulInfo: 'SOUL: orchestrator.soul.md | Routing: Fallback fuer alle unbekannten Intents',
},
]
export function AgentTypesSection() {
return (
<div className="space-y-4">
<p className="text-gray-600 mb-4">
Jeder Agent hat eine spezialisierte Rolle im System. Die Agents kommunizieren ueber den Message Bus
und nutzen das Shared Brain fuer konsistente Entscheidungen.
</p>
<div className="grid gap-4">
{AGENTS.map(agent => (
<AgentCard key={agent.name} {...agent} />
))}
</div>
</div>
)
}

View File

@@ -0,0 +1,73 @@
export function DatabaseSchemaSection() {
return (
<div className="space-y-4">
<p className="text-gray-600 mb-4">
Das Agent-System nutzt PostgreSQL fuer persistente Daten und Valkey (Redis) fuer Caching und Pub/Sub.
</p>
<div className="space-y-4">
{/* agent_sessions */}
<div className="bg-white border border-gray-200 rounded-xl p-4">
<h4 className="font-semibold text-gray-900 mb-2 font-mono">agent_sessions</h4>
<p className="text-sm text-gray-600 mb-3">Speichert Session-Daten mit Checkpoints</p>
<div className="bg-gray-50 rounded-lg p-3 font-mono text-xs overflow-x-auto">
<pre>{`
CREATE TABLE agent_sessions (
id UUID PRIMARY KEY,
agent_type VARCHAR(50) NOT NULL,
user_id UUID REFERENCES users(id),
state VARCHAR(20) NOT NULL DEFAULT 'active',
context JSONB DEFAULT '{}',
checkpoints JSONB DEFAULT '[]',
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW(),
last_heartbeat TIMESTAMPTZ DEFAULT NOW()
);
`}</pre>
</div>
</div>
{/* agent_memory */}
<div className="bg-white border border-gray-200 rounded-xl p-4">
<h4 className="font-semibold text-gray-900 mb-2 font-mono">agent_memory</h4>
<p className="text-sm text-gray-600 mb-3">Langzeit-Gedaechtnis mit TTL</p>
<div className="bg-gray-50 rounded-lg p-3 font-mono text-xs overflow-x-auto">
<pre>{`
CREATE TABLE agent_memory (
id UUID PRIMARY KEY,
namespace VARCHAR(100) NOT NULL,
key VARCHAR(500) NOT NULL,
value JSONB NOT NULL,
agent_id VARCHAR(50) NOT NULL,
access_count INTEGER DEFAULT 0,
created_at TIMESTAMPTZ DEFAULT NOW(),
expires_at TIMESTAMPTZ,
UNIQUE(namespace, key)
);
`}</pre>
</div>
</div>
{/* agent_messages */}
<div className="bg-white border border-gray-200 rounded-xl p-4">
<h4 className="font-semibold text-gray-900 mb-2 font-mono">agent_messages</h4>
<p className="text-sm text-gray-600 mb-3">Audit-Trail fuer Inter-Agent Kommunikation</p>
<div className="bg-gray-50 rounded-lg p-3 font-mono text-xs overflow-x-auto">
<pre>{`
CREATE TABLE agent_messages (
id UUID PRIMARY KEY,
sender VARCHAR(50) NOT NULL,
receiver VARCHAR(50) NOT NULL,
message_type VARCHAR(50) NOT NULL,
payload JSONB NOT NULL,
priority INTEGER DEFAULT 1,
correlation_id UUID,
created_at TIMESTAMPTZ DEFAULT NOW()
);
`}</pre>
</div>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,75 @@
export function MessageBusSection() {
return (
<div className="space-y-4">
<p className="text-gray-600 mb-4">
Der Message Bus ermoeglicht die asynchrone Kommunikation zwischen Agents via Redis Pub/Sub.
Er unterstuetzt Prioritaeten, Request-Response-Pattern und Broadcast-Nachrichten.
</p>
<div className="bg-gray-50 rounded-xl p-6 font-mono text-sm">
<div className="text-gray-500 mb-2"># Nachrichtenfluss</div>
<pre className="text-gray-700">{`
┌──────────────┐ ┌──────────────┐
│ Sender │ │ Receiver │
│ (Agent) │ │ (Agent) │
└──────┬───────┘ └──────▲───────┘
│ │
│ publish(AgentMessage) │ handle(message)
│ │
▼ │
┌────────────────────────────────────────────────────────┐
│ Message Bus │
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │
│ │ Priority Q │ │ Routing │ │ Logging │ │
│ │ HIGH/NORMAL │ │ Rules │ │ Audit │ │
│ └─────────────┘ └─────────────┘ └─────────────┘ │
│ │
│ Redis Pub/Sub │
└────────────────────────────────────────────────────────┘
`}</pre>
</div>
<div className="mt-6">
<h4 className="font-semibold text-gray-900 mb-3">Nachrichtentypen</h4>
<div className="overflow-x-auto">
<table className="min-w-full border border-gray-200 rounded-lg">
<thead className="bg-gray-50">
<tr>
<th className="px-4 py-2 text-left text-sm font-medium text-gray-900">Typ</th>
<th className="px-4 py-2 text-left text-sm font-medium text-gray-900">Prioritaet</th>
<th className="px-4 py-2 text-left text-sm font-medium text-gray-900">Beschreibung</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-200">
<tr>
<td className="px-4 py-2 text-sm font-mono text-gray-700">task_request</td>
<td className="px-4 py-2"><span className="px-2 py-1 bg-yellow-100 text-yellow-700 text-xs rounded">NORMAL</span></td>
<td className="px-4 py-2 text-sm text-gray-600">Neue Aufgabe an Agent senden</td>
</tr>
<tr>
<td className="px-4 py-2 text-sm font-mono text-gray-700">task_response</td>
<td className="px-4 py-2"><span className="px-2 py-1 bg-yellow-100 text-yellow-700 text-xs rounded">NORMAL</span></td>
<td className="px-4 py-2 text-sm text-gray-600">Antwort auf task_request</td>
</tr>
<tr>
<td className="px-4 py-2 text-sm font-mono text-gray-700">escalation</td>
<td className="px-4 py-2"><span className="px-2 py-1 bg-orange-100 text-orange-700 text-xs rounded">HIGH</span></td>
<td className="px-4 py-2 text-sm text-gray-600">Eskalation an anderen Agent</td>
</tr>
<tr>
<td className="px-4 py-2 text-sm font-mono text-gray-700">alert</td>
<td className="px-4 py-2"><span className="px-2 py-1 bg-red-100 text-red-700 text-xs rounded">CRITICAL</span></td>
<td className="px-4 py-2 text-sm text-gray-600">Kritische Benachrichtigung</td>
</tr>
<tr>
<td className="px-4 py-2 text-sm font-mono text-gray-700">heartbeat</td>
<td className="px-4 py-2"><span className="px-2 py-1 bg-gray-100 text-gray-700 text-xs rounded">LOW</span></td>
<td className="px-4 py-2 text-sm text-gray-600">Liveness-Signal</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,71 @@
import { Server, Brain, GitBranch } from 'lucide-react'
export function OverviewSection() {
return (
<div className="space-y-6">
<p className="text-gray-600">
Das Breakpilot Multi-Agent-System basiert auf dem Mission Control Konzept. Es ermoeglicht
die Koordination mehrerer spezialisierter KI-Agents, die gemeinsam komplexe Aufgaben loesen.
</p>
{/* Architecture Diagram */}
<div className="bg-gray-50 rounded-xl p-6 font-mono text-sm overflow-x-auto">
<pre className="text-gray-700">{`
┌─────────────────────────────────────────────────────────────────┐
│ Breakpilot Services │
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────────────────┐ │
│ │Voice Service│ │Klausur Svc │ │ Admin-v2 / AlertAgent │ │
│ └──────┬──────┘ └──────┬──────┘ └───────────┬─────────────┘ │
│ │ │ │ │
│ └────────────────┼──────────────────────┘ │
│ │ │
│ ┌───────────────────────▼───────────────────────────────────┐ │
│ │ Agent Core │ │
│ │ ┌─────────────┐ ┌─────────────┐ ┌───────────────────┐ │ │
│ │ │ Sessions │ │Shared Brain │ │ Orchestrator │ │ │
│ │ │ - Manager │ │ - Memory │ │ - Message Bus │ │ │
│ │ │ - Heartbeat │ │ - Context │ │ - Supervisor │ │ │
│ │ │ - Checkpoint│ │ - Knowledge │ │ - Task Router │ │ │
│ │ └─────────────┘ └─────────────┘ └───────────────────┘ │ │
│ └───────────────────────────────────────────────────────────┘ │
│ │ │
│ ┌───────────────────────▼───────────────────────────────────┐ │
│ │ Infrastructure │ │
│ │ Valkey (Redis) PostgreSQL Qdrant │ │
│ └───────────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────────┘
`}</pre>
</div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div className="bg-blue-50 border border-blue-200 rounded-xl p-4">
<div className="flex items-center gap-2 mb-2">
<Server className="w-5 h-5 text-blue-600" />
<span className="font-semibold text-blue-900">Session Management</span>
</div>
<p className="text-sm text-blue-700">
Verwaltet Agent-Lifecycles mit State Machine, Checkpoints und automatischer Recovery.
</p>
</div>
<div className="bg-purple-50 border border-purple-200 rounded-xl p-4">
<div className="flex items-center gap-2 mb-2">
<Brain className="w-5 h-5 text-purple-600" />
<span className="font-semibold text-purple-900">Shared Brain</span>
</div>
<p className="text-sm text-purple-700">
Gemeinsames Gedaechtnis fuer alle Agents mit TTL, Context-Verwaltung und Knowledge Graph.
</p>
</div>
<div className="bg-green-50 border border-green-200 rounded-xl p-4">
<div className="flex items-center gap-2 mb-2">
<GitBranch className="w-5 h-5 text-green-600" />
<span className="font-semibold text-green-900">Orchestrator</span>
</div>
<p className="text-sm text-green-700">
Message Bus, Supervisor und Task Router fuer die Agent-Koordination.
</p>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,56 @@
export function SessionLifecycleSection() {
return (
<div className="space-y-4">
<p className="text-gray-600 mb-4">
Sessions verwalten den Zustand von Agent-Interaktionen. Jede Session hat einen definierten
Lebenszyklus mit Checkpoints fuer Recovery.
</p>
<div className="bg-gray-50 rounded-xl p-6 font-mono text-sm">
<div className="text-gray-500 mb-2"># Session State Machine</div>
<pre className="text-gray-700">{`
┌─────────────────────────────────────┐
│ │
▼ │
┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐
│ ACTIVE │───▶│ PAUSED │───▶│ COMPLETED│ │ FAILED │
└──────────┘ └──────────┘ └──────────┘ └──────────┘
│ │ ▲
│ │ │
└───────────────┴───────────────────────────────┘
(bei Fehler)
States:
- ACTIVE: Session laeuft, Agent verarbeitet Tasks
- PAUSED: Session pausiert, wartet auf Eingabe
- COMPLETED: Session erfolgreich beendet
- FAILED: Session mit Fehler beendet
`}</pre>
</div>
<div className="mt-6">
<h4 className="font-semibold text-gray-900 mb-3">Heartbeat Monitoring</h4>
<div className="bg-white border border-gray-200 rounded-xl p-5">
<div className="grid grid-cols-3 gap-4 text-center">
<div>
<div className="text-2xl font-bold text-gray-900">30s</div>
<div className="text-sm text-gray-500">Timeout</div>
</div>
<div>
<div className="text-2xl font-bold text-gray-900">5s</div>
<div className="text-sm text-gray-500">Check Interval</div>
</div>
<div>
<div className="text-2xl font-bold text-gray-900">3</div>
<div className="text-sm text-gray-500">Max Missed Beats</div>
</div>
</div>
<p className="text-sm text-gray-600 mt-4 text-center">
Nach 3 verpassten Heartbeats wird der Agent als ausgefallen markiert und die
Restart-Policy greift (max. 3 Versuche).
</p>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,83 @@
import { Database, Activity, GitBranch } from 'lucide-react'
export function SharedBrainSection() {
return (
<div className="space-y-4">
<p className="text-gray-600 mb-4">
Das Shared Brain speichert Wissen und Kontext, auf den alle Agents zugreifen koennen.
Es besteht aus drei Komponenten: Memory Store, Context Manager und Knowledge Graph.
</p>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div className="bg-white border border-gray-200 rounded-xl p-5">
<div className="flex items-center gap-2 mb-3">
<Database className="w-5 h-5 text-blue-600" />
<h4 className="font-semibold text-gray-900">Memory Store</h4>
</div>
<p className="text-sm text-gray-600 mb-3">
Langzeit-Gedaechtnis fuer Fakten, Entscheidungen und Lernfortschritte.
</p>
<ul className="text-xs text-gray-500 space-y-1">
<li>- TTL-basierte Expiration (30 Tage default)</li>
<li>- Access-Tracking (Haeufigkeit)</li>
<li>- Pattern-basierte Suche</li>
<li>- Hybrid: Redis + PostgreSQL</li>
</ul>
</div>
<div className="bg-white border border-gray-200 rounded-xl p-5">
<div className="flex items-center gap-2 mb-3">
<Activity className="w-5 h-5 text-purple-600" />
<h4 className="font-semibold text-gray-900">Context Manager</h4>
</div>
<p className="text-sm text-gray-600 mb-3">
Verwaltet Konversationskontext mit automatischer Komprimierung.
</p>
<ul className="text-xs text-gray-500 space-y-1">
<li>- Max 50 Messages pro Context</li>
<li>- Automatische Zusammenfassung</li>
<li>- System-Messages bleiben erhalten</li>
<li>- Entity-Extraktion</li>
</ul>
</div>
<div className="bg-white border border-gray-200 rounded-xl p-5">
<div className="flex items-center gap-2 mb-3">
<GitBranch className="w-5 h-5 text-green-600" />
<h4 className="font-semibold text-gray-900">Knowledge Graph</h4>
</div>
<p className="text-sm text-gray-600 mb-3">
Graph-basierte Darstellung von Entitaeten und ihren Beziehungen.
</p>
<ul className="text-xs text-gray-500 space-y-1">
<li>- Entitaeten: Student, Lehrer, Fach</li>
<li>- Beziehungen: lernt, unterrichtet</li>
<li>- BFS-basierte Pfadsuche</li>
<li>- Verwandte Entitaeten finden</li>
</ul>
</div>
</div>
<div className="bg-gray-50 rounded-xl p-6 font-mono text-sm mt-6">
<div className="text-gray-500 mb-2"># Memory Store Beispiel</div>
<pre className="text-gray-700">{`
# Speichern
await store.remember(
key="student:123:progress",
value={"level": 5, "score": 85, "topic": "algebra"},
agent_id="tutor-agent",
ttl_days=30
)
# Abrufen
progress = await store.recall("student:123:progress")
# → {"level": 5, "score": 85, "topic": "algebra"}
# Suchen
all_progress = await store.search("student:123:*")
# → [Memory(...), Memory(...), ...]
`}</pre>
</div>
</div>
)
}

View File

@@ -0,0 +1,68 @@
export function SoulFilesSection() {
return (
<div className="space-y-4">
<p className="text-gray-600 mb-4">
SOUL-Dateien (Semantic Outline for Unified Learning) definieren die Persoenlichkeit und
Verhaltensregeln jedes Agents. Sie bestimmen, wie ein Agent kommuniziert, entscheidet und eskaliert.
</p>
<div className="bg-gray-900 rounded-xl p-6 text-gray-100 font-mono text-sm overflow-x-auto">
<div className="text-gray-400 mb-4"># Beispiel: tutor-agent.soul.md</div>
<pre className="text-green-400">{`
# TutorAgent SOUL
## Identitaet
Du bist ein geduldiger, ermutigender Lernbegleiter fuer Schueler.
Dein Ziel ist es, Verstaendnis zu foerdern, nicht Antworten vorzugeben.
## Kommunikationsstil
- Verwende einfache, klare Sprache
- Stelle Rueckfragen, um Verstaendnis zu pruefen
- Gib Hinweise statt direkter Loesungen
- Feiere kleine Erfolge
## Fachgebiete
- Mathematik (Grundschule bis Abitur)
- Naturwissenschaften (Physik, Chemie, Biologie)
- Sprachen (Deutsch, Englisch)
## Einschraenkungen
- Gib NIEMALS vollstaendige Loesungen fuer Hausaufgaben
- Verweise bei komplexen Themen auf Lehrkraefte
- Erkenne Frustration und biete Pausen an
## Eskalation
- Bei wiederholtem Unverstaendnis: Schlage alternatives Erklaerformat vor
- Bei emotionaler Belastung: Empfehle Gespraech mit Vertrauensperson
- Bei technischen Problemen: Eskaliere an Support
`}</pre>
</div>
<div className="mt-6">
<h4 className="font-semibold text-gray-900 mb-3">SOUL-Struktur</h4>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="bg-white border border-gray-200 rounded-lg p-4">
<h5 className="font-medium text-gray-900 mb-2">Identitaet</h5>
<p className="text-sm text-gray-600">Wer ist der Agent? Welche Rolle nimmt er ein?</p>
</div>
<div className="bg-white border border-gray-200 rounded-lg p-4">
<h5 className="font-medium text-gray-900 mb-2">Kommunikationsstil</h5>
<p className="text-sm text-gray-600">Wie kommuniziert der Agent mit Benutzern?</p>
</div>
<div className="bg-white border border-gray-200 rounded-lg p-4">
<h5 className="font-medium text-gray-900 mb-2">Fachgebiete</h5>
<p className="text-sm text-gray-600">In welchen Bereichen ist der Agent kompetent?</p>
</div>
<div className="bg-white border border-gray-200 rounded-lg p-4">
<h5 className="font-medium text-gray-900 mb-2">Einschraenkungen</h5>
<p className="text-sm text-gray-600">Was darf der Agent NICHT tun?</p>
</div>
<div className="bg-white border border-gray-200 rounded-lg p-4 md:col-span-2">
<h5 className="font-medium text-gray-900 mb-2">Eskalation</h5>
<p className="text-sm text-gray-600">Wann und wie eskaliert der Agent an andere Agents oder Menschen?</p>
</div>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,82 @@
export function TaskRoutingSection() {
return (
<div className="space-y-4">
<p className="text-gray-600 mb-4">
Der Task Router entscheidet, welcher Agent eine Anfrage bearbeitet. Er verwendet
Intent-basierte Regeln mit Prioritaeten und Fallback-Ketten.
</p>
<div className="overflow-x-auto">
<table className="min-w-full border border-gray-200 rounded-lg">
<thead className="bg-gray-50">
<tr>
<th className="px-4 py-2 text-left text-sm font-medium text-gray-900">Intent-Pattern</th>
<th className="px-4 py-2 text-left text-sm font-medium text-gray-900">Ziel-Agent</th>
<th className="px-4 py-2 text-left text-sm font-medium text-gray-900">Prioritaet</th>
<th className="px-4 py-2 text-left text-sm font-medium text-gray-900">Fallback</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-200">
<tr>
<td className="px-4 py-2 text-sm font-mono text-blue-700">learning_*</td>
<td className="px-4 py-2 text-sm text-gray-700">TutorAgent</td>
<td className="px-4 py-2 text-sm text-gray-700">10</td>
<td className="px-4 py-2 text-sm text-gray-500">Orchestrator</td>
</tr>
<tr>
<td className="px-4 py-2 text-sm font-mono text-blue-700">help_*, question_*</td>
<td className="px-4 py-2 text-sm text-gray-700">TutorAgent</td>
<td className="px-4 py-2 text-sm text-gray-700">8</td>
<td className="px-4 py-2 text-sm text-gray-500">Orchestrator</td>
</tr>
<tr>
<td className="px-4 py-2 text-sm font-mono text-green-700">grade_*, evaluate_*</td>
<td className="px-4 py-2 text-sm text-gray-700">GraderAgent</td>
<td className="px-4 py-2 text-sm text-gray-700">10</td>
<td className="px-4 py-2 text-sm text-gray-500">Orchestrator</td>
</tr>
<tr>
<td className="px-4 py-2 text-sm font-mono text-amber-700">quality_*, review_*</td>
<td className="px-4 py-2 text-sm text-gray-700">QualityJudge</td>
<td className="px-4 py-2 text-sm text-gray-700">10</td>
<td className="px-4 py-2 text-sm text-gray-500">GraderAgent</td>
</tr>
<tr>
<td className="px-4 py-2 text-sm font-mono text-red-700">alert_*, monitor_*</td>
<td className="px-4 py-2 text-sm text-gray-700">AlertAgent</td>
<td className="px-4 py-2 text-sm text-gray-700">10</td>
<td className="px-4 py-2 text-sm text-gray-500">Orchestrator</td>
</tr>
<tr className="bg-gray-50">
<td className="px-4 py-2 text-sm font-mono text-gray-500">* (alle anderen)</td>
<td className="px-4 py-2 text-sm text-gray-700">Orchestrator</td>
<td className="px-4 py-2 text-sm text-gray-700">0</td>
<td className="px-4 py-2 text-sm text-gray-500">-</td>
</tr>
</tbody>
</table>
</div>
<div className="mt-6 grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="bg-white border border-gray-200 rounded-xl p-4">
<h4 className="font-semibold text-gray-900 mb-2">Routing-Strategien</h4>
<ul className="text-sm text-gray-600 space-y-2">
<li><span className="font-mono text-blue-600">ROUND_ROBIN</span> - Gleichmaessige Verteilung</li>
<li><span className="font-mono text-blue-600">LEAST_LOADED</span> - Agent mit wenigsten Tasks</li>
<li><span className="font-mono text-blue-600">PRIORITY</span> - Hoechste Prioritaet zuerst</li>
<li><span className="font-mono text-blue-600">RANDOM</span> - Zufaellige Auswahl</li>
</ul>
</div>
<div className="bg-white border border-gray-200 rounded-xl p-4">
<h4 className="font-semibold text-gray-900 mb-2">Fallback-Verhalten</h4>
<ul className="text-sm text-gray-600 space-y-2">
<li>1. Versuche Ziel-Agent zu erreichen</li>
<li>2. Bei Timeout: Fallback-Agent nutzen</li>
<li>3. Bei Fehler: Orchestrator uebernimmt</li>
<li>4. Bei kritischen Fehlern: Alert an Admin</li>
</ul>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,9 @@
export { OverviewSection } from './OverviewSection'
export { AgentTypesSection } from './AgentTypesSection'
export { SoulFilesSection } from './SoulFilesSection'
export { MessageBusSection } from './MessageBusSection'
export { SharedBrainSection } from './SharedBrainSection'
export { TaskRoutingSection } from './TaskRoutingSection'
export { SessionLifecycleSection } from './SessionLifecycleSection'
export { DatabaseSchemaSection } from './DatabaseSchemaSection'
export type { Section } from './types'

View File

@@ -0,0 +1,6 @@
export interface Section {
id: string
title: string
icon: React.ReactNode
content: React.ReactNode
}

View File

@@ -2,17 +2,78 @@
import { useState } from 'react'
import Link from 'next/link'
import { ArrowLeft, Cpu, Brain, MessageSquare, Database, Activity, Shield, ChevronDown, ChevronRight, GitBranch, Layers, Server, FileText, AlertTriangle, CheckCircle, Zap, RefreshCw } from 'lucide-react'
import {
ArrowLeft, Cpu, Brain, MessageSquare, Database,
Activity, ChevronDown, ChevronRight, GitBranch,
Layers, FileText, Zap, RefreshCw,
} from 'lucide-react'
import {
OverviewSection,
AgentTypesSection,
SoulFilesSection,
MessageBusSection,
SharedBrainSection,
TaskRoutingSection,
SessionLifecycleSection,
DatabaseSchemaSection,
} from './_components'
import type { Section } from './_components'
interface Section {
id: string
title: string
icon: React.ReactNode
content: React.ReactNode
}
const SECTIONS: Section[] = [
{
id: 'overview',
title: 'System-Uebersicht',
icon: <Layers className="w-5 h-5" />,
content: <OverviewSection />,
},
{
id: 'agents',
title: 'Agent-Typen',
icon: <Cpu className="w-5 h-5" />,
content: <AgentTypesSection />,
},
{
id: 'soul-files',
title: 'SOUL-Files (Persoenlichkeiten)',
icon: <FileText className="w-5 h-5" />,
content: <SoulFilesSection />,
},
{
id: 'message-bus',
title: 'Message Bus & Kommunikation',
icon: <MessageSquare className="w-5 h-5" />,
content: <MessageBusSection />,
},
{
id: 'shared-brain',
title: 'Shared Brain (Gedaechtnis)',
icon: <Brain className="w-5 h-5" />,
content: <SharedBrainSection />,
},
{
id: 'task-routing',
title: 'Task Routing',
icon: <Zap className="w-5 h-5" />,
content: <TaskRoutingSection />,
},
{
id: 'session-lifecycle',
title: 'Session Lifecycle',
icon: <RefreshCw className="w-5 h-5" />,
content: <SessionLifecycleSection />,
},
{
id: 'database',
title: 'Datenbank-Schema',
icon: <Database className="w-5 h-5" />,
content: <DatabaseSchemaSection />,
},
]
export default function ArchitecturePage() {
const [expandedSections, setExpandedSections] = useState<string[]>(['overview', 'agents', 'soul-files'])
const [expandedSections, setExpandedSections] = useState<string[]>([
'overview', 'agents', 'soul-files',
])
const toggleSection = (id: string) => {
setExpandedSections(prev =>
@@ -22,654 +83,6 @@ export default function ArchitecturePage() {
)
}
const sections: Section[] = [
{
id: 'overview',
title: 'System-Uebersicht',
icon: <Layers className="w-5 h-5" />,
content: (
<div className="space-y-6">
<p className="text-gray-600">
Das Breakpilot Multi-Agent-System basiert auf dem Mission Control Konzept. Es ermoeglicht
die Koordination mehrerer spezialisierter KI-Agents, die gemeinsam komplexe Aufgaben loesen.
</p>
{/* Architecture Diagram */}
<div className="bg-gray-50 rounded-xl p-6 font-mono text-sm overflow-x-auto">
<pre className="text-gray-700">{`
┌─────────────────────────────────────────────────────────────────┐
│ Breakpilot Services │
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────────────────┐ │
│ │Voice Service│ │Klausur Svc │ │ Admin-v2 / AlertAgent │ │
│ └──────┬──────┘ └──────┬──────┘ └───────────┬─────────────┘ │
│ │ │ │ │
│ └────────────────┼──────────────────────┘ │
│ │ │
│ ┌───────────────────────▼───────────────────────────────────┐ │
│ │ Agent Core │ │
│ │ ┌─────────────┐ ┌─────────────┐ ┌───────────────────┐ │ │
│ │ │ Sessions │ │Shared Brain │ │ Orchestrator │ │ │
│ │ │ - Manager │ │ - Memory │ │ - Message Bus │ │ │
│ │ │ - Heartbeat │ │ - Context │ │ - Supervisor │ │ │
│ │ │ - Checkpoint│ │ - Knowledge │ │ - Task Router │ │ │
│ │ └─────────────┘ └─────────────┘ └───────────────────┘ │ │
│ └───────────────────────────────────────────────────────────┘ │
│ │ │
│ ┌───────────────────────▼───────────────────────────────────┐ │
│ │ Infrastructure │ │
│ │ Valkey (Redis) PostgreSQL Qdrant │ │
│ └───────────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────────┘
`}</pre>
</div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div className="bg-blue-50 border border-blue-200 rounded-xl p-4">
<div className="flex items-center gap-2 mb-2">
<Server className="w-5 h-5 text-blue-600" />
<span className="font-semibold text-blue-900">Session Management</span>
</div>
<p className="text-sm text-blue-700">
Verwaltet Agent-Lifecycles mit State Machine, Checkpoints und automatischer Recovery.
</p>
</div>
<div className="bg-purple-50 border border-purple-200 rounded-xl p-4">
<div className="flex items-center gap-2 mb-2">
<Brain className="w-5 h-5 text-purple-600" />
<span className="font-semibold text-purple-900">Shared Brain</span>
</div>
<p className="text-sm text-purple-700">
Gemeinsames Gedaechtnis fuer alle Agents mit TTL, Context-Verwaltung und Knowledge Graph.
</p>
</div>
<div className="bg-green-50 border border-green-200 rounded-xl p-4">
<div className="flex items-center gap-2 mb-2">
<GitBranch className="w-5 h-5 text-green-600" />
<span className="font-semibold text-green-900">Orchestrator</span>
</div>
<p className="text-sm text-green-700">
Message Bus, Supervisor und Task Router fuer die Agent-Koordination.
</p>
</div>
</div>
</div>
)
},
{
id: 'agents',
title: 'Agent-Typen',
icon: <Cpu className="w-5 h-5" />,
content: (
<div className="space-y-4">
<p className="text-gray-600 mb-4">
Jeder Agent hat eine spezialisierte Rolle im System. Die Agents kommunizieren ueber den Message Bus
und nutzen das Shared Brain fuer konsistente Entscheidungen.
</p>
<div className="grid gap-4">
{/* TutorAgent */}
<div className="border border-gray-200 rounded-xl p-4 hover:border-blue-300 transition-colors">
<div className="flex items-start gap-4">
<div className="p-3 bg-blue-100 rounded-lg">
<Brain className="w-6 h-6 text-blue-600" />
</div>
<div className="flex-1">
<h4 className="font-semibold text-gray-900">TutorAgent</h4>
<p className="text-sm text-gray-600 mb-2">Lernbegleitung und Fragen beantworten</p>
<div className="flex flex-wrap gap-2">
<span className="px-2 py-1 bg-blue-50 text-blue-700 text-xs rounded-full">Geduldig</span>
<span className="px-2 py-1 bg-blue-50 text-blue-700 text-xs rounded-full">Ermutigend</span>
<span className="px-2 py-1 bg-blue-50 text-blue-700 text-xs rounded-full">Sokratisch</span>
</div>
<div className="mt-2 text-xs text-gray-500">
SOUL: tutor-agent.soul.md | Routing: learning_*, help_*, question_*
</div>
</div>
</div>
</div>
{/* GraderAgent */}
<div className="border border-gray-200 rounded-xl p-4 hover:border-green-300 transition-colors">
<div className="flex items-start gap-4">
<div className="p-3 bg-green-100 rounded-lg">
<CheckCircle className="w-6 h-6 text-green-600" />
</div>
<div className="flex-1">
<h4 className="font-semibold text-gray-900">GraderAgent</h4>
<p className="text-sm text-gray-600 mb-2">Klausur-Korrektur und Bewertung</p>
<div className="flex flex-wrap gap-2">
<span className="px-2 py-1 bg-green-50 text-green-700 text-xs rounded-full">Objektiv</span>
<span className="px-2 py-1 bg-green-50 text-green-700 text-xs rounded-full">Fair</span>
<span className="px-2 py-1 bg-green-50 text-green-700 text-xs rounded-full">Konstruktiv</span>
</div>
<div className="mt-2 text-xs text-gray-500">
SOUL: grader-agent.soul.md | Routing: grade_*, evaluate_*, correct_*
</div>
</div>
</div>
</div>
{/* QualityJudge */}
<div className="border border-gray-200 rounded-xl p-4 hover:border-amber-300 transition-colors">
<div className="flex items-start gap-4">
<div className="p-3 bg-amber-100 rounded-lg">
<Shield className="w-6 h-6 text-amber-600" />
</div>
<div className="flex-1">
<h4 className="font-semibold text-gray-900">QualityJudge</h4>
<p className="text-sm text-gray-600 mb-2">BQAS Qualitaetspruefung</p>
<div className="flex flex-wrap gap-2">
<span className="px-2 py-1 bg-amber-50 text-amber-700 text-xs rounded-full">Kritisch</span>
<span className="px-2 py-1 bg-amber-50 text-amber-700 text-xs rounded-full">Praezise</span>
<span className="px-2 py-1 bg-amber-50 text-amber-700 text-xs rounded-full">Schnell</span>
</div>
<div className="mt-2 text-xs text-gray-500">
SOUL: quality-judge.soul.md | Routing: quality_*, review_*, validate_*
</div>
</div>
</div>
</div>
{/* AlertAgent */}
<div className="border border-gray-200 rounded-xl p-4 hover:border-red-300 transition-colors">
<div className="flex items-start gap-4">
<div className="p-3 bg-red-100 rounded-lg">
<AlertTriangle className="w-6 h-6 text-red-600" />
</div>
<div className="flex-1">
<h4 className="font-semibold text-gray-900">AlertAgent</h4>
<p className="text-sm text-gray-600 mb-2">Monitoring und Benachrichtigungen</p>
<div className="flex flex-wrap gap-2">
<span className="px-2 py-1 bg-red-50 text-red-700 text-xs rounded-full">Wachsam</span>
<span className="px-2 py-1 bg-red-50 text-red-700 text-xs rounded-full">Proaktiv</span>
<span className="px-2 py-1 bg-red-50 text-red-700 text-xs rounded-full">Priorisierend</span>
</div>
<div className="mt-2 text-xs text-gray-500">
SOUL: alert-agent.soul.md | Routing: alert_*, monitor_*, notify_*
</div>
</div>
</div>
</div>
{/* Orchestrator */}
<div className="border border-gray-200 rounded-xl p-4 hover:border-purple-300 transition-colors">
<div className="flex items-start gap-4">
<div className="p-3 bg-purple-100 rounded-lg">
<MessageSquare className="w-6 h-6 text-purple-600" />
</div>
<div className="flex-1">
<h4 className="font-semibold text-gray-900">Orchestrator</h4>
<p className="text-sm text-gray-600 mb-2">Task-Koordination und Routing</p>
<div className="flex flex-wrap gap-2">
<span className="px-2 py-1 bg-purple-50 text-purple-700 text-xs rounded-full">Koordinierend</span>
<span className="px-2 py-1 bg-purple-50 text-purple-700 text-xs rounded-full">Effizient</span>
<span className="px-2 py-1 bg-purple-50 text-purple-700 text-xs rounded-full">Zuverlaessig</span>
</div>
<div className="mt-2 text-xs text-gray-500">
SOUL: orchestrator.soul.md | Routing: Fallback fuer alle unbekannten Intents
</div>
</div>
</div>
</div>
</div>
</div>
)
},
{
id: 'soul-files',
title: 'SOUL-Files (Persoenlichkeiten)',
icon: <FileText className="w-5 h-5" />,
content: (
<div className="space-y-4">
<p className="text-gray-600 mb-4">
SOUL-Dateien (Semantic Outline for Unified Learning) definieren die Persoenlichkeit und
Verhaltensregeln jedes Agents. Sie bestimmen, wie ein Agent kommuniziert, entscheidet und eskaliert.
</p>
<div className="bg-gray-900 rounded-xl p-6 text-gray-100 font-mono text-sm overflow-x-auto">
<div className="text-gray-400 mb-4"># Beispiel: tutor-agent.soul.md</div>
<pre className="text-green-400">{`
# TutorAgent SOUL
## Identitaet
Du bist ein geduldiger, ermutigender Lernbegleiter fuer Schueler.
Dein Ziel ist es, Verstaendnis zu foerdern, nicht Antworten vorzugeben.
## Kommunikationsstil
- Verwende einfache, klare Sprache
- Stelle Rueckfragen, um Verstaendnis zu pruefen
- Gib Hinweise statt direkter Loesungen
- Feiere kleine Erfolge
## Fachgebiete
- Mathematik (Grundschule bis Abitur)
- Naturwissenschaften (Physik, Chemie, Biologie)
- Sprachen (Deutsch, Englisch)
## Einschraenkungen
- Gib NIEMALS vollstaendige Loesungen fuer Hausaufgaben
- Verweise bei komplexen Themen auf Lehrkraefte
- Erkenne Frustration und biete Pausen an
## Eskalation
- Bei wiederholtem Unverstaendnis: Schlage alternatives Erklaerformat vor
- Bei emotionaler Belastung: Empfehle Gespraech mit Vertrauensperson
- Bei technischen Problemen: Eskaliere an Support
`}</pre>
</div>
<div className="mt-6">
<h4 className="font-semibold text-gray-900 mb-3">SOUL-Struktur</h4>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="bg-white border border-gray-200 rounded-lg p-4">
<h5 className="font-medium text-gray-900 mb-2">Identitaet</h5>
<p className="text-sm text-gray-600">Wer ist der Agent? Welche Rolle nimmt er ein?</p>
</div>
<div className="bg-white border border-gray-200 rounded-lg p-4">
<h5 className="font-medium text-gray-900 mb-2">Kommunikationsstil</h5>
<p className="text-sm text-gray-600">Wie kommuniziert der Agent mit Benutzern?</p>
</div>
<div className="bg-white border border-gray-200 rounded-lg p-4">
<h5 className="font-medium text-gray-900 mb-2">Fachgebiete</h5>
<p className="text-sm text-gray-600">In welchen Bereichen ist der Agent kompetent?</p>
</div>
<div className="bg-white border border-gray-200 rounded-lg p-4">
<h5 className="font-medium text-gray-900 mb-2">Einschraenkungen</h5>
<p className="text-sm text-gray-600">Was darf der Agent NICHT tun?</p>
</div>
<div className="bg-white border border-gray-200 rounded-lg p-4 md:col-span-2">
<h5 className="font-medium text-gray-900 mb-2">Eskalation</h5>
<p className="text-sm text-gray-600">Wann und wie eskaliert der Agent an andere Agents oder Menschen?</p>
</div>
</div>
</div>
</div>
)
},
{
id: 'message-bus',
title: 'Message Bus & Kommunikation',
icon: <MessageSquare className="w-5 h-5" />,
content: (
<div className="space-y-4">
<p className="text-gray-600 mb-4">
Der Message Bus ermoeglicht die asynchrone Kommunikation zwischen Agents via Redis Pub/Sub.
Er unterstuetzt Prioritaeten, Request-Response-Pattern und Broadcast-Nachrichten.
</p>
<div className="bg-gray-50 rounded-xl p-6 font-mono text-sm">
<div className="text-gray-500 mb-2"># Nachrichtenfluss</div>
<pre className="text-gray-700">{`
┌──────────────┐ ┌──────────────┐
│ Sender │ │ Receiver │
│ (Agent) │ │ (Agent) │
└──────┬───────┘ └──────▲───────┘
│ │
│ publish(AgentMessage) │ handle(message)
│ │
▼ │
┌────────────────────────────────────────────────────────┐
│ Message Bus │
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │
│ │ Priority Q │ │ Routing │ │ Logging │ │
│ │ HIGH/NORMAL │ │ Rules │ │ Audit │ │
│ └─────────────┘ └─────────────┘ └─────────────┘ │
│ │
│ Redis Pub/Sub │
└────────────────────────────────────────────────────────┘
`}</pre>
</div>
<div className="mt-6">
<h4 className="font-semibold text-gray-900 mb-3">Nachrichtentypen</h4>
<div className="overflow-x-auto">
<table className="min-w-full border border-gray-200 rounded-lg">
<thead className="bg-gray-50">
<tr>
<th className="px-4 py-2 text-left text-sm font-medium text-gray-900">Typ</th>
<th className="px-4 py-2 text-left text-sm font-medium text-gray-900">Prioritaet</th>
<th className="px-4 py-2 text-left text-sm font-medium text-gray-900">Beschreibung</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-200">
<tr>
<td className="px-4 py-2 text-sm font-mono text-gray-700">task_request</td>
<td className="px-4 py-2"><span className="px-2 py-1 bg-yellow-100 text-yellow-700 text-xs rounded">NORMAL</span></td>
<td className="px-4 py-2 text-sm text-gray-600">Neue Aufgabe an Agent senden</td>
</tr>
<tr>
<td className="px-4 py-2 text-sm font-mono text-gray-700">task_response</td>
<td className="px-4 py-2"><span className="px-2 py-1 bg-yellow-100 text-yellow-700 text-xs rounded">NORMAL</span></td>
<td className="px-4 py-2 text-sm text-gray-600">Antwort auf task_request</td>
</tr>
<tr>
<td className="px-4 py-2 text-sm font-mono text-gray-700">escalation</td>
<td className="px-4 py-2"><span className="px-2 py-1 bg-orange-100 text-orange-700 text-xs rounded">HIGH</span></td>
<td className="px-4 py-2 text-sm text-gray-600">Eskalation an anderen Agent</td>
</tr>
<tr>
<td className="px-4 py-2 text-sm font-mono text-gray-700">alert</td>
<td className="px-4 py-2"><span className="px-2 py-1 bg-red-100 text-red-700 text-xs rounded">CRITICAL</span></td>
<td className="px-4 py-2 text-sm text-gray-600">Kritische Benachrichtigung</td>
</tr>
<tr>
<td className="px-4 py-2 text-sm font-mono text-gray-700">heartbeat</td>
<td className="px-4 py-2"><span className="px-2 py-1 bg-gray-100 text-gray-700 text-xs rounded">LOW</span></td>
<td className="px-4 py-2 text-sm text-gray-600">Liveness-Signal</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
)
},
{
id: 'shared-brain',
title: 'Shared Brain (Gedaechtnis)',
icon: <Brain className="w-5 h-5" />,
content: (
<div className="space-y-4">
<p className="text-gray-600 mb-4">
Das Shared Brain speichert Wissen und Kontext, auf den alle Agents zugreifen koennen.
Es besteht aus drei Komponenten: Memory Store, Context Manager und Knowledge Graph.
</p>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div className="bg-white border border-gray-200 rounded-xl p-5">
<div className="flex items-center gap-2 mb-3">
<Database className="w-5 h-5 text-blue-600" />
<h4 className="font-semibold text-gray-900">Memory Store</h4>
</div>
<p className="text-sm text-gray-600 mb-3">
Langzeit-Gedaechtnis fuer Fakten, Entscheidungen und Lernfortschritte.
</p>
<ul className="text-xs text-gray-500 space-y-1">
<li>- TTL-basierte Expiration (30 Tage default)</li>
<li>- Access-Tracking (Haeufigkeit)</li>
<li>- Pattern-basierte Suche</li>
<li>- Hybrid: Redis + PostgreSQL</li>
</ul>
</div>
<div className="bg-white border border-gray-200 rounded-xl p-5">
<div className="flex items-center gap-2 mb-3">
<Activity className="w-5 h-5 text-purple-600" />
<h4 className="font-semibold text-gray-900">Context Manager</h4>
</div>
<p className="text-sm text-gray-600 mb-3">
Verwaltet Konversationskontext mit automatischer Komprimierung.
</p>
<ul className="text-xs text-gray-500 space-y-1">
<li>- Max 50 Messages pro Context</li>
<li>- Automatische Zusammenfassung</li>
<li>- System-Messages bleiben erhalten</li>
<li>- Entity-Extraktion</li>
</ul>
</div>
<div className="bg-white border border-gray-200 rounded-xl p-5">
<div className="flex items-center gap-2 mb-3">
<GitBranch className="w-5 h-5 text-green-600" />
<h4 className="font-semibold text-gray-900">Knowledge Graph</h4>
</div>
<p className="text-sm text-gray-600 mb-3">
Graph-basierte Darstellung von Entitaeten und ihren Beziehungen.
</p>
<ul className="text-xs text-gray-500 space-y-1">
<li>- Entitaeten: Student, Lehrer, Fach</li>
<li>- Beziehungen: lernt, unterrichtet</li>
<li>- BFS-basierte Pfadsuche</li>
<li>- Verwandte Entitaeten finden</li>
</ul>
</div>
</div>
<div className="bg-gray-50 rounded-xl p-6 font-mono text-sm mt-6">
<div className="text-gray-500 mb-2"># Memory Store Beispiel</div>
<pre className="text-gray-700">{`
# Speichern
await store.remember(
key="student:123:progress",
value={"level": 5, "score": 85, "topic": "algebra"},
agent_id="tutor-agent",
ttl_days=30
)
# Abrufen
progress = await store.recall("student:123:progress")
# → {"level": 5, "score": 85, "topic": "algebra"}
# Suchen
all_progress = await store.search("student:123:*")
# → [Memory(...), Memory(...), ...]
`}</pre>
</div>
</div>
)
},
{
id: 'task-routing',
title: 'Task Routing',
icon: <Zap className="w-5 h-5" />,
content: (
<div className="space-y-4">
<p className="text-gray-600 mb-4">
Der Task Router entscheidet, welcher Agent eine Anfrage bearbeitet. Er verwendet
Intent-basierte Regeln mit Prioritaeten und Fallback-Ketten.
</p>
<div className="overflow-x-auto">
<table className="min-w-full border border-gray-200 rounded-lg">
<thead className="bg-gray-50">
<tr>
<th className="px-4 py-2 text-left text-sm font-medium text-gray-900">Intent-Pattern</th>
<th className="px-4 py-2 text-left text-sm font-medium text-gray-900">Ziel-Agent</th>
<th className="px-4 py-2 text-left text-sm font-medium text-gray-900">Prioritaet</th>
<th className="px-4 py-2 text-left text-sm font-medium text-gray-900">Fallback</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-200">
<tr>
<td className="px-4 py-2 text-sm font-mono text-blue-700">learning_*</td>
<td className="px-4 py-2 text-sm text-gray-700">TutorAgent</td>
<td className="px-4 py-2 text-sm text-gray-700">10</td>
<td className="px-4 py-2 text-sm text-gray-500">Orchestrator</td>
</tr>
<tr>
<td className="px-4 py-2 text-sm font-mono text-blue-700">help_*, question_*</td>
<td className="px-4 py-2 text-sm text-gray-700">TutorAgent</td>
<td className="px-4 py-2 text-sm text-gray-700">8</td>
<td className="px-4 py-2 text-sm text-gray-500">Orchestrator</td>
</tr>
<tr>
<td className="px-4 py-2 text-sm font-mono text-green-700">grade_*, evaluate_*</td>
<td className="px-4 py-2 text-sm text-gray-700">GraderAgent</td>
<td className="px-4 py-2 text-sm text-gray-700">10</td>
<td className="px-4 py-2 text-sm text-gray-500">Orchestrator</td>
</tr>
<tr>
<td className="px-4 py-2 text-sm font-mono text-amber-700">quality_*, review_*</td>
<td className="px-4 py-2 text-sm text-gray-700">QualityJudge</td>
<td className="px-4 py-2 text-sm text-gray-700">10</td>
<td className="px-4 py-2 text-sm text-gray-500">GraderAgent</td>
</tr>
<tr>
<td className="px-4 py-2 text-sm font-mono text-red-700">alert_*, monitor_*</td>
<td className="px-4 py-2 text-sm text-gray-700">AlertAgent</td>
<td className="px-4 py-2 text-sm text-gray-700">10</td>
<td className="px-4 py-2 text-sm text-gray-500">Orchestrator</td>
</tr>
<tr className="bg-gray-50">
<td className="px-4 py-2 text-sm font-mono text-gray-500">* (alle anderen)</td>
<td className="px-4 py-2 text-sm text-gray-700">Orchestrator</td>
<td className="px-4 py-2 text-sm text-gray-700">0</td>
<td className="px-4 py-2 text-sm text-gray-500">-</td>
</tr>
</tbody>
</table>
</div>
<div className="mt-6 grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="bg-white border border-gray-200 rounded-xl p-4">
<h4 className="font-semibold text-gray-900 mb-2">Routing-Strategien</h4>
<ul className="text-sm text-gray-600 space-y-2">
<li><span className="font-mono text-blue-600">ROUND_ROBIN</span> - Gleichmaessige Verteilung</li>
<li><span className="font-mono text-blue-600">LEAST_LOADED</span> - Agent mit wenigsten Tasks</li>
<li><span className="font-mono text-blue-600">PRIORITY</span> - Hoechste Prioritaet zuerst</li>
<li><span className="font-mono text-blue-600">RANDOM</span> - Zufaellige Auswahl</li>
</ul>
</div>
<div className="bg-white border border-gray-200 rounded-xl p-4">
<h4 className="font-semibold text-gray-900 mb-2">Fallback-Verhalten</h4>
<ul className="text-sm text-gray-600 space-y-2">
<li>1. Versuche Ziel-Agent zu erreichen</li>
<li>2. Bei Timeout: Fallback-Agent nutzen</li>
<li>3. Bei Fehler: Orchestrator uebernimmt</li>
<li>4. Bei kritischen Fehlern: Alert an Admin</li>
</ul>
</div>
</div>
</div>
)
},
{
id: 'session-lifecycle',
title: 'Session Lifecycle',
icon: <RefreshCw className="w-5 h-5" />,
content: (
<div className="space-y-4">
<p className="text-gray-600 mb-4">
Sessions verwalten den Zustand von Agent-Interaktionen. Jede Session hat einen definierten
Lebenszyklus mit Checkpoints fuer Recovery.
</p>
<div className="bg-gray-50 rounded-xl p-6 font-mono text-sm">
<div className="text-gray-500 mb-2"># Session State Machine</div>
<pre className="text-gray-700">{`
┌─────────────────────────────────────┐
│ │
▼ │
┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐
│ ACTIVE │───▶│ PAUSED │───▶│ COMPLETED│ │ FAILED │
└──────────┘ └──────────┘ └──────────┘ └──────────┘
│ │ ▲
│ │ │
└───────────────┴───────────────────────────────┘
(bei Fehler)
States:
- ACTIVE: Session laeuft, Agent verarbeitet Tasks
- PAUSED: Session pausiert, wartet auf Eingabe
- COMPLETED: Session erfolgreich beendet
- FAILED: Session mit Fehler beendet
`}</pre>
</div>
<div className="mt-6">
<h4 className="font-semibold text-gray-900 mb-3">Heartbeat Monitoring</h4>
<div className="bg-white border border-gray-200 rounded-xl p-5">
<div className="grid grid-cols-3 gap-4 text-center">
<div>
<div className="text-2xl font-bold text-gray-900">30s</div>
<div className="text-sm text-gray-500">Timeout</div>
</div>
<div>
<div className="text-2xl font-bold text-gray-900">5s</div>
<div className="text-sm text-gray-500">Check Interval</div>
</div>
<div>
<div className="text-2xl font-bold text-gray-900">3</div>
<div className="text-sm text-gray-500">Max Missed Beats</div>
</div>
</div>
<p className="text-sm text-gray-600 mt-4 text-center">
Nach 3 verpassten Heartbeats wird der Agent als ausgefallen markiert und die
Restart-Policy greift (max. 3 Versuche).
</p>
</div>
</div>
</div>
)
},
{
id: 'database',
title: 'Datenbank-Schema',
icon: <Database className="w-5 h-5" />,
content: (
<div className="space-y-4">
<p className="text-gray-600 mb-4">
Das Agent-System nutzt PostgreSQL fuer persistente Daten und Valkey (Redis) fuer Caching und Pub/Sub.
</p>
<div className="space-y-4">
{/* agent_sessions */}
<div className="bg-white border border-gray-200 rounded-xl p-4">
<h4 className="font-semibold text-gray-900 mb-2 font-mono">agent_sessions</h4>
<p className="text-sm text-gray-600 mb-3">Speichert Session-Daten mit Checkpoints</p>
<div className="bg-gray-50 rounded-lg p-3 font-mono text-xs overflow-x-auto">
<pre>{`
CREATE TABLE agent_sessions (
id UUID PRIMARY KEY,
agent_type VARCHAR(50) NOT NULL,
user_id UUID REFERENCES users(id),
state VARCHAR(20) NOT NULL DEFAULT 'active',
context JSONB DEFAULT '{}',
checkpoints JSONB DEFAULT '[]',
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW(),
last_heartbeat TIMESTAMPTZ DEFAULT NOW()
);
`}</pre>
</div>
</div>
{/* agent_memory */}
<div className="bg-white border border-gray-200 rounded-xl p-4">
<h4 className="font-semibold text-gray-900 mb-2 font-mono">agent_memory</h4>
<p className="text-sm text-gray-600 mb-3">Langzeit-Gedaechtnis mit TTL</p>
<div className="bg-gray-50 rounded-lg p-3 font-mono text-xs overflow-x-auto">
<pre>{`
CREATE TABLE agent_memory (
id UUID PRIMARY KEY,
namespace VARCHAR(100) NOT NULL,
key VARCHAR(500) NOT NULL,
value JSONB NOT NULL,
agent_id VARCHAR(50) NOT NULL,
access_count INTEGER DEFAULT 0,
created_at TIMESTAMPTZ DEFAULT NOW(),
expires_at TIMESTAMPTZ,
UNIQUE(namespace, key)
);
`}</pre>
</div>
</div>
{/* agent_messages */}
<div className="bg-white border border-gray-200 rounded-xl p-4">
<h4 className="font-semibold text-gray-900 mb-2 font-mono">agent_messages</h4>
<p className="text-sm text-gray-600 mb-3">Audit-Trail fuer Inter-Agent Kommunikation</p>
<div className="bg-gray-50 rounded-lg p-3 font-mono text-xs overflow-x-auto">
<pre>{`
CREATE TABLE agent_messages (
id UUID PRIMARY KEY,
sender VARCHAR(50) NOT NULL,
receiver VARCHAR(50) NOT NULL,
message_type VARCHAR(50) NOT NULL,
payload JSONB NOT NULL,
priority INTEGER DEFAULT 1,
correlation_id UUID,
created_at TIMESTAMPTZ DEFAULT NOW()
);
`}</pre>
</div>
</div>
</div>
</div>
)
}
]
return (
<div className="p-6 max-w-5xl mx-auto">
{/* Header */}
@@ -696,7 +109,7 @@ CREATE TABLE agent_messages (
<div className="bg-gray-50 rounded-xl p-5 mb-8">
<h2 className="font-semibold text-gray-900 mb-3">Inhaltsverzeichnis</h2>
<div className="grid grid-cols-2 md:grid-cols-4 gap-2">
{sections.map(section => (
{SECTIONS.map(section => (
<button
key={section.id}
onClick={() => {
@@ -716,7 +129,7 @@ CREATE TABLE agent_messages (
{/* Sections */}
<div className="space-y-4">
{sections.map(section => (
{SECTIONS.map(section => (
<div
key={section.id}
id={section.id}
@@ -749,7 +162,7 @@ CREATE TABLE agent_messages (
{/* Footer Links */}
<div className="mt-8 bg-teal-50 border border-teal-200 rounded-xl p-5">
<h3 className="font-semibold text-teal-900 mb-3">Weiterführende Ressourcen</h3>
<h3 className="font-semibold text-teal-900 mb-3">Weiterfuehrende Ressourcen</h3>
<div className="grid grid-cols-1 md:grid-cols-3 gap-3">
<Link
href="/ai/agents"

View File

@@ -0,0 +1,134 @@
'use client'
/**
* Export tab: export training data in various formats.
*/
import { useState } from 'react'
import Link from 'next/link'
import { API_BASE } from '../constants'
import type { OCRSession, OCRStats } from '../types'
interface ExportTabProps {
sessions: OCRSession[]
selectedSession: string | null
setSelectedSession: (id: string | null) => void
stats: OCRStats | null
setError: (error: string | null) => void
}
export function ExportTab({
sessions,
selectedSession,
setSelectedSession,
stats,
setError,
}: ExportTabProps) {
const [exportFormat, setExportFormat] = useState<'generic' | 'trocr' | 'llama_vision'>('generic')
const [exporting, setExporting] = useState(false)
const [exportResult, setExportResult] = useState<{
exported_count: number
batch_id: string
samples?: Array<Record<string, unknown>>
} | null>(null)
const handleExport = async () => {
setExporting(true)
try {
const res = await fetch(`${API_BASE}/api/v1/ocr-label/export`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
export_format: exportFormat,
session_id: selectedSession,
}),
})
if (res.ok) {
const data = await res.json()
setExportResult(data)
} else {
setError('Export fehlgeschlagen')
}
} catch {
setError('Netzwerkfehler')
} finally {
setExporting(false)
}
}
return (
<div className="space-y-6">
<div className="bg-white rounded-lg shadow p-6">
<h3 className="text-lg font-semibold mb-4">Training-Daten exportieren</h3>
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Export-Format</label>
<select
value={exportFormat}
onChange={(e) => setExportFormat(e.target.value as typeof exportFormat)}
className="w-full px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-primary-500"
>
<option value="generic">Generic JSON</option>
<option value="trocr">TrOCR Fine-Tuning</option>
<option value="llama_vision">Llama Vision Fine-Tuning</option>
</select>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Session (optional)</label>
<select
value={selectedSession || ''}
onChange={(e) => setSelectedSession(e.target.value || null)}
className="w-full px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-primary-500"
>
<option value="">Alle Sessions</option>
{sessions.map((session) => (
<option key={session.id} value={session.id}>{session.name}</option>
))}
</select>
</div>
<button
onClick={handleExport}
disabled={exporting || (stats?.exportable_items || 0) === 0}
className="w-full px-4 py-2 bg-primary-600 text-white rounded-lg hover:bg-primary-700 disabled:opacity-50"
>
{exporting ? 'Exportiere...' : `${stats?.exportable_items || 0} Samples exportieren`}
</button>
{/* Cross-Link to Magic Help for TrOCR Fine-Tuning */}
{exportFormat === 'trocr' && (stats?.exportable_items || 0) > 0 && (
<Link
href="/ai/magic-help?source=ocr-labeling"
className="w-full mt-3 px-4 py-2 bg-purple-100 text-purple-700 border border-purple-300 rounded-lg hover:bg-purple-200 flex items-center justify-center gap-2 transition-colors"
>
<span></span>
Mit Magic Help testen & fine-tunen
</Link>
)}
</div>
</div>
{exportResult && (
<div className="bg-white rounded-lg shadow p-6">
<h3 className="text-lg font-semibold mb-4">Export-Ergebnis</h3>
<div className="bg-green-50 border border-green-200 rounded-lg p-4 mb-4">
<p className="text-green-800">
{exportResult.exported_count} Samples erfolgreich exportiert
</p>
<p className="text-sm text-green-600">
Batch: {exportResult.batch_id}
</p>
</div>
<div className="bg-slate-50 p-4 rounded-lg overflow-auto max-h-64">
<pre className="text-xs">{JSON.stringify(exportResult.samples?.slice(0, 3), null, 2)}</pre>
{(exportResult.samples?.length || 0) > 3 && (
<p className="text-slate-500 mt-2">... und {(exportResult.samples?.length ?? 0) - 3} weitere</p>
)}
</div>
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,197 @@
'use client'
/**
* Labeling tab: image viewer, OCR text, correction input, and queue preview.
*/
import { API_BASE } from '../constants'
import type { OCRItem } from '../types'
interface LabelingTabProps {
queue: OCRItem[]
currentItem: OCRItem | null
currentIndex: number
correctedText: string
setCorrectedText: (text: string) => void
goToNext: () => void
goToPrev: () => void
selectQueueItem: (idx: number) => void
confirmItem: () => void
correctItem: () => void
skipItem: () => void
}
export function LabelingTab({
queue,
currentItem,
currentIndex,
correctedText,
setCorrectedText,
goToNext,
goToPrev,
selectQueueItem,
confirmItem,
correctItem,
skipItem,
}: LabelingTabProps) {
return (
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
{/* Left: Image Viewer */}
<div className="lg:col-span-2 bg-white rounded-lg shadow p-4">
<div className="flex items-center justify-between mb-4">
<h3 className="text-lg font-semibold">Bild</h3>
<div className="flex items-center gap-2">
<button
onClick={goToPrev}
disabled={currentIndex === 0}
className="p-2 rounded hover:bg-slate-100 disabled:opacity-50"
title="Zurueck (Pfeiltaste links)"
>
<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>
</button>
<span className="text-sm text-slate-600">
{currentIndex + 1} / {queue.length}
</span>
<button
onClick={goToNext}
disabled={currentIndex >= queue.length - 1}
className="p-2 rounded hover:bg-slate-100 disabled:opacity-50"
title="Weiter (Pfeiltaste rechts)"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
</svg>
</button>
</div>
</div>
{currentItem ? (
<div className="relative bg-slate-100 rounded-lg overflow-hidden" style={{ minHeight: '400px' }}>
<img
src={currentItem.image_url || `${API_BASE}${currentItem.image_path}`}
alt="OCR Bild"
className="w-full h-auto max-h-[600px] object-contain"
onError={(e) => {
const target = e.target as HTMLImageElement
target.style.display = 'none'
}}
/>
</div>
) : (
<div className="flex items-center justify-center h-64 bg-slate-100 rounded-lg">
<p className="text-slate-500">Keine Bilder in der Warteschlange</p>
</div>
)}
</div>
{/* Right: OCR Text & Actions */}
<div className="bg-white rounded-lg shadow p-4">
<div className="space-y-4">
{/* OCR Result */}
<div>
<div className="flex items-center justify-between mb-2">
<h3 className="text-lg font-semibold">OCR-Ergebnis</h3>
{currentItem?.ocr_confidence && (
<span className={`text-sm px-2 py-1 rounded ${
currentItem.ocr_confidence > 0.8
? 'bg-green-100 text-green-800'
: currentItem.ocr_confidence > 0.5
? 'bg-yellow-100 text-yellow-800'
: 'bg-red-100 text-red-800'
}`}>
{Math.round(currentItem.ocr_confidence * 100)}% Konfidenz
</span>
)}
</div>
<div className="bg-slate-50 p-3 rounded-lg min-h-[100px] text-sm">
{currentItem?.ocr_text || <span className="text-slate-400">Kein OCR-Text</span>}
</div>
</div>
{/* Correction Input */}
<div>
<h3 className="text-lg font-semibold mb-2">Korrektur</h3>
<textarea
value={correctedText}
onChange={(e) => setCorrectedText(e.target.value)}
placeholder="Korrigierter Text..."
className="w-full h-32 p-3 border border-slate-200 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-transparent"
/>
</div>
{/* Actions */}
<div className="flex flex-col gap-2">
<button
onClick={confirmItem}
disabled={!currentItem}
className="w-full px-4 py-3 bg-green-600 text-white rounded-lg hover:bg-green-700 disabled:opacity-50 flex items-center justify-center gap-2"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
Korrekt (Enter)
</button>
<button
onClick={correctItem}
disabled={!currentItem || !correctedText.trim() || correctedText === currentItem?.ocr_text}
className="w-full px-4 py-3 bg-primary-600 text-white rounded-lg hover:bg-primary-700 disabled:opacity-50 flex items-center justify-center gap-2"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15.232 5.232l3.536 3.536m-2.036-5.036a2.5 2.5 0 113.536 3.536L6.5 21.036H3v-3.572L16.732 3.732z" />
</svg>
Korrektur speichern
</button>
<button
onClick={skipItem}
disabled={!currentItem}
className="w-full px-4 py-2 bg-slate-200 text-slate-700 rounded-lg hover:bg-slate-300 disabled:opacity-50 flex items-center justify-center gap-2"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 5l7 7-7 7M5 5l7 7-7 7" />
</svg>
Ueberspringen (S)
</button>
</div>
{/* Keyboard Shortcuts */}
<div className="text-xs text-slate-500 mt-4">
<p className="font-medium mb-1">Tastaturkuerzel:</p>
<p>Enter = Bestaetigen | S = Ueberspringen</p>
<p>Pfeiltasten = Navigation</p>
</div>
</div>
</div>
{/* Bottom: Queue Preview */}
<div className="lg:col-span-3 bg-white rounded-lg shadow p-4">
<h3 className="text-lg font-semibold mb-4">Warteschlange ({queue.length} Items)</h3>
<div className="flex gap-2 overflow-x-auto pb-2">
{queue.slice(0, 10).map((item, idx) => (
<button
key={item.id}
onClick={() => selectQueueItem(idx)}
className={`flex-shrink-0 w-24 h-24 rounded-lg overflow-hidden border-2 ${
idx === currentIndex
? 'border-primary-500'
: 'border-transparent hover:border-slate-300'
}`}
>
<img
src={item.image_url || `${API_BASE}${item.image_path}`}
alt=""
className="w-full h-full object-cover"
/>
</button>
))}
{queue.length > 10 && (
<div className="flex-shrink-0 w-24 h-24 rounded-lg bg-slate-100 flex items-center justify-center text-slate-500">
+{queue.length - 10} mehr
</div>
)}
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,166 @@
'use client'
/**
* Sessions tab: create new sessions and list existing ones.
*/
import { useState } from 'react'
import { API_BASE } from '../constants'
import type { OCRSession, CreateSessionRequest, OCRModel } from '../types'
interface SessionsTabProps {
sessions: OCRSession[]
selectedSession: string | null
setSelectedSession: (id: string | null) => void
fetchSessions: () => Promise<void>
setError: (error: string | null) => void
}
export function SessionsTab({
sessions,
selectedSession,
setSelectedSession,
fetchSessions,
setError,
}: SessionsTabProps) {
const [newSession, setNewSession] = useState<CreateSessionRequest>({
name: '',
source_type: 'klausur',
description: '',
ocr_model: 'llama3.2-vision:11b',
})
const createSession = async () => {
try {
const res = await fetch(`${API_BASE}/api/v1/ocr-label/sessions`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(newSession),
})
if (res.ok) {
setNewSession({ name: '', source_type: 'klausur', description: '', ocr_model: 'llama3.2-vision:11b' })
fetchSessions()
} else {
setError('Session erstellen fehlgeschlagen')
}
} catch {
setError('Netzwerkfehler')
}
}
return (
<div className="space-y-6">
{/* Create Session */}
<div className="bg-white rounded-lg shadow p-6">
<h3 className="text-lg font-semibold mb-4">Neue Session erstellen</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Name</label>
<input
type="text"
value={newSession.name}
onChange={(e) => setNewSession(prev => ({ ...prev, name: e.target.value }))}
placeholder="z.B. Mathe Klausur Q1 2025"
className="w-full px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-primary-500"
/>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Typ</label>
<select
value={newSession.source_type}
onChange={(e) => setNewSession(prev => ({ ...prev, source_type: e.target.value as 'klausur' | 'handwriting_sample' | 'scan' }))}
className="w-full px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-primary-500"
>
<option value="klausur">Klausur</option>
<option value="handwriting_sample">Handschriftprobe</option>
<option value="scan">Scan</option>
</select>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">OCR Modell</label>
<select
value={newSession.ocr_model}
onChange={(e) => setNewSession(prev => ({ ...prev, ocr_model: e.target.value as OCRModel }))}
className="w-full px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-primary-500"
>
<option value="llama3.2-vision:11b">llama3.2-vision:11b - Vision LLM (Standard)</option>
<option value="trocr">TrOCR - Microsoft Transformer (schnell)</option>
<option value="paddleocr">PaddleOCR + LLM (4x schneller)</option>
<option value="donut">Donut - Document Understanding (strukturiert)</option>
</select>
<p className="mt-1 text-xs text-slate-500">
{newSession.ocr_model === 'paddleocr' && 'PaddleOCR erkennt Text schnell, LLM strukturiert die Ergebnisse.'}
{newSession.ocr_model === 'donut' && 'Speziell fuer Dokumente mit Tabellen und Formularen.'}
{newSession.ocr_model === 'trocr' && 'Schnelles Transformer-Modell fuer gedruckten Text.'}
{newSession.ocr_model === 'llama3.2-vision:11b' && 'Beste Qualitaet bei Handschrift, aber langsamer.'}
</p>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Beschreibung</label>
<input
type="text"
value={newSession.description}
onChange={(e) => setNewSession(prev => ({ ...prev, description: e.target.value }))}
placeholder="Optional..."
className="w-full px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-primary-500"
/>
</div>
</div>
<button
onClick={createSession}
disabled={!newSession.name}
className="mt-4 px-4 py-2 bg-primary-600 text-white rounded-lg hover:bg-primary-700 disabled:opacity-50"
>
Session erstellen
</button>
</div>
{/* Sessions List */}
<div className="bg-white rounded-lg shadow">
<div className="px-6 py-4 border-b border-slate-200">
<h3 className="text-lg font-semibold">Sessions ({sessions.length})</h3>
</div>
<div className="divide-y divide-slate-200">
{sessions.map((session) => (
<div
key={session.id}
className={`p-4 hover:bg-slate-50 cursor-pointer ${
selectedSession === session.id ? 'bg-primary-50 border-l-4 border-primary-500' : ''
}`}
onClick={() => setSelectedSession(session.id === selectedSession ? null : session.id)}
>
<div className="flex items-center justify-between">
<div>
<h4 className="font-medium">{session.name}</h4>
<p className="text-sm text-slate-500">
{session.source_type} | {session.ocr_model}
</p>
</div>
<div className="text-right">
<p className="text-sm font-medium">
{session.labeled_items}/{session.total_items} gelabelt
</p>
<div className="w-32 bg-slate-200 rounded-full h-2 mt-1">
<div
className="bg-primary-600 rounded-full h-2"
style={{
width: `${session.total_items > 0 ? (session.labeled_items / session.total_items) * 100 : 0}%`
}}
/>
</div>
</div>
</div>
{session.description && (
<p className="text-sm text-slate-600 mt-2">{session.description}</p>
)}
</div>
))}
{sessions.length === 0 && (
<p className="p-4 text-slate-500 text-center">Keine Sessions vorhanden</p>
)}
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,76 @@
'use client'
/**
* Stats tab: global statistics, detailed breakdown, and progress bar.
*/
import type { OCRStats } from '../types'
interface StatsTabProps {
stats: OCRStats | null
}
export function StatsTab({ stats }: StatsTabProps) {
return (
<div className="space-y-6">
{/* Global Stats */}
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
<div className="bg-white rounded-lg shadow p-6">
<h4 className="text-sm font-medium text-slate-500">Gesamt Items</h4>
<p className="text-3xl font-bold mt-2">{stats?.total_items || 0}</p>
</div>
<div className="bg-white rounded-lg shadow p-6">
<h4 className="text-sm font-medium text-slate-500">Gelabelt</h4>
<p className="text-3xl font-bold mt-2 text-green-600">{stats?.labeled_items || 0}</p>
</div>
<div className="bg-white rounded-lg shadow p-6">
<h4 className="text-sm font-medium text-slate-500">Ausstehend</h4>
<p className="text-3xl font-bold mt-2 text-yellow-600">{stats?.pending_items || 0}</p>
</div>
<div className="bg-white rounded-lg shadow p-6">
<h4 className="text-sm font-medium text-slate-500">OCR-Genauigkeit</h4>
<p className="text-3xl font-bold mt-2">{stats?.accuracy_rate || 0}%</p>
</div>
</div>
{/* Detailed Stats */}
<div className="bg-white rounded-lg shadow p-6">
<h3 className="text-lg font-semibold mb-4">Details</h3>
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<div>
<p className="text-sm text-slate-500">Bestaetigt</p>
<p className="text-xl font-semibold text-green-600">{stats?.confirmed_items || 0}</p>
</div>
<div>
<p className="text-sm text-slate-500">Korrigiert</p>
<p className="text-xl font-semibold text-primary-600">{stats?.corrected_items || 0}</p>
</div>
<div>
<p className="text-sm text-slate-500">Exportierbar</p>
<p className="text-xl font-semibold">{stats?.exportable_items || 0}</p>
</div>
<div>
<p className="text-sm text-slate-500">Durchschn. Label-Zeit</p>
<p className="text-xl font-semibold">{stats?.avg_label_time_seconds || 0}s</p>
</div>
</div>
</div>
{/* Progress Bar */}
{stats?.total_items ? (
<div className="bg-white rounded-lg shadow p-6">
<h3 className="text-lg font-semibold mb-4">Fortschritt</h3>
<div className="w-full bg-slate-200 rounded-full h-4">
<div
className="bg-primary-600 rounded-full h-4 transition-all"
style={{ width: `${(stats.labeled_items / stats.total_items) * 100}%` }}
/>
</div>
<p className="text-sm text-slate-500 mt-2">
{Math.round((stats.labeled_items / stats.total_items) * 100)}% abgeschlossen
</p>
</div>
) : null}
</div>
)
}

View File

@@ -0,0 +1,160 @@
'use client'
/**
* Upload tab: session selection, drag-and-drop upload, and upload results.
*/
import { useState, useRef } from 'react'
import { API_BASE } from '../constants'
import type { OCRSession, UploadResult } from '../types'
interface UploadTabProps {
sessions: OCRSession[]
selectedSession: string | null
setSelectedSession: (id: string | null) => void
fetchQueue: () => Promise<void>
fetchStats: () => Promise<void>
setError: (error: string | null) => void
}
export function UploadTab({
sessions,
selectedSession,
setSelectedSession,
fetchQueue,
fetchStats,
setError,
}: UploadTabProps) {
const [uploading, setUploading] = useState(false)
const [uploadResults, setUploadResults] = useState<UploadResult[]>([])
const fileInputRef = useRef<HTMLInputElement>(null)
const handleUpload = async (files: FileList) => {
if (!selectedSession) {
setError('Bitte zuerst eine Session auswaehlen')
return
}
setUploading(true)
const formData = new FormData()
Array.from(files).forEach(file => formData.append('files', file))
formData.append('run_ocr', 'true')
try {
const res = await fetch(`${API_BASE}/api/v1/ocr-label/sessions/${selectedSession}/upload`, {
method: 'POST',
body: formData,
})
if (res.ok) {
const data = await res.json()
setUploadResults(data.items || [])
fetchQueue()
fetchStats()
} else {
setError('Upload fehlgeschlagen')
}
} catch {
setError('Netzwerkfehler beim Upload')
} finally {
setUploading(false)
}
}
return (
<div className="space-y-6">
{/* Session Selection */}
<div className="bg-white rounded-lg shadow p-6">
<h3 className="text-lg font-semibold mb-4">Session auswaehlen</h3>
<select
value={selectedSession || ''}
onChange={(e) => setSelectedSession(e.target.value || null)}
className="w-full px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-primary-500"
>
<option value="">-- Session waehlen --</option>
{sessions.map((session) => (
<option key={session.id} value={session.id}>
{session.name} ({session.total_items} Items)
</option>
))}
</select>
</div>
{/* Upload Area */}
<div className="bg-white rounded-lg shadow p-6">
<h3 className="text-lg font-semibold mb-4">Bilder hochladen</h3>
<div
className={`border-2 border-dashed rounded-lg p-8 text-center ${
selectedSession ? 'border-slate-300 hover:border-primary-500' : 'border-slate-200 opacity-50'
}`}
onDragOver={(e) => {
e.preventDefault()
e.currentTarget.classList.add('border-primary-500', 'bg-primary-50')
}}
onDragLeave={(e) => {
e.currentTarget.classList.remove('border-primary-500', 'bg-primary-50')
}}
onDrop={(e) => {
e.preventDefault()
e.currentTarget.classList.remove('border-primary-500', 'bg-primary-50')
if (e.dataTransfer.files.length > 0) {
handleUpload(e.dataTransfer.files)
}
}}
>
<input
ref={fileInputRef}
type="file"
multiple
accept="image/png,image/jpeg,image/jpg"
onChange={(e) => e.target.files && handleUpload(e.target.files)}
className="hidden"
disabled={!selectedSession}
/>
{uploading ? (
<div className="flex flex-col items-center gap-2">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary-600" />
<p>Hochladen & OCR ausfuehren...</p>
</div>
) : (
<>
<svg className="w-12 h-12 text-slate-400 mx-auto mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z" />
</svg>
<p className="text-slate-600 mb-2">
Bilder hierher ziehen oder{' '}
<button
onClick={() => fileInputRef.current?.click()}
disabled={!selectedSession}
className="text-primary-600 hover:underline"
>
auswaehlen
</button>
</p>
<p className="text-sm text-slate-500">PNG, JPG (max. 10MB pro Bild)</p>
</>
)}
</div>
</div>
{/* Upload Results */}
{uploadResults.length > 0 && (
<div className="bg-white rounded-lg shadow p-6">
<h3 className="text-lg font-semibold mb-4">Upload-Ergebnisse ({uploadResults.length})</h3>
<div className="space-y-2">
{uploadResults.map((result) => (
<div key={result.id} className="flex items-center justify-between p-2 bg-slate-50 rounded">
<span className="text-sm">{result.filename}</span>
<span className={`text-xs px-2 py-1 rounded ${
result.ocr_text ? 'bg-green-100 text-green-800' : 'bg-yellow-100 text-yellow-800'
}`}>
{result.ocr_text ? `OCR OK (${Math.round((result.ocr_confidence || 0) * 100)}%)` : 'Kein OCR'}
</span>
</div>
))}
</div>
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,59 @@
/**
* Constants and tab definitions for OCR Labeling page.
*/
import type { JSX } from 'react'
// API Base URL for klausur-service
export const API_BASE = process.env.NEXT_PUBLIC_KLAUSUR_SERVICE_URL || 'http://localhost:8086'
// Tab definitions
export type TabId = 'labeling' | 'sessions' | 'upload' | 'stats' | 'export'
export const tabs: { id: TabId; name: string; icon: JSX.Element }[] = [
{
id: 'labeling',
name: 'Labeling',
icon: (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15.232 5.232l3.536 3.536m-2.036-5.036a2.5 2.5 0 113.536 3.536L6.5 21.036H3v-3.572L16.732 3.732z" />
</svg>
),
},
{
id: 'sessions',
name: 'Sessions',
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: 'upload',
name: 'Upload',
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 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-8l-4-4m0 0L8 8m4-4v12" />
</svg>
),
},
{
id: 'stats',
name: 'Statistiken',
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: 'export',
name: 'Export',
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 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" />
</svg>
),
},
]

View File

@@ -10,903 +10,20 @@
* OCR-Labeling → RAG Pipeline → Daten & RAG
*/
import { useState, useEffect, useCallback, useRef } from 'react'
import Link from 'next/link'
import { useState } from 'react'
import { PagePurpose } from '@/components/common/PagePurpose'
import { AIModuleSidebarResponsive } from '@/components/ai/AIModuleSidebar'
import type {
OCRSession,
OCRItem,
OCRStats,
TrainingSample,
CreateSessionRequest,
OCRModel,
} from './types'
// API Base URL for klausur-service
const API_BASE = process.env.NEXT_PUBLIC_KLAUSUR_SERVICE_URL || 'http://localhost:8086'
// Tab definitions
type TabId = 'labeling' | 'sessions' | 'upload' | 'stats' | 'export'
const tabs: { id: TabId; name: string; icon: JSX.Element }[] = [
{
id: 'labeling',
name: 'Labeling',
icon: (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15.232 5.232l3.536 3.536m-2.036-5.036a2.5 2.5 0 113.536 3.536L6.5 21.036H3v-3.572L16.732 3.732z" />
</svg>
),
},
{
id: 'sessions',
name: 'Sessions',
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: 'upload',
name: 'Upload',
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 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-8l-4-4m0 0L8 8m4-4v12" />
</svg>
),
},
{
id: 'stats',
name: 'Statistiken',
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: 'export',
name: 'Export',
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 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" />
</svg>
),
},
]
import { tabs, type TabId } from './constants'
import { useOcrLabeling } from './useOcrLabeling'
import { LabelingTab } from './_components/LabelingTab'
import { SessionsTab } from './_components/SessionsTab'
import { UploadTab } from './_components/UploadTab'
import { StatsTab } from './_components/StatsTab'
import { ExportTab } from './_components/ExportTab'
export default function OCRLabelingPage() {
const [activeTab, setActiveTab] = useState<TabId>('labeling')
const [sessions, setSessions] = useState<OCRSession[]>([])
const [selectedSession, setSelectedSession] = useState<string | null>(null)
const [queue, setQueue] = useState<OCRItem[]>([])
const [currentItem, setCurrentItem] = useState<OCRItem | null>(null)
const [currentIndex, setCurrentIndex] = useState(0)
const [stats, setStats] = useState<OCRStats | null>(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [correctedText, setCorrectedText] = useState('')
const [labelStartTime, setLabelStartTime] = useState<number | null>(null)
// Fetch sessions
const fetchSessions = useCallback(async () => {
try {
const res = await fetch(`${API_BASE}/api/v1/ocr-label/sessions`)
if (res.ok) {
const data = await res.json()
setSessions(data)
}
} catch (err) {
console.error('Failed to fetch sessions:', err)
}
}, [])
// Fetch queue
const fetchQueue = useCallback(async () => {
try {
const url = selectedSession
? `${API_BASE}/api/v1/ocr-label/queue?session_id=${selectedSession}&limit=20`
: `${API_BASE}/api/v1/ocr-label/queue?limit=20`
const res = await fetch(url)
if (res.ok) {
const data = await res.json()
setQueue(data)
if (data.length > 0 && !currentItem) {
setCurrentItem(data[0])
setCurrentIndex(0)
setCorrectedText(data[0].ocr_text || '')
setLabelStartTime(Date.now())
}
}
} catch (err) {
console.error('Failed to fetch queue:', err)
}
}, [selectedSession, currentItem])
// Fetch stats
const fetchStats = useCallback(async () => {
try {
const url = selectedSession
? `${API_BASE}/api/v1/ocr-label/stats?session_id=${selectedSession}`
: `${API_BASE}/api/v1/ocr-label/stats`
const res = await fetch(url)
if (res.ok) {
const data = await res.json()
setStats(data)
}
} catch (err) {
console.error('Failed to fetch stats:', err)
}
}, [selectedSession])
// Initial data load
useEffect(() => {
const loadData = async () => {
setLoading(true)
await Promise.all([fetchSessions(), fetchQueue(), fetchStats()])
setLoading(false)
}
loadData()
}, [fetchSessions, fetchQueue, fetchStats])
// Refresh queue when session changes
useEffect(() => {
setCurrentItem(null)
setCurrentIndex(0)
fetchQueue()
fetchStats()
}, [selectedSession, fetchQueue, fetchStats])
// Navigate to next item
const goToNext = () => {
if (currentIndex < queue.length - 1) {
const nextIndex = currentIndex + 1
setCurrentIndex(nextIndex)
setCurrentItem(queue[nextIndex])
setCorrectedText(queue[nextIndex].ocr_text || '')
setLabelStartTime(Date.now())
} else {
// Refresh queue
fetchQueue()
}
}
// Navigate to previous item
const goToPrev = () => {
if (currentIndex > 0) {
const prevIndex = currentIndex - 1
setCurrentIndex(prevIndex)
setCurrentItem(queue[prevIndex])
setCorrectedText(queue[prevIndex].ocr_text || '')
setLabelStartTime(Date.now())
}
}
// Calculate label time
const getLabelTime = (): number | undefined => {
if (!labelStartTime) return undefined
return Math.round((Date.now() - labelStartTime) / 1000)
}
// Confirm item
const confirmItem = async () => {
if (!currentItem) return
try {
const res = await fetch(`${API_BASE}/api/v1/ocr-label/confirm`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
item_id: currentItem.id,
label_time_seconds: getLabelTime(),
}),
})
if (res.ok) {
// Remove from queue and go to next
setQueue(prev => prev.filter(item => item.id !== currentItem.id))
goToNext()
fetchStats()
} else {
setError('Bestaetigung fehlgeschlagen')
}
} catch (err) {
setError('Netzwerkfehler')
}
}
// Correct item
const correctItem = async () => {
if (!currentItem || !correctedText.trim()) return
try {
const res = await fetch(`${API_BASE}/api/v1/ocr-label/correct`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
item_id: currentItem.id,
ground_truth: correctedText.trim(),
label_time_seconds: getLabelTime(),
}),
})
if (res.ok) {
setQueue(prev => prev.filter(item => item.id !== currentItem.id))
goToNext()
fetchStats()
} else {
setError('Korrektur fehlgeschlagen')
}
} catch (err) {
setError('Netzwerkfehler')
}
}
// Skip item
const skipItem = async () => {
if (!currentItem) return
try {
const res = await fetch(`${API_BASE}/api/v1/ocr-label/skip`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ item_id: currentItem.id }),
})
if (res.ok) {
setQueue(prev => prev.filter(item => item.id !== currentItem.id))
goToNext()
fetchStats()
} else {
setError('Ueberspringen fehlgeschlagen')
}
} catch (err) {
setError('Netzwerkfehler')
}
}
// Keyboard shortcuts
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
// Only handle if not in text input
if (e.target instanceof HTMLTextAreaElement) return
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault()
confirmItem()
} else if (e.key === 'ArrowRight') {
goToNext()
} else if (e.key === 'ArrowLeft') {
goToPrev()
} else if (e.key === 's' && !e.ctrlKey && !e.metaKey) {
skipItem()
}
}
window.addEventListener('keydown', handleKeyDown)
return () => window.removeEventListener('keydown', handleKeyDown)
}, [currentItem, correctedText])
// Render Labeling Tab
const renderLabelingTab = () => (
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
{/* Left: Image Viewer */}
<div className="lg:col-span-2 bg-white rounded-lg shadow p-4">
<div className="flex items-center justify-between mb-4">
<h3 className="text-lg font-semibold">Bild</h3>
<div className="flex items-center gap-2">
<button
onClick={goToPrev}
disabled={currentIndex === 0}
className="p-2 rounded hover:bg-slate-100 disabled:opacity-50"
title="Zurueck (Pfeiltaste links)"
>
<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>
</button>
<span className="text-sm text-slate-600">
{currentIndex + 1} / {queue.length}
</span>
<button
onClick={goToNext}
disabled={currentIndex >= queue.length - 1}
className="p-2 rounded hover:bg-slate-100 disabled:opacity-50"
title="Weiter (Pfeiltaste rechts)"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
</svg>
</button>
</div>
</div>
{currentItem ? (
<div className="relative bg-slate-100 rounded-lg overflow-hidden" style={{ minHeight: '400px' }}>
<img
src={currentItem.image_url || `${API_BASE}${currentItem.image_path}`}
alt="OCR Bild"
className="w-full h-auto max-h-[600px] object-contain"
onError={(e) => {
// Fallback if image fails to load
const target = e.target as HTMLImageElement
target.style.display = 'none'
}}
/>
</div>
) : (
<div className="flex items-center justify-center h-64 bg-slate-100 rounded-lg">
<p className="text-slate-500">Keine Bilder in der Warteschlange</p>
</div>
)}
</div>
{/* Right: OCR Text & Actions */}
<div className="bg-white rounded-lg shadow p-4">
<div className="space-y-4">
{/* OCR Result */}
<div>
<div className="flex items-center justify-between mb-2">
<h3 className="text-lg font-semibold">OCR-Ergebnis</h3>
{currentItem?.ocr_confidence && (
<span className={`text-sm px-2 py-1 rounded ${
currentItem.ocr_confidence > 0.8
? 'bg-green-100 text-green-800'
: currentItem.ocr_confidence > 0.5
? 'bg-yellow-100 text-yellow-800'
: 'bg-red-100 text-red-800'
}`}>
{Math.round(currentItem.ocr_confidence * 100)}% Konfidenz
</span>
)}
</div>
<div className="bg-slate-50 p-3 rounded-lg min-h-[100px] text-sm">
{currentItem?.ocr_text || <span className="text-slate-400">Kein OCR-Text</span>}
</div>
</div>
{/* Correction Input */}
<div>
<h3 className="text-lg font-semibold mb-2">Korrektur</h3>
<textarea
value={correctedText}
onChange={(e) => setCorrectedText(e.target.value)}
placeholder="Korrigierter Text..."
className="w-full h-32 p-3 border border-slate-200 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-transparent"
/>
</div>
{/* Actions */}
<div className="flex flex-col gap-2">
<button
onClick={confirmItem}
disabled={!currentItem}
className="w-full px-4 py-3 bg-green-600 text-white rounded-lg hover:bg-green-700 disabled:opacity-50 flex items-center justify-center gap-2"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
Korrekt (Enter)
</button>
<button
onClick={correctItem}
disabled={!currentItem || !correctedText.trim() || correctedText === currentItem?.ocr_text}
className="w-full px-4 py-3 bg-primary-600 text-white rounded-lg hover:bg-primary-700 disabled:opacity-50 flex items-center justify-center gap-2"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15.232 5.232l3.536 3.536m-2.036-5.036a2.5 2.5 0 113.536 3.536L6.5 21.036H3v-3.572L16.732 3.732z" />
</svg>
Korrektur speichern
</button>
<button
onClick={skipItem}
disabled={!currentItem}
className="w-full px-4 py-2 bg-slate-200 text-slate-700 rounded-lg hover:bg-slate-300 disabled:opacity-50 flex items-center justify-center gap-2"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 5l7 7-7 7M5 5l7 7-7 7" />
</svg>
Ueberspringen (S)
</button>
</div>
{/* Keyboard Shortcuts */}
<div className="text-xs text-slate-500 mt-4">
<p className="font-medium mb-1">Tastaturkuerzel:</p>
<p>Enter = Bestaetigen | S = Ueberspringen</p>
<p>Pfeiltasten = Navigation</p>
</div>
</div>
</div>
{/* Bottom: Queue Preview */}
<div className="lg:col-span-3 bg-white rounded-lg shadow p-4">
<h3 className="text-lg font-semibold mb-4">Warteschlange ({queue.length} Items)</h3>
<div className="flex gap-2 overflow-x-auto pb-2">
{queue.slice(0, 10).map((item, idx) => (
<button
key={item.id}
onClick={() => {
setCurrentIndex(idx)
setCurrentItem(item)
setCorrectedText(item.ocr_text || '')
setLabelStartTime(Date.now())
}}
className={`flex-shrink-0 w-24 h-24 rounded-lg overflow-hidden border-2 ${
idx === currentIndex
? 'border-primary-500'
: 'border-transparent hover:border-slate-300'
}`}
>
<img
src={item.image_url || `${API_BASE}${item.image_path}`}
alt=""
className="w-full h-full object-cover"
/>
</button>
))}
{queue.length > 10 && (
<div className="flex-shrink-0 w-24 h-24 rounded-lg bg-slate-100 flex items-center justify-center text-slate-500">
+{queue.length - 10} mehr
</div>
)}
</div>
</div>
</div>
)
// Render Sessions Tab
const renderSessionsTab = () => {
const [newSession, setNewSession] = useState<CreateSessionRequest>({
name: '',
source_type: 'klausur',
description: '',
ocr_model: 'llama3.2-vision:11b',
})
const createSession = async () => {
try {
const res = await fetch(`${API_BASE}/api/v1/ocr-label/sessions`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(newSession),
})
if (res.ok) {
setNewSession({ name: '', source_type: 'klausur', description: '', ocr_model: 'llama3.2-vision:11b' })
fetchSessions()
} else {
setError('Session erstellen fehlgeschlagen')
}
} catch (err) {
setError('Netzwerkfehler')
}
}
return (
<div className="space-y-6">
{/* Create Session */}
<div className="bg-white rounded-lg shadow p-6">
<h3 className="text-lg font-semibold mb-4">Neue Session erstellen</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Name</label>
<input
type="text"
value={newSession.name}
onChange={(e) => setNewSession(prev => ({ ...prev, name: e.target.value }))}
placeholder="z.B. Mathe Klausur Q1 2025"
className="w-full px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-primary-500"
/>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Typ</label>
<select
value={newSession.source_type}
onChange={(e) => setNewSession(prev => ({ ...prev, source_type: e.target.value as 'klausur' | 'handwriting_sample' | 'scan' }))}
className="w-full px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-primary-500"
>
<option value="klausur">Klausur</option>
<option value="handwriting_sample">Handschriftprobe</option>
<option value="scan">Scan</option>
</select>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">OCR Modell</label>
<select
value={newSession.ocr_model}
onChange={(e) => setNewSession(prev => ({ ...prev, ocr_model: e.target.value as OCRModel }))}
className="w-full px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-primary-500"
>
<option value="llama3.2-vision:11b">llama3.2-vision:11b - Vision LLM (Standard)</option>
<option value="trocr">TrOCR - Microsoft Transformer (schnell)</option>
<option value="paddleocr">PaddleOCR + LLM (4x schneller)</option>
<option value="donut">Donut - Document Understanding (strukturiert)</option>
</select>
<p className="mt-1 text-xs text-slate-500">
{newSession.ocr_model === 'paddleocr' && 'PaddleOCR erkennt Text schnell, LLM strukturiert die Ergebnisse.'}
{newSession.ocr_model === 'donut' && 'Speziell fuer Dokumente mit Tabellen und Formularen.'}
{newSession.ocr_model === 'trocr' && 'Schnelles Transformer-Modell fuer gedruckten Text.'}
{newSession.ocr_model === 'llama3.2-vision:11b' && 'Beste Qualitaet bei Handschrift, aber langsamer.'}
</p>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Beschreibung</label>
<input
type="text"
value={newSession.description}
onChange={(e) => setNewSession(prev => ({ ...prev, description: e.target.value }))}
placeholder="Optional..."
className="w-full px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-primary-500"
/>
</div>
</div>
<button
onClick={createSession}
disabled={!newSession.name}
className="mt-4 px-4 py-2 bg-primary-600 text-white rounded-lg hover:bg-primary-700 disabled:opacity-50"
>
Session erstellen
</button>
</div>
{/* Sessions List */}
<div className="bg-white rounded-lg shadow">
<div className="px-6 py-4 border-b border-slate-200">
<h3 className="text-lg font-semibold">Sessions ({sessions.length})</h3>
</div>
<div className="divide-y divide-slate-200">
{sessions.map((session) => (
<div
key={session.id}
className={`p-4 hover:bg-slate-50 cursor-pointer ${
selectedSession === session.id ? 'bg-primary-50 border-l-4 border-primary-500' : ''
}`}
onClick={() => setSelectedSession(session.id === selectedSession ? null : session.id)}
>
<div className="flex items-center justify-between">
<div>
<h4 className="font-medium">{session.name}</h4>
<p className="text-sm text-slate-500">
{session.source_type} | {session.ocr_model}
</p>
</div>
<div className="text-right">
<p className="text-sm font-medium">
{session.labeled_items}/{session.total_items} gelabelt
</p>
<div className="w-32 bg-slate-200 rounded-full h-2 mt-1">
<div
className="bg-primary-600 rounded-full h-2"
style={{
width: `${session.total_items > 0 ? (session.labeled_items / session.total_items) * 100 : 0}%`
}}
/>
</div>
</div>
</div>
{session.description && (
<p className="text-sm text-slate-600 mt-2">{session.description}</p>
)}
</div>
))}
{sessions.length === 0 && (
<p className="p-4 text-slate-500 text-center">Keine Sessions vorhanden</p>
)}
</div>
</div>
</div>
)
}
// Render Upload Tab
const renderUploadTab = () => {
const [uploading, setUploading] = useState(false)
const [uploadResults, setUploadResults] = useState<any[]>([])
const fileInputRef = useRef<HTMLInputElement>(null)
const handleUpload = async (files: FileList) => {
if (!selectedSession) {
setError('Bitte zuerst eine Session auswaehlen')
return
}
setUploading(true)
const formData = new FormData()
Array.from(files).forEach(file => formData.append('files', file))
formData.append('run_ocr', 'true')
try {
const res = await fetch(`${API_BASE}/api/v1/ocr-label/sessions/${selectedSession}/upload`, {
method: 'POST',
body: formData,
})
if (res.ok) {
const data = await res.json()
setUploadResults(data.items || [])
fetchQueue()
fetchStats()
} else {
setError('Upload fehlgeschlagen')
}
} catch (err) {
setError('Netzwerkfehler beim Upload')
} finally {
setUploading(false)
}
}
return (
<div className="space-y-6">
{/* Session Selection */}
<div className="bg-white rounded-lg shadow p-6">
<h3 className="text-lg font-semibold mb-4">Session auswaehlen</h3>
<select
value={selectedSession || ''}
onChange={(e) => setSelectedSession(e.target.value || null)}
className="w-full px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-primary-500"
>
<option value="">-- Session waehlen --</option>
{sessions.map((session) => (
<option key={session.id} value={session.id}>
{session.name} ({session.total_items} Items)
</option>
))}
</select>
</div>
{/* Upload Area */}
<div className="bg-white rounded-lg shadow p-6">
<h3 className="text-lg font-semibold mb-4">Bilder hochladen</h3>
<div
className={`border-2 border-dashed rounded-lg p-8 text-center ${
selectedSession ? 'border-slate-300 hover:border-primary-500' : 'border-slate-200 opacity-50'
}`}
onDragOver={(e) => {
e.preventDefault()
e.currentTarget.classList.add('border-primary-500', 'bg-primary-50')
}}
onDragLeave={(e) => {
e.currentTarget.classList.remove('border-primary-500', 'bg-primary-50')
}}
onDrop={(e) => {
e.preventDefault()
e.currentTarget.classList.remove('border-primary-500', 'bg-primary-50')
if (e.dataTransfer.files.length > 0) {
handleUpload(e.dataTransfer.files)
}
}}
>
<input
ref={fileInputRef}
type="file"
multiple
accept="image/png,image/jpeg,image/jpg"
onChange={(e) => e.target.files && handleUpload(e.target.files)}
className="hidden"
disabled={!selectedSession}
/>
{uploading ? (
<div className="flex flex-col items-center gap-2">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary-600" />
<p>Hochladen & OCR ausfuehren...</p>
</div>
) : (
<>
<svg className="w-12 h-12 text-slate-400 mx-auto mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z" />
</svg>
<p className="text-slate-600 mb-2">
Bilder hierher ziehen oder{' '}
<button
onClick={() => fileInputRef.current?.click()}
disabled={!selectedSession}
className="text-primary-600 hover:underline"
>
auswaehlen
</button>
</p>
<p className="text-sm text-slate-500">PNG, JPG (max. 10MB pro Bild)</p>
</>
)}
</div>
</div>
{/* Upload Results */}
{uploadResults.length > 0 && (
<div className="bg-white rounded-lg shadow p-6">
<h3 className="text-lg font-semibold mb-4">Upload-Ergebnisse ({uploadResults.length})</h3>
<div className="space-y-2">
{uploadResults.map((result) => (
<div key={result.id} className="flex items-center justify-between p-2 bg-slate-50 rounded">
<span className="text-sm">{result.filename}</span>
<span className={`text-xs px-2 py-1 rounded ${
result.ocr_text ? 'bg-green-100 text-green-800' : 'bg-yellow-100 text-yellow-800'
}`}>
{result.ocr_text ? `OCR OK (${Math.round((result.ocr_confidence || 0) * 100)}%)` : 'Kein OCR'}
</span>
</div>
))}
</div>
</div>
)}
</div>
)
}
// Render Stats Tab
const renderStatsTab = () => (
<div className="space-y-6">
{/* Global Stats */}
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
<div className="bg-white rounded-lg shadow p-6">
<h4 className="text-sm font-medium text-slate-500">Gesamt Items</h4>
<p className="text-3xl font-bold mt-2">{stats?.total_items || 0}</p>
</div>
<div className="bg-white rounded-lg shadow p-6">
<h4 className="text-sm font-medium text-slate-500">Gelabelt</h4>
<p className="text-3xl font-bold mt-2 text-green-600">{stats?.labeled_items || 0}</p>
</div>
<div className="bg-white rounded-lg shadow p-6">
<h4 className="text-sm font-medium text-slate-500">Ausstehend</h4>
<p className="text-3xl font-bold mt-2 text-yellow-600">{stats?.pending_items || 0}</p>
</div>
<div className="bg-white rounded-lg shadow p-6">
<h4 className="text-sm font-medium text-slate-500">OCR-Genauigkeit</h4>
<p className="text-3xl font-bold mt-2">{stats?.accuracy_rate || 0}%</p>
</div>
</div>
{/* Detailed Stats */}
<div className="bg-white rounded-lg shadow p-6">
<h3 className="text-lg font-semibold mb-4">Details</h3>
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<div>
<p className="text-sm text-slate-500">Bestaetigt</p>
<p className="text-xl font-semibold text-green-600">{stats?.confirmed_items || 0}</p>
</div>
<div>
<p className="text-sm text-slate-500">Korrigiert</p>
<p className="text-xl font-semibold text-primary-600">{stats?.corrected_items || 0}</p>
</div>
<div>
<p className="text-sm text-slate-500">Exportierbar</p>
<p className="text-xl font-semibold">{stats?.exportable_items || 0}</p>
</div>
<div>
<p className="text-sm text-slate-500">Durchschn. Label-Zeit</p>
<p className="text-xl font-semibold">{stats?.avg_label_time_seconds || 0}s</p>
</div>
</div>
</div>
{/* Progress Bar */}
{stats?.total_items ? (
<div className="bg-white rounded-lg shadow p-6">
<h3 className="text-lg font-semibold mb-4">Fortschritt</h3>
<div className="w-full bg-slate-200 rounded-full h-4">
<div
className="bg-primary-600 rounded-full h-4 transition-all"
style={{ width: `${(stats.labeled_items / stats.total_items) * 100}%` }}
/>
</div>
<p className="text-sm text-slate-500 mt-2">
{Math.round((stats.labeled_items / stats.total_items) * 100)}% abgeschlossen
</p>
</div>
) : null}
</div>
)
// Render Export Tab
const renderExportTab = () => {
const [exportFormat, setExportFormat] = useState<'generic' | 'trocr' | 'llama_vision'>('generic')
const [exporting, setExporting] = useState(false)
const [exportResult, setExportResult] = useState<any>(null)
const handleExport = async () => {
setExporting(true)
try {
const res = await fetch(`${API_BASE}/api/v1/ocr-label/export`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
export_format: exportFormat,
session_id: selectedSession,
}),
})
if (res.ok) {
const data = await res.json()
setExportResult(data)
} else {
setError('Export fehlgeschlagen')
}
} catch (err) {
setError('Netzwerkfehler')
} finally {
setExporting(false)
}
}
return (
<div className="space-y-6">
<div className="bg-white rounded-lg shadow p-6">
<h3 className="text-lg font-semibold mb-4">Training-Daten exportieren</h3>
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Export-Format</label>
<select
value={exportFormat}
onChange={(e) => setExportFormat(e.target.value as typeof exportFormat)}
className="w-full px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-primary-500"
>
<option value="generic">Generic JSON</option>
<option value="trocr">TrOCR Fine-Tuning</option>
<option value="llama_vision">Llama Vision Fine-Tuning</option>
</select>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Session (optional)</label>
<select
value={selectedSession || ''}
onChange={(e) => setSelectedSession(e.target.value || null)}
className="w-full px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-primary-500"
>
<option value="">Alle Sessions</option>
{sessions.map((session) => (
<option key={session.id} value={session.id}>{session.name}</option>
))}
</select>
</div>
<button
onClick={handleExport}
disabled={exporting || (stats?.exportable_items || 0) === 0}
className="w-full px-4 py-2 bg-primary-600 text-white rounded-lg hover:bg-primary-700 disabled:opacity-50"
>
{exporting ? 'Exportiere...' : `${stats?.exportable_items || 0} Samples exportieren`}
</button>
{/* Cross-Link to Magic Help for TrOCR Fine-Tuning */}
{exportFormat === 'trocr' && (stats?.exportable_items || 0) > 0 && (
<Link
href="/ai/magic-help?source=ocr-labeling"
className="w-full mt-3 px-4 py-2 bg-purple-100 text-purple-700 border border-purple-300 rounded-lg hover:bg-purple-200 flex items-center justify-center gap-2 transition-colors"
>
<span></span>
Mit Magic Help testen & fine-tunen
</Link>
)}
</div>
</div>
{exportResult && (
<div className="bg-white rounded-lg shadow p-6">
<h3 className="text-lg font-semibold mb-4">Export-Ergebnis</h3>
<div className="bg-green-50 border border-green-200 rounded-lg p-4 mb-4">
<p className="text-green-800">
{exportResult.exported_count} Samples erfolgreich exportiert
</p>
<p className="text-sm text-green-600">
Batch: {exportResult.batch_id}
</p>
</div>
<div className="bg-slate-50 p-4 rounded-lg overflow-auto max-h-64">
<pre className="text-xs">{JSON.stringify(exportResult.samples?.slice(0, 3), null, 2)}</pre>
{(exportResult.samples?.length || 0) > 3 && (
<p className="text-slate-500 mt-2">... und {exportResult.samples.length - 3} weitere</p>
)}
</div>
</div>
)}
</div>
)
}
const hook = useOcrLabeling()
return (
<div className="p-6">
@@ -939,10 +56,10 @@ export default function OCRLabelingPage() {
<AIModuleSidebarResponsive currentModule="ocr-labeling" />
{/* Error Toast */}
{error && (
{hook.error && (
<div className="fixed top-4 right-4 bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded z-50">
<span>{error}</span>
<button onClick={() => setError(null)} className="ml-4">X</button>
<span>{hook.error}</span>
<button onClick={() => hook.setError(null)} className="ml-4">X</button>
</div>
)}
@@ -969,17 +86,58 @@ export default function OCRLabelingPage() {
</div>
{/* Tab Content */}
{loading ? (
{hook.loading ? (
<div className="flex items-center justify-center h-64">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary-600" />
</div>
) : (
<>
{activeTab === 'labeling' && renderLabelingTab()}
{activeTab === 'sessions' && renderSessionsTab()}
{activeTab === 'upload' && renderUploadTab()}
{activeTab === 'stats' && renderStatsTab()}
{activeTab === 'export' && renderExportTab()}
{activeTab === 'labeling' && (
<LabelingTab
queue={hook.queue}
currentItem={hook.currentItem}
currentIndex={hook.currentIndex}
correctedText={hook.correctedText}
setCorrectedText={hook.setCorrectedText}
goToNext={hook.goToNext}
goToPrev={hook.goToPrev}
selectQueueItem={hook.selectQueueItem}
confirmItem={hook.confirmItem}
correctItem={hook.correctItem}
skipItem={hook.skipItem}
/>
)}
{activeTab === 'sessions' && (
<SessionsTab
sessions={hook.sessions}
selectedSession={hook.selectedSession}
setSelectedSession={hook.setSelectedSession}
fetchSessions={hook.fetchSessions}
setError={hook.setError}
/>
)}
{activeTab === 'upload' && (
<UploadTab
sessions={hook.sessions}
selectedSession={hook.selectedSession}
setSelectedSession={hook.setSelectedSession}
fetchQueue={hook.fetchQueue}
fetchStats={hook.fetchStats}
setError={hook.setError}
/>
)}
{activeTab === 'stats' && (
<StatsTab stats={hook.stats} />
)}
{activeTab === 'export' && (
<ExportTab
sessions={hook.sessions}
selectedSession={hook.selectedSession}
setSelectedSession={hook.setSelectedSession}
stats={hook.stats}
setError={hook.setError}
/>
)}
</>
)}
</div>

View File

@@ -0,0 +1,255 @@
'use client'
/**
* Custom hook encapsulating all state and API logic for the OCR Labeling page.
*/
import { useState, useEffect, useCallback } from 'react'
import { API_BASE } from './constants'
import type { OCRSession, OCRItem, OCRStats } from './types'
export function useOcrLabeling() {
const [sessions, setSessions] = useState<OCRSession[]>([])
const [selectedSession, setSelectedSession] = useState<string | null>(null)
const [queue, setQueue] = useState<OCRItem[]>([])
const [currentItem, setCurrentItem] = useState<OCRItem | null>(null)
const [currentIndex, setCurrentIndex] = useState(0)
const [stats, setStats] = useState<OCRStats | null>(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [correctedText, setCorrectedText] = useState('')
const [labelStartTime, setLabelStartTime] = useState<number | null>(null)
// Fetch sessions
const fetchSessions = useCallback(async () => {
try {
const res = await fetch(`${API_BASE}/api/v1/ocr-label/sessions`)
if (res.ok) {
const data = await res.json()
setSessions(data)
}
} catch (err) {
console.error('Failed to fetch sessions:', err)
}
}, [])
// Fetch queue
const fetchQueue = useCallback(async () => {
try {
const url = selectedSession
? `${API_BASE}/api/v1/ocr-label/queue?session_id=${selectedSession}&limit=20`
: `${API_BASE}/api/v1/ocr-label/queue?limit=20`
const res = await fetch(url)
if (res.ok) {
const data = await res.json()
setQueue(data)
if (data.length > 0 && !currentItem) {
setCurrentItem(data[0])
setCurrentIndex(0)
setCorrectedText(data[0].ocr_text || '')
setLabelStartTime(Date.now())
}
}
} catch (err) {
console.error('Failed to fetch queue:', err)
}
}, [selectedSession, currentItem])
// Fetch stats
const fetchStats = useCallback(async () => {
try {
const url = selectedSession
? `${API_BASE}/api/v1/ocr-label/stats?session_id=${selectedSession}`
: `${API_BASE}/api/v1/ocr-label/stats`
const res = await fetch(url)
if (res.ok) {
const data = await res.json()
setStats(data)
}
} catch (err) {
console.error('Failed to fetch stats:', err)
}
}, [selectedSession])
// Initial data load
useEffect(() => {
const loadData = async () => {
setLoading(true)
await Promise.all([fetchSessions(), fetchQueue(), fetchStats()])
setLoading(false)
}
loadData()
}, [fetchSessions, fetchQueue, fetchStats])
// Refresh queue when session changes
useEffect(() => {
setCurrentItem(null)
setCurrentIndex(0)
fetchQueue()
fetchStats()
}, [selectedSession, fetchQueue, fetchStats])
// Navigate to next item
const goToNext = useCallback(() => {
if (currentIndex < queue.length - 1) {
const nextIndex = currentIndex + 1
setCurrentIndex(nextIndex)
setCurrentItem(queue[nextIndex])
setCorrectedText(queue[nextIndex].ocr_text || '')
setLabelStartTime(Date.now())
} else {
fetchQueue()
}
}, [currentIndex, queue, fetchQueue])
// Navigate to previous item
const goToPrev = useCallback(() => {
if (currentIndex > 0) {
const prevIndex = currentIndex - 1
setCurrentIndex(prevIndex)
setCurrentItem(queue[prevIndex])
setCorrectedText(queue[prevIndex].ocr_text || '')
setLabelStartTime(Date.now())
}
}, [currentIndex, queue])
// Calculate label time
const getLabelTime = useCallback((): number | undefined => {
if (!labelStartTime) return undefined
return Math.round((Date.now() - labelStartTime) / 1000)
}, [labelStartTime])
// Select a queue item by index
const selectQueueItem = useCallback((idx: number) => {
if (idx >= 0 && idx < queue.length) {
setCurrentIndex(idx)
setCurrentItem(queue[idx])
setCorrectedText(queue[idx].ocr_text || '')
setLabelStartTime(Date.now())
}
}, [queue])
// Confirm item
const confirmItem = useCallback(async () => {
if (!currentItem) return
try {
const res = await fetch(`${API_BASE}/api/v1/ocr-label/confirm`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
item_id: currentItem.id,
label_time_seconds: getLabelTime(),
}),
})
if (res.ok) {
setQueue(prev => prev.filter(item => item.id !== currentItem.id))
goToNext()
fetchStats()
} else {
setError('Bestaetigung fehlgeschlagen')
}
} catch {
setError('Netzwerkfehler')
}
}, [currentItem, getLabelTime, goToNext, fetchStats])
// Correct item
const correctItem = useCallback(async () => {
if (!currentItem || !correctedText.trim()) return
try {
const res = await fetch(`${API_BASE}/api/v1/ocr-label/correct`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
item_id: currentItem.id,
ground_truth: correctedText.trim(),
label_time_seconds: getLabelTime(),
}),
})
if (res.ok) {
setQueue(prev => prev.filter(item => item.id !== currentItem.id))
goToNext()
fetchStats()
} else {
setError('Korrektur fehlgeschlagen')
}
} catch {
setError('Netzwerkfehler')
}
}, [currentItem, correctedText, getLabelTime, goToNext, fetchStats])
// Skip item
const skipItem = useCallback(async () => {
if (!currentItem) return
try {
const res = await fetch(`${API_BASE}/api/v1/ocr-label/skip`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ item_id: currentItem.id }),
})
if (res.ok) {
setQueue(prev => prev.filter(item => item.id !== currentItem.id))
goToNext()
fetchStats()
} else {
setError('Ueberspringen fehlgeschlagen')
}
} catch {
setError('Netzwerkfehler')
}
}, [currentItem, goToNext, fetchStats])
// Keyboard shortcuts
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.target instanceof HTMLTextAreaElement) return
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault()
confirmItem()
} else if (e.key === 'ArrowRight') {
goToNext()
} else if (e.key === 'ArrowLeft') {
goToPrev()
} else if (e.key === 's' && !e.ctrlKey && !e.metaKey) {
skipItem()
}
}
window.addEventListener('keydown', handleKeyDown)
return () => window.removeEventListener('keydown', handleKeyDown)
}, [confirmItem, goToNext, goToPrev, skipItem])
return {
// State
sessions,
selectedSession,
setSelectedSession,
queue,
currentItem,
currentIndex,
stats,
loading,
error,
setError,
correctedText,
setCorrectedText,
// Actions
fetchSessions,
fetchQueue,
fetchStats,
goToNext,
goToPrev,
selectQueueItem,
confirmItem,
correctItem,
skipItem,
}
}

View File

@@ -0,0 +1,74 @@
import type { ChunkDetail } from './types'
interface ResultsListProps {
results: ChunkDetail[]
selectedChunk: ChunkDetail | null
searchQuery: string
onSelect: (chunk: ChunkDetail) => void
}
function highlightText(text: string, query: string) {
if (!query) return text
const words = query.toLowerCase().split(' ').filter(w => w.length > 2)
let result = text
words.forEach(word => {
const regex = new RegExp(`(${word})`, 'gi')
result = result.replace(regex, '<mark class="bg-yellow-200 dark:bg-yellow-800 px-0.5 rounded">$1</mark>')
})
return result
}
export function ResultsList({ results, selectedChunk, searchQuery, onSelect }: ResultsListProps) {
return (
<div className="bg-white dark:bg-slate-800 rounded-lg shadow p-4">
<h3 className="text-md font-semibold text-gray-900 dark:text-white mb-4">
Gefundene Chunks ({results.length})
</h3>
<div className="space-y-3 max-h-[600px] overflow-y-auto">
{results.map((result, idx) => (
<div
key={idx}
onClick={() => onSelect(result)}
className={`p-4 border rounded-lg cursor-pointer transition-all ${
selectedChunk?.text === result.text
? 'border-blue-500 bg-blue-50 dark:bg-blue-900/20'
: 'border-gray-200 dark:border-slate-700 hover:border-gray-300 dark:hover:border-slate-600'
}`}
>
{/* Header */}
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-2">
<span className="text-xs font-medium px-2 py-0.5 bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400 rounded">
{result.regulation_code}
</span>
{result.article && (
<span className="text-xs text-gray-500 dark:text-gray-400">
Art. {result.article}
{result.paragraph && ` Abs. ${result.paragraph}`}
</span>
)}
</div>
<span className="text-xs text-gray-400">
Score: {(result.score || 0).toFixed(3)}
</span>
</div>
{/* Text Preview */}
<p
className="text-sm text-gray-700 dark:text-gray-300 line-clamp-4"
dangerouslySetInnerHTML={{
__html: highlightText(result.text.substring(0, 400) + (result.text.length > 400 ? '...' : ''), searchQuery)
}}
/>
{/* Metadata */}
<div className="mt-2 flex items-center gap-4 text-xs text-gray-400">
<span>Chunk #{result.chunk_index || idx}</span>
<span>{result.text.length} Zeichen</span>
</div>
</div>
))}
</div>
</div>
)
}

View File

@@ -0,0 +1,112 @@
import { REGULATIONS, SAMPLE_QUERIES } from './types'
interface SearchSectionProps {
searchQuery: string
selectedRegulation: string
topK: number
searching: boolean
onSearchQueryChange: (v: string) => void
onRegulationChange: (v: string) => void
onTopKChange: (v: number) => void
onSearch: () => void
onSampleQuery: (query: string, reg: string) => void
}
export function SearchSection({
searchQuery,
selectedRegulation,
topK,
searching,
onSearchQueryChange,
onRegulationChange,
onTopKChange,
onSearch,
onSampleQuery,
}: SearchSectionProps) {
return (
<>
{/* Quick Sample Queries */}
<div className="bg-white dark:bg-slate-800 rounded-lg shadow p-4">
<h3 className="text-sm font-medium text-gray-700 dark:text-gray-300 mb-3">
Schnell-Stichproben
</h3>
<div className="flex flex-wrap gap-2">
{SAMPLE_QUERIES.map((sq, idx) => (
<button
key={idx}
onClick={() => onSampleQuery(sq.query, sq.reg)}
className="px-3 py-1.5 text-xs bg-gray-100 hover:bg-gray-200 dark:bg-slate-700 dark:hover:bg-slate-600 text-gray-700 dark:text-gray-300 rounded-full transition-colors"
>
{sq.label}
</button>
))}
</div>
</div>
{/* Search Section */}
<div className="bg-white dark:bg-slate-800 rounded-lg shadow p-6">
<h2 className="text-lg font-semibold text-gray-900 dark:text-white mb-4">
Chunk-Suche
</h2>
<div className="space-y-4">
{/* Search Input */}
<div className="flex gap-4">
<div className="flex-1">
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Suchbegriff / Paragraph / Artikeltext
</label>
<input
type="text"
value={searchQuery}
onChange={(e) => onSearchQueryChange(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && onSearch()}
placeholder="z.B. 'Recht auf Löschung' oder 'Art. 17 Abs. 1'"
className="w-full px-4 py-2 border border-gray-300 dark:border-slate-600 rounded-lg focus:ring-2 focus:ring-blue-500 dark:bg-slate-700 dark:text-white"
/>
</div>
<div className="w-48">
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Regulierung
</label>
<select
value={selectedRegulation}
onChange={(e) => onRegulationChange(e.target.value)}
className="w-full px-3 py-2 border border-gray-300 dark:border-slate-600 rounded-lg focus:ring-2 focus:ring-blue-500 dark:bg-slate-700 dark:text-white"
>
<option value="">Alle</option>
{REGULATIONS.map((reg) => (
<option key={reg.code} value={reg.code}>
{reg.name}
</option>
))}
</select>
</div>
<div className="w-24">
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Anzahl
</label>
<select
value={topK}
onChange={(e) => onTopKChange(parseInt(e.target.value))}
className="w-full px-3 py-2 border border-gray-300 dark:border-slate-600 rounded-lg focus:ring-2 focus:ring-blue-500 dark:bg-slate-700 dark:text-white"
>
<option value="5">5</option>
<option value="10">10</option>
<option value="20">20</option>
</select>
</div>
</div>
<button
onClick={onSearch}
disabled={searching || !searchQuery.trim()}
className="px-6 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
{searching ? 'Suche laeuft...' : 'Suchen'}
</button>
</div>
</div>
</>
)
}

View File

@@ -0,0 +1,150 @@
import type { ChunkDetail, TraceabilityResult } from './types'
interface TraceabilityPanelProps {
selectedChunk: ChunkDetail | null
loadingTrace: boolean
traceability: TraceabilityResult | null
}
export function TraceabilityPanel({ selectedChunk, loadingTrace, traceability }: TraceabilityPanelProps) {
return (
<div className="bg-white dark:bg-slate-800 rounded-lg shadow p-4">
<h3 className="text-md font-semibold text-gray-900 dark:text-white mb-4">
Traceability
</h3>
{!selectedChunk ? (
<div className="text-center py-12 text-gray-500 dark:text-gray-400">
<svg className="w-12 h-12 mx-auto mb-4 opacity-50" 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>
<p>Waehlen Sie einen Chunk aus der Liste, um die Traceability zu sehen.</p>
</div>
) : loadingTrace ? (
<div className="text-center py-12">
<div className="animate-spin w-8 h-8 border-2 border-blue-500 border-t-transparent rounded-full mx-auto mb-4"></div>
<p className="text-gray-500 dark:text-gray-400">Lade Traceability...</p>
</div>
) : traceability ? (
<div className="space-y-6">
{/* Selected Chunk Detail */}
<ChunkDetailSection chunk={traceability.chunk} />
<ArrowDown />
{/* Requirements */}
<RequirementsSection requirements={traceability.requirements} />
<ArrowDown />
{/* Controls */}
<ControlsSection controls={traceability.controls} />
</div>
) : null}
</div>
)
}
function ChunkDetailSection({ chunk }: { chunk: ChunkDetail }) {
return (
<div className="border-l-4 border-blue-500 pl-4">
<h4 className="text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Ausgewaehlter Chunk
</h4>
<div className="bg-gray-50 dark:bg-slate-700 rounded p-3">
<div className="flex items-center gap-2 mb-2">
<span className="text-xs font-medium px-2 py-0.5 bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400 rounded">
{chunk.regulation_code}
</span>
{chunk.article && (
<span className="text-xs text-gray-500 dark:text-gray-400">
Art. {chunk.article}
{chunk.paragraph && ` Abs. ${chunk.paragraph}`}
</span>
)}
</div>
<p className="text-sm text-gray-600 dark:text-gray-300 whitespace-pre-wrap">
{chunk.text}
</p>
{chunk.source_url && (
<a
href={chunk.source_url}
target="_blank"
rel="noopener noreferrer"
className="mt-2 inline-flex items-center gap-1 text-xs text-blue-600 hover:underline"
>
Quelle oeffnen
</a>
)}
</div>
</div>
)
}
function RequirementsSection({ requirements }: { requirements: TraceabilityResult['requirements'] }) {
return (
<div className="border-l-4 border-orange-500 pl-4">
<h4 className="text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Extrahierte Anforderungen ({requirements.length})
</h4>
{requirements.length > 0 ? (
<div className="space-y-2">
{requirements.map((req, idx) => (
<div key={idx} className="bg-orange-50 dark:bg-orange-900/20 rounded p-3">
<div className="flex items-center gap-2 mb-1">
<span className="text-xs font-medium text-orange-700 dark:text-orange-400">
{req.category || 'Anforderung'}
</span>
</div>
<p className="text-sm text-gray-600 dark:text-gray-300">{req.text}</p>
</div>
))}
</div>
) : (
<p className="text-sm text-gray-500 dark:text-gray-400 italic">
Keine Anforderungen aus diesem Chunk extrahiert.
<br />
<span className="text-xs">(Requirements-Extraktion ist noch nicht implementiert)</span>
</p>
)}
</div>
)
}
function ControlsSection({ controls }: { controls: TraceabilityResult['controls'] }) {
return (
<div className="border-l-4 border-green-500 pl-4">
<h4 className="text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Abgeleitete Controls ({controls.length})
</h4>
{controls.length > 0 ? (
<div className="space-y-2">
{controls.map((ctrl, idx) => (
<div key={idx} className="bg-green-50 dark:bg-green-900/20 rounded p-3">
<div className="font-medium text-sm text-green-700 dark:text-green-400 mb-1">
{ctrl.name}
</div>
<p className="text-sm text-gray-600 dark:text-gray-300">{ctrl.description}</p>
</div>
))}
</div>
) : (
<p className="text-sm text-gray-500 dark:text-gray-400 italic">
Keine Controls aus diesem Chunk abgeleitet.
<br />
<span className="text-xs">(Control-Ableitung ist noch nicht implementiert)</span>
</p>
)}
</div>
)
}
function ArrowDown() {
return (
<div className="flex justify-center">
<svg className="w-6 h-6 text-gray-400" 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>
</div>
)
}

View File

@@ -0,0 +1,66 @@
export interface ChunkDetail {
id: string
text: string
regulation_code: string
regulation_name: string
article: string | null
paragraph: string | null
chunk_index: number
chunk_position: 'beginning' | 'middle' | 'end'
source_url: string
score?: number
}
export interface Requirement {
id: string
text: string
category: string
source_chunk_id: string
regulation_code: string
}
export interface Control {
id: string
name: string
description: string
source_requirement_ids: string[]
regulation_codes: string[]
}
export interface TraceabilityResult {
chunk: ChunkDetail
requirements: Requirement[]
controls: Control[]
}
export const API_PROXY = '/api/legal-corpus'
export const REGULATIONS = [
{ code: 'GDPR', name: 'DSGVO' },
{ code: 'EPRIVACY', name: 'ePrivacy' },
{ code: 'TDDDG', name: 'TDDDG' },
{ code: 'SCC', name: 'Standardvertragsklauseln' },
{ code: 'DPF', name: 'EU-US DPF' },
{ code: 'AIACT', name: 'EU AI Act' },
{ code: 'CRA', name: 'Cyber Resilience Act' },
{ code: 'NIS2', name: 'NIS2' },
{ code: 'EUCSA', name: 'EU Cybersecurity Act' },
{ code: 'DATAACT', name: 'Data Act' },
{ code: 'DGA', name: 'Data Governance Act' },
{ code: 'DSA', name: 'Digital Services Act' },
{ code: 'EAA', name: 'Accessibility Act' },
{ code: 'DSM', name: 'DSM-Urheberrecht' },
{ code: 'PLD', name: 'Produkthaftung' },
{ code: 'GPSR', name: 'Product Safety' },
{ code: 'BSI-TR-03161-1', name: 'BSI-TR Teil 1' },
{ code: 'BSI-TR-03161-2', name: 'BSI-TR Teil 2' },
{ code: 'BSI-TR-03161-3', name: 'BSI-TR Teil 3' },
]
export const SAMPLE_QUERIES = [
{ label: 'Art. 17 DSGVO (Recht auf Loeschung)', query: 'Recht auf Löschung Artikel 17', reg: 'GDPR' },
{ label: 'Einwilligung TDDDG', query: 'Einwilligung Endeinrichtung speichern', reg: 'TDDDG' },
{ label: 'AI Act Hochrisiko', query: 'Hochrisiko-KI-System Anforderungen', reg: 'AIACT' },
{ label: 'NIS2 Sicherheitsmaßnahmen', query: 'Cybersicherheitsrisikomanagement Maßnahmen', reg: 'NIS2' },
{ label: 'BSI Authentifizierung', query: 'Authentifizierung Zwei-Faktor mobile', reg: 'BSI-TR-03161-1' },
]

View File

@@ -0,0 +1,93 @@
import { useState, useCallback } from 'react'
import type { ChunkDetail, TraceabilityResult } from './types'
import { API_PROXY } from './types'
export function useQualitySearch() {
const [searchQuery, setSearchQuery] = useState('')
const [searchResults, setSearchResults] = useState<ChunkDetail[]>([])
const [searching, setSearching] = useState(false)
const [selectedRegulation, setSelectedRegulation] = useState<string>('')
const [topK, setTopK] = useState(10)
const [selectedChunk, setSelectedChunk] = useState<ChunkDetail | null>(null)
const [traceability, setTraceability] = useState<TraceabilityResult | null>(null)
const [loadingTrace, setLoadingTrace] = useState(false)
const handleSearch = useCallback(async () => {
if (!searchQuery.trim()) return
setSearching(true)
setSearchResults([])
setSelectedChunk(null)
setTraceability(null)
try {
let url = `${API_PROXY}?action=search&query=${encodeURIComponent(searchQuery)}&top_k=${topK}`
if (selectedRegulation) {
url += `&regulations=${encodeURIComponent(selectedRegulation)}`
}
const res = await fetch(url)
if (res.ok) {
const data = await res.json()
setSearchResults(data.results || [])
}
} catch (error) {
console.error('Search failed:', error)
} finally {
setSearching(false)
}
}, [searchQuery, selectedRegulation, topK])
const loadTraceability = useCallback(async (chunk: ChunkDetail) => {
setSelectedChunk(chunk)
setLoadingTrace(true)
try {
const res = await fetch(
`${API_PROXY}?action=traceability&chunk_id=${encodeURIComponent(chunk.id || chunk.regulation_code + '_' + chunk.chunk_index)}&regulation=${encodeURIComponent(chunk.regulation_code)}`
)
if (res.ok) {
const data = await res.json()
setTraceability({
chunk,
requirements: data.requirements || [],
controls: data.controls || [],
})
} else {
setTraceability({ chunk, requirements: [], controls: [] })
}
} catch (error) {
console.error('Failed to load traceability:', error)
setTraceability({ chunk, requirements: [], controls: [] })
} finally {
setLoadingTrace(false)
}
}, [])
const handleSampleQuery = (query: string, reg: string) => {
setSearchQuery(query)
setSelectedRegulation(reg)
setTimeout(() => {
handleSearch()
}, 100)
}
return {
searchQuery,
setSearchQuery,
searchResults,
searching,
selectedRegulation,
setSelectedRegulation,
topK,
setTopK,
selectedChunk,
traceability,
loadingTrace,
handleSearch,
loadTraceability,
handleSampleQuery,
}
}

View File

@@ -5,184 +5,34 @@
*
* Ermoeglicht Auditoren:
* - Chunk-Suche und Stichproben
* - Traceability: Chunk Requirement Control
* - Traceability: Chunk -> Requirement -> Control
* - Dokumenten-Vollstaendigkeitspruefung
*/
import { useState, useCallback } from 'react'
import Link from 'next/link'
import { PagePurpose } from '@/components/common/PagePurpose'
const API_PROXY = '/api/legal-corpus'
// Types
interface ChunkDetail {
id: string
text: string
regulation_code: string
regulation_name: string
article: string | null
paragraph: string | null
chunk_index: number
chunk_position: 'beginning' | 'middle' | 'end'
source_url: string
score?: number
}
interface Requirement {
id: string
text: string
category: string
source_chunk_id: string
regulation_code: string
}
interface Control {
id: string
name: string
description: string
source_requirement_ids: string[]
regulation_codes: string[]
}
interface TraceabilityResult {
chunk: ChunkDetail
requirements: Requirement[]
controls: Control[]
}
// Regulations for filtering
const REGULATIONS = [
{ code: 'GDPR', name: 'DSGVO' },
{ code: 'EPRIVACY', name: 'ePrivacy' },
{ code: 'TDDDG', name: 'TDDDG' },
{ code: 'SCC', name: 'Standardvertragsklauseln' },
{ code: 'DPF', name: 'EU-US DPF' },
{ code: 'AIACT', name: 'EU AI Act' },
{ code: 'CRA', name: 'Cyber Resilience Act' },
{ code: 'NIS2', name: 'NIS2' },
{ code: 'EUCSA', name: 'EU Cybersecurity Act' },
{ code: 'DATAACT', name: 'Data Act' },
{ code: 'DGA', name: 'Data Governance Act' },
{ code: 'DSA', name: 'Digital Services Act' },
{ code: 'EAA', name: 'Accessibility Act' },
{ code: 'DSM', name: 'DSM-Urheberrecht' },
{ code: 'PLD', name: 'Produkthaftung' },
{ code: 'GPSR', name: 'Product Safety' },
{ code: 'BSI-TR-03161-1', name: 'BSI-TR Teil 1' },
{ code: 'BSI-TR-03161-2', name: 'BSI-TR Teil 2' },
{ code: 'BSI-TR-03161-3', name: 'BSI-TR Teil 3' },
]
const TYPE_COLORS: Record<string, string> = {
eu_regulation: 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400',
eu_directive: 'bg-purple-100 text-purple-700 dark:bg-purple-900/30 dark:text-purple-400',
de_law: 'bg-yellow-100 text-yellow-700 dark:bg-yellow-900/30 dark:text-yellow-400',
bsi_standard: 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400',
}
import { useQualitySearch } from './_components/useQualitySearch'
import { SearchSection } from './_components/SearchSection'
import { ResultsList } from './_components/ResultsList'
import { TraceabilityPanel } from './_components/TraceabilityPanel'
export default function QualityPage() {
// Search state
const [searchQuery, setSearchQuery] = useState('')
const [searchResults, setSearchResults] = useState<ChunkDetail[]>([])
const [searching, setSearching] = useState(false)
const [selectedRegulation, setSelectedRegulation] = useState<string>('')
const [topK, setTopK] = useState(10)
// Traceability state
const [selectedChunk, setSelectedChunk] = useState<ChunkDetail | null>(null)
const [traceability, setTraceability] = useState<TraceabilityResult | null>(null)
const [loadingTrace, setLoadingTrace] = useState(false)
// Quick sample queries for auditors
const sampleQueries = [
{ label: 'Art. 17 DSGVO (Recht auf Loeschung)', query: 'Recht auf Löschung Artikel 17', reg: 'GDPR' },
{ label: 'Einwilligung TDDDG', query: 'Einwilligung Endeinrichtung speichern', reg: 'TDDDG' },
{ label: 'AI Act Hochrisiko', query: 'Hochrisiko-KI-System Anforderungen', reg: 'AIACT' },
{ label: 'NIS2 Sicherheitsmaßnahmen', query: 'Cybersicherheitsrisikomanagement Maßnahmen', reg: 'NIS2' },
{ label: 'BSI Authentifizierung', query: 'Authentifizierung Zwei-Faktor mobile', reg: 'BSI-TR-03161-1' },
]
const handleSearch = useCallback(async () => {
if (!searchQuery.trim()) return
setSearching(true)
setSearchResults([])
setSelectedChunk(null)
setTraceability(null)
try {
let url = `${API_PROXY}?action=search&query=${encodeURIComponent(searchQuery)}&top_k=${topK}`
if (selectedRegulation) {
url += `&regulations=${encodeURIComponent(selectedRegulation)}`
}
const res = await fetch(url)
if (res.ok) {
const data = await res.json()
setSearchResults(data.results || [])
}
} catch (error) {
console.error('Search failed:', error)
} finally {
setSearching(false)
}
}, [searchQuery, selectedRegulation, topK])
const loadTraceability = useCallback(async (chunk: ChunkDetail) => {
setSelectedChunk(chunk)
setLoadingTrace(true)
try {
// Try to load traceability (requirements and controls derived from this chunk)
const res = await fetch(`${API_PROXY}?action=traceability&chunk_id=${encodeURIComponent(chunk.id || chunk.regulation_code + '_' + chunk.chunk_index)}&regulation=${encodeURIComponent(chunk.regulation_code)}`)
if (res.ok) {
const data = await res.json()
setTraceability({
chunk,
requirements: data.requirements || [],
controls: data.controls || [],
})
} else {
// If traceability endpoint doesn't exist yet, show placeholder
setTraceability({
chunk,
requirements: [],
controls: [],
})
}
} catch (error) {
console.error('Failed to load traceability:', error)
setTraceability({
chunk,
requirements: [],
controls: [],
})
} finally {
setLoadingTrace(false)
}
}, [])
const handleSampleQuery = (query: string, reg: string) => {
setSearchQuery(query)
setSelectedRegulation(reg)
// Auto-search after setting
setTimeout(() => {
handleSearch()
}, 100)
}
const highlightText = (text: string, query: string) => {
if (!query) return text
const words = query.toLowerCase().split(' ').filter(w => w.length > 2)
let result = text
words.forEach(word => {
const regex = new RegExp(`(${word})`, 'gi')
result = result.replace(regex, '<mark class="bg-yellow-200 dark:bg-yellow-800 px-0.5 rounded">$1</mark>')
})
return result
}
const {
searchQuery,
setSearchQuery,
searchResults,
searching,
selectedRegulation,
setSelectedRegulation,
topK,
setTopK,
selectedChunk,
traceability,
loadingTrace,
handleSearch,
loadTraceability,
handleSampleQuery,
} = useQualitySearch()
return (
<div className="space-y-6">
@@ -214,265 +64,32 @@ export default function QualityPage() {
}}
/>
{/* Quick Sample Queries */}
<div className="bg-white dark:bg-slate-800 rounded-lg shadow p-4">
<h3 className="text-sm font-medium text-gray-700 dark:text-gray-300 mb-3">
Schnell-Stichproben
</h3>
<div className="flex flex-wrap gap-2">
{sampleQueries.map((sq, idx) => (
<button
key={idx}
onClick={() => handleSampleQuery(sq.query, sq.reg)}
className="px-3 py-1.5 text-xs bg-gray-100 hover:bg-gray-200 dark:bg-slate-700 dark:hover:bg-slate-600 text-gray-700 dark:text-gray-300 rounded-full transition-colors"
>
{sq.label}
</button>
))}
</div>
</div>
{/* Search Section */}
<div className="bg-white dark:bg-slate-800 rounded-lg shadow p-6">
<h2 className="text-lg font-semibold text-gray-900 dark:text-white mb-4">
Chunk-Suche
</h2>
<div className="space-y-4">
{/* Search Input */}
<div className="flex gap-4">
<div className="flex-1">
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Suchbegriff / Paragraph / Artikeltext
</label>
<input
type="text"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && handleSearch()}
placeholder="z.B. 'Recht auf Löschung' oder 'Art. 17 Abs. 1'"
className="w-full px-4 py-2 border border-gray-300 dark:border-slate-600 rounded-lg focus:ring-2 focus:ring-blue-500 dark:bg-slate-700 dark:text-white"
<SearchSection
searchQuery={searchQuery}
selectedRegulation={selectedRegulation}
topK={topK}
searching={searching}
onSearchQueryChange={setSearchQuery}
onRegulationChange={setSelectedRegulation}
onTopKChange={setTopK}
onSearch={handleSearch}
onSampleQuery={handleSampleQuery}
/>
</div>
<div className="w-48">
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Regulierung
</label>
<select
value={selectedRegulation}
onChange={(e) => setSelectedRegulation(e.target.value)}
className="w-full px-3 py-2 border border-gray-300 dark:border-slate-600 rounded-lg focus:ring-2 focus:ring-blue-500 dark:bg-slate-700 dark:text-white"
>
<option value="">Alle</option>
{REGULATIONS.map((reg) => (
<option key={reg.code} value={reg.code}>
{reg.name}
</option>
))}
</select>
</div>
<div className="w-24">
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Anzahl
</label>
<select
value={topK}
onChange={(e) => setTopK(parseInt(e.target.value))}
className="w-full px-3 py-2 border border-gray-300 dark:border-slate-600 rounded-lg focus:ring-2 focus:ring-blue-500 dark:bg-slate-700 dark:text-white"
>
<option value="5">5</option>
<option value="10">10</option>
<option value="20">20</option>
</select>
</div>
</div>
<button
onClick={handleSearch}
disabled={searching || !searchQuery.trim()}
className="px-6 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
{searching ? 'Suche laeuft...' : 'Suchen'}
</button>
</div>
</div>
{/* Results Grid */}
{searchResults.length > 0 && (
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{/* Search Results List */}
<div className="bg-white dark:bg-slate-800 rounded-lg shadow p-4">
<h3 className="text-md font-semibold text-gray-900 dark:text-white mb-4">
Gefundene Chunks ({searchResults.length})
</h3>
<div className="space-y-3 max-h-[600px] overflow-y-auto">
{searchResults.map((result, idx) => (
<div
key={idx}
onClick={() => loadTraceability(result)}
className={`p-4 border rounded-lg cursor-pointer transition-all ${
selectedChunk?.text === result.text
? 'border-blue-500 bg-blue-50 dark:bg-blue-900/20'
: 'border-gray-200 dark:border-slate-700 hover:border-gray-300 dark:hover:border-slate-600'
}`}
>
{/* Header */}
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-2">
<span className="text-xs font-medium px-2 py-0.5 bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400 rounded">
{result.regulation_code}
</span>
{result.article && (
<span className="text-xs text-gray-500 dark:text-gray-400">
Art. {result.article}
{result.paragraph && ` Abs. ${result.paragraph}`}
</span>
)}
</div>
<span className="text-xs text-gray-400">
Score: {(result.score || 0).toFixed(3)}
</span>
</div>
{/* Text Preview */}
<p
className="text-sm text-gray-700 dark:text-gray-300 line-clamp-4"
dangerouslySetInnerHTML={{
__html: highlightText(result.text.substring(0, 400) + (result.text.length > 400 ? '...' : ''), searchQuery)
}}
<ResultsList
results={searchResults}
selectedChunk={selectedChunk}
searchQuery={searchQuery}
onSelect={loadTraceability}
/>
<TraceabilityPanel
selectedChunk={selectedChunk}
loadingTrace={loadingTrace}
traceability={traceability}
/>
{/* Metadata */}
<div className="mt-2 flex items-center gap-4 text-xs text-gray-400">
<span>Chunk #{result.chunk_index || idx}</span>
<span>{result.text.length} Zeichen</span>
</div>
</div>
))}
</div>
</div>
{/* Traceability Panel */}
<div className="bg-white dark:bg-slate-800 rounded-lg shadow p-4">
<h3 className="text-md font-semibold text-gray-900 dark:text-white mb-4">
Traceability
</h3>
{!selectedChunk ? (
<div className="text-center py-12 text-gray-500 dark:text-gray-400">
<svg className="w-12 h-12 mx-auto mb-4 opacity-50" 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>
<p>Waehlen Sie einen Chunk aus der Liste, um die Traceability zu sehen.</p>
</div>
) : loadingTrace ? (
<div className="text-center py-12">
<div className="animate-spin w-8 h-8 border-2 border-blue-500 border-t-transparent rounded-full mx-auto mb-4"></div>
<p className="text-gray-500 dark:text-gray-400">Lade Traceability...</p>
</div>
) : traceability ? (
<div className="space-y-6">
{/* Selected Chunk Detail */}
<div className="border-l-4 border-blue-500 pl-4">
<h4 className="text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
📄 Ausgewaehlter Chunk
</h4>
<div className="bg-gray-50 dark:bg-slate-700 rounded p-3">
<div className="flex items-center gap-2 mb-2">
<span className="text-xs font-medium px-2 py-0.5 bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400 rounded">
{traceability.chunk.regulation_code}
</span>
{traceability.chunk.article && (
<span className="text-xs text-gray-500 dark:text-gray-400">
Art. {traceability.chunk.article}
{traceability.chunk.paragraph && ` Abs. ${traceability.chunk.paragraph}`}
</span>
)}
</div>
<p className="text-sm text-gray-600 dark:text-gray-300 whitespace-pre-wrap">
{traceability.chunk.text}
</p>
{traceability.chunk.source_url && (
<a
href={traceability.chunk.source_url}
target="_blank"
rel="noopener noreferrer"
className="mt-2 inline-flex items-center gap-1 text-xs text-blue-600 hover:underline"
>
🔗 Quelle oeffnen
</a>
)}
</div>
</div>
{/* Arrow Down */}
<div className="flex justify-center">
<svg className="w-6 h-6 text-gray-400" 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>
</div>
{/* Requirements */}
<div className="border-l-4 border-orange-500 pl-4">
<h4 className="text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
📋 Extrahierte Anforderungen ({traceability.requirements.length})
</h4>
{traceability.requirements.length > 0 ? (
<div className="space-y-2">
{traceability.requirements.map((req, idx) => (
<div key={idx} className="bg-orange-50 dark:bg-orange-900/20 rounded p-3">
<div className="flex items-center gap-2 mb-1">
<span className="text-xs font-medium text-orange-700 dark:text-orange-400">
{req.category || 'Anforderung'}
</span>
</div>
<p className="text-sm text-gray-600 dark:text-gray-300">{req.text}</p>
</div>
))}
</div>
) : (
<p className="text-sm text-gray-500 dark:text-gray-400 italic">
Keine Anforderungen aus diesem Chunk extrahiert.
<br />
<span className="text-xs">(Requirements-Extraktion ist noch nicht implementiert)</span>
</p>
)}
</div>
{/* Arrow Down */}
<div className="flex justify-center">
<svg className="w-6 h-6 text-gray-400" 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>
</div>
{/* Controls */}
<div className="border-l-4 border-green-500 pl-4">
<h4 className="text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Abgeleitete Controls ({traceability.controls.length})
</h4>
{traceability.controls.length > 0 ? (
<div className="space-y-2">
{traceability.controls.map((ctrl, idx) => (
<div key={idx} className="bg-green-50 dark:bg-green-900/20 rounded p-3">
<div className="font-medium text-sm text-green-700 dark:text-green-400 mb-1">
{ctrl.name}
</div>
<p className="text-sm text-gray-600 dark:text-gray-300">{ctrl.description}</p>
</div>
))}
</div>
) : (
<p className="text-sm text-gray-500 dark:text-gray-400 italic">
Keine Controls aus diesem Chunk abgeleitet.
<br />
<span className="text-xs">(Control-Ableitung ist noch nicht implementiert)</span>
</p>
)}
</div>
</div>
) : null}
</div>
</div>
)}
@@ -510,13 +127,13 @@ export default function QualityPage() {
{/* Audit Info */}
<div className="bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg p-4">
<h3 className="text-sm font-medium text-blue-800 dark:text-blue-400 mb-2">
Hinweise fuer Auditoren
Hinweise fuer Auditoren
</h3>
<ul className="text-sm text-blue-700 dark:text-blue-300 space-y-1 list-disc list-inside">
<li>Die Suche ist semantisch - aehnliche Begriffe werden gefunden, auch wenn die exakte Formulierung abweicht</li>
<li>Jeder Chunk entspricht einem logischen Textabschnitt aus dem Originaldokument</li>
<li>Die Traceability zeigt, wie aus dem Originaltext Anforderungen und Controls abgeleitet wurden</li>
<li>Klicken Sie auf "Quelle oeffnen", um das Originaldokument zu pruefen</li>
<li>Klicken Sie auf &quot;Quelle oeffnen&quot;, um das Originaldokument zu pruefen</li>
</ul>
</div>
</div>

View File

@@ -0,0 +1,129 @@
'use client'
import { ChevronRight } from 'lucide-react'
import type { BacklogItem, BacklogCategory } from '../types'
import { statusLabels, priorityLabels } from '../data'
interface BacklogItemCardProps {
item: BacklogItem
category: BacklogCategory | undefined
isExpanded: boolean
onToggleExpand: (id: string) => void
onUpdateStatus: (id: string, status: BacklogItem['status']) => void
onToggleSubtask: (itemId: string, subtaskId: string) => void
}
export function BacklogItemCard({
item,
category,
isExpanded,
onToggleExpand,
onUpdateStatus,
onToggleSubtask,
}: BacklogItemCardProps) {
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(item.id)}
>
<div className="flex items-start gap-3">
{/* Expand Icon */}
<button className="mt-1 text-slate-400">
<ChevronRight
className={`w-5 h-5 transition-transform ${isExpanded ? 'rotate-90' : ''}`}
/>
</button>
{/* Content */}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1 flex-wrap">
<h3 className="font-semibold text-slate-900">{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>
{item.notes && (
<p className="text-xs text-slate-400 mb-2 italic">{item.notes}</p>
)}
<div className="flex items-center gap-3 text-xs">
<span className={`px-2 py-1 rounded border ${category?.bgColor}`}>
{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(item.id, 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 */}
{totalSubtasks > 0 && (
<div className="mt-3 ml-8">
<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-12">
<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(item.id, subtask.id)}
className="w-4 h-4 rounded border-slate-300 text-blue-600 focus:ring-blue-500"
/>
<span
className={`text-sm ${
subtask.completed ? 'text-slate-400 line-through' : 'text-slate-700'
}`}
>
{subtask.title}
</span>
</li>
))}
</ul>
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,46 @@
'use client'
import type { BacklogCategory, CategoryProgress } from '../types'
interface CategoryCardsProps {
categories: BacklogCategory[]
selectedCategory: string | null
onSelectCategory: (id: string | null) => void
getCategoryProgress: (categoryId: string) => CategoryProgress
}
export function CategoryCards({
categories,
selectedCategory,
onSelectCategory,
getCategoryProgress,
}: CategoryCardsProps) {
return (
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-5 gap-3">
{categories.map((cat) => {
const catProgress = getCategoryProgress(cat.id)
return (
<button
key={cat.id}
onClick={() => onSelectCategory(selectedCategory === cat.id ? null : cat.id)}
className={`p-3 rounded-xl border-2 text-left transition-all ${
selectedCategory === cat.id
? `${cat.bgColor} ring-2 ring-offset-2`
: 'bg-white border-slate-200 hover:border-slate-300'
}`}
>
<div className="flex items-center gap-2 mb-1">
<span className={selectedCategory === cat.id ? cat.color : 'text-slate-500'}>
{cat.icon}
</span>
<span className="font-medium text-xs truncate">{cat.name}</span>
</div>
<div className="text-xs text-slate-500">
{catProgress.completed}/{catProgress.total}
</div>
</button>
)
})}
</div>
)
}

View File

@@ -0,0 +1,55 @@
'use client'
import { Search } from 'lucide-react'
interface FilterBarProps {
searchQuery: string
onSearchChange: (query: string) => void
selectedPriority: string | null
onPriorityChange: (priority: string | null) => void
hasActiveFilters: boolean
onClearFilters: () => void
}
export function FilterBar({
searchQuery,
onSearchChange,
selectedPriority,
onPriorityChange,
hasActiveFilters,
onClearFilters,
}: FilterBarProps) {
return (
<div className="flex flex-wrap gap-4">
<div className="flex-1 min-w-[200px] relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-400" />
<input
type="text"
placeholder="Suchen..."
value={searchQuery}
onChange={(e) => onSearchChange(e.target.value)}
className="w-full pl-10 pr-4 py-2 border border-slate-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
</div>
<select
value={selectedPriority || ''}
onChange={(e) => onPriorityChange(e.target.value || null)}
className="px-4 py-2 border border-slate-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
>
<option value="">Alle Prioritaeten</option>
<option value="critical">Kritisch</option>
<option value="high">Hoch</option>
<option value="medium">Mittel</option>
<option value="low">Niedrig</option>
</select>
{hasActiveFilters && (
<button
onClick={onClearFilters}
className="px-4 py-2 text-sm text-slate-600 hover:text-slate-900"
>
Filter zuruecksetzen
</button>
)}
</div>
)
}

View File

@@ -0,0 +1,25 @@
'use client'
import { AlertTriangle } from 'lucide-react'
export function InfoBox() {
return (
<div className="bg-amber-50 border border-amber-200 rounded-xl p-6">
<div className="flex items-start gap-4">
<AlertTriangle className="w-6 h-6 text-amber-600 flex-shrink-0 mt-0.5" />
<div>
<h3 className="font-semibold text-amber-900">Wichtiger Hinweis</h3>
<p className="text-sm text-amber-800 mt-1">
Diese Backlog-Liste muss vollstaendig abgearbeitet sein, bevor BreakPilot in den
Produktivbetrieb gehen kann. Alle kritischen Items muessen abgeschlossen sein. Der
Fortschritt wird lokal im Browser gespeichert und kann mit &quot;Zuruecksetzen&quot;
auf die Standardwerte zurueckgesetzt werden.
</p>
<p className="text-xs text-amber-700 mt-2">
Letzte Aktualisierung: 2026-02-03
</p>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,38 @@
'use client'
import type { BacklogProgress } from '../types'
interface OverallProgressProps {
progress: BacklogProgress
onReset: () => void
}
export function OverallProgress({ progress, onReset }: OverallProgressProps) {
return (
<div className="bg-white rounded-xl border border-slate-200 p-6">
<div className="flex items-center justify-between mb-4">
<div>
<h2 className="text-lg font-semibold text-slate-900">Gesamtfortschritt</h2>
<p className="text-sm text-slate-500">
{progress.completed} von {progress.total} Aufgaben abgeschlossen
</p>
</div>
<div className="flex items-center gap-4">
<button
onClick={onReset}
className="text-sm text-slate-500 hover:text-slate-700"
>
Zuruecksetzen
</button>
<div className="text-3xl font-bold text-blue-600">{progress.percentage}%</div>
</div>
</div>
<div className="w-full bg-slate-200 rounded-full h-3">
<div
className="bg-blue-600 h-3 rounded-full transition-all duration-500"
style={{ width: `${progress.percentage}%` }}
/>
</div>
</div>
)
}

View File

@@ -0,0 +1,506 @@
import type { BacklogItem } from './types'
// UPDATED: 2026-02-03 - Reflects actual project state
export const initialBacklogItems: BacklogItem[] = [
// ==================== MODULE PROGRESS ====================
{
id: 'mod-1',
title: 'Consent Service (Go) - 90% fertig',
description: 'DSGVO Consent Management Microservice - Production Ready',
category: 'modules',
priority: 'high',
status: 'in_progress',
notes: 'Port 8081. Umfangreiche Tests. JWT Auth, OAuth 2.0, TOTP 2FA, DSR Workflow, Matrix/Jitsi Integration, Session Management, PII Redactor.',
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: 'Session Management & Middleware', completed: true },
{ id: 'mod-1-7', title: 'PII Redactor & Security Headers', completed: true },
{ id: 'mod-1-8', title: 'Performance Tests (High-Load)', completed: false },
],
},
{
id: 'mod-2',
title: 'Admin-v2 Frontend (Next.js 15) - 95% fertig',
description: 'Neues Admin Dashboard - Feature Complete',
category: 'modules',
priority: 'critical',
status: 'completed',
notes: 'Port 3002. 73 Seiten, 154 Dateien, 50k+ Zeilen Code. Alle Module migriert.',
subtasks: [
{ id: 'mod-2-1', title: 'Layout mit Sidebar Navigation', completed: true },
{ id: 'mod-2-2', title: 'AI Module (Agents, RAG, Quality, LLM Compare)', completed: true },
{ id: 'mod-2-3', title: 'Compliance Module (AI Act, DSFA, Controls, Evidence)', completed: true },
{ id: 'mod-2-4', title: 'Communication Module (Mail, Matrix, Video-Chat, Alerts)', completed: true },
{ id: 'mod-2-5', title: 'DSGVO Module (Advisory Board, Consent, DSR, TOM, VVT)', completed: true },
{ id: 'mod-2-6', title: 'Infrastructure Module (CI/CD, GPU, SBOM, Security, Tests)', completed: true },
{ id: 'mod-2-7', title: 'Education Module (Edu-Search, Foerderantrag)', completed: true },
{ id: 'mod-2-8', title: 'Wizard Framework (Stepper, TestRunner, etc.)', completed: true },
{ id: 'mod-2-9', title: 'API Proxy Routes', completed: true },
{ id: 'mod-2-10', title: 'E2E Tests mit Playwright', completed: false },
],
},
{
id: 'mod-3',
title: 'Studio-v2 Frontend (Next.js 15) - 90% fertig',
description: 'Lehrer/Schueler Studio mit Apple Weather UI',
category: 'modules',
priority: 'high',
status: 'completed',
notes: 'Port 3001. 21 Seiten, 111 Dateien, 38k+ Zeilen. Experimental Dashboard, Korrektur, Geo-Lernwelt.',
subtasks: [
{ id: 'mod-3-1', title: 'Experimental Dashboard (Glassmorphism)', completed: true },
{ id: 'mod-3-2', title: 'Korrektur-System mit Fairness-Analyse', completed: true },
{ id: 'mod-3-3', title: 'Geo-Lernwelt (Maps, AOI)', completed: true },
{ id: 'mod-3-4', title: 'Voice Components', completed: true },
{ id: 'mod-3-5', title: 'Worksheet Editor', completed: true },
{ id: 'mod-3-6', title: 'Alerts & B2B Migration Wizard', completed: true },
{ id: 'mod-3-7', title: 'Document Upload & QR Code', completed: true },
{ id: 'mod-3-8', title: 'Messages & Meet Integration', completed: true },
],
},
{
id: 'mod-4',
title: 'Backend (Python FastAPI) - 85% fertig',
description: 'Hauptbackend mit umfangreichen Erweiterungen',
category: 'modules',
priority: 'critical',
status: 'in_progress',
notes: 'Port 8000. 238 Dateien, 94k+ Zeilen. Alerts Agent, Compliance, Classroom Engine, Game, Klausur.',
subtasks: [
{ id: 'mod-4-1', title: 'Alerts Agent (Rules, Digests, Actions)', completed: true },
{ id: 'mod-4-2', title: 'Compliance Module (AI Act, ISMS, Audit)', completed: true },
{ id: 'mod-4-3', title: 'Classroom Engine (FSM, Analytics, Timer)', completed: true },
{ id: 'mod-4-4', title: 'Game API (Learning Rules, Quiz)', completed: true },
{ id: 'mod-4-5', title: 'Klausur Backend (OCR, Correction)', completed: true },
{ id: 'mod-4-6', title: 'Unit API & Analytics', completed: true },
{ id: 'mod-4-7', title: 'Middleware (Rate Limiter, Security)', completed: true },
{ id: 'mod-4-8', title: 'Session Management (RBAC)', completed: true },
{ id: 'mod-4-9', title: 'Transcription Worker', completed: true },
{ id: 'mod-4-10', title: 'Alembic Migrations', completed: true },
{ id: 'mod-4-11', title: 'Integration Tests erweitern', completed: false },
],
},
{
id: 'mod-5',
title: 'Klausur Service (Python) - 85% fertig',
description: 'BYOEH Abitur-Klausurkorrektur System',
category: 'modules',
priority: 'critical',
status: 'in_progress',
notes: 'Port 8086. 45 Dateien, 20k+ Zeilen. BYOEH, Qdrant RAG, Embedding Service, Legal Corpus.',
subtasks: [
{ id: 'mod-5-1', title: 'BYOEH Upload & Encryption', completed: true },
{ id: 'mod-5-2', title: 'Key-Sharing zwischen Pruefern', completed: true },
{ id: 'mod-5-3', title: 'Qdrant RAG Integration', completed: true },
{ id: 'mod-5-4', title: 'Hybrid Search (Keyword + Semantic)', completed: true },
{ id: 'mod-5-5', title: 'Embedding Service', completed: true },
{ id: 'mod-5-6', title: 'Legal Corpus Ingestion', completed: true },
{ id: 'mod-5-7', title: 'PDF Export', completed: true },
{ id: 'mod-5-8', title: 'OCR Pipeline (TrOCR, Vision)', completed: true },
{ id: 'mod-5-9', title: 'Vocab Worksheet API', completed: true },
{ id: 'mod-5-10', title: 'KI-gestuetzte Korrektur verbessern', completed: false },
],
},
{
id: 'mod-6',
title: 'Agent-Core - 80% fertig',
description: 'Multi-Agent Architecture Framework',
category: 'modules',
priority: 'high',
status: 'in_progress',
notes: 'Neuer Service. Sessions, Shared Brain, Orchestrator, SOUL Files.',
subtasks: [
{ id: 'mod-6-1', title: 'Session Management & Heartbeat', completed: true },
{ id: 'mod-6-2', title: 'Checkpoint System', completed: true },
{ id: 'mod-6-3', title: 'Memory Store (mit TTL)', completed: true },
{ id: 'mod-6-4', title: 'Context Manager', completed: true },
{ id: 'mod-6-5', title: 'Knowledge Graph', completed: true },
{ id: 'mod-6-6', title: 'Message Bus (Pub/Sub)', completed: true },
{ id: 'mod-6-7', title: 'Supervisor & Task Router', completed: true },
{ id: 'mod-6-8', title: 'SOUL Files (Agent Personalities)', completed: true },
{ id: 'mod-6-9', title: 'Integration mit Voice Service', completed: false },
],
},
{
id: 'mod-7',
title: 'AI Compliance SDK (Go) - 75% fertig',
description: 'UCCA Obligations Framework',
category: 'modules',
priority: 'high',
status: 'in_progress',
notes: 'Neuer Go Service. AI Act, DSGVO, NIS2 Module. Policy Engine.',
subtasks: [
{ id: 'mod-7-1', title: 'UCCA Obligations Framework', completed: true },
{ id: 'mod-7-2', title: 'AI Act Module', completed: true },
{ id: 'mod-7-3', title: 'DSGVO Module', completed: true },
{ id: 'mod-7-4', title: 'NIS2 Module', completed: true },
{ id: 'mod-7-5', title: 'Policy Engine', completed: true },
{ id: 'mod-7-6', title: 'Legal RAG Integration', completed: true },
{ id: 'mod-7-7', title: 'Audit Trail & Export', completed: true },
{ id: 'mod-7-8', title: 'Escalation System', completed: false },
{ id: 'mod-7-9', title: 'Funding/Foerderantrag Wizard', completed: false },
],
},
{
id: 'mod-8',
title: 'Geo Service (Python) - 70% fertig',
description: 'Geographic Data Service fuer Geo-Lernwelt',
category: 'modules',
priority: 'medium',
status: 'in_progress',
notes: 'Neuer Service. AOI Packager, DEM Service, Tile Server.',
subtasks: [
{ id: 'mod-8-1', title: 'AOI Packager', completed: true },
{ id: 'mod-8-2', title: 'DEM Service', completed: true },
{ id: 'mod-8-3', title: 'OSM Extractor', completed: true },
{ id: 'mod-8-4', title: 'Tile Server', completed: true },
{ id: 'mod-8-5', title: 'Learning Generator', completed: true },
{ id: 'mod-8-6', title: 'License Checker', completed: true },
{ id: 'mod-8-7', title: 'Unity Integration', completed: false },
{ id: 'mod-8-8', title: 'Performance Optimization', completed: false },
],
},
{
id: 'mod-9',
title: 'Edu-Search Service (Go) - 65% fertig',
description: 'Educational Search mit Policy Engine',
category: 'modules',
priority: 'medium',
status: 'in_progress',
notes: 'Policy Handlers, Bundeslaender Policies, PII Detector.',
subtasks: [
{ id: 'mod-9-1', title: 'Policy Enforcer', completed: true },
{ id: 'mod-9-2', title: 'PII Detector', completed: true },
{ id: 'mod-9-3', title: 'Bundeslaender Policies', completed: true },
{ id: 'mod-9-4', title: 'German Universities Data', completed: true },
{ id: 'mod-9-5', title: 'Search API erweitern', completed: false },
{ id: 'mod-9-6', title: 'Caching Layer', completed: false },
],
},
// ==================== CI/CD PIPELINES ====================
{
id: 'cicd-1',
title: 'Woodpecker CI Setup',
description: 'Self-hosted CI/CD auf Mac Mini',
category: 'cicd',
priority: 'critical',
status: 'completed',
notes: 'Implementiert. Woodpecker CI laeuft auf macmini:8082. Pipelines fuer alle Services.',
subtasks: [
{ id: 'cicd-1-1', title: 'Woodpecker Server & Agent installiert', completed: true },
{ id: 'cicd-1-2', title: 'Gitea Integration', completed: true },
{ id: 'cicd-1-3', title: 'Docker Build Pipelines', completed: true },
{ id: 'cicd-1-4', title: 'Test Pipelines (Go, Python, Node)', completed: true },
],
},
{
id: 'cicd-2',
title: 'SBOM Generation Pipeline',
description: 'Automatische SBOM-Generierung in CI',
category: 'cicd',
priority: 'high',
status: 'completed',
notes: 'Implementiert in .gitea/workflows/sbom.yaml',
subtasks: [
{ id: 'cicd-2-1', title: 'CycloneDX SBOM Generation', completed: true },
{ id: 'cicd-2-2', title: 'Artifact Upload', completed: true },
{ id: 'cicd-2-3', title: 'SBOM Viewer in Admin', completed: true },
],
},
{
id: 'cicd-3',
title: 'Production Deployment Pipeline',
description: 'Kontrolliertes Deployment mit Rollback',
category: 'cicd',
priority: 'critical',
status: 'not_started',
subtasks: [
{ id: 'cicd-3-1', title: 'Blue-Green oder Canary Strategy', completed: false },
{ id: 'cicd-3-2', title: 'Automatischer Rollback', completed: false },
{ id: 'cicd-3-3', title: 'Health Checks nach Deploy', completed: false },
{ id: 'cicd-3-4', title: 'Deployment Notifications', completed: false },
],
},
// ==================== SECURITY ====================
{
id: 'sec-1',
title: 'Dependency Vulnerability Scanning',
description: 'Automatische Pruefung auf Schwachstellen',
category: 'security',
priority: 'critical',
status: 'completed',
notes: 'Dependabot konfiguriert fuer Go, Python, npm, Docker.',
subtasks: [
{ id: 'sec-1-1', title: 'Dependabot fuer Go', completed: true },
{ id: 'sec-1-2', title: 'Dependabot fuer Python', completed: true },
{ id: 'sec-1-3', title: 'Dependabot fuer npm', completed: true },
{ id: 'sec-1-4', title: 'Block Merge bei kritischen CVEs', completed: true },
],
},
{
id: 'sec-2',
title: 'Container Image Scanning',
description: 'Trivy Scans fuer alle Docker Images',
category: 'security',
priority: 'high',
status: 'completed',
notes: 'Trivy in CI integriert.',
subtasks: [
{ id: 'sec-2-1', title: 'Trivy Integration', completed: true },
{ id: 'sec-2-2', title: 'Base Image Policy', completed: true },
{ id: 'sec-2-3', title: 'Scan Report bei Build', completed: true },
],
},
{
id: 'sec-3',
title: 'SAST (Static Application Security Testing)',
description: 'Code-Analyse auf Sicherheitsluecken',
category: 'security',
priority: 'high',
status: 'completed',
notes: 'Gosec, Bandit, npm audit in CI.',
subtasks: [
{ id: 'sec-3-1', title: 'Gosec fuer Go', completed: true },
{ id: 'sec-3-2', title: 'Bandit fuer Python', completed: true },
{ id: 'sec-3-3', title: 'npm audit', completed: true },
{ id: 'sec-3-4', title: 'Semgrep Regeln', completed: false },
],
},
{
id: 'sec-4',
title: 'Secret Scanning',
description: 'Verhindern dass Secrets in Git landen',
category: 'security',
priority: 'critical',
status: 'completed',
notes: 'Gitleaks in CI. SSH Keys in .gitignore.',
subtasks: [
{ id: 'sec-4-1', title: 'Gitleaks Pre-commit', completed: true },
{ id: 'sec-4-2', title: 'SSH Keys in .gitignore', completed: true },
{ id: 'sec-4-3', title: 'Historische Commits gescannt', completed: true },
],
},
// ==================== TESTING ====================
{
id: 'test-1',
title: 'Backend Test Coverage erweitern',
description: 'Integration & E2E Tests fuer Backend APIs',
category: 'testing',
priority: 'high',
status: 'in_progress',
notes: '238 Backend-Dateien, davon 20+ Test-Dateien.',
subtasks: [
{ id: 'test-1-1', title: 'Alerts Agent Tests', completed: true },
{ id: 'test-1-2', title: 'Compliance API Tests', completed: true },
{ id: 'test-1-3', title: 'Classroom API Tests', completed: true },
{ id: 'test-1-4', title: 'Session Middleware Tests', completed: true },
{ id: 'test-1-5', title: 'Load Testing mit k6', completed: false },
],
},
{
id: 'test-2',
title: 'Frontend E2E Tests',
description: 'Playwright Tests fuer Admin-v2 und Studio-v2',
category: 'testing',
priority: 'critical',
status: 'not_started',
notes: 'Kritischer Mangel - keine E2E Tests!',
subtasks: [
{ id: 'test-2-1', title: 'Playwright Setup', completed: false },
{ id: 'test-2-2', title: 'Admin-v2 Critical Paths', completed: false },
{ id: 'test-2-3', title: 'Studio-v2 User Flows', completed: false },
{ id: 'test-2-4', title: 'Visual Regression', completed: false },
],
},
{
id: 'test-3',
title: 'Agent-Core Tests',
description: 'Unit Tests fuer Multi-Agent Framework',
category: 'testing',
priority: 'high',
status: 'completed',
notes: 'Umfangreiche Test-Suite vorhanden.',
subtasks: [
{ id: 'test-3-1', title: 'Session Manager Tests', completed: true },
{ id: 'test-3-2', title: 'Memory Store Tests', completed: true },
{ id: 'test-3-3', title: 'Message Bus Tests', completed: true },
{ id: 'test-3-4', title: 'Task Router Tests', completed: true },
{ id: 'test-3-5', title: 'Heartbeat Tests', completed: true },
],
},
// ==================== RBAC ====================
{
id: 'rbac-1',
title: 'Gitea Team Permissions',
description: 'Team-basierte Zugriffsrechte',
category: 'rbac',
priority: 'high',
status: 'not_started',
subtasks: [
{ id: 'rbac-1-1', title: 'Maintainers Team (Full Access)', completed: false },
{ id: 'rbac-1-2', title: 'Developers Team (Write)', completed: false },
{ id: 'rbac-1-3', title: 'Reviewers Team (Read + Review)', completed: false },
],
},
{
id: 'rbac-2',
title: 'Admin Panel Access Control',
description: 'Rollenbasierte Zugriffsrechte im Admin',
category: 'rbac',
priority: 'medium',
status: 'in_progress',
notes: 'RBAC Middleware im Backend implementiert.',
subtasks: [
{ id: 'rbac-2-1', title: 'RBAC Middleware', completed: true },
{ id: 'rbac-2-2', title: 'Session Store', completed: true },
{ id: 'rbac-2-3', title: 'Protected Routes', completed: true },
{ id: 'rbac-2-4', title: 'Admin Authentication UI', completed: false },
],
},
// ==================== GIT ====================
{
id: 'git-1',
title: 'Protected Branches Setup',
description: 'Schutz fuer main Branch',
category: 'git',
priority: 'critical',
status: 'not_started',
subtasks: [
{ id: 'git-1-1', title: 'No direct push to main', completed: false },
{ id: 'git-1-2', title: 'Require PR with Approval', completed: false },
{ id: 'git-1-3', title: 'Require Status Checks', completed: false },
],
},
{
id: 'git-2',
title: 'Alle Dateien committet',
description: 'Keine ungetrackten Produktionsdateien',
category: 'git',
priority: 'critical',
status: 'completed',
notes: 'Am 2026-02-03 bereinigt: ~870 Dateien, 329k Zeilen committet.',
subtasks: [
{ id: 'git-2-1', title: 'admin-v2 (154 Dateien)', completed: true },
{ id: 'git-2-2', title: 'studio-v2 (111 Dateien)', completed: true },
{ id: 'git-2-3', title: 'backend (238 Dateien)', completed: true },
{ id: 'git-2-4', title: 'website (120 Dateien)', completed: true },
{ id: 'git-2-5', title: 'klausur-service (45 Dateien)', completed: true },
{ id: 'git-2-6', title: 'consent-service (15 Dateien)', completed: true },
{ id: 'git-2-7', title: 'Neue Services (161 Dateien)', completed: true },
{ id: 'git-2-8', title: '.gitignore aktualisiert', completed: true },
],
},
// ==================== RELEASE ====================
{
id: 'rel-1',
title: 'Semantic Versioning',
description: 'Automatische Versionierung nach SemVer',
category: 'release',
priority: 'high',
status: 'not_started',
subtasks: [
{ id: 'rel-1-1', title: 'Conventional Commits', completed: false },
{ id: 'rel-1-2', title: 'Automatische Git Tags', completed: false },
{ id: 'rel-1-3', title: 'CHANGELOG Generation', completed: false },
],
},
// ==================== DATA ====================
{
id: 'data-1',
title: 'Database Backup Strategy',
description: 'Automatische Backups mit Retention',
category: 'data',
priority: 'critical',
status: 'not_started',
subtasks: [
{ id: 'data-1-1', title: 'Taegliche Backups', completed: false },
{ id: 'data-1-2', title: 'Point-in-Time Recovery', completed: false },
{ id: 'data-1-3', title: 'Backup Encryption', completed: false },
{ id: 'data-1-4', title: 'Restore Test dokumentieren', completed: false },
],
},
{
id: 'data-2',
title: 'Customer Data Protection',
description: 'Schutz von Stammdaten & Dokumenten',
category: 'data',
priority: 'critical',
status: 'in_progress',
subtasks: [
{ id: 'data-2-1', title: 'Encryption at Rest', completed: true },
{ id: 'data-2-2', title: 'Audit Log fuer Consent', completed: true },
{ id: 'data-2-3', title: 'PII Masking in Logs', completed: true },
{ id: 'data-2-4', title: 'Secure Document Storage', completed: false },
],
},
// ==================== COMPLIANCE ====================
{
id: 'sbom-1',
title: 'SBOM erstellt und dokumentiert',
description: 'Software Bill of Materials',
category: 'compliance',
priority: 'high',
status: 'completed',
notes: 'Umfassende SBOM in /admin/sbom verfuegbar.',
subtasks: [
{ id: 'sbom-1-1', title: 'Go Dependencies', completed: true },
{ id: 'sbom-1-2', title: 'Python Dependencies', completed: true },
{ id: 'sbom-1-3', title: 'npm Dependencies', completed: true },
{ id: 'sbom-1-4', title: 'Docker Base Images', completed: true },
{ id: 'sbom-1-5', title: 'CycloneDX Export', completed: true },
],
},
{
id: 'sbom-2',
title: 'Lizenz-Compliance',
description: 'Alle Lizenzen geprueft',
category: 'compliance',
priority: 'high',
status: 'in_progress',
subtasks: [
{ id: 'sbom-2-1', title: 'Lizenzen identifiziert', completed: true },
{ id: 'sbom-2-2', title: 'Kompatibilitaet geprueft', completed: false },
{ id: 'sbom-2-3', title: 'LICENSES.md erstellt', completed: false },
],
},
// ==================== APPROVAL ====================
{
id: 'appr-1',
title: 'Release Approval Gates',
description: 'Mehrstufige Freigabe',
category: 'approval',
priority: 'critical',
status: 'not_started',
subtasks: [
{ id: 'appr-1-1', title: 'QA Sign-off', completed: false },
{ id: 'appr-1-2', title: 'Security Review', completed: false },
{ id: 'appr-1-3', title: 'Product Owner Freigabe', completed: false },
],
},
{
id: 'appr-2',
title: 'Post-Deployment Verification',
description: 'Checks nach Deployment',
category: 'approval',
priority: 'high',
status: 'not_started',
subtasks: [
{ id: 'appr-2-1', title: 'Smoke Tests', completed: false },
{ id: 'appr-2-2', title: 'Error Rate Monitoring', completed: false },
{ id: 'appr-2-3', title: 'Rollback Kriterien', completed: false },
],
},
]

View File

@@ -0,0 +1,111 @@
import {
Package,
RefreshCw,
Shield,
ClipboardCheck,
Users,
GitBranch,
Tag,
Database,
FileText,
CheckSquare,
} from 'lucide-react'
import type { BacklogCategory, BacklogItem } from './types'
export const categories: BacklogCategory[] = [
{
id: 'modules',
name: 'Module Progress',
icon: <Package className="w-5 h-5" />,
color: 'text-violet-700',
bgColor: 'bg-violet-100 border-violet-300',
description: 'Fertigstellungsgrad aller Services & Module',
},
{
id: 'cicd',
name: 'CI/CD Pipelines',
icon: <RefreshCw className="w-5 h-5" />,
color: 'text-blue-700',
bgColor: 'bg-blue-100 border-blue-300',
description: 'Build, Test & Deployment Automation',
},
{
id: 'security',
name: 'Security & Vulnerability',
icon: <Shield className="w-5 h-5" />,
color: 'text-red-700',
bgColor: 'bg-red-100 border-red-300',
description: 'Security Scans, Dependency Checks & Penetration Testing',
},
{
id: 'testing',
name: 'Testing & Quality',
icon: <ClipboardCheck className="w-5 h-5" />,
color: 'text-emerald-700',
bgColor: 'bg-emerald-100 border-emerald-300',
description: 'Unit Tests, Integration Tests & E2E Testing',
},
{
id: 'rbac',
name: 'RBAC & Access Control',
icon: <Users className="w-5 h-5" />,
color: 'text-purple-700',
bgColor: 'bg-purple-100 border-purple-300',
description: 'Developer Roles, Permissions & Team Management',
},
{
id: 'git',
name: 'Git & Branch Protection',
icon: <GitBranch className="w-5 h-5" />,
color: 'text-orange-700',
bgColor: 'bg-orange-100 border-orange-300',
description: 'Protected Branches, Merge Requests & Code Reviews',
},
{
id: 'release',
name: 'Release Management',
icon: <Tag className="w-5 h-5" />,
color: 'text-green-700',
bgColor: 'bg-green-100 border-green-300',
description: 'Versioning, Changelog & Release Notes',
},
{
id: 'data',
name: 'Data Protection',
icon: <Database className="w-5 h-5" />,
color: 'text-cyan-700',
bgColor: 'bg-cyan-100 border-cyan-300',
description: 'Backup, Migration & Customer Data Safety',
},
{
id: 'compliance',
name: 'Compliance & SBOM',
icon: <FileText className="w-5 h-5" />,
color: 'text-teal-700',
bgColor: 'bg-teal-100 border-teal-300',
description: 'SBOM, Lizenzen & Open Source Compliance',
},
{
id: 'approval',
name: 'Approval Workflow',
icon: <CheckSquare className="w-5 h-5" />,
color: 'text-indigo-700',
bgColor: 'bg-indigo-100 border-indigo-300',
description: 'Developer Approval, QA Sign-off & Release Gates',
},
]
export const statusLabels: Record<BacklogItem['status'], { label: string; color: string }> = {
not_started: { label: 'Nicht begonnen', color: 'bg-slate-100 text-slate-600' },
in_progress: { label: 'In Arbeit', color: 'bg-blue-100 text-blue-700' },
review: { label: 'In Review', color: 'bg-yellow-100 text-yellow-700' },
completed: { label: 'Abgeschlossen', color: 'bg-green-100 text-green-700' },
blocked: { label: 'Blockiert', color: 'bg-red-100 text-red-700' },
}
export const priorityLabels: Record<BacklogItem['priority'], { label: string; color: string }> = {
critical: { label: 'Kritisch', color: 'bg-red-500 text-white' },
high: { label: 'Hoch', color: 'bg-orange-500 text-white' },
medium: { label: 'Mittel', color: 'bg-yellow-500 text-white' },
low: { label: 'Niedrig', color: 'bg-slate-500 text-white' },
}

View File

@@ -0,0 +1,6 @@
/**
* Barrel re-export for backlog data.
* Split into categories.tsx (UI definitions + labels) and backlog-items.ts (item data).
*/
export { categories, statusLabels, priorityLabels } from './categories'
export { initialBacklogItems } from './backlog-items'

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,32 @@
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
bgColor: string
description: string
}
export interface BacklogProgress {
total: number
completed: number
percentage: number
}
export interface CategoryProgress {
total: number
completed: number
}

View File

@@ -0,0 +1,128 @@
'use client'
import { useState, useEffect, useCallback, useMemo } from 'react'
import type { BacklogItem, BacklogProgress, CategoryProgress } from './types'
import { initialBacklogItems } from './data'
const STORAGE_KEY = 'backlogItems-v2'
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(STORAGE_KEY)
if (saved) {
try {
setItems(JSON.parse(saved))
} catch (e) {
console.error('Failed to load backlog items:', e)
}
}
}, [])
// Save state to localStorage
useEffect(() => {
localStorage.setItem(STORAGE_KEY, JSON.stringify(items))
}, [items])
const filteredItems = useMemo(() => {
return 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
})
}, [items, selectedCategory, selectedPriority, searchQuery])
const toggleExpand = useCallback((id: string) => {
setExpandedItems((prev) => {
const next = new Set(prev)
if (next.has(id)) {
next.delete(id)
} else {
next.add(id)
}
return next
})
}, [])
const updateItemStatus = useCallback((id: string, status: BacklogItem['status']) => {
setItems((prev) => prev.map((item) => (item.id === id ? { ...item, status } : item)))
}, [])
const toggleSubtask = useCallback((itemId: string, subtaskId: string) => {
setItems((prev) =>
prev.map((item) => {
if (item.id !== itemId) return item
return {
...item,
subtasks: item.subtasks?.map((st) =>
st.id === subtaskId ? { ...st, completed: !st.completed } : st
),
}
})
)
}, [])
const progress: BacklogProgress = useMemo(() => {
const total = items.length
const completed = items.filter((i) => i.status === 'completed').length
return { total, completed, percentage: Math.round((completed / total) * 100) }
}, [items])
const getCategoryProgress = useCallback(
(categoryId: string): CategoryProgress => {
const categoryItems = items.filter((i) => i.category === categoryId)
const completed = categoryItems.filter((i) => i.status === 'completed').length
return { total: categoryItems.length, completed }
},
[items]
)
const resetToDefaults = useCallback(() => {
if (confirm('Backlog auf Standardwerte zuruecksetzen? Alle lokalen Aenderungen gehen verloren.')) {
setItems(initialBacklogItems)
localStorage.removeItem(STORAGE_KEY)
}
}, [])
const clearFilters = useCallback(() => {
setSelectedCategory(null)
setSelectedPriority(null)
setSearchQuery('')
}, [])
const hasActiveFilters = !!(selectedCategory || selectedPriority || searchQuery)
return {
items,
filteredItems,
selectedCategory,
setSelectedCategory,
selectedPriority,
setSelectedPriority,
expandedItems,
searchQuery,
setSearchQuery,
toggleExpand,
updateItemStatus,
toggleSubtask,
progress,
getCategoryProgress,
resetToDefaults,
clearFilters,
hasActiveFilters,
}
}

View File

@@ -0,0 +1,103 @@
'use client'
export function AuditTab() {
return (
<div className="space-y-6">
<h3 className="text-sm font-semibold text-slate-700 uppercase tracking-wide">Audit-relevante Informationen</h3>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{/* Database Info */}
<div className="bg-white rounded-xl border border-slate-200 p-4">
<h4 className="font-semibold text-slate-900 mb-3 flex items-center gap-2">
<svg className="w-5 h-5 text-green-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="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 4" />
</svg>
Datenbank
</h4>
<div className="space-y-2">
<div className="flex items-center justify-between py-2 border-b border-slate-100">
<span className="text-sm text-slate-600">Tabellen</span>
<span className="text-sm font-medium bg-green-100 text-green-800 px-2 py-0.5 rounded">4 (topics, items, rules, profiles)</span>
</div>
<div className="flex items-center justify-between py-2 border-b border-slate-100">
<span className="text-sm text-slate-600">Indizes</span>
<span className="text-sm font-medium bg-green-100 text-green-800 px-2 py-0.5 rounded">URL-Hash, Topic-ID, Status</span>
</div>
<div className="flex items-center justify-between py-2">
<span className="text-sm text-slate-600">Backups</span>
<span className="text-sm font-medium bg-green-100 text-green-800 px-2 py-0.5 rounded">PostgreSQL pg_dump</span>
</div>
</div>
</div>
{/* API Security */}
<div className="bg-white rounded-xl border border-slate-200 p-4">
<h4 className="font-semibold text-slate-900 mb-3 flex items-center gap-2">
<svg className="w-5 h-5 text-green-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" />
</svg>
API Sicherheit
</h4>
<div className="space-y-2">
<div className="flex items-center justify-between py-2 border-b border-slate-100">
<span className="text-sm text-slate-600">Authentifizierung</span>
<span className="text-sm font-medium bg-amber-100 text-amber-800 px-2 py-0.5 rounded">Bearer Token (geplant)</span>
</div>
<div className="flex items-center justify-between py-2 border-b border-slate-100">
<span className="text-sm text-slate-600">Rate Limiting</span>
<span className="text-sm font-medium bg-amber-100 text-amber-800 px-2 py-0.5 rounded">Nicht implementiert</span>
</div>
<div className="flex items-center justify-between py-2">
<span className="text-sm text-slate-600">Input Validation</span>
<span className="text-sm font-medium bg-green-100 text-green-800 px-2 py-0.5 rounded">Pydantic Models</span>
</div>
</div>
</div>
{/* Logging */}
<div className="bg-white rounded-xl border border-slate-200 p-4">
<h4 className="font-semibold text-slate-900 mb-3 flex items-center gap-2">
<svg className="w-5 h-5 text-green-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 17v-2m3 2v-4m3 4v-6m2 10H7a2 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>
Logging & Monitoring
</h4>
<div className="space-y-2">
<div className="flex items-center justify-between py-2 border-b border-slate-100">
<span className="text-sm text-slate-600">Structured Logging</span>
<span className="text-sm font-medium bg-green-100 text-green-800 px-2 py-0.5 rounded">Python logging</span>
</div>
<div className="flex items-center justify-between py-2 border-b border-slate-100">
<span className="text-sm text-slate-600">Metriken</span>
<span className="text-sm font-medium bg-green-100 text-green-800 px-2 py-0.5 rounded">Stats Endpoint</span>
</div>
<div className="flex items-center justify-between py-2">
<span className="text-sm text-slate-600">Health Checks</span>
<span className="text-sm font-medium bg-green-100 text-green-800 px-2 py-0.5 rounded">/api/alerts/health</span>
</div>
</div>
</div>
</div>
{/* Privacy Notes */}
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4">
<h4 className="text-sm font-semibold text-blue-800 mb-2">Datenschutz-Hinweise</h4>
<ul className="space-y-1">
{[
'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',
].map((text, i) => (
<li key={i} className="text-sm text-blue-700 flex items-start gap-2">
<svg className="w-4 h-4 mt-0.5 text-blue-500 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
{text}
</li>
))}
</ul>
</div>
</div>
)
}

View File

@@ -0,0 +1,68 @@
'use client'
import type { AlertItem, Topic } from '../types'
import { formatTimeAgo, getScoreBadgeClass } from '../useAlertsData'
interface DashboardTabProps {
topics: Topic[]
alerts: AlertItem[]
error: string | null
}
export function DashboardTab({ topics, alerts, error }: DashboardTabProps) {
return (
<div className="space-y-6">
{/* Quick Actions */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div className="bg-slate-50 rounded-xl 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-white rounded-lg border border-slate-200">
<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>
))}
{topics.length === 0 && (
<div className="text-sm text-slate-500 text-center py-4">Keine Topics konfiguriert</div>
)}
</div>
</div>
<div className="bg-slate-50 rounded-xl 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) => {
const badge = getScoreBadgeClass(alert.relevance_score)
return (
<div key={alert.id} className="p-3 bg-white rounded-lg border border-slate-200">
<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>
{badge && <span className={`px-2 py-0.5 rounded text-xs font-semibold ${badge.cls}`}>{badge.pct}%</span>}
</div>
</div>
)
})}
{alerts.length === 0 && (
<div className="text-sm text-slate-500 text-center py-4">Keine Alerts vorhanden</div>
)}
</div>
</div>
</div>
{error && (
<div className="bg-amber-50 border border-amber-200 rounded-lg p-4">
<p className="text-sm text-amber-800">
<strong>Hinweis:</strong> API nicht erreichbar. Demo-Daten werden angezeigt.
</p>
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,214 @@
'use client'
const ARCHITECTURE_DIAGRAM = `
┌─────────────────────────────────────────────────────────────────────┐
│ BreakPilot Alerts Frontend │
│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ ┌──────────┐│
│ │ Dashboard │ │ Inbox │ │ Topics │ │ Profile ││
│ └──────────────┘ └──────────────┘ └──────────────┘ └──────────┘│
└───────────────────────────────┬─────────────────────────────────────┘
v
┌─────────────────────────────────────────────────────────────────────┐
│ Ingestion Layer │
│ ┌────────────────┐ ┌────────────────┐ ┌────────────────┐ │
│ │ RSS Fetcher │ │ Email Parser │ │ APScheduler │ │
│ └───────┬────────┘ └───────┬────────┘ └───────┬────────┘ │
│ └───────────────────┼───────────────────┘ │
│ ┌──────────────────────────────────────────────────────┐ │
│ │ Deduplication (URL-Hash + SimHash) │ │
│ └──────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────────────┘
v
┌─────────────────────────────────────────────────────────────────────┐
│ Processing Layer │
│ ┌──────────────────────────────────────────────────────┐ │
│ │ Rule Engine │ │
│ └──────────────────────────────────────────────────────┘ │
│ ┌──────────────────────────────────────────────────────┐ │
│ │ LLM Relevance Scorer │ │
│ │ Output: { score, decision: KEEP/DROP/REVIEW } │ │
│ └──────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────────────┘
v
┌─────────────────────────────────────────────────────────────────────┐
│ Action Layer │
│ ┌────────────────┐ ┌────────────────┐ ┌────────────────┐ │
│ │ Email Action │ │ Webhook Action │ │ Slack Action │ │
│ └────────────────┘ └────────────────┘ └────────────────┘ │
└─────────────────────────────────────────────────────────────────────┘
v
┌─────────────────────────────────────────────────────────────────────┐
│ Storage Layer │
│ ┌────────────────┐ ┌────────────────┐ ┌────────────────┐ │
│ │ PostgreSQL │ │ Valkey │ │ LLM Gateway │ │
│ └────────────────┘ └────────────────┘ └────────────────┘ │
└─────────────────────────────────────────────────────────────────────┘`
const API_ENDPOINTS = [
{ endpoint: '/api/alerts/inbox', method: 'GET', desc: 'Inbox Items abrufen' },
{ endpoint: '/api/alerts/ingest', method: 'POST', desc: 'Manuell Alert importieren' },
{ endpoint: '/api/alerts/topics', method: 'GET/POST', desc: 'Topics verwalten' },
{ endpoint: '/api/alerts/rules', method: 'GET/POST', desc: 'Regeln verwalten' },
{ endpoint: '/api/alerts/profile', method: 'GET/PUT', desc: 'Profil abrufen/aktualisieren' },
{ endpoint: '/api/alerts/stats', method: 'GET', desc: 'Statistiken abrufen' },
]
const RULE_OPERATORS = [
{ op: 'contains', desc: 'Text enthaelt', example: 'title contains "Inklusion"' },
{ op: 'not_contains', desc: 'Text enthaelt nicht', example: 'title not_contains "Werbung"' },
{ op: 'equals', desc: 'Exakte Uebereinstimmung', example: 'status equals "new"' },
{ op: 'regex', desc: 'Regulaerer Ausdruck', example: 'title regex "\\d{4}"' },
{ op: 'gt / lt', desc: 'Groesser/Kleiner', example: 'relevance_score gt 0.8' },
]
const SCORING_ROWS = [
{ decision: 'KEEP', range: '0.7 - 1.0', meaning: 'Klar relevant, in Inbox anzeigen', bgCls: 'bg-green-50', textCls: 'text-green-800' },
{ decision: 'REVIEW', range: '0.4 - 0.7', meaning: 'Unsicher, Nutzer entscheidet', bgCls: 'bg-amber-50', textCls: 'text-amber-800' },
{ decision: 'DROP', range: '0.0 - 0.4', meaning: 'Irrelevant, automatisch archivieren', bgCls: 'bg-red-50', textCls: 'text-red-800' },
]
const CONTACTS = [
{ role: 'Technischer Support', addr: 'support@breakpilot.de' },
{ role: 'Datenschutzbeauftragter', addr: 'dsb@breakpilot.de' },
{ role: 'Dokumentation', addr: 'docs.breakpilot.de' },
]
export function DocumentationTab() {
return (
<div className="bg-white rounded-xl border border-slate-200 p-6 overflow-auto max-h-[calc(100vh-350px)]">
<div className="prose prose-slate max-w-none prose-headings:font-semibold prose-h1:text-2xl prose-h2:text-xl prose-h3:text-lg">
{/* 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>
{/* Architecture Diagram */}
<h2>Systemarchitektur</h2>
<div className="not-prose bg-slate-900 rounded-lg p-4 overflow-x-auto">
<pre className="text-green-400 text-xs">{ARCHITECTURE_DIAGRAM}</pre>
</div>
{/* API Endpoints */}
<h2>API Endpoints</h2>
<div className="not-prose overflow-x-auto">
<table className="min-w-full text-sm border border-slate-200 rounded-lg">
<thead className="bg-slate-50">
<tr>
<th className="px-4 py-2 text-left font-semibold text-slate-700 border-b">Endpoint</th>
<th className="px-4 py-2 text-left font-semibold text-slate-700 border-b">Methode</th>
<th className="px-4 py-2 text-left font-semibold text-slate-700 border-b">Beschreibung</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-200">
{API_ENDPOINTS.map((row) => (
<tr key={row.endpoint}>
<td className="px-4 py-2 font-mono text-xs">{row.endpoint}</td>
<td className="px-4 py-2">{row.method}</td>
<td className="px-4 py-2 text-slate-600">{row.desc}</td>
</tr>
))}
</tbody>
</table>
</div>
{/* Rule Engine */}
<h2>Rule Engine - Operatoren</h2>
<div className="not-prose overflow-x-auto">
<table className="min-w-full text-sm border border-slate-200 rounded-lg">
<thead className="bg-slate-50">
<tr>
<th className="px-4 py-2 text-left font-semibold text-slate-700 border-b">Operator</th>
<th className="px-4 py-2 text-left font-semibold text-slate-700 border-b">Beschreibung</th>
<th className="px-4 py-2 text-left font-semibold text-slate-700 border-b">Beispiel</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-200">
{RULE_OPERATORS.map((row) => (
<tr key={row.op}>
<td className="px-4 py-2 font-mono text-xs">{row.op}</td>
<td className="px-4 py-2">{row.desc}</td>
<td className="px-4 py-2 text-slate-600">{row.example}</td>
</tr>
))}
</tbody>
</table>
</div>
{/* Scoring */}
<h2>LLM Relevanz-Scoring</h2>
<div className="not-prose overflow-x-auto">
<table className="min-w-full text-sm border border-slate-200 rounded-lg">
<thead className="bg-slate-50">
<tr>
<th className="px-4 py-2 text-left font-semibold text-slate-700 border-b">Entscheidung</th>
<th className="px-4 py-2 text-left font-semibold text-slate-700 border-b">Score-Bereich</th>
<th className="px-4 py-2 text-left font-semibold text-slate-700 border-b">Bedeutung</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-200">
{SCORING_ROWS.map((row) => (
<tr key={row.decision} className={row.bgCls}>
<td className={`px-4 py-2 font-semibold ${row.textCls}`}>{row.decision}</td>
<td className="px-4 py-2">{row.range}</td>
<td className="px-4 py-2">{row.meaning}</td>
</tr>
))}
</tbody>
</table>
</div>
{/* Contact */}
<h2>Kontakt & Support</h2>
<div className="not-prose overflow-x-auto">
<table className="min-w-full text-sm border border-slate-200 rounded-lg">
<thead className="bg-slate-50">
<tr>
<th className="px-4 py-2 text-left font-semibold text-slate-700 border-b">Kontakt</th>
<th className="px-4 py-2 text-left font-semibold text-slate-700 border-b">Adresse</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-200">
{CONTACTS.map((row) => (
<tr key={row.role}>
<td className="px-4 py-2">{row.role}</td>
<td className="px-4 py-2">{row.addr}</td>
</tr>
))}
</tbody>
</table>
</div>
{/* 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>
)
}

View File

@@ -0,0 +1,82 @@
'use client'
import type { AlertItem } from '../types'
import { formatTimeAgo, getScoreBadgeClass, getDecisionBadgeClass } from '../useAlertsData'
interface InboxTabProps {
filteredAlerts: AlertItem[]
inboxFilter: string
setInboxFilter: (filter: string) => void
}
export function InboxTab({ filteredAlerts, inboxFilter, setInboxFilter }: 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-green-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) => {
const scoreBadge = getScoreBadgeClass(alert.relevance_score)
const decBadge = getDecisionBadgeClass(alert.relevance_decision)
return (
<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-green-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">
{scoreBadge && <span className={`px-2 py-0.5 rounded text-xs font-semibold ${scoreBadge.cls}`}>{scoreBadge.pct}%</span>}
</td>
<td className="p-4">
{decBadge && <span className={`px-2 py-0.5 rounded text-xs font-semibold uppercase ${decBadge.cls}`}>{decBadge.decision}</span>}
</td>
<td className="p-4 text-sm text-slate-500">{formatTimeAgo(alert.fetched_at)}</td>
</tr>
)
})}
{filteredAlerts.length === 0 && (
<tr>
<td colSpan={5} className="p-8 text-center text-slate-500">
Keine Alerts gefunden
</td>
</tr>
)}
</tbody>
</table>
</div>
</div>
)
}

View File

@@ -0,0 +1,78 @@
'use client'
import type { Profile } from '../types'
interface ProfileTabProps {
profile: Profile | null
}
export 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 focus:ring-2 focus:ring-green-500 focus:border-green-500"
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 focus:ring-2 focus:ring-green-500 focus:border-green-500"
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 focus:ring-2 focus:ring-green-500 focus:border-green-500"
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 focus:ring-2 focus:ring-green-500 focus:border-green-500"
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-green-600 text-white rounded-lg text-sm font-medium hover:bg-green-700">
Profil speichern
</button>
</div>
</div>
</div>
)
}

View File

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

View File

@@ -0,0 +1,30 @@
'use client'
import type { Stats } from '../types'
interface StatsOverviewProps {
stats: Stats | null
}
export function StatsOverview({ stats }: StatsOverviewProps) {
return (
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 mb-6">
<div className="bg-white rounded-xl border border-slate-200 p-4 shadow-sm">
<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 shadow-sm">
<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 shadow-sm">
<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 shadow-sm">
<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>
)
}

View File

@@ -0,0 +1,54 @@
'use client'
import type { Topic } from '../types'
import { formatTimeAgo } from '../useAlertsData'
interface TopicsTabProps {
topics: Topic[]
}
export 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-green-600 text-white rounded-lg text-sm font-medium hover:bg-green-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-green-100 rounded-lg flex items-center justify-center">
<svg className="w-5 h-5 text-green-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="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>
))}
{topics.length === 0 && (
<div className="col-span-full text-center py-8 text-slate-500">
Keine Topics konfiguriert
</div>
)}
</div>
</div>
)
}

View File

@@ -7,256 +7,22 @@
* Provides inbox management, topic configuration, rule builder, and relevance profiles
*/
import { useEffect, useState, useCallback } from 'react'
import { useState } from 'react'
import { PagePurpose } from '@/components/common/PagePurpose'
import type { TabId } from './types'
import { useAlertsData } from './useAlertsData'
import { StatsOverview } from './_components/StatsOverview'
import { DashboardTab } from './_components/DashboardTab'
import { InboxTab } from './_components/InboxTab'
import { TopicsTab } from './_components/TopicsTab'
import { RulesTab } from './_components/RulesTab'
import { ProfileTab } from './_components/ProfileTab'
import { AuditTab } from './_components/AuditTab'
import { DocumentationTab } from './_components/DocumentationTab'
// Types
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[]
}
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
}
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
}
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
}
}
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
}
// Tab type
type TabId = 'dashboard' | 'inbox' | 'topics' | 'rules' | 'profile' | 'audit' | 'documentation'
export default function AlertsPage() {
const [activeTab, setActiveTab] = useState<TabId>('dashboard')
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 API_BASE = '/api/alerts'
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.topics || data.items || [])
}
if (rulesRes.ok) {
const data = await rulesRes.json()
setRules(data.rules || data.items || [])
}
if (profileRes.ok) setProfile(await profileRes.json())
setError(null)
} catch (err) {
setError(err instanceof Error ? err.message : 'Verbindungsfehler')
// Set demo data
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 formatTimeAgo = (dateStr: string | null) => {
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`
}
const 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>
}
const 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>
)
}
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
})
const tabs: { id: TabId; label: string; badge?: number }[] = [
const TABS: { id: TabId; label: string; hasBadge?: boolean }[] = [
{ id: 'dashboard', label: 'Dashboard' },
{ id: 'inbox', label: 'Inbox', badge: stats?.new_alerts || 0 },
{ id: 'inbox', label: 'Inbox', hasBadge: true },
{ id: 'topics', label: 'Topics' },
{ id: 'rules', label: 'Regeln' },
{ id: 'profile', label: 'Profil' },
@@ -264,7 +30,11 @@ export default function AlertsPage() {
{ id: 'documentation', label: 'Dokumentation' },
]
if (loading) {
export default function AlertsPage() {
const [activeTab, setActiveTab] = useState<TabId>('dashboard')
const data = useAlertsData()
if (data.loading) {
return (
<div className="flex items-center justify-center h-64">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-green-600" />
@@ -274,7 +44,6 @@ export default function AlertsPage() {
return (
<div>
{/* Page Purpose */}
<PagePurpose
title="Alerts Monitoring"
purpose="Google Alerts & Feed-Ueberwachung mit KI-gestuetzter Relevanzpruefung. Verwalten Sie Topics, konfigurieren Sie Filterregeln und nutzen Sie LLM-basiertes Scoring fuer automatische Kategorisierung."
@@ -291,31 +60,15 @@ export default function AlertsPage() {
defaultCollapsed={false}
/>
{/* Stats Overview */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 mb-6">
<div className="bg-white rounded-xl border border-slate-200 p-4 shadow-sm">
<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 shadow-sm">
<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 shadow-sm">
<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 shadow-sm">
<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>
<StatsOverview stats={data.stats} />
{/* Tab Navigation */}
<div className="bg-white rounded-lg shadow mb-6">
<div className="border-b border-slate-200 px-4">
<nav className="flex gap-4 overflow-x-auto">
{tabs.map((tab) => (
{TABS.map((tab) => {
const badge = tab.hasBadge ? (data.stats?.new_alerts || 0) : 0
return (
<button
key={tab.id}
onClick={() => setActiveTab(tab.id)}
@@ -326,585 +79,33 @@ export default function AlertsPage() {
}`}
>
{tab.label}
{tab.badge !== undefined && tab.badge > 0 && (
{badge > 0 && (
<span className="px-2 py-0.5 rounded-full text-xs font-semibold bg-red-500 text-white">
{tab.badge}
{badge}
</span>
)}
</button>
))}
)
})}
</nav>
</div>
<div className="p-6">
{/* Dashboard Tab */}
{activeTab === 'dashboard' && (
<div className="space-y-6">
{/* Quick Actions */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div className="bg-slate-50 rounded-xl 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-white rounded-lg border border-slate-200">
<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>
))}
{topics.length === 0 && (
<div className="text-sm text-slate-500 text-center py-4">Keine Topics konfiguriert</div>
<DashboardTab topics={data.topics} alerts={data.alerts} error={data.error} />
)}
</div>
</div>
<div className="bg-slate-50 rounded-xl 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-white rounded-lg border border-slate-200">
<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>
))}
{alerts.length === 0 && (
<div className="text-sm text-slate-500 text-center py-4">Keine Alerts vorhanden</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>
)}
{/* Inbox Tab */}
{activeTab === 'inbox' && (
<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-green-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-green-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>
))}
{filteredAlerts.length === 0 && (
<tr>
<td colSpan={5} className="p-8 text-center text-slate-500">
Keine Alerts gefunden
</td>
</tr>
)}
</tbody>
</table>
</div>
</div>
)}
{/* Topics Tab */}
{activeTab === 'topics' && (
<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-green-600 text-white rounded-lg text-sm font-medium hover:bg-green-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-green-100 rounded-lg flex items-center justify-center">
<svg className="w-5 h-5 text-green-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="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>
))}
{topics.length === 0 && (
<div className="col-span-full text-center py-8 text-slate-500">
Keine Topics konfiguriert
</div>
)}
</div>
</div>
)}
{/* Rules Tab */}
{activeTab === 'rules' && (
<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-green-600 text-white rounded-lg text-sm font-medium hover:bg-green-700">
+ Regel erstellen
</button>
</div>
<div className="bg-white rounded-xl border border-slate-200 divide-y divide-slate-100">
{rules.map((rule) => (
<div key={rule.id} className="p-4 flex items-center gap-4">
<div className="text-slate-400 cursor-grab">
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 6h16M4 12h16M4 18h16" />
</svg>
</div>
<div className="flex-1">
<div className="font-medium text-slate-900">{rule.name}</div>
<div className="text-sm text-slate-500">
Wenn: {rule.conditions[0]?.field} {rule.conditions[0]?.operator} &quot;{rule.conditions[0]?.value}&quot;
</div>
</div>
<span className={`px-3 py-1 rounded text-xs font-semibold uppercase ${
rule.action_type === 'keep' ? 'bg-green-100 text-green-800' :
rule.action_type === 'drop' ? 'bg-red-100 text-red-800' :
rule.action_type === 'email' ? 'bg-blue-100 text-blue-800' :
'bg-purple-100 text-purple-800'
}`}>
{rule.action_type}
</span>
<div
className={`w-12 h-6 rounded-full relative cursor-pointer transition-colors ${
rule.is_active ? 'bg-green-500' : 'bg-slate-300'
}`}
>
<div
className={`absolute w-5 h-5 bg-white rounded-full top-0.5 transition-all shadow ${
rule.is_active ? 'left-6' : 'left-0.5'
}`}
<InboxTab
filteredAlerts={data.filteredAlerts}
inboxFilter={data.inboxFilter}
setInboxFilter={data.setInboxFilter}
/>
</div>
</div>
))}
{rules.length === 0 && (
<div className="p-8 text-center text-slate-500">
Keine Regeln konfiguriert
</div>
)}
</div>
</div>
)}
{/* Profile Tab */}
{activeTab === 'profile' && (
<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 focus:ring-2 focus:ring-green-500 focus:border-green-500"
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 focus:ring-2 focus:ring-green-500 focus:border-green-500"
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 focus:ring-2 focus:ring-green-500 focus:border-green-500"
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 focus:ring-2 focus:ring-green-500 focus:border-green-500"
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-green-600 text-white rounded-lg text-sm font-medium hover:bg-green-700">
Profil speichern
</button>
</div>
</div>
</div>
)}
{/* Audit Tab */}
{activeTab === 'audit' && (
<div className="space-y-6">
<h3 className="text-sm font-semibold text-slate-700 uppercase tracking-wide">Audit-relevante Informationen</h3>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{/* Database Info */}
<div className="bg-white rounded-xl border border-slate-200 p-4">
<h4 className="font-semibold text-slate-900 mb-3 flex items-center gap-2">
<svg className="w-5 h-5 text-green-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="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 4" />
</svg>
Datenbank
</h4>
<div className="space-y-2">
<div className="flex items-center justify-between py-2 border-b border-slate-100">
<span className="text-sm text-slate-600">Tabellen</span>
<span className="text-sm font-medium bg-green-100 text-green-800 px-2 py-0.5 rounded">4 (topics, items, rules, profiles)</span>
</div>
<div className="flex items-center justify-between py-2 border-b border-slate-100">
<span className="text-sm text-slate-600">Indizes</span>
<span className="text-sm font-medium bg-green-100 text-green-800 px-2 py-0.5 rounded">URL-Hash, Topic-ID, Status</span>
</div>
<div className="flex items-center justify-between py-2">
<span className="text-sm text-slate-600">Backups</span>
<span className="text-sm font-medium bg-green-100 text-green-800 px-2 py-0.5 rounded">PostgreSQL pg_dump</span>
</div>
</div>
</div>
{/* API Security */}
<div className="bg-white rounded-xl border border-slate-200 p-4">
<h4 className="font-semibold text-slate-900 mb-3 flex items-center gap-2">
<svg className="w-5 h-5 text-green-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" />
</svg>
API Sicherheit
</h4>
<div className="space-y-2">
<div className="flex items-center justify-between py-2 border-b border-slate-100">
<span className="text-sm text-slate-600">Authentifizierung</span>
<span className="text-sm font-medium bg-amber-100 text-amber-800 px-2 py-0.5 rounded">Bearer Token (geplant)</span>
</div>
<div className="flex items-center justify-between py-2 border-b border-slate-100">
<span className="text-sm text-slate-600">Rate Limiting</span>
<span className="text-sm font-medium bg-amber-100 text-amber-800 px-2 py-0.5 rounded">Nicht implementiert</span>
</div>
<div className="flex items-center justify-between py-2">
<span className="text-sm text-slate-600">Input Validation</span>
<span className="text-sm font-medium bg-green-100 text-green-800 px-2 py-0.5 rounded">Pydantic Models</span>
</div>
</div>
</div>
{/* Logging */}
<div className="bg-white rounded-xl border border-slate-200 p-4">
<h4 className="font-semibold text-slate-900 mb-3 flex items-center gap-2">
<svg className="w-5 h-5 text-green-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 17v-2m3 2v-4m3 4v-6m2 10H7a2 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>
Logging & Monitoring
</h4>
<div className="space-y-2">
<div className="flex items-center justify-between py-2 border-b border-slate-100">
<span className="text-sm text-slate-600">Structured Logging</span>
<span className="text-sm font-medium bg-green-100 text-green-800 px-2 py-0.5 rounded">Python logging</span>
</div>
<div className="flex items-center justify-between py-2 border-b border-slate-100">
<span className="text-sm text-slate-600">Metriken</span>
<span className="text-sm font-medium bg-green-100 text-green-800 px-2 py-0.5 rounded">Stats Endpoint</span>
</div>
<div className="flex items-center justify-between py-2">
<span className="text-sm text-slate-600">Health Checks</span>
<span className="text-sm font-medium bg-green-100 text-green-800 px-2 py-0.5 rounded">/api/alerts/health</span>
</div>
</div>
</div>
</div>
{/* Privacy Notes */}
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4">
<h4 className="text-sm font-semibold text-blue-800 mb-2">Datenschutz-Hinweise</h4>
<ul className="space-y-1">
<li className="text-sm text-blue-700 flex items-start gap-2">
<svg className="w-4 h-4 mt-0.5 text-blue-500 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
Alle Daten werden in Deutschland gespeichert (PostgreSQL)
</li>
<li className="text-sm text-blue-700 flex items-start gap-2">
<svg className="w-4 h-4 mt-0.5 text-blue-500 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
Keine personenbezogenen Daten in Alerts (nur URLs und Snippets)
</li>
<li className="text-sm text-blue-700 flex items-start gap-2">
<svg className="w-4 h-4 mt-0.5 text-blue-500 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
LLM-Verarbeitung kann on-premise mit Ollama/vLLM erfolgen
</li>
<li className="text-sm text-blue-700 flex items-start gap-2">
<svg className="w-4 h-4 mt-0.5 text-blue-500 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
DSGVO-konforme Datenverarbeitung
</li>
</ul>
</div>
</div>
)}
{/* Documentation Tab */}
{activeTab === 'documentation' && (
<div className="bg-white rounded-xl border border-slate-200 p-6 overflow-auto max-h-[calc(100vh-350px)]">
<div className="prose prose-slate max-w-none prose-headings:font-semibold prose-h1:text-2xl prose-h2:text-xl prose-h3:text-lg">
{/* 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>
{/* Architecture Diagram */}
<h2>Systemarchitektur</h2>
<div className="not-prose bg-slate-900 rounded-lg p-4 overflow-x-auto">
<pre className="text-green-400 text-xs">{`
┌─────────────────────────────────────────────────────────────────────┐
│ BreakPilot Alerts Frontend │
│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ ┌──────────┐│
│ │ Dashboard │ │ Inbox │ │ Topics │ │ Profile ││
│ └──────────────┘ └──────────────┘ └──────────────┘ └──────────┘│
└───────────────────────────────┬─────────────────────────────────────┘
v
┌─────────────────────────────────────────────────────────────────────┐
│ Ingestion Layer │
│ ┌────────────────┐ ┌────────────────┐ ┌────────────────┐ │
│ │ RSS Fetcher │ │ Email Parser │ │ APScheduler │ │
│ └───────┬────────┘ └───────┬────────┘ └───────┬────────┘ │
│ └───────────────────┼───────────────────┘ │
│ ┌──────────────────────────────────────────────────────┐ │
│ │ Deduplication (URL-Hash + SimHash) │ │
│ └──────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────────────┘
v
┌─────────────────────────────────────────────────────────────────────┐
│ Processing Layer │
│ ┌──────────────────────────────────────────────────────┐ │
│ │ Rule Engine │ │
│ └──────────────────────────────────────────────────────┘ │
│ ┌──────────────────────────────────────────────────────┐ │
│ │ LLM Relevance Scorer │ │
│ │ Output: { score, decision: KEEP/DROP/REVIEW } │ │
│ └──────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────────────┘
v
┌─────────────────────────────────────────────────────────────────────┐
│ Action Layer │
│ ┌────────────────┐ ┌────────────────┐ ┌────────────────┐ │
│ │ Email Action │ │ Webhook Action │ │ Slack Action │ │
│ └────────────────┘ └────────────────┘ └────────────────┘ │
└─────────────────────────────────────────────────────────────────────┘
v
┌─────────────────────────────────────────────────────────────────────┐
│ Storage Layer │
│ ┌────────────────┐ ┌────────────────┐ ┌────────────────┐ │
│ │ PostgreSQL │ │ Valkey │ │ LLM Gateway │ │
│ └────────────────┘ └────────────────┘ └────────────────┘ │
└─────────────────────────────────────────────────────────────────────┘`}</pre>
</div>
{/* API Endpoints */}
<h2>API Endpoints</h2>
<div className="not-prose overflow-x-auto">
<table className="min-w-full text-sm border border-slate-200 rounded-lg">
<thead className="bg-slate-50">
<tr>
<th className="px-4 py-2 text-left font-semibold text-slate-700 border-b">Endpoint</th>
<th className="px-4 py-2 text-left font-semibold text-slate-700 border-b">Methode</th>
<th className="px-4 py-2 text-left font-semibold text-slate-700 border-b">Beschreibung</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-200">
<tr><td className="px-4 py-2 font-mono text-xs">/api/alerts/inbox</td><td className="px-4 py-2">GET</td><td className="px-4 py-2 text-slate-600">Inbox Items abrufen</td></tr>
<tr><td className="px-4 py-2 font-mono text-xs">/api/alerts/ingest</td><td className="px-4 py-2">POST</td><td className="px-4 py-2 text-slate-600">Manuell Alert importieren</td></tr>
<tr><td className="px-4 py-2 font-mono text-xs">/api/alerts/topics</td><td className="px-4 py-2">GET/POST</td><td className="px-4 py-2 text-slate-600">Topics verwalten</td></tr>
<tr><td className="px-4 py-2 font-mono text-xs">/api/alerts/rules</td><td className="px-4 py-2">GET/POST</td><td className="px-4 py-2 text-slate-600">Regeln verwalten</td></tr>
<tr><td className="px-4 py-2 font-mono text-xs">/api/alerts/profile</td><td className="px-4 py-2">GET/PUT</td><td className="px-4 py-2 text-slate-600">Profil abrufen/aktualisieren</td></tr>
<tr><td className="px-4 py-2 font-mono text-xs">/api/alerts/stats</td><td className="px-4 py-2">GET</td><td className="px-4 py-2 text-slate-600">Statistiken abrufen</td></tr>
</tbody>
</table>
</div>
{/* Rule Engine */}
<h2>Rule Engine - Operatoren</h2>
<div className="not-prose overflow-x-auto">
<table className="min-w-full text-sm border border-slate-200 rounded-lg">
<thead className="bg-slate-50">
<tr>
<th className="px-4 py-2 text-left font-semibold text-slate-700 border-b">Operator</th>
<th className="px-4 py-2 text-left font-semibold text-slate-700 border-b">Beschreibung</th>
<th className="px-4 py-2 text-left font-semibold text-slate-700 border-b">Beispiel</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-200">
<tr><td className="px-4 py-2 font-mono text-xs">contains</td><td className="px-4 py-2">Text enthaelt</td><td className="px-4 py-2 text-slate-600">title contains &quot;Inklusion&quot;</td></tr>
<tr><td className="px-4 py-2 font-mono text-xs">not_contains</td><td className="px-4 py-2">Text enthaelt nicht</td><td className="px-4 py-2 text-slate-600">title not_contains &quot;Werbung&quot;</td></tr>
<tr><td className="px-4 py-2 font-mono text-xs">equals</td><td className="px-4 py-2">Exakte Uebereinstimmung</td><td className="px-4 py-2 text-slate-600">status equals &quot;new&quot;</td></tr>
<tr><td className="px-4 py-2 font-mono text-xs">regex</td><td className="px-4 py-2">Regulaerer Ausdruck</td><td className="px-4 py-2 text-slate-600">title regex &quot;\d&#123;4&#125;&quot;</td></tr>
<tr><td className="px-4 py-2 font-mono text-xs">gt / lt</td><td className="px-4 py-2">Groesser/Kleiner</td><td className="px-4 py-2 text-slate-600">relevance_score gt 0.8</td></tr>
</tbody>
</table>
</div>
{/* Scoring */}
<h2>LLM Relevanz-Scoring</h2>
<div className="not-prose overflow-x-auto">
<table className="min-w-full text-sm border border-slate-200 rounded-lg">
<thead className="bg-slate-50">
<tr>
<th className="px-4 py-2 text-left font-semibold text-slate-700 border-b">Entscheidung</th>
<th className="px-4 py-2 text-left font-semibold text-slate-700 border-b">Score-Bereich</th>
<th className="px-4 py-2 text-left font-semibold text-slate-700 border-b">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>
{/* Contact */}
<h2>Kontakt & Support</h2>
<div className="not-prose overflow-x-auto">
<table className="min-w-full text-sm border border-slate-200 rounded-lg">
<thead className="bg-slate-50">
<tr>
<th className="px-4 py-2 text-left font-semibold text-slate-700 border-b">Kontakt</th>
<th className="px-4 py-2 text-left font-semibold text-slate-700 border-b">Adresse</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-200">
<tr><td className="px-4 py-2">Technischer Support</td><td className="px-4 py-2">support@breakpilot.de</td></tr>
<tr><td className="px-4 py-2">Datenschutzbeauftragter</td><td className="px-4 py-2">dsb@breakpilot.de</td></tr>
<tr><td className="px-4 py-2">Dokumentation</td><td className="px-4 py-2">docs.breakpilot.de</td></tr>
</tbody>
</table>
</div>
{/* 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>
)}
{activeTab === 'topics' && <TopicsTab topics={data.topics} />}
{activeTab === 'rules' && <RulesTab rules={data.rules} />}
{activeTab === 'profile' && <ProfileTab profile={data.profile} />}
{activeTab === 'audit' && <AuditTab />}
{activeTab === 'documentation' && <DocumentationTab />}
</div>
</div>
</div>

View File

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

View File

@@ -0,0 +1,191 @@
'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.topics || data.items || [])
}
if (rulesRes.ok) {
const data = await rulesRes.json()
setRules(data.rules || data.items || [])
}
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,
}
}
// --- Helper functions (pure, no hooks) ---
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 getScoreBadgeClass(score: number | null): { pct: number; cls: string } | 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 { pct, cls }
}
export function getDecisionBadgeClass(decision: string | null): { decision: string; cls: 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 { decision, cls: styles[decision] || 'bg-slate-100' }
}

View File

@@ -0,0 +1,120 @@
'use client'
import { useState } from 'react'
export function AISettingsTab() {
const [settings, setSettings] = useState({
autoAnalyze: true,
autoCreateTasks: true,
analysisModel: 'breakpilot-teacher-8b',
confidenceThreshold: 0.7,
})
return (
<div className="space-y-6">
<div>
<h2 className="text-lg font-semibold text-slate-900">KI-Einstellungen</h2>
<p className="text-sm text-slate-500">Konfigurieren Sie die automatische E-Mail-Analyse</p>
</div>
<div className="bg-white rounded-lg border border-slate-200 p-6 space-y-6">
{/* Auto-Analyze */}
<div className="flex items-center justify-between">
<div>
<h3 className="text-sm font-medium text-slate-900">Automatische Analyse</h3>
<p className="text-sm text-slate-500">E-Mails automatisch beim Empfang analysieren</p>
</div>
<button
onClick={() => setSettings({ ...settings, autoAnalyze: !settings.autoAnalyze })}
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${
settings.autoAnalyze ? 'bg-blue-600' : 'bg-slate-200'
}`}
>
<span
className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${
settings.autoAnalyze ? 'translate-x-6' : 'translate-x-1'
}`}
/>
</button>
</div>
{/* Auto-Create Tasks */}
<div className="flex items-center justify-between">
<div>
<h3 className="text-sm font-medium text-slate-900">Aufgaben automatisch erstellen</h3>
<p className="text-sm text-slate-500">Erkannte Fristen als Aufgaben anlegen</p>
</div>
<button
onClick={() => setSettings({ ...settings, autoCreateTasks: !settings.autoCreateTasks })}
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${
settings.autoCreateTasks ? 'bg-blue-600' : 'bg-slate-200'
}`}
>
<span
className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${
settings.autoCreateTasks ? 'translate-x-6' : 'translate-x-1'
}`}
/>
</button>
</div>
{/* Model Selection */}
<div>
<label className="block text-sm font-medium text-slate-700 mb-2">Analyse-Modell</label>
<select
value={settings.analysisModel}
onChange={(e) => setSettings({ ...settings, analysisModel: e.target.value })}
className="w-full md:w-64 px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-blue-500"
>
<option value="breakpilot-teacher-8b">BreakPilot Teacher 8B (schnell)</option>
<option value="breakpilot-teacher-70b">BreakPilot Teacher 70B (genau)</option>
<option value="llama-3.1-8b-instruct">Llama 3.1 8B Instruct</option>
</select>
</div>
{/* Confidence Threshold */}
<div>
<label className="block text-sm font-medium text-slate-700 mb-2">
Konfidenz-Schwelle: {Math.round(settings.confidenceThreshold * 100)}%
</label>
<input
type="range"
min="0.5"
max="0.95"
step="0.05"
value={settings.confidenceThreshold}
onChange={(e) => setSettings({ ...settings, confidenceThreshold: parseFloat(e.target.value) })}
className="w-full md:w-64"
/>
<p className="text-xs text-slate-500 mt-1">
Mindest-Konfidenz fuer automatische Aufgabenerstellung
</p>
</div>
</div>
{/* Sender Classification */}
<div className="bg-white rounded-lg border border-slate-200 p-6">
<h3 className="text-sm font-medium text-slate-700 mb-4">Bekannte Absender (Niedersachsen)</h3>
<div className="grid grid-cols-2 md:grid-cols-3 gap-3">
{[
{ domain: '@mk.niedersachsen.de', type: 'Kultusministerium', priority: 'Hoch' },
{ domain: '@rlsb.de', type: 'RLSB', priority: 'Hoch' },
{ domain: '@landesschulbehoerde-nds.de', type: 'Landesschulbehoerde', priority: 'Hoch' },
{ domain: '@nibis.de', type: 'NiBiS', priority: 'Mittel' },
{ domain: '@schultraeger.de', type: 'Schultraeger', priority: 'Mittel' },
].map((sender) => (
<div key={sender.domain} className="p-3 bg-slate-50 rounded-lg">
<p className="text-sm font-mono text-slate-700">{sender.domain}</p>
<p className="text-xs text-slate-500">{sender.type}</p>
<span className={`text-xs px-2 py-0.5 rounded ${
sender.priority === 'Hoch' ? 'bg-red-100 text-red-700' : 'bg-yellow-100 text-yellow-700'
}`}>
{sender.priority}
</span>
</div>
))}
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,355 @@
'use client'
import { useState } from 'react'
import type { EmailAccount } from '../types'
import { API_BASE } from '../types'
interface AccountsTabProps {
accounts: EmailAccount[]
loading: boolean
onRefresh: () => void
}
const statusColors: Record<EmailAccount['status'], string> = {
active: 'bg-green-100 text-green-800',
inactive: 'bg-gray-100 text-gray-800',
error: 'bg-red-100 text-red-800',
syncing: 'bg-yellow-100 text-yellow-800',
}
const statusLabels: Record<EmailAccount['status'], string> = {
active: 'Aktiv',
inactive: 'Inaktiv',
error: 'Fehler',
syncing: 'Synchronisiert...',
}
export function AccountsTab({ accounts, loading, onRefresh }: AccountsTabProps) {
const [showAddModal, setShowAddModal] = useState(false)
const testConnection = async (accountId: string) => {
try {
const res = await fetch(`${API_BASE}/api/v1/mail/accounts/${accountId}/test`, {
method: 'POST',
})
if (res.ok) {
alert('Verbindung erfolgreich!')
} else {
alert('Verbindungsfehler')
}
} catch {
alert('Verbindungsfehler')
}
}
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h2 className="text-lg font-semibold text-slate-900">E-Mail-Konten</h2>
<p className="text-sm text-slate-500">Verwalten Sie die verbundenen E-Mail-Konten</p>
</div>
<button
onClick={() => setShowAddModal(true)}
className="px-4 py-2 text-sm font-medium text-white bg-blue-600 rounded-lg hover:bg-blue-700 flex items-center gap-2"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 6v6m0 0v6m0-6h6m-6 0H6" />
</svg>
Konto hinzufuegen
</button>
</div>
{/* Loading State */}
{loading && (
<div className="flex items-center justify-center py-12">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600"></div>
</div>
)}
{/* Accounts Grid */}
{!loading && (
<div className="grid gap-4">
{accounts.length === 0 ? (
<EmptyAccountsState />
) : (
accounts.map((account) => (
<AccountCard
key={account.id}
account={account}
onTestConnection={testConnection}
/>
))
)}
</div>
)}
{/* Add Account Modal */}
{showAddModal && (
<AddAccountModal
onClose={() => setShowAddModal(false)}
onSuccess={() => { setShowAddModal(false); onRefresh() }}
/>
)}
</div>
)
}
function EmptyAccountsState() {
return (
<div className="bg-slate-50 rounded-lg p-8 text-center">
<svg className="w-12 h-12 text-slate-400 mx-auto mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" />
</svg>
<h3 className="text-lg font-medium text-slate-900 mb-2">Keine E-Mail-Konten</h3>
<p className="text-slate-500 mb-4">Fuegen Sie Ihr erstes E-Mail-Konto hinzu.</p>
</div>
)
}
function AccountCard({
account,
onTestConnection
}: {
account: EmailAccount
onTestConnection: (id: string) => void
}) {
return (
<div className="bg-white rounded-lg border border-slate-200 p-6 hover:shadow-md transition-shadow">
<div className="flex items-start justify-between">
<div className="flex items-center gap-4">
<div className="w-12 h-12 bg-blue-100 rounded-lg flex items-center justify-center">
<svg className="w-6 h-6 text-blue-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" />
</svg>
</div>
<div>
<h3 className="text-lg font-semibold text-slate-900">
{account.displayName || account.email}
</h3>
<p className="text-sm text-slate-500">{account.email}</p>
</div>
</div>
<div className="flex items-center gap-3">
<span className={`px-3 py-1 rounded-full text-xs font-medium ${statusColors[account.status]}`}>
{statusLabels[account.status]}
</span>
<button
onClick={() => onTestConnection(account.id)}
className="p-2 text-slate-400 hover:text-slate-600"
title="Verbindung testen"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 10V3L4 14h7v7l9-11h-7z" />
</svg>
</button>
</div>
</div>
<div className="mt-4 grid grid-cols-2 md:grid-cols-4 gap-4">
<div>
<p className="text-xs text-slate-500 uppercase tracking-wider">E-Mails</p>
<p className="text-lg font-semibold text-slate-900">{account.emailCount}</p>
</div>
<div>
<p className="text-xs text-slate-500 uppercase tracking-wider">Ungelesen</p>
<p className="text-lg font-semibold text-slate-900">{account.unreadCount}</p>
</div>
<div>
<p className="text-xs text-slate-500 uppercase tracking-wider">IMAP</p>
<p className="text-sm font-mono text-slate-700">{account.imapHost}:{account.imapPort}</p>
</div>
<div>
<p className="text-xs text-slate-500 uppercase tracking-wider">Letzte Sync</p>
<p className="text-sm text-slate-700">
{account.lastSync
? new Date(account.lastSync).toLocaleString('de-DE')
: 'Nie'}
</p>
</div>
</div>
</div>
)
}
function AddAccountModal({
onClose,
onSuccess
}: {
onClose: () => void
onSuccess: () => void
}) {
const [formData, setFormData] = useState({
email: '',
displayName: '',
imapHost: '',
imapPort: 993,
smtpHost: '',
smtpPort: 587,
username: '',
password: '',
})
const [submitting, setSubmitting] = useState(false)
const [error, setError] = useState<string | null>(null)
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
setSubmitting(true)
setError(null)
try {
const res = await fetch(`${API_BASE}/api/v1/mail/accounts`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
email: formData.email,
display_name: formData.displayName,
imap_host: formData.imapHost,
imap_port: formData.imapPort,
smtp_host: formData.smtpHost,
smtp_port: formData.smtpPort,
username: formData.username,
password: formData.password,
}),
})
if (res.ok) {
onSuccess()
} else {
const data = await res.json()
setError(data.detail || 'Fehler beim Hinzufuegen des Kontos')
}
} catch {
setError('Netzwerkfehler')
} finally {
setSubmitting(false)
}
}
return (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
<div className="bg-white rounded-lg shadow-xl max-w-lg w-full mx-4 max-h-[90vh] overflow-y-auto">
<div className="p-6 border-b border-slate-200">
<h2 className="text-lg font-semibold text-slate-900">E-Mail-Konto hinzufuegen</h2>
</div>
<form onSubmit={handleSubmit} className="p-6 space-y-4">
{error && (
<div className="p-3 bg-red-50 text-red-700 rounded-lg text-sm">{error}</div>
)}
<div className="grid grid-cols-2 gap-4">
<div className="col-span-2">
<label className="block text-sm font-medium text-slate-700 mb-1">E-Mail-Adresse</label>
<input
type="email"
required
value={formData.email}
onChange={(e) => setFormData({ ...formData, email: e.target.value })}
className="w-full px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-blue-500"
placeholder="schulleitung@grundschule-xy.de"
/>
</div>
<div className="col-span-2">
<label className="block text-sm font-medium text-slate-700 mb-1">Anzeigename</label>
<input
type="text"
value={formData.displayName}
onChange={(e) => setFormData({ ...formData, displayName: e.target.value })}
className="w-full px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-blue-500"
placeholder="Schulleitung"
/>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">IMAP Server</label>
<input
type="text"
required
value={formData.imapHost}
onChange={(e) => setFormData({ ...formData, imapHost: e.target.value })}
className="w-full px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-blue-500"
placeholder="imap.example.com"
/>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">IMAP Port</label>
<input
type="number"
required
value={formData.imapPort}
onChange={(e) => setFormData({ ...formData, imapPort: parseInt(e.target.value) })}
className="w-full px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-blue-500"
/>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">SMTP Server</label>
<input
type="text"
required
value={formData.smtpHost}
onChange={(e) => setFormData({ ...formData, smtpHost: e.target.value })}
className="w-full px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-blue-500"
placeholder="smtp.example.com"
/>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">SMTP Port</label>
<input
type="number"
required
value={formData.smtpPort}
onChange={(e) => setFormData({ ...formData, smtpPort: parseInt(e.target.value) })}
className="w-full px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-blue-500"
/>
</div>
<div className="col-span-2">
<label className="block text-sm font-medium text-slate-700 mb-1">Benutzername</label>
<input
type="text"
required
value={formData.username}
onChange={(e) => setFormData({ ...formData, username: e.target.value })}
className="w-full px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-blue-500"
/>
</div>
<div className="col-span-2">
<label className="block text-sm font-medium text-slate-700 mb-1">Passwort</label>
<input
type="password"
required
value={formData.password}
onChange={(e) => setFormData({ ...formData, password: e.target.value })}
className="w-full px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-blue-500"
/>
<p className="text-xs text-slate-500 mt-1">
Das Passwort wird verschluesselt in Vault gespeichert.
</p>
</div>
</div>
<div className="flex justify-end gap-3 pt-4 border-t border-slate-200">
<button
type="button"
onClick={onClose}
className="px-4 py-2 text-sm font-medium text-slate-700 hover:bg-slate-100 rounded-lg"
>
Abbrechen
</button>
<button
type="submit"
disabled={submitting}
className="px-4 py-2 text-sm font-medium text-white bg-blue-600 rounded-lg hover:bg-blue-700 disabled:opacity-50"
>
{submitting ? 'Speichern...' : 'Konto hinzufuegen'}
</button>
</div>
</form>
</div>
</div>
)
}

View File

@@ -0,0 +1,56 @@
'use client'
import { useState } from 'react'
const actionLabels: Record<string, string> = {
account_created: 'Konto erstellt',
email_analyzed: 'E-Mail analysiert',
task_created: 'Aufgabe erstellt',
sync_completed: 'Sync abgeschlossen',
}
export function AuditLogTab() {
const [logs] = useState([
{ id: '1', action: 'account_created', user: 'admin@breakpilot.de', timestamp: new Date().toISOString(), details: 'Konto schulleitung@example.de hinzugefuegt' },
{ id: '2', action: 'email_analyzed', user: 'system', timestamp: new Date(Date.now() - 3600000).toISOString(), details: '5 E-Mails analysiert' },
{ id: '3', action: 'task_created', user: 'system', timestamp: new Date(Date.now() - 7200000).toISOString(), details: 'Aufgabe aus Fristenerkennung erstellt' },
])
return (
<div className="space-y-6">
<div>
<h2 className="text-lg font-semibold text-slate-900">Audit-Log</h2>
<p className="text-sm text-slate-500">Alle Aktionen im Mail-System</p>
</div>
<div className="bg-white rounded-lg border border-slate-200 overflow-hidden">
<table className="min-w-full divide-y divide-slate-200">
<thead className="bg-slate-50">
<tr>
<th className="px-6 py-3 text-left text-xs font-medium text-slate-500 uppercase">Zeit</th>
<th className="px-6 py-3 text-left text-xs font-medium text-slate-500 uppercase">Aktion</th>
<th className="px-6 py-3 text-left text-xs font-medium text-slate-500 uppercase">Benutzer</th>
<th className="px-6 py-3 text-left text-xs font-medium text-slate-500 uppercase">Details</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-200">
{logs.map((log) => (
<tr key={log.id} className="hover:bg-slate-50">
<td className="px-6 py-4 text-sm text-slate-500">
{new Date(log.timestamp).toLocaleString('de-DE')}
</td>
<td className="px-6 py-4">
<span className="px-2 py-1 bg-blue-100 text-blue-700 text-xs rounded font-medium">
{actionLabels[log.action] || log.action}
</span>
</td>
<td className="px-6 py-4 text-sm text-slate-700">{log.user}</td>
<td className="px-6 py-4 text-sm text-slate-500">{log.details}</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)
}

View File

@@ -0,0 +1,171 @@
'use client'
import type { MailStats, SyncStatus } from '../types'
import { API_BASE } from '../types'
interface OverviewTabProps {
stats: MailStats | null
syncStatus: SyncStatus | null
loading: boolean
onRefresh: () => void
}
export function OverviewTab({ stats, syncStatus, loading, onRefresh }: OverviewTabProps) {
const triggerSync = async () => {
try {
await fetch(`${API_BASE}/api/v1/mail/sync/all`, {
method: 'POST',
})
onRefresh()
} catch (err) {
console.error('Failed to trigger sync:', err)
}
}
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h2 className="text-lg font-semibold text-slate-900">System-Uebersicht</h2>
<p className="text-sm text-slate-500">Status aller E-Mail-Konten und Aufgaben</p>
</div>
<div className="flex gap-3">
<button
onClick={onRefresh}
className="px-4 py-2 text-sm font-medium text-slate-700 bg-white border border-slate-300 rounded-lg hover:bg-slate-50"
>
Aktualisieren
</button>
<button
onClick={triggerSync}
disabled={syncStatus?.running}
className="px-4 py-2 text-sm font-medium text-white bg-blue-600 rounded-lg hover:bg-blue-700 disabled:opacity-50"
>
{syncStatus?.running ? 'Synchronisiert...' : 'Alle synchronisieren'}
</button>
</div>
</div>
{/* Loading State */}
{loading && (
<div className="flex items-center justify-center py-12">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600"></div>
</div>
)}
{/* Stats Grid */}
{!loading && stats && (
<>
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<StatCard
title="E-Mail-Konten"
value={stats.totalAccounts}
subtitle={`${stats.activeAccounts} aktiv`}
color="blue"
/>
<StatCard
title="E-Mails gesamt"
value={stats.totalEmails}
subtitle={`${stats.unreadEmails} ungelesen`}
color="green"
/>
<StatCard
title="Aufgaben"
value={stats.totalTasks}
subtitle={`${stats.pendingTasks} offen`}
color="yellow"
/>
<StatCard
title="Ueberfaellig"
value={stats.overdueTasks}
color={stats.overdueTasks > 0 ? 'red' : 'green'}
/>
</div>
{/* Sync Status */}
<div className="bg-white rounded-lg border border-slate-200 p-6">
<h3 className="text-sm font-medium text-slate-700 mb-4">Synchronisierung</h3>
<div className="flex items-center gap-4">
{syncStatus?.running ? (
<>
<div className="w-3 h-3 bg-yellow-500 rounded-full animate-pulse"></div>
<span className="text-slate-600">
Synchronisiere {syncStatus.accountsInProgress.length} Konto(en)...
</span>
</>
) : (
<>
<div className="w-3 h-3 bg-green-500 rounded-full"></div>
<span className="text-slate-600">Bereit</span>
</>
)}
{stats.lastSyncTime && (
<span className="text-sm text-slate-500 ml-auto">
Letzte Sync: {new Date(stats.lastSyncTime).toLocaleString('de-DE')}
</span>
)}
</div>
{syncStatus?.errors && syncStatus.errors.length > 0 && (
<div className="mt-4 p-4 bg-red-50 rounded-lg">
<h4 className="text-sm font-medium text-red-800 mb-2">Fehler</h4>
<ul className="text-sm text-red-700 space-y-1">
{syncStatus.errors.slice(0, 3).map((error, i) => (
<li key={i}>{error}</li>
))}
</ul>
</div>
)}
</div>
{/* AI Stats */}
<div className="bg-white rounded-lg border border-slate-200 p-6">
<h3 className="text-sm font-medium text-slate-700 mb-4">KI-Analyse</h3>
<div className="grid grid-cols-2 md:grid-cols-4 gap-6">
<div>
<p className="text-xs text-slate-500 uppercase tracking-wider">Analysiert</p>
<p className="text-2xl font-bold text-slate-900">{stats.aiAnalyzedCount}</p>
</div>
<div>
<p className="text-xs text-slate-500 uppercase tracking-wider">Analyse-Rate</p>
<p className="text-2xl font-bold text-slate-900">
{stats.totalEmails > 0
? `${Math.round((stats.aiAnalyzedCount / stats.totalEmails) * 100)}%`
: '0%'}
</p>
</div>
</div>
</div>
</>
)}
</div>
)
}
function StatCard({
title,
value,
subtitle,
color = 'blue'
}: {
title: string
value: number
subtitle?: string
color?: 'blue' | 'green' | 'yellow' | 'red'
}) {
const colorClasses = {
blue: 'text-blue-600',
green: 'text-green-600',
yellow: 'text-yellow-600',
red: 'text-red-600',
}
return (
<div className="bg-white rounded-lg border border-slate-200 p-6">
<p className="text-xs text-slate-500 uppercase tracking-wider mb-1">{title}</p>
<p className={`text-3xl font-bold ${colorClasses[color]}`}>{value.toLocaleString()}</p>
{subtitle && <p className="text-sm text-slate-500 mt-1">{subtitle}</p>}
</div>
)
}

View File

@@ -0,0 +1,55 @@
'use client'
import { useState } from 'react'
export function TemplatesTab() {
const [templates] = useState([
{ id: '1', name: 'Eingangsbestaetigung', category: 'Standard', usageCount: 45 },
{ id: '2', name: 'Terminbestaetigung', category: 'Termine', usageCount: 23 },
{ id: '3', name: 'Elternbrief-Vorlage', category: 'Eltern', usageCount: 67 },
])
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h2 className="text-lg font-semibold text-slate-900">E-Mail-Vorlagen</h2>
<p className="text-sm text-slate-500">Verwalten Sie Antwort-Templates</p>
</div>
<button className="px-4 py-2 text-sm font-medium text-white bg-blue-600 rounded-lg hover:bg-blue-700 flex items-center gap-2">
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 6v6m0 0v6m0-6h6m-6 0H6" />
</svg>
Vorlage erstellen
</button>
</div>
<div className="bg-white rounded-lg border border-slate-200 overflow-hidden">
<table className="min-w-full divide-y divide-slate-200">
<thead className="bg-slate-50">
<tr>
<th className="px-6 py-3 text-left text-xs font-medium text-slate-500 uppercase">Name</th>
<th className="px-6 py-3 text-left text-xs font-medium text-slate-500 uppercase">Kategorie</th>
<th className="px-6 py-3 text-left text-xs font-medium text-slate-500 uppercase">Verwendet</th>
<th className="px-6 py-3 text-right text-xs font-medium text-slate-500 uppercase">Aktionen</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-200">
{templates.map((template) => (
<tr key={template.id} className="hover:bg-slate-50">
<td className="px-6 py-4 text-sm font-medium text-slate-900">{template.name}</td>
<td className="px-6 py-4">
<span className="px-2 py-1 bg-slate-100 text-slate-700 text-xs rounded">{template.category}</span>
</td>
<td className="px-6 py-4 text-sm text-slate-500">{template.usageCount}x</td>
<td className="px-6 py-4 text-right">
<button className="text-blue-600 hover:text-blue-800 text-sm font-medium">Bearbeiten</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)
}

View File

@@ -8,105 +8,20 @@
* and configuring AI analysis settings.
*/
import { useState, useEffect, useCallback } from 'react'
import { useState } from 'react'
import Link from 'next/link'
import { PagePurpose } from '@/components/common/PagePurpose'
import { useMailData } from './useMailData'
import { tabs, type TabId } from './types'
import { OverviewTab } from './_components/OverviewTab'
import { AccountsTab } from './_components/AccountsTab'
import { AISettingsTab } from './_components/AISettingsTab'
import { TemplatesTab } from './_components/TemplatesTab'
import { AuditLogTab } from './_components/AuditLogTab'
// API Base URL for backend operations (accounts, sync, etc.)
const API_BASE = process.env.NEXT_PUBLIC_KLAUSUR_SERVICE_URL || 'http://macmini:8086'
// Types
interface EmailAccount {
id: string
email: string
displayName: string
imapHost: string
imapPort: number
smtpHost: string
smtpPort: number
status: 'active' | 'inactive' | 'error' | 'syncing'
lastSync: string | null
emailCount: number
unreadCount: number
createdAt: string
}
interface MailStats {
totalAccounts: number
activeAccounts: number
totalEmails: number
unreadEmails: number
totalTasks: number
pendingTasks: number
overdueTasks: number
aiAnalyzedCount: number
lastSyncTime: string | null
}
interface SyncStatus {
running: boolean
accountsInProgress: string[]
lastCompleted: string | null
errors: string[]
}
// Tab definitions
type TabId = 'overview' | 'accounts' | 'ai-settings' | 'templates' | 'logs'
const tabs: { id: TabId; name: string }[] = [
{ id: 'overview', name: 'Uebersicht' },
{ id: 'accounts', name: 'Konten' },
{ id: 'ai-settings', name: 'KI-Einstellungen' },
{ id: 'templates', name: 'Vorlagen' },
{ id: 'logs', name: 'Audit-Log' },
]
// Main Component
export default function MailAdminPage() {
const [activeTab, setActiveTab] = useState<TabId>('overview')
const [stats, setStats] = useState<MailStats | null>(null)
const [accounts, setAccounts] = useState<EmailAccount[]>([])
const [syncStatus, setSyncStatus] = useState<SyncStatus | null>(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const fetchData = useCallback(async () => {
try {
setLoading(true)
// Fetch stats via our proxy API (avoids CORS/mixed-content issues)
const response = await fetch('/api/admin/mail')
if (response.ok) {
const data = await response.json()
setStats(data.stats)
setAccounts(data.accounts)
setSyncStatus(data.syncStatus)
setError(null)
} else {
const errorData = await response.json().catch(() => ({}))
throw new Error(errorData.details || `API returned ${response.status}`)
}
} catch (err) {
console.error('Failed to fetch mail data:', err)
setError('Verbindung zum Mail-Service (Mailpit) fehlgeschlagen. Laeuft Mailpit auf Port 8025?')
} finally {
setLoading(false)
}
}, [])
useEffect(() => {
fetchData()
// Refresh every 10 seconds if syncing
const interval = setInterval(() => {
if (syncStatus?.running) {
fetchData()
}
}, 10000)
return () => clearInterval(interval)
}, [fetchData, syncStatus?.running])
const { stats, accounts, syncStatus, loading, error, fetchData } = useMailData()
return (
<div>
@@ -190,757 +105,9 @@ export default function MailAdminPage() {
onRefresh={fetchData}
/>
)}
{activeTab === 'ai-settings' && (
<AISettingsTab />
)}
{activeTab === 'templates' && (
<TemplatesTab />
)}
{activeTab === 'logs' && (
<AuditLogTab />
)}
</div>
)
}
// ============================================================================
// Overview Tab
// ============================================================================
function OverviewTab({
stats,
syncStatus,
loading,
onRefresh
}: {
stats: MailStats | null
syncStatus: SyncStatus | null
loading: boolean
onRefresh: () => void
}) {
const triggerSync = async () => {
try {
await fetch(`${API_BASE}/api/v1/mail/sync/all`, {
method: 'POST',
})
onRefresh()
} catch (err) {
console.error('Failed to trigger sync:', err)
}
}
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h2 className="text-lg font-semibold text-slate-900">System-Uebersicht</h2>
<p className="text-sm text-slate-500">Status aller E-Mail-Konten und Aufgaben</p>
</div>
<div className="flex gap-3">
<button
onClick={onRefresh}
className="px-4 py-2 text-sm font-medium text-slate-700 bg-white border border-slate-300 rounded-lg hover:bg-slate-50"
>
Aktualisieren
</button>
<button
onClick={triggerSync}
disabled={syncStatus?.running}
className="px-4 py-2 text-sm font-medium text-white bg-blue-600 rounded-lg hover:bg-blue-700 disabled:opacity-50"
>
{syncStatus?.running ? 'Synchronisiert...' : 'Alle synchronisieren'}
</button>
</div>
</div>
{/* Loading State */}
{loading && (
<div className="flex items-center justify-center py-12">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600"></div>
</div>
)}
{/* Stats Grid */}
{!loading && stats && (
<>
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<StatCard
title="E-Mail-Konten"
value={stats.totalAccounts}
subtitle={`${stats.activeAccounts} aktiv`}
color="blue"
/>
<StatCard
title="E-Mails gesamt"
value={stats.totalEmails}
subtitle={`${stats.unreadEmails} ungelesen`}
color="green"
/>
<StatCard
title="Aufgaben"
value={stats.totalTasks}
subtitle={`${stats.pendingTasks} offen`}
color="yellow"
/>
<StatCard
title="Ueberfaellig"
value={stats.overdueTasks}
color={stats.overdueTasks > 0 ? 'red' : 'green'}
/>
</div>
{/* Sync Status */}
<div className="bg-white rounded-lg border border-slate-200 p-6">
<h3 className="text-sm font-medium text-slate-700 mb-4">Synchronisierung</h3>
<div className="flex items-center gap-4">
{syncStatus?.running ? (
<>
<div className="w-3 h-3 bg-yellow-500 rounded-full animate-pulse"></div>
<span className="text-slate-600">
Synchronisiere {syncStatus.accountsInProgress.length} Konto(en)...
</span>
</>
) : (
<>
<div className="w-3 h-3 bg-green-500 rounded-full"></div>
<span className="text-slate-600">Bereit</span>
</>
)}
{stats.lastSyncTime && (
<span className="text-sm text-slate-500 ml-auto">
Letzte Sync: {new Date(stats.lastSyncTime).toLocaleString('de-DE')}
</span>
)}
</div>
{syncStatus?.errors && syncStatus.errors.length > 0 && (
<div className="mt-4 p-4 bg-red-50 rounded-lg">
<h4 className="text-sm font-medium text-red-800 mb-2">Fehler</h4>
<ul className="text-sm text-red-700 space-y-1">
{syncStatus.errors.slice(0, 3).map((error, i) => (
<li key={i}>{error}</li>
))}
</ul>
</div>
)}
</div>
{/* AI Stats */}
<div className="bg-white rounded-lg border border-slate-200 p-6">
<h3 className="text-sm font-medium text-slate-700 mb-4">KI-Analyse</h3>
<div className="grid grid-cols-2 md:grid-cols-4 gap-6">
<div>
<p className="text-xs text-slate-500 uppercase tracking-wider">Analysiert</p>
<p className="text-2xl font-bold text-slate-900">{stats.aiAnalyzedCount}</p>
</div>
<div>
<p className="text-xs text-slate-500 uppercase tracking-wider">Analyse-Rate</p>
<p className="text-2xl font-bold text-slate-900">
{stats.totalEmails > 0
? `${Math.round((stats.aiAnalyzedCount / stats.totalEmails) * 100)}%`
: '0%'}
</p>
</div>
</div>
</div>
</>
)}
</div>
)
}
function StatCard({
title,
value,
subtitle,
color = 'blue'
}: {
title: string
value: number
subtitle?: string
color?: 'blue' | 'green' | 'yellow' | 'red'
}) {
const colorClasses = {
blue: 'text-blue-600',
green: 'text-green-600',
yellow: 'text-yellow-600',
red: 'text-red-600',
}
return (
<div className="bg-white rounded-lg border border-slate-200 p-6">
<p className="text-xs text-slate-500 uppercase tracking-wider mb-1">{title}</p>
<p className={`text-3xl font-bold ${colorClasses[color]}`}>{value.toLocaleString()}</p>
{subtitle && <p className="text-sm text-slate-500 mt-1">{subtitle}</p>}
</div>
)
}
// ============================================================================
// Accounts Tab
// ============================================================================
function AccountsTab({
accounts,
loading,
onRefresh
}: {
accounts: EmailAccount[]
loading: boolean
onRefresh: () => void
}) {
const [showAddModal, setShowAddModal] = useState(false)
const testConnection = async (accountId: string) => {
try {
const res = await fetch(`${API_BASE}/api/v1/mail/accounts/${accountId}/test`, {
method: 'POST',
})
if (res.ok) {
alert('Verbindung erfolgreich!')
} else {
alert('Verbindungsfehler')
}
} catch (err) {
alert('Verbindungsfehler')
}
}
const statusColors = {
active: 'bg-green-100 text-green-800',
inactive: 'bg-gray-100 text-gray-800',
error: 'bg-red-100 text-red-800',
syncing: 'bg-yellow-100 text-yellow-800',
}
const statusLabels = {
active: 'Aktiv',
inactive: 'Inaktiv',
error: 'Fehler',
syncing: 'Synchronisiert...',
}
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h2 className="text-lg font-semibold text-slate-900">E-Mail-Konten</h2>
<p className="text-sm text-slate-500">Verwalten Sie die verbundenen E-Mail-Konten</p>
</div>
<button
onClick={() => setShowAddModal(true)}
className="px-4 py-2 text-sm font-medium text-white bg-blue-600 rounded-lg hover:bg-blue-700 flex items-center gap-2"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 6v6m0 0v6m0-6h6m-6 0H6" />
</svg>
Konto hinzufuegen
</button>
</div>
{/* Loading State */}
{loading && (
<div className="flex items-center justify-center py-12">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600"></div>
</div>
)}
{/* Accounts Grid */}
{!loading && (
<div className="grid gap-4">
{accounts.length === 0 ? (
<div className="bg-slate-50 rounded-lg p-8 text-center">
<svg className="w-12 h-12 text-slate-400 mx-auto mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" />
</svg>
<h3 className="text-lg font-medium text-slate-900 mb-2">Keine E-Mail-Konten</h3>
<p className="text-slate-500 mb-4">Fuegen Sie Ihr erstes E-Mail-Konto hinzu.</p>
</div>
) : (
accounts.map((account) => (
<div
key={account.id}
className="bg-white rounded-lg border border-slate-200 p-6 hover:shadow-md transition-shadow"
>
<div className="flex items-start justify-between">
<div className="flex items-center gap-4">
<div className="w-12 h-12 bg-blue-100 rounded-lg flex items-center justify-center">
<svg className="w-6 h-6 text-blue-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" />
</svg>
</div>
<div>
<h3 className="text-lg font-semibold text-slate-900">
{account.displayName || account.email}
</h3>
<p className="text-sm text-slate-500">{account.email}</p>
</div>
</div>
<div className="flex items-center gap-3">
<span className={`px-3 py-1 rounded-full text-xs font-medium ${statusColors[account.status]}`}>
{statusLabels[account.status]}
</span>
<button
onClick={() => testConnection(account.id)}
className="p-2 text-slate-400 hover:text-slate-600"
title="Verbindung testen"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 10V3L4 14h7v7l9-11h-7z" />
</svg>
</button>
</div>
</div>
<div className="mt-4 grid grid-cols-2 md:grid-cols-4 gap-4">
<div>
<p className="text-xs text-slate-500 uppercase tracking-wider">E-Mails</p>
<p className="text-lg font-semibold text-slate-900">{account.emailCount}</p>
</div>
<div>
<p className="text-xs text-slate-500 uppercase tracking-wider">Ungelesen</p>
<p className="text-lg font-semibold text-slate-900">{account.unreadCount}</p>
</div>
<div>
<p className="text-xs text-slate-500 uppercase tracking-wider">IMAP</p>
<p className="text-sm font-mono text-slate-700">{account.imapHost}:{account.imapPort}</p>
</div>
<div>
<p className="text-xs text-slate-500 uppercase tracking-wider">Letzte Sync</p>
<p className="text-sm text-slate-700">
{account.lastSync
? new Date(account.lastSync).toLocaleString('de-DE')
: 'Nie'}
</p>
</div>
</div>
</div>
))
)}
</div>
)}
{/* Add Account Modal */}
{showAddModal && (
<AddAccountModal onClose={() => setShowAddModal(false)} onSuccess={() => { setShowAddModal(false); onRefresh(); }} />
)}
</div>
)
}
function AddAccountModal({
onClose,
onSuccess
}: {
onClose: () => void
onSuccess: () => void
}) {
const [formData, setFormData] = useState({
email: '',
displayName: '',
imapHost: '',
imapPort: 993,
smtpHost: '',
smtpPort: 587,
username: '',
password: '',
})
const [submitting, setSubmitting] = useState(false)
const [error, setError] = useState<string | null>(null)
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
setSubmitting(true)
setError(null)
try {
const res = await fetch(`${API_BASE}/api/v1/mail/accounts`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
email: formData.email,
display_name: formData.displayName,
imap_host: formData.imapHost,
imap_port: formData.imapPort,
smtp_host: formData.smtpHost,
smtp_port: formData.smtpPort,
username: formData.username,
password: formData.password,
}),
})
if (res.ok) {
onSuccess()
} else {
const data = await res.json()
setError(data.detail || 'Fehler beim Hinzufuegen des Kontos')
}
} catch (err) {
setError('Netzwerkfehler')
} finally {
setSubmitting(false)
}
}
return (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
<div className="bg-white rounded-lg shadow-xl max-w-lg w-full mx-4 max-h-[90vh] overflow-y-auto">
<div className="p-6 border-b border-slate-200">
<h2 className="text-lg font-semibold text-slate-900">E-Mail-Konto hinzufuegen</h2>
</div>
<form onSubmit={handleSubmit} className="p-6 space-y-4">
{error && (
<div className="p-3 bg-red-50 text-red-700 rounded-lg text-sm">{error}</div>
)}
<div className="grid grid-cols-2 gap-4">
<div className="col-span-2">
<label className="block text-sm font-medium text-slate-700 mb-1">E-Mail-Adresse</label>
<input
type="email"
required
value={formData.email}
onChange={(e) => setFormData({ ...formData, email: e.target.value })}
className="w-full px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-blue-500"
placeholder="schulleitung@grundschule-xy.de"
/>
</div>
<div className="col-span-2">
<label className="block text-sm font-medium text-slate-700 mb-1">Anzeigename</label>
<input
type="text"
value={formData.displayName}
onChange={(e) => setFormData({ ...formData, displayName: e.target.value })}
className="w-full px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-blue-500"
placeholder="Schulleitung"
/>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">IMAP Server</label>
<input
type="text"
required
value={formData.imapHost}
onChange={(e) => setFormData({ ...formData, imapHost: e.target.value })}
className="w-full px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-blue-500"
placeholder="imap.example.com"
/>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">IMAP Port</label>
<input
type="number"
required
value={formData.imapPort}
onChange={(e) => setFormData({ ...formData, imapPort: parseInt(e.target.value) })}
className="w-full px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-blue-500"
/>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">SMTP Server</label>
<input
type="text"
required
value={formData.smtpHost}
onChange={(e) => setFormData({ ...formData, smtpHost: e.target.value })}
className="w-full px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-blue-500"
placeholder="smtp.example.com"
/>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">SMTP Port</label>
<input
type="number"
required
value={formData.smtpPort}
onChange={(e) => setFormData({ ...formData, smtpPort: parseInt(e.target.value) })}
className="w-full px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-blue-500"
/>
</div>
<div className="col-span-2">
<label className="block text-sm font-medium text-slate-700 mb-1">Benutzername</label>
<input
type="text"
required
value={formData.username}
onChange={(e) => setFormData({ ...formData, username: e.target.value })}
className="w-full px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-blue-500"
/>
</div>
<div className="col-span-2">
<label className="block text-sm font-medium text-slate-700 mb-1">Passwort</label>
<input
type="password"
required
value={formData.password}
onChange={(e) => setFormData({ ...formData, password: e.target.value })}
className="w-full px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-blue-500"
/>
<p className="text-xs text-slate-500 mt-1">
Das Passwort wird verschluesselt in Vault gespeichert.
</p>
</div>
</div>
<div className="flex justify-end gap-3 pt-4 border-t border-slate-200">
<button
type="button"
onClick={onClose}
className="px-4 py-2 text-sm font-medium text-slate-700 hover:bg-slate-100 rounded-lg"
>
Abbrechen
</button>
<button
type="submit"
disabled={submitting}
className="px-4 py-2 text-sm font-medium text-white bg-blue-600 rounded-lg hover:bg-blue-700 disabled:opacity-50"
>
{submitting ? 'Speichern...' : 'Konto hinzufuegen'}
</button>
</div>
</form>
</div>
</div>
)
}
// ============================================================================
// AI Settings Tab
// ============================================================================
function AISettingsTab() {
const [settings, setSettings] = useState({
autoAnalyze: true,
autoCreateTasks: true,
analysisModel: 'breakpilot-teacher-8b',
confidenceThreshold: 0.7,
})
return (
<div className="space-y-6">
<div>
<h2 className="text-lg font-semibold text-slate-900">KI-Einstellungen</h2>
<p className="text-sm text-slate-500">Konfigurieren Sie die automatische E-Mail-Analyse</p>
</div>
<div className="bg-white rounded-lg border border-slate-200 p-6 space-y-6">
{/* Auto-Analyze */}
<div className="flex items-center justify-between">
<div>
<h3 className="text-sm font-medium text-slate-900">Automatische Analyse</h3>
<p className="text-sm text-slate-500">E-Mails automatisch beim Empfang analysieren</p>
</div>
<button
onClick={() => setSettings({ ...settings, autoAnalyze: !settings.autoAnalyze })}
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${
settings.autoAnalyze ? 'bg-blue-600' : 'bg-slate-200'
}`}
>
<span
className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${
settings.autoAnalyze ? 'translate-x-6' : 'translate-x-1'
}`}
/>
</button>
</div>
{/* Auto-Create Tasks */}
<div className="flex items-center justify-between">
<div>
<h3 className="text-sm font-medium text-slate-900">Aufgaben automatisch erstellen</h3>
<p className="text-sm text-slate-500">Erkannte Fristen als Aufgaben anlegen</p>
</div>
<button
onClick={() => setSettings({ ...settings, autoCreateTasks: !settings.autoCreateTasks })}
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${
settings.autoCreateTasks ? 'bg-blue-600' : 'bg-slate-200'
}`}
>
<span
className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${
settings.autoCreateTasks ? 'translate-x-6' : 'translate-x-1'
}`}
/>
</button>
</div>
{/* Model Selection */}
<div>
<label className="block text-sm font-medium text-slate-700 mb-2">Analyse-Modell</label>
<select
value={settings.analysisModel}
onChange={(e) => setSettings({ ...settings, analysisModel: e.target.value })}
className="w-full md:w-64 px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-blue-500"
>
<option value="breakpilot-teacher-8b">BreakPilot Teacher 8B (schnell)</option>
<option value="breakpilot-teacher-70b">BreakPilot Teacher 70B (genau)</option>
<option value="llama-3.1-8b-instruct">Llama 3.1 8B Instruct</option>
</select>
</div>
{/* Confidence Threshold */}
<div>
<label className="block text-sm font-medium text-slate-700 mb-2">
Konfidenz-Schwelle: {Math.round(settings.confidenceThreshold * 100)}%
</label>
<input
type="range"
min="0.5"
max="0.95"
step="0.05"
value={settings.confidenceThreshold}
onChange={(e) => setSettings({ ...settings, confidenceThreshold: parseFloat(e.target.value) })}
className="w-full md:w-64"
/>
<p className="text-xs text-slate-500 mt-1">
Mindest-Konfidenz fuer automatische Aufgabenerstellung
</p>
</div>
</div>
{/* Sender Classification */}
<div className="bg-white rounded-lg border border-slate-200 p-6">
<h3 className="text-sm font-medium text-slate-700 mb-4">Bekannte Absender (Niedersachsen)</h3>
<div className="grid grid-cols-2 md:grid-cols-3 gap-3">
{[
{ domain: '@mk.niedersachsen.de', type: 'Kultusministerium', priority: 'Hoch' },
{ domain: '@rlsb.de', type: 'RLSB', priority: 'Hoch' },
{ domain: '@landesschulbehoerde-nds.de', type: 'Landesschulbehoerde', priority: 'Hoch' },
{ domain: '@nibis.de', type: 'NiBiS', priority: 'Mittel' },
{ domain: '@schultraeger.de', type: 'Schultraeger', priority: 'Mittel' },
].map((sender) => (
<div key={sender.domain} className="p-3 bg-slate-50 rounded-lg">
<p className="text-sm font-mono text-slate-700">{sender.domain}</p>
<p className="text-xs text-slate-500">{sender.type}</p>
<span className={`text-xs px-2 py-0.5 rounded ${
sender.priority === 'Hoch' ? 'bg-red-100 text-red-700' : 'bg-yellow-100 text-yellow-700'
}`}>
{sender.priority}
</span>
</div>
))}
</div>
</div>
</div>
)
}
// ============================================================================
// Templates Tab
// ============================================================================
function TemplatesTab() {
const [templates] = useState([
{ id: '1', name: 'Eingangsbestaetigung', category: 'Standard', usageCount: 45 },
{ id: '2', name: 'Terminbestaetigung', category: 'Termine', usageCount: 23 },
{ id: '3', name: 'Elternbrief-Vorlage', category: 'Eltern', usageCount: 67 },
])
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h2 className="text-lg font-semibold text-slate-900">E-Mail-Vorlagen</h2>
<p className="text-sm text-slate-500">Verwalten Sie Antwort-Templates</p>
</div>
<button className="px-4 py-2 text-sm font-medium text-white bg-blue-600 rounded-lg hover:bg-blue-700 flex items-center gap-2">
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 6v6m0 0v6m0-6h6m-6 0H6" />
</svg>
Vorlage erstellen
</button>
</div>
<div className="bg-white rounded-lg border border-slate-200 overflow-hidden">
<table className="min-w-full divide-y divide-slate-200">
<thead className="bg-slate-50">
<tr>
<th className="px-6 py-3 text-left text-xs font-medium text-slate-500 uppercase">Name</th>
<th className="px-6 py-3 text-left text-xs font-medium text-slate-500 uppercase">Kategorie</th>
<th className="px-6 py-3 text-left text-xs font-medium text-slate-500 uppercase">Verwendet</th>
<th className="px-6 py-3 text-right text-xs font-medium text-slate-500 uppercase">Aktionen</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-200">
{templates.map((template) => (
<tr key={template.id} className="hover:bg-slate-50">
<td className="px-6 py-4 text-sm font-medium text-slate-900">{template.name}</td>
<td className="px-6 py-4">
<span className="px-2 py-1 bg-slate-100 text-slate-700 text-xs rounded">{template.category}</span>
</td>
<td className="px-6 py-4 text-sm text-slate-500">{template.usageCount}x</td>
<td className="px-6 py-4 text-right">
<button className="text-blue-600 hover:text-blue-800 text-sm font-medium">Bearbeiten</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)
}
// ============================================================================
// Audit Log Tab
// ============================================================================
function AuditLogTab() {
const [logs] = useState([
{ id: '1', action: 'account_created', user: 'admin@breakpilot.de', timestamp: new Date().toISOString(), details: 'Konto schulleitung@example.de hinzugefuegt' },
{ id: '2', action: 'email_analyzed', user: 'system', timestamp: new Date(Date.now() - 3600000).toISOString(), details: '5 E-Mails analysiert' },
{ id: '3', action: 'task_created', user: 'system', timestamp: new Date(Date.now() - 7200000).toISOString(), details: 'Aufgabe aus Fristenerkennung erstellt' },
])
const actionLabels: Record<string, string> = {
account_created: 'Konto erstellt',
email_analyzed: 'E-Mail analysiert',
task_created: 'Aufgabe erstellt',
sync_completed: 'Sync abgeschlossen',
}
return (
<div className="space-y-6">
<div>
<h2 className="text-lg font-semibold text-slate-900">Audit-Log</h2>
<p className="text-sm text-slate-500">Alle Aktionen im Mail-System</p>
</div>
<div className="bg-white rounded-lg border border-slate-200 overflow-hidden">
<table className="min-w-full divide-y divide-slate-200">
<thead className="bg-slate-50">
<tr>
<th className="px-6 py-3 text-left text-xs font-medium text-slate-500 uppercase">Zeit</th>
<th className="px-6 py-3 text-left text-xs font-medium text-slate-500 uppercase">Aktion</th>
<th className="px-6 py-3 text-left text-xs font-medium text-slate-500 uppercase">Benutzer</th>
<th className="px-6 py-3 text-left text-xs font-medium text-slate-500 uppercase">Details</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-200">
{logs.map((log) => (
<tr key={log.id} className="hover:bg-slate-50">
<td className="px-6 py-4 text-sm text-slate-500">
{new Date(log.timestamp).toLocaleString('de-DE')}
</td>
<td className="px-6 py-4">
<span className="px-2 py-1 bg-blue-100 text-blue-700 text-xs rounded font-medium">
{actionLabels[log.action] || log.action}
</span>
</td>
<td className="px-6 py-4 text-sm text-slate-700">{log.user}</td>
<td className="px-6 py-4 text-sm text-slate-500">{log.details}</td>
</tr>
))}
</tbody>
</table>
</div>
{activeTab === 'ai-settings' && <AISettingsTab />}
{activeTab === 'templates' && <TemplatesTab />}
{activeTab === 'logs' && <AuditLogTab />}
</div>
)
}

View File

@@ -0,0 +1,50 @@
/**
* Types and constants for the Unified Inbox Mail Admin Page
*/
// API Base URL for backend operations (accounts, sync, etc.)
export const API_BASE = process.env.NEXT_PUBLIC_KLAUSUR_SERVICE_URL || 'http://macmini:8086'
export interface EmailAccount {
id: string
email: string
displayName: string
imapHost: string
imapPort: number
smtpHost: string
smtpPort: number
status: 'active' | 'inactive' | 'error' | 'syncing'
lastSync: string | null
emailCount: number
unreadCount: number
createdAt: string
}
export interface MailStats {
totalAccounts: number
activeAccounts: number
totalEmails: number
unreadEmails: number
totalTasks: number
pendingTasks: number
overdueTasks: number
aiAnalyzedCount: number
lastSyncTime: string | null
}
export interface SyncStatus {
running: boolean
accountsInProgress: string[]
lastCompleted: string | null
errors: string[]
}
export type TabId = 'overview' | 'accounts' | 'ai-settings' | 'templates' | 'logs'
export const tabs: { id: TabId; name: string }[] = [
{ id: 'overview', name: 'Uebersicht' },
{ id: 'accounts', name: 'Konten' },
{ id: 'ai-settings', name: 'KI-Einstellungen' },
{ id: 'templates', name: 'Vorlagen' },
{ id: 'logs', name: 'Audit-Log' },
]

View File

@@ -0,0 +1,61 @@
'use client'
import { useState, useEffect, useCallback } from 'react'
import type { EmailAccount, MailStats, SyncStatus } from './types'
export interface UseMailDataReturn {
stats: MailStats | null
accounts: EmailAccount[]
syncStatus: SyncStatus | null
loading: boolean
error: string | null
fetchData: () => Promise<void>
}
export function useMailData(): UseMailDataReturn {
const [stats, setStats] = useState<MailStats | null>(null)
const [accounts, setAccounts] = useState<EmailAccount[]>([])
const [syncStatus, setSyncStatus] = useState<SyncStatus | null>(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const fetchData = useCallback(async () => {
try {
setLoading(true)
// Fetch stats via our proxy API (avoids CORS/mixed-content issues)
const response = await fetch('/api/admin/mail')
if (response.ok) {
const data = await response.json()
setStats(data.stats)
setAccounts(data.accounts)
setSyncStatus(data.syncStatus)
setError(null)
} else {
const errorData = await response.json().catch(() => ({}))
throw new Error(errorData.details || `API returned ${response.status}`)
}
} catch (err) {
console.error('Failed to fetch mail data:', err)
setError('Verbindung zum Mail-Service (Mailpit) fehlgeschlagen. Laeuft Mailpit auf Port 8025?')
} finally {
setLoading(false)
}
}, [])
useEffect(() => {
fetchData()
// Refresh every 10 seconds if syncing
const interval = setInterval(() => {
if (syncStatus?.running) {
fetchData()
}
}, 10000)
return () => clearInterval(interval)
}, [fetchData, syncStatus?.running])
return { stats, accounts, syncStatus, loading, error, fetchData }
}

View File

@@ -0,0 +1,35 @@
'use client'
import { useState } from 'react'
import type { ColorShade } from '../constants'
export function ColorSwatch({ color }: { color: ColorShade }) {
const [copied, setCopied] = useState(false)
const copyToClipboard = () => {
navigator.clipboard.writeText(color.value)
setCopied(true)
setTimeout(() => setCopied(false), 2000)
}
return (
<button
onClick={copyToClipboard}
className="group flex flex-col items-center"
title={`Klicken zum Kopieren: ${color.value}`}
>
<div
className="w-16 h-16 rounded-xl shadow-sm border border-slate-200 transition-transform group-hover:scale-110 flex items-center justify-center"
style={{ backgroundColor: color.value }}
>
{copied && (
<svg className={`w-5 h-5 ${color.text === 'light' ? 'text-white' : 'text-slate-900'}`} fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
)}
</div>
<span className="text-xs text-slate-600 mt-1 font-medium">{color.name}</span>
<span className="text-xs text-slate-400 font-mono">{color.value}</span>
</button>
)
}

View File

@@ -0,0 +1,123 @@
'use client'
import {
PRIMARY_COLORS,
ACCENT_COLORS,
CATEGORY_COLORS,
SEMANTIC_COLORS,
} from '../constants'
import { ColorSwatch } from './ColorSwatch'
export function ColorsTab() {
return (
<div className="space-y-6">
{/* Primary Color */}
<div className="bg-white rounded-xl border border-slate-200 p-6 shadow-sm">
<h3 className="text-lg font-semibold text-slate-900 mb-2">{PRIMARY_COLORS.name}</h3>
<p className="text-sm text-slate-500 mb-4">{PRIMARY_COLORS.description}</p>
<div className="flex gap-4 flex-wrap">
{PRIMARY_COLORS.shades.map((shade) => (
<ColorSwatch key={shade.name} color={shade} />
))}
</div>
</div>
{/* Accent Color */}
<div className="bg-white rounded-xl border border-slate-200 p-6 shadow-sm">
<h3 className="text-lg font-semibold text-slate-900 mb-2">{ACCENT_COLORS.name}</h3>
<p className="text-sm text-slate-500 mb-4">{ACCENT_COLORS.description}</p>
<div className="flex gap-4 flex-wrap">
{ACCENT_COLORS.shades.map((shade) => (
<ColorSwatch key={shade.name} color={shade} />
))}
</div>
{/* Logo Gradient Preview */}
<div className="mt-6 pt-4 border-t border-slate-200">
<h4 className="text-sm font-medium text-slate-700 mb-3">Logo-Gradient</h4>
<div className="flex items-center gap-4">
<div className="w-16 h-16 rounded-xl flex items-center justify-center" style={{ background: 'linear-gradient(to bottom right, #0ea5e9, #d946ef)' }}>
<span className="text-2xl font-bold text-white">B</span>
</div>
<div className="text-sm text-slate-600">
<code className="bg-slate-100 px-2 py-1 rounded font-mono text-xs">
bg-gradient-to-br from-primary-500 to-accent-500
</code>
<p className="mt-1 text-slate-500">Sky Blue (#0ea5e9) Fuchsia (#d946ef)</p>
</div>
</div>
</div>
</div>
{/* Category Colors */}
<div className="bg-white rounded-xl border border-slate-200 p-6 shadow-sm">
<h3 className="text-lg font-semibold text-slate-900 mb-4">Kategorie-Farben</h3>
<p className="text-sm text-slate-500 mb-6">
Jede Kategorie hat eine eigene Farbe fuer Navigation, Module und Akzente.
</p>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{CATEGORY_COLORS.map((cat) => (
<div key={cat.id} className="border border-slate-200 rounded-xl p-4">
<div className="flex items-center gap-3 mb-3">
<div
className="w-8 h-8 rounded-lg"
style={{ backgroundColor: cat.main }}
/>
<div>
<h4 className="font-medium text-slate-900">{cat.name}</h4>
<p className="text-xs text-slate-500">{cat.description}</p>
</div>
</div>
<div className="flex gap-2">
{cat.shades.map((shade) => (
<ColorSwatch key={shade.name} color={shade} />
))}
</div>
</div>
))}
</div>
</div>
{/* Semantic Colors */}
<div className="bg-white rounded-xl border border-slate-200 p-6 shadow-sm">
<h3 className="text-lg font-semibold text-slate-900 mb-4">Semantische Farben</h3>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
{Object.entries(SEMANTIC_COLORS).map(([key, palette]) => (
<div key={key} className="border border-slate-200 rounded-xl p-4">
<h4 className="font-medium text-slate-900 mb-3">{palette.name}</h4>
<div className="flex gap-2">
{palette.shades.map((shade) => (
<ColorSwatch key={shade.name} color={shade} />
))}
</div>
</div>
))}
</div>
</div>
{/* Usage Guidelines */}
<div className="bg-white rounded-xl border border-slate-200 p-6 shadow-sm">
<h3 className="text-lg font-semibold text-slate-900 mb-4">Verwendungsrichtlinien</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div>
<h4 className="font-medium text-slate-700 mb-2">Primary (Sky Blue)</h4>
<ul className="text-sm text-slate-600 space-y-1">
<li>- Primaere Buttons und CTAs</li>
<li>- Links und interaktive Elemente</li>
<li>- Fokuszustaende</li>
<li>- Ausgewaehlte Navigation</li>
</ul>
</div>
<div>
<h4 className="font-medium text-slate-700 mb-2">Kategorie-Farben</h4>
<ul className="text-sm text-slate-600 space-y-1">
<li>- Sidebar-Navigation Icons</li>
<li>- Modul-Cards und Badges</li>
<li>- Bereichs-spezifische Akzente</li>
<li>- Breadcrumbs und Headers</li>
</ul>
</div>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,176 @@
'use client'
import { CATEGORY_COLORS } from '../constants'
export function ComponentsTab() {
return (
<div className="space-y-6">
{/* Buttons */}
<div className="bg-white rounded-xl border border-slate-200 p-6 shadow-sm">
<h3 className="text-lg font-semibold text-slate-900 mb-4">Buttons</h3>
<div className="flex flex-wrap gap-4 mb-6">
<button className="px-6 py-2 bg-primary-600 text-white font-medium rounded-lg hover:bg-primary-700 transition-colors">
Primary
</button>
<button className="px-6 py-2 bg-slate-100 text-slate-700 font-medium rounded-lg hover:bg-slate-200 transition-colors">
Secondary
</button>
<button className="px-6 py-2 border border-slate-300 text-slate-700 font-medium rounded-lg hover:bg-slate-50 transition-colors">
Outline
</button>
<button className="px-6 py-2 text-primary-600 font-medium rounded-lg hover:bg-primary-50 transition-colors">
Ghost
</button>
<button className="px-6 py-2 bg-red-600 text-white font-medium rounded-lg hover:bg-red-700 transition-colors">
Danger
</button>
</div>
<div className="flex flex-wrap gap-4">
<button className="px-4 py-1.5 bg-primary-600 text-white text-sm font-medium rounded-lg">
Small
</button>
<button className="px-6 py-2 bg-primary-600 text-white font-medium rounded-lg">
Medium
</button>
<button className="px-8 py-3 bg-primary-600 text-white text-lg font-medium rounded-lg">
Large
</button>
</div>
</div>
{/* Inputs */}
<div className="bg-white rounded-xl border border-slate-200 p-6 shadow-sm">
<h3 className="text-lg font-semibold text-slate-900 mb-4">Eingabefelder</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Standard Input</label>
<input
type="text"
placeholder="Placeholder..."
className="w-full px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500 outline-none"
/>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Mit Icon</label>
<div className="relative">
<input
type="text"
placeholder="Suchen..."
className="w-full pl-10 pr-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500 outline-none"
/>
<svg className="w-5 h-5 text-slate-400 absolute left-3 top-2.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
</svg>
</div>
</div>
</div>
</div>
{/* Cards */}
<div className="bg-white rounded-xl border border-slate-200 p-6 shadow-sm">
<h3 className="text-lg font-semibold text-slate-900 mb-4">Cards</h3>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div className="bg-white rounded-xl border border-slate-200 p-4 hover:shadow-lg hover:border-primary-300 transition-all cursor-pointer">
<div className="w-10 h-10 bg-primary-100 rounded-lg flex items-center justify-center mb-3">
<svg className="w-5 h-5 text-primary-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 10V3L4 14h7v7l9-11h-7z" />
</svg>
</div>
<h4 className="font-semibold text-slate-900 mb-1">Feature Card</h4>
<p className="text-sm text-slate-600">Standard Card mit Hover-Effekt.</p>
</div>
<div className="rounded-xl p-4 text-white" style={{ background: 'linear-gradient(to bottom right, #0ea5e9, #d946ef)' }}>
<h4 className="font-semibold mb-1">Highlight Card</h4>
<p className="text-sm text-white/80">Mit Gradient-Hintergrund.</p>
</div>
<div className="bg-slate-900 rounded-xl p-4 text-white">
<h4 className="font-semibold mb-1">Dark Card</h4>
<p className="text-sm text-slate-400">Sidebar-Style.</p>
</div>
</div>
</div>
{/* Category Cards */}
<div className="bg-white rounded-xl border border-slate-200 p-6 shadow-sm">
<h3 className="text-lg font-semibold text-slate-900 mb-4">Kategorie-Cards</h3>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
{CATEGORY_COLORS.slice(0, 3).map((cat) => (
<div
key={cat.id}
className="rounded-xl border p-4 hover:shadow-lg transition-all cursor-pointer"
style={{
borderColor: cat.shades[0].value,
backgroundColor: cat.shades[0].value + '40'
}}
>
<div
className="w-10 h-10 rounded-lg flex items-center justify-center mb-3"
style={{ backgroundColor: cat.main }}
>
<svg className="w-5 h-5 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<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>
</div>
<h4 className="font-semibold text-slate-900 mb-1">{cat.name.split(' (')[0]}</h4>
<p className="text-sm text-slate-600">{cat.description}</p>
</div>
))}
</div>
</div>
{/* Badges */}
<div className="bg-white rounded-xl border border-slate-200 p-6 shadow-sm">
<h3 className="text-lg font-semibold text-slate-900 mb-4">Badges & Tags</h3>
<div className="flex flex-wrap gap-2 mb-4">
<span className="px-2.5 py-1 bg-primary-100 text-primary-700 text-xs font-medium rounded-full">Primary</span>
<span className="px-2.5 py-1 bg-green-100 text-green-700 text-xs font-medium rounded-full">Success</span>
<span className="px-2.5 py-1 bg-amber-100 text-amber-700 text-xs font-medium rounded-full">Warning</span>
<span className="px-2.5 py-1 bg-red-100 text-red-700 text-xs font-medium rounded-full">Danger</span>
<span className="px-2.5 py-1 bg-slate-100 text-slate-700 text-xs font-medium rounded-full">Neutral</span>
</div>
<h4 className="font-medium text-slate-700 mb-2 mt-4">Kategorie-Badges</h4>
<div className="flex flex-wrap gap-2">
{CATEGORY_COLORS.map((cat) => (
<span
key={cat.id}
className="px-2.5 py-1 text-xs font-medium rounded-full"
style={{
backgroundColor: cat.shades[0].value,
color: cat.shades[3].value
}}
>
{cat.name.split(' (')[0]}
</span>
))}
</div>
</div>
{/* Border Radius */}
<div className="bg-white rounded-xl border border-slate-200 p-6 shadow-sm">
<h3 className="text-lg font-semibold text-slate-900 mb-4">Border Radius</h3>
<div className="flex gap-6 items-end">
<div className="text-center">
<div className="w-16 h-16 bg-primary-500 rounded"></div>
<span className="text-xs text-slate-500 mt-2 block">rounded (4px)</span>
</div>
<div className="text-center">
<div className="w-16 h-16 bg-primary-500 rounded-lg"></div>
<span className="text-xs text-slate-500 mt-2 block">rounded-lg (8px)</span>
</div>
<div className="text-center">
<div className="w-16 h-16 bg-primary-500 rounded-xl"></div>
<span className="text-xs text-slate-500 mt-2 block">rounded-xl (12px)</span>
</div>
<div className="text-center">
<div className="w-16 h-16 bg-primary-500 rounded-2xl"></div>
<span className="text-xs text-slate-500 mt-2 block">rounded-2xl (16px)</span>
</div>
<div className="text-center">
<div className="w-16 h-16 bg-primary-500 rounded-full"></div>
<span className="text-xs text-slate-500 mt-2 block">rounded-full</span>
</div>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,269 @@
'use client'
function LogoDisplay() {
return (
<div className="bg-white rounded-xl border border-slate-200 p-6 shadow-sm">
<h3 className="text-lg font-semibold text-slate-900 mb-4">Logo</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-8">
<div className="bg-white border border-slate-200 rounded-xl p-8 flex items-center justify-center">
<div className="flex items-center gap-3">
<div className="w-12 h-12 rounded-xl flex items-center justify-center" style={{ background: 'linear-gradient(to bottom right, #0ea5e9, #d946ef)' }}>
<span className="text-2xl font-bold text-white">B</span>
</div>
<div>
<span className="text-2xl font-bold text-slate-900">BreakPilot</span>
<span className="text-sm text-slate-500 block">Admin v2</span>
</div>
</div>
</div>
<div className="bg-slate-900 rounded-xl p-8 flex items-center justify-center">
<div className="flex items-center gap-3">
<div className="w-12 h-12 rounded-xl flex items-center justify-center" style={{ background: 'linear-gradient(to bottom right, #38bdf8, #e879f9)' }}>
<span className="text-2xl font-bold text-white">B</span>
</div>
<div>
<span className="text-2xl font-bold text-white">BreakPilot</span>
<span className="text-sm text-slate-400 block">Admin v2</span>
</div>
</div>
</div>
</div>
{/* Gradient Info */}
<div className="mt-6 pt-4 border-t border-slate-200">
<div className="flex items-center gap-4">
<code className="bg-slate-100 px-3 py-1.5 rounded-lg font-mono text-xs text-slate-700">
background: linear-gradient(to bottom right, #0ea5e9, #d946ef)
</code>
<span className="text-sm text-slate-500">Primary Accent (Sky Blue Fuchsia)</span>
</div>
</div>
</div>
)
}
function LogoVariations() {
return (
<div className="bg-white rounded-xl border border-slate-200 p-6 shadow-sm">
<h3 className="text-lg font-semibold text-slate-900 mb-4">Logo-Varianten</h3>
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<div className="border border-slate-200 rounded-xl p-4 flex flex-col items-center gap-2">
<div className="w-12 h-12 rounded-xl flex items-center justify-center" style={{ background: 'linear-gradient(to bottom right, #0ea5e9, #d946ef)' }}>
<span className="text-2xl font-bold text-white">B</span>
</div>
<span className="text-xs text-slate-600">Icon Only</span>
</div>
<div className="border border-slate-200 rounded-xl p-4 flex flex-col items-center gap-2">
<span className="text-xl font-bold text-slate-900">BreakPilot</span>
<span className="text-xs text-slate-600">Text Only</span>
</div>
<div className="border border-slate-200 rounded-xl p-4 flex flex-col items-center gap-2">
<div className="flex items-center gap-2">
<div className="w-8 h-8 rounded-lg flex items-center justify-center" style={{ background: 'linear-gradient(to bottom right, #0ea5e9, #d946ef)' }}>
<span className="text-lg font-bold text-white">B</span>
</div>
<span className="text-lg font-bold text-slate-900">BreakPilot</span>
</div>
<span className="text-xs text-slate-600">Horizontal</span>
</div>
<div className="border border-slate-200 rounded-xl p-4 flex flex-col items-center gap-2">
<div className="flex flex-col items-center">
<div className="w-10 h-10 rounded-xl flex items-center justify-center mb-1" style={{ background: 'linear-gradient(to bottom right, #0ea5e9, #d946ef)' }}>
<span className="text-xl font-bold text-white">B</span>
</div>
<span className="text-sm font-bold text-slate-900">BreakPilot</span>
</div>
<span className="text-xs text-slate-600">Stacked</span>
</div>
</div>
</div>
)
}
function FaviconSizes() {
return (
<div className="bg-white rounded-xl border border-slate-200 p-6 shadow-sm">
<h3 className="text-lg font-semibold text-slate-900 mb-4">Favicon & App Icon</h3>
<div className="flex gap-6 items-end">
{[
{ size: 'w-8 h-8', rounded: 'rounded', text: 'text-sm', label: '16x16' },
{ size: 'w-12 h-12', rounded: 'rounded-lg', text: 'text-xl', label: '32x32' },
{ size: 'w-16 h-16', rounded: 'rounded-xl', text: 'text-2xl', label: '180x180' },
{ size: 'w-24 h-24', rounded: 'rounded-2xl', text: 'text-4xl', label: '512x512' },
].map((icon) => (
<div key={icon.label} className="text-center">
<div className={`${icon.size} ${icon.rounded} flex items-center justify-center`} style={{ background: 'linear-gradient(to bottom right, #0ea5e9, #d946ef)' }}>
<span className={`${icon.text} font-bold text-white`}>B</span>
</div>
<span className="text-xs text-slate-500 mt-2 block">{icon.label}</span>
</div>
))}
</div>
</div>
)
}
function LogoDonts() {
return (
<div className="bg-white rounded-xl border border-slate-200 p-6 shadow-sm">
<h3 className="text-lg font-semibold text-slate-900 mb-4">Nicht erlaubt</h3>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div className="border border-red-200 bg-red-50 rounded-xl p-4 text-center">
<div className="flex items-center gap-2 justify-center opacity-50">
<div className="w-8 h-8 bg-red-500 rounded-lg flex items-center justify-center">
<span className="text-lg font-bold text-white">B</span>
</div>
<span className="text-lg font-bold text-red-500">BreakPilot</span>
</div>
<span className="text-xs text-red-600 mt-2 block">Falsche Farben</span>
</div>
<div className="border border-red-200 bg-red-50 rounded-xl p-4 text-center">
<div className="flex items-center gap-2 justify-center" style={{ transform: 'skewX(-10deg)' }}>
<div className="w-8 h-8 rounded-lg flex items-center justify-center" style={{ background: 'linear-gradient(to bottom right, #0ea5e9, #d946ef)' }}>
<span className="text-lg font-bold text-white">B</span>
</div>
<span className="text-lg font-bold text-slate-900">BreakPilot</span>
</div>
<span className="text-xs text-red-600 mt-2 block">Verzerrt</span>
</div>
<div className="border border-red-200 bg-red-50 rounded-xl p-4 text-center">
<div className="flex items-center gap-2 justify-center">
<div className="w-8 h-8 rounded-lg flex items-center justify-center" style={{ background: 'linear-gradient(to bottom right, #0ea5e9, #d946ef)' }}>
<span className="text-lg font-bold text-white">B</span>
</div>
<span className="text-lg font-bold text-slate-900">Break Pilot</span>
</div>
<span className="text-xs text-red-600 mt-2 block">Falsche Schreibweise</span>
</div>
</div>
</div>
)
}
function StudioLogo() {
return (
<div className="bg-white rounded-xl border border-slate-200 p-6 shadow-sm">
<h3 className="text-lg font-semibold text-slate-900 mb-2">Studio Logo (BP mit Fluegeln)</h3>
<p className="text-sm text-slate-500 mb-6">
Das Studio-Logo verwendet ein rotes Farbschema und zeigt &quot;BP&quot; mit stilisierten Fluegeln.
Es gibt drei Design-Varianten fuer unterschiedliche Anwendungsfaelle.
</p>
{/* Variante A: Cupertino Clean */}
<div className="mb-6 p-4 bg-slate-50 rounded-xl">
<h4 className="font-medium text-slate-700 mb-4">A: Cupertino Clean</h4>
<div className="flex items-center gap-8">
<div className="flex items-center gap-3">
<div className="w-12 h-12 relative">
<div className="absolute inset-0 rounded-xl shadow-lg" style={{ background: 'linear-gradient(to bottom right, #ef4444, #b91c1c)' }} />
<div className="absolute inset-0 flex items-center justify-center">
<svg viewBox="0 0 40 40" className="w-9 h-9">
<circle cx="20" cy="20" r="14" fill="rgba(255,255,255,0.15)" />
<text x="20" y="26" textAnchor="middle" fill="white" fontSize="16" fontWeight="bold" fontFamily="system-ui">BP</text>
<path d="M6 22 L12 20 M28 20 L34 22" stroke="rgba(255,255,255,0.5)" strokeWidth="1.5" strokeLinecap="round" />
</svg>
</div>
</div>
<div>
<span className="font-semibold text-slate-900 text-lg">Break<span className="text-red-600">Pilot</span></span>
<span className="text-slate-400 text-sm block">Studio</span>
</div>
</div>
<div className="text-sm text-slate-600">
<code className="bg-white px-2 py-1 rounded font-mono text-xs">#ef4444 #b91c1c</code>
<p className="mt-1 text-slate-500">Minimalistisch, SF-Style, BP mit Fluegel-Linien</p>
</div>
</div>
</div>
{/* Variante B: Glassmorphism Pro */}
<div className="mb-6 p-4 rounded-xl" style={{ background: 'linear-gradient(to bottom right, #312e81, #581c87, #831843)' }}>
<h4 className="font-medium text-white/80 mb-4">B: Glassmorphism Pro</h4>
<div className="flex items-center gap-8">
<div className="flex items-center gap-4">
<div className="w-12 h-12 relative">
<div className="absolute inset-0 rounded-2xl blur-md opacity-50" style={{ background: 'linear-gradient(to bottom right, #f87171, #dc2626)' }} />
<div className="absolute inset-0 rounded-2xl border border-white/20 shadow-xl" style={{ background: 'linear-gradient(to bottom right, rgba(248,113,113,0.9), rgba(220,38,38,0.9))' }} />
<div className="absolute inset-0 flex items-center justify-center">
<svg viewBox="0 0 40 40" className="w-9 h-9">
<path d="M10 28 L20 8 L30 28 L20 22 Z" fill="rgba(255,255,255,0.9)" />
<path d="M14 24 L20 12 L26 24 L20 20 Z" fill="rgba(255,255,255,0.3)" />
</svg>
</div>
</div>
<div>
<span className="font-semibold text-white text-lg">BreakPilot</span>
<span className="text-white/60 text-sm block">Studio</span>
</div>
</div>
<div className="text-sm text-white/70">
<code className="bg-white/10 px-2 py-1 rounded font-mono text-xs">#f87171 #dc2626</code>
<p className="mt-1 text-white/50">Glassmorphism, Glow-Effekt, Papierflugzeug-Silhouette</p>
</div>
</div>
</div>
{/* Variante C: Bento Style */}
<div className="mb-6 p-4 bg-black rounded-xl">
<h4 className="font-medium text-white/80 mb-4">C: Bento Style</h4>
<div className="flex items-center gap-8">
<div className="flex items-center gap-4">
<div className="w-12 h-12 relative">
<div className="absolute inset-0 rounded-xl" style={{ background: 'linear-gradient(to bottom right, #ef4444, #991b1b)' }} />
<div className="absolute inset-0 flex items-center justify-center">
<svg viewBox="0 0 40 40" className="w-9 h-9">
<circle cx="15" cy="14" r="5" stroke="white" strokeWidth="1.5" fill="none" />
<circle cx="15" cy="24" r="5" stroke="white" strokeWidth="1.5" fill="none" />
<line x1="27" y1="10" x2="27" y2="30" stroke="white" strokeWidth="1.5" strokeLinecap="round" />
<circle cx="27" cy="15" r="4" stroke="white" strokeWidth="1.5" fill="none" />
</svg>
</div>
</div>
<div>
<span className="font-semibold text-white text-lg">BreakPilot</span>
<span className="text-white/40 text-sm ml-2">Studio</span>
</div>
</div>
<div className="text-sm text-white/70">
<code className="bg-white/10 px-2 py-1 rounded font-mono text-xs">#ef4444 #991b1b</code>
<p className="mt-1 text-white/50">Geometrisch, Monoweight, B+P als Kreise/Linien</p>
</div>
</div>
</div>
{/* Studio Farbpalette */}
<div className="pt-4 border-t border-slate-200">
<h4 className="text-sm font-medium text-slate-700 mb-3">Studio Farbpalette (Rot)</h4>
<div className="flex gap-3">
{[
{ bg: '#fef2f2', label: 'red-50' },
{ bg: '#f87171', label: 'red-400' },
{ bg: '#ef4444', label: 'red-500' },
{ bg: '#dc2626', label: 'red-600' },
{ bg: '#b91c1c', label: 'red-700' },
{ bg: '#991b1b', label: 'red-800' },
].map((swatch) => (
<div key={swatch.label} className="text-center">
<div className="w-12 h-12 rounded-lg" style={{ backgroundColor: swatch.bg }} />
<span className="text-xs text-slate-500 mt-1 block">{swatch.label}</span>
</div>
))}
</div>
<p className="text-xs text-slate-500 mt-3">
Quelle: <code className="bg-slate-100 px-1.5 py-0.5 rounded">/studio-v2/components/Logo.tsx</code>
</p>
</div>
</div>
)
}
export function LogoTab() {
return (
<div className="space-y-6">
<LogoDisplay />
<LogoVariations />
<FaviconSizes />
<LogoDonts />
<StudioLogo />
</div>
)
}

View File

@@ -0,0 +1,173 @@
'use client'
import { TYPOGRAPHY, SPACING } from '../constants'
export function TypographyTab() {
return (
<div className="space-y-6">
{/* Font Family */}
<div className="bg-white rounded-xl border border-slate-200 p-6 shadow-sm">
<h3 className="text-lg font-semibold text-slate-900 mb-4">Schriftart: Inter</h3>
<div className="bg-slate-50 rounded-lg p-4 font-mono text-sm text-slate-600 mb-4">
font-family: {TYPOGRAPHY.fontFamily};
</div>
<p className="text-sm text-slate-600">
Inter ist eine moderne, variable Sans-Serif Schrift, optimiert fuer Bildschirme.
Sie ist unter der SIL Open Font License verfuegbar und frei fuer kommerzielle Nutzung.
</p>
</div>
{/* Font Licenses */}
<div className="bg-white rounded-xl border border-slate-200 p-6 shadow-sm">
<h3 className="text-lg font-semibold text-slate-900 mb-4">Lizenzen</h3>
<div className="space-y-4">
{/* Inter License */}
<div className="border border-slate-200 rounded-xl p-4">
<div className="flex items-start justify-between mb-2">
<div>
<h4 className="font-semibold text-slate-900">Inter Font</h4>
<p className="text-sm text-slate-500">Designer: Rasmus Andersson</p>
</div>
<span className="px-2.5 py-1 bg-green-100 text-green-700 text-xs font-medium rounded-full">
OFL-1.1
</span>
</div>
<div className="grid grid-cols-2 gap-4 mt-3 text-sm">
<div>
<span className="text-slate-500">Lizenz:</span>
<span className="ml-2 text-slate-700">SIL Open Font License 1.1</span>
</div>
<div>
<span className="text-slate-500">Kommerzielle Nutzung:</span>
<span className="ml-2 text-green-600 font-medium">Ja, uneingeschraenkt</span>
</div>
<div>
<span className="text-slate-500">Attribution erforderlich:</span>
<span className="ml-2 text-slate-700">Nein</span>
</div>
<div>
<span className="text-slate-500">Repository:</span>
<a href="https://github.com/rsms/inter" target="_blank" rel="noopener noreferrer" className="ml-2 text-primary-600 hover:underline">
github.com/rsms/inter
</a>
</div>
</div>
</div>
{/* Heroicons License */}
<div className="border border-slate-200 rounded-xl p-4">
<div className="flex items-start justify-between mb-2">
<div>
<h4 className="font-semibold text-slate-900">Heroicons</h4>
<p className="text-sm text-slate-500">Designer: Tailwind Labs</p>
</div>
<span className="px-2.5 py-1 bg-blue-100 text-blue-700 text-xs font-medium rounded-full">
MIT
</span>
</div>
<div className="grid grid-cols-2 gap-4 mt-3 text-sm">
<div>
<span className="text-slate-500">Lizenz:</span>
<span className="ml-2 text-slate-700">MIT License</span>
</div>
<div>
<span className="text-slate-500">Kommerzielle Nutzung:</span>
<span className="ml-2 text-green-600 font-medium">Ja, uneingeschraenkt</span>
</div>
<div>
<span className="text-slate-500">Attribution erforderlich:</span>
<span className="ml-2 text-slate-700">Nein (empfohlen)</span>
</div>
<div>
<span className="text-slate-500">Repository:</span>
<a href="https://github.com/tailwindlabs/heroicons" target="_blank" rel="noopener noreferrer" className="ml-2 text-primary-600 hover:underline">
github.com/tailwindlabs/heroicons
</a>
</div>
</div>
</div>
{/* License Summary */}
<div className="bg-green-50 border border-green-200 rounded-xl p-4">
<div className="flex items-center gap-2 mb-2">
<svg className="w-5 h-5 text-green-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<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>
<h4 className="font-semibold text-green-800">Lizenz-Compliance</h4>
</div>
<p className="text-sm text-green-700">
Alle verwendeten Schriftarten und Icons sind Open Source und fuer kommerzielle Nutzung freigegeben.
Keine Attribution erforderlich. Vollstaendige Dokumentation in der SBOM.
</p>
</div>
</div>
</div>
{/* Font Weights */}
<div className="bg-white rounded-xl border border-slate-200 p-6 shadow-sm">
<h3 className="text-lg font-semibold text-slate-900 mb-4">Schriftschnitte</h3>
<div className="space-y-4">
{TYPOGRAPHY.weights.map((w) => (
<div key={w.weight} className="flex items-center gap-6 pb-4 border-b border-slate-100 last:border-0 last:pb-0">
<span
className="text-2xl w-64"
style={{ fontWeight: w.weight }}
>
The quick brown fox
</span>
<div className="flex-1">
<span className="font-medium text-slate-900">{w.name} ({w.weight})</span>
<span className="text-sm text-slate-500 ml-4">{w.usage}</span>
</div>
</div>
))}
</div>
</div>
{/* Font Sizes */}
<div className="bg-white rounded-xl border border-slate-200 p-6 shadow-sm">
<h3 className="text-lg font-semibold text-slate-900 mb-4">Schriftgroessen</h3>
<div className="space-y-3">
{TYPOGRAPHY.sizes.map((s) => (
<div key={s.name} className="flex items-baseline gap-4 border-b border-slate-100 pb-3 last:border-0">
<span className="w-16 text-sm font-mono text-slate-500 bg-slate-100 px-2 py-0.5 rounded">{s.name}</span>
<span className="w-36 text-sm text-slate-600">{s.size}</span>
<span className="text-sm text-slate-500">{s.usage}</span>
</div>
))}
</div>
</div>
{/* Headings Preview */}
<div className="bg-white rounded-xl border border-slate-200 p-6 shadow-sm">
<h3 className="text-lg font-semibold text-slate-900 mb-4">Ueberschriften-Hierarchie</h3>
<div className="space-y-4">
<div className="text-4xl font-bold text-slate-900">H1: Hauptueberschrift</div>
<div className="text-3xl font-bold text-slate-900">H2: Abschnittsueberschrift</div>
<div className="text-2xl font-semibold text-slate-900">H3: Unterabschnitt</div>
<div className="text-xl font-semibold text-slate-900">H4: Card-Titel</div>
<div className="text-lg font-medium text-slate-900">H5: Kleiner Titel</div>
<div className="text-base font-medium text-slate-900">H6: Label</div>
</div>
</div>
{/* Spacing */}
<div className="bg-white rounded-xl border border-slate-200 p-6 shadow-sm">
<h3 className="text-lg font-semibold text-slate-900 mb-4">Spacing Scale</h3>
<div className="space-y-3">
{SPACING.map((s) => (
<div key={s.name} className="flex items-center gap-4">
<span className="w-12 text-sm font-mono text-slate-500 bg-slate-100 px-2 py-0.5 rounded text-center">{s.name}</span>
<div
className="bg-primary-500 h-4 rounded"
style={{ width: `${parseInt(s.value) * 16}px` }}
/>
<span className="text-sm text-slate-600">{s.value}</span>
<span className="text-sm text-slate-400">{s.usage}</span>
</div>
))}
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,122 @@
'use client'
import { VOICE_TONE } from '../constants'
export function VoiceTab() {
return (
<div className="space-y-6">
{/* Brand Attributes */}
<div className="bg-white rounded-xl border border-slate-200 p-6 shadow-sm">
<h3 className="text-lg font-semibold text-slate-900 mb-4">Markenpersoenlichkeit</h3>
<div className="flex flex-wrap gap-3">
{VOICE_TONE.attributes.map((attr) => (
<span
key={attr}
className="px-4 py-2 bg-primary-100 text-primary-700 rounded-full font-medium"
>
{attr}
</span>
))}
</div>
</div>
{/* Do & Dont */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div className="bg-white rounded-xl border border-slate-200 p-6 shadow-sm">
<h3 className="text-lg font-semibold text-green-600 mb-4 flex items-center gap-2">
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
So schreiben wir
</h3>
<ul className="space-y-2">
{VOICE_TONE.doList.map((item) => (
<li key={item} className="flex items-center gap-2 text-slate-600">
<span className="w-1.5 h-1.5 bg-green-500 rounded-full flex-shrink-0"></span>
{item}
</li>
))}
</ul>
</div>
<div className="bg-white rounded-xl border border-slate-200 p-6 shadow-sm">
<h3 className="text-lg font-semibold text-red-600 mb-4 flex items-center gap-2">
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
Das vermeiden wir
</h3>
<ul className="space-y-2">
{VOICE_TONE.dontList.map((item) => (
<li key={item} className="flex items-center gap-2 text-slate-600">
<span className="w-1.5 h-1.5 bg-red-500 rounded-full flex-shrink-0"></span>
{item}
</li>
))}
</ul>
</div>
</div>
{/* Example Texts */}
<div className="bg-white rounded-xl border border-slate-200 p-6 shadow-sm">
<h3 className="text-lg font-semibold text-slate-900 mb-4">Beispieltexte</h3>
<div className="space-y-4">
<div className="p-4 bg-green-50 border border-green-200 rounded-xl">
<span className="text-xs text-green-600 font-medium mb-2 block">GUT</span>
<p className="text-slate-700">
&quot;Verwalten Sie Ihre Datenschutz-Dokumente einfach und sicher. Alle Aenderungen werden protokolliert.&quot;
</p>
</div>
<div className="p-4 bg-red-50 border border-red-200 rounded-xl">
<span className="text-xs text-red-600 font-medium mb-2 block">SCHLECHT</span>
<p className="text-slate-700">
&quot;Unsere revolutionaere KI-Loesung optimiert Ihre Compliance-Workflows durch state-of-the-art NLP.&quot;
</p>
</div>
</div>
</div>
{/* Target Audience */}
<div className="bg-white rounded-xl border border-slate-200 p-6 shadow-sm">
<h3 className="text-lg font-semibold text-slate-900 mb-4">Zielgruppe</h3>
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
{[
{
iconBg: 'bg-education-100', iconColor: 'text-education-600',
iconPath: 'M12 14l9-5-9-5-9 5 9 5z',
iconPath2: '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',
title: 'Lehrkraefte', desc: 'Zeitersparnis, einfache Bedienung',
},
{
iconBg: 'bg-compliance-100', iconColor: 'text-compliance-600',
iconPath: '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',
title: 'DSB', desc: 'DSGVO-Compliance, Dokumentation',
},
{
iconBg: 'bg-infrastructure-100', iconColor: 'text-infrastructure-600',
iconPath: 'M10 20l4-16m4 4l4 4-4 4M6 16l-4-4 4-4',
title: 'Entwickler', desc: 'APIs, Integration, DevOps',
},
{
iconBg: 'bg-communication-100', iconColor: 'text-communication-600',
iconPath: 'M19 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16m14 0h2m-2 0h-5m-9 0H3m2 0h5M9 7h1m-1 4h1m4-4h1m-1 4h1m-5 10v-5a1 1 0 011-1h2a1 1 0 011 1v5m-4 0h4',
title: 'Schulleitung', desc: 'Ueberblick, Kosten, Compliance',
},
].map((audience) => (
<div key={audience.title} className="p-4 bg-slate-50 rounded-xl text-center">
<div className={`w-12 h-12 ${audience.iconBg} rounded-xl flex items-center justify-center mx-auto mb-2`}>
<svg className={`w-6 h-6 ${audience.iconColor}`} fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d={audience.iconPath} />
{audience.iconPath2 && (
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d={audience.iconPath2} />
)}
</svg>
</div>
<h4 className="font-semibold text-slate-900">{audience.title}</h4>
<p className="text-sm text-slate-600 mt-1">{audience.desc}</p>
</div>
))}
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,253 @@
/**
* Brandbook Constants - Color palettes, typography, spacing, voice & tone
*/
export interface ColorShade {
name: string
value: string
text: 'dark' | 'light'
}
export interface ColorPalette {
name: string
description: string
shades: ColorShade[]
}
export interface CategoryColor {
id: string
name: string
description: string
main: string
shades: ColorShade[]
}
// Primary brand color (Sky Blue)
export const PRIMARY_COLORS: ColorPalette = {
name: 'Primary (Sky Blue)',
description: 'Haupt-Markenfarbe fuer Buttons, Links und Akzente',
shades: [
{ name: '50', value: '#f0f9ff', text: 'dark' },
{ name: '100', value: '#e0f2fe', text: 'dark' },
{ name: '200', value: '#bae6fd', text: 'dark' },
{ name: '300', value: '#7dd3fc', text: 'dark' },
{ name: '400', value: '#38bdf8', text: 'dark' },
{ name: '500', value: '#0ea5e9', text: 'light' },
{ name: '600', value: '#0284c7', text: 'light' },
{ name: '700', value: '#0369a1', text: 'light' },
{ name: '800', value: '#075985', text: 'light' },
{ name: '900', value: '#0c4a6e', text: 'light' },
],
}
// Accent brand color (Fuchsia) - for logo gradient and highlights
export const ACCENT_COLORS: ColorPalette = {
name: 'Accent (Fuchsia)',
description: 'Akzentfarbe fuer Logo-Gradient und besondere Highlights',
shades: [
{ name: '50', value: '#fdf4ff', text: 'dark' },
{ name: '100', value: '#fae8ff', text: 'dark' },
{ name: '200', value: '#f5d0fe', text: 'dark' },
{ name: '300', value: '#f0abfc', text: 'dark' },
{ name: '400', value: '#e879f9', text: 'dark' },
{ name: '500', value: '#d946ef', text: 'light' },
{ name: '600', value: '#c026d3', text: 'light' },
{ name: '700', value: '#a21caf', text: 'light' },
{ name: '800', value: '#86198f', text: 'light' },
{ name: '900', value: '#701a75', text: 'light' },
],
}
// Category colors for navigation and modules
export const CATEGORY_COLORS: CategoryColor[] = [
{
id: 'dsgvo',
name: 'DSGVO (Violet)',
description: 'Datenschutz & Betroffenenrechte',
main: '#7c3aed',
shades: [
{ name: '100', value: '#ede9fe', text: 'dark' },
{ name: '500', value: '#8b5cf6', text: 'light' },
{ name: '600', value: '#7c3aed', text: 'light' },
{ name: '700', value: '#6d28d9', text: 'light' },
],
},
{
id: 'compliance',
name: 'Compliance (Purple)',
description: 'Audit, Controls & Regulierung',
main: '#9333ea',
shades: [
{ name: '100', value: '#f3e8ff', text: 'dark' },
{ name: '500', value: '#a855f7', text: 'light' },
{ name: '600', value: '#9333ea', text: 'light' },
{ name: '700', value: '#7e22ce', text: 'light' },
],
},
{
id: 'ai',
name: 'KI & Automatisierung (Teal)',
description: 'LLM, OCR, RAG & Machine Learning',
main: '#14b8a6',
shades: [
{ name: '100', value: '#ccfbf1', text: 'dark' },
{ name: '500', value: '#14b8a6', text: 'light' },
{ name: '600', value: '#0d9488', text: 'light' },
{ name: '700', value: '#0f766e', text: 'light' },
],
},
{
id: 'infrastructure',
name: 'Infrastruktur (Orange)',
description: 'GPU, Security, CI/CD & Monitoring',
main: '#f97316',
shades: [
{ name: '100', value: '#ffedd5', text: 'dark' },
{ name: '500', value: '#f97316', text: 'light' },
{ name: '600', value: '#ea580c', text: 'light' },
{ name: '700', value: '#c2410c', text: 'light' },
],
},
{
id: 'education',
name: 'Bildung & Schule (Blue)',
description: 'Bildungsquellen & Lehrplaene',
main: '#3b82f6',
shades: [
{ name: '100', value: '#dbeafe', text: 'dark' },
{ name: '500', value: '#3b82f6', text: 'light' },
{ name: '600', value: '#2563eb', text: 'light' },
{ name: '700', value: '#1d4ed8', text: 'light' },
],
},
{
id: 'communication',
name: 'Kommunikation (Green)',
description: 'Matrix, E-Mail & Benachrichtigungen',
main: '#22c55e',
shades: [
{ name: '100', value: '#dcfce7', text: 'dark' },
{ name: '500', value: '#22c55e', text: 'light' },
{ name: '600', value: '#16a34a', text: 'light' },
{ name: '700', value: '#15803d', text: 'light' },
],
},
{
id: 'development',
name: 'Entwicklung (Slate)',
description: 'Workflow, Game, Docs & Brandbook',
main: '#64748b',
shades: [
{ name: '100', value: '#f1f5f9', text: 'dark' },
{ name: '500', value: '#64748b', text: 'light' },
{ name: '600', value: '#475569', text: 'light' },
{ name: '700', value: '#334155', text: 'light' },
],
},
]
// Semantic colors
export const SEMANTIC_COLORS: Record<string, { name: string; shades: ColorShade[] }> = {
success: {
name: 'Success (Emerald)',
shades: [
{ name: '100', value: '#d1fae5', text: 'dark' },
{ name: '500', value: '#10b981', text: 'light' },
{ name: '600', value: '#059669', text: 'light' },
],
},
warning: {
name: 'Warning (Amber)',
shades: [
{ name: '100', value: '#fef3c7', text: 'dark' },
{ name: '500', value: '#f59e0b', text: 'dark' },
{ name: '600', value: '#d97706', text: 'light' },
],
},
danger: {
name: 'Danger (Red)',
shades: [
{ name: '100', value: '#fee2e2', text: 'dark' },
{ name: '500', value: '#ef4444', text: 'light' },
{ name: '600', value: '#dc2626', text: 'light' },
],
},
neutral: {
name: 'Neutral (Slate)',
shades: [
{ name: '50', value: '#f8fafc', text: 'dark' },
{ name: '100', value: '#f1f5f9', text: 'dark' },
{ name: '200', value: '#e2e8f0', text: 'dark' },
{ name: '300', value: '#cbd5e1', text: 'dark' },
{ name: '400', value: '#94a3b8', text: 'dark' },
{ name: '500', value: '#64748b', text: 'light' },
{ name: '600', value: '#475569', text: 'light' },
{ name: '700', value: '#334155', text: 'light' },
{ name: '800', value: '#1e293b', text: 'light' },
{ name: '900', value: '#0f172a', text: 'light' },
],
},
}
export const TYPOGRAPHY = {
fontFamily: "'Inter', system-ui, -apple-system, sans-serif",
weights: [
{ weight: 400, name: 'Regular', usage: 'Fliesstext, Beschreibungen' },
{ weight: 500, name: 'Medium', usage: 'Labels, Buttons, Navigation' },
{ weight: 600, name: 'Semi-Bold', usage: 'Ueberschriften H3-H6, Card-Titel' },
{ weight: 700, name: 'Bold', usage: 'Ueberschriften H1-H2, CTAs' },
],
sizes: [
{ name: 'xs', size: '0.75rem (12px)', usage: 'Footnotes, Badges' },
{ name: 'sm', size: '0.875rem (14px)', usage: 'Nebentext, Labels' },
{ name: 'base', size: '1rem (16px)', usage: 'Fliesstext, Body' },
{ name: 'lg', size: '1.125rem (18px)', usage: 'Lead Text' },
{ name: 'xl', size: '1.25rem (20px)', usage: 'H4, Card Titles' },
{ name: '2xl', size: '1.5rem (24px)', usage: 'H3' },
{ name: '3xl', size: '1.875rem (30px)', usage: 'H2' },
{ name: '4xl', size: '2.25rem (36px)', usage: 'H1, Hero' },
],
}
export const SPACING = [
{ name: '1', value: '0.25rem (4px)', usage: 'Minimaler Abstand' },
{ name: '2', value: '0.5rem (8px)', usage: 'Kompakter Abstand' },
{ name: '3', value: '0.75rem (12px)', usage: 'Standard klein' },
{ name: '4', value: '1rem (16px)', usage: 'Standard' },
{ name: '6', value: '1.5rem (24px)', usage: 'Card Padding' },
{ name: '8', value: '2rem (32px)', usage: 'Section Spacing' },
]
export const VOICE_TONE = {
attributes: [
'Professionell & Vertrauenswuerdig',
'Klar & Direkt',
'Hilfreich & Unterstuetzend',
'Modern & Innovativ',
'DSGVO-konform & Sicher',
],
doList: [
'Einfache, klare Sprache verwenden',
'Aktiv formulieren',
'Nutzenorientiert schreiben',
'Konkrete Beispiele geben',
'Technische Begriffe erklaeren',
],
dontList: [
'Fachjargon ohne Erklaerung',
'Passive Formulierungen',
'Uebertriebene Versprechen',
'Marketing-Floskeln',
'Unpersoenliche Ansprache',
],
}
export type BrandbookTab = 'colors' | 'typography' | 'components' | 'logo' | 'voice'
export const TABS: { id: BrandbookTab; label: string }[] = [
{ id: 'colors', label: 'Farben' },
{ id: 'typography', label: 'Typografie' },
{ id: 'components', label: 'Komponenten' },
{ id: 'logo', label: 'Logo' },
{ id: 'voice', label: 'Tonalitaet' },
]

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,60 @@
'use client'
import type { ScreenDefinition } from '../types'
interface CategoryFilterProps {
screens: ScreenDefinition[]
categories: string[]
colors: Record<string, { bg: string; border: string; text: string }>
labels: Record<string, string>
selectedCategory: string | null
selectedNode: string | null
onSelectCategory: (category: string | null) => void
onReset: () => void
}
export function CategoryFilter({
screens,
categories,
colors,
labels,
selectedCategory,
selectedNode,
onSelectCategory,
onReset,
}: CategoryFilterProps) {
return (
<div className="bg-white rounded-xl border border-slate-200 p-4 shadow-sm">
<div className="flex flex-wrap gap-2">
<button
onClick={onReset}
className={`px-4 py-2 rounded-lg text-sm font-medium transition-colors ${
selectedCategory === null && !selectedNode
? 'bg-slate-800 text-white'
: 'bg-slate-100 text-slate-700 hover:bg-slate-200'
}`}
>
Alle ({screens.length})
</button>
{categories.map((key) => {
const count = screens.filter(s => s.category === key).length
const catColors = colors[key] || { bg: '#f1f5f9', border: '#94a3b8', text: '#475569' }
return (
<button
key={key}
onClick={() => onSelectCategory(selectedCategory === key ? null : key)}
className="px-4 py-2 rounded-lg text-sm font-medium transition-all flex items-center gap-2"
style={{
background: selectedCategory === key ? catColors.border : catColors.bg,
color: selectedCategory === key ? 'white' : catColors.text,
}}
>
<span className="w-3 h-3 rounded-full" style={{ background: catColors.border }} />
{labels[key]} ({count})
</button>
)
})}
</div>
</div>
)
}

View File

@@ -0,0 +1,51 @@
'use client'
import type { ScreenDefinition } from '../types'
interface ConnectedScreensListProps {
selectedNode: string | null
connectedScreens: ScreenDefinition[]
colors: Record<string, { bg: string; border: string; text: string }>
baseUrl: string
}
export function ConnectedScreensList({
selectedNode,
connectedScreens,
colors,
baseUrl,
}: ConnectedScreensListProps) {
if (!selectedNode || connectedScreens.length <= 1) return null
return (
<div className="bg-white rounded-xl border border-slate-200 p-4 shadow-sm">
<div className="text-sm font-medium text-slate-700 mb-3">Verbundene Screens:</div>
<div className="flex flex-wrap gap-2">
{connectedScreens.map((screen) => {
const catColors = colors[screen.category] || { bg: '#f1f5f9', border: '#94a3b8', text: '#475569' }
const isCurrentNode = screen.id === selectedNode
return (
<button
key={screen.id}
onClick={() => {
if (screen.url) {
window.open(`${baseUrl}${screen.url}`, '_blank')
}
}}
className={`px-3 py-2 rounded-lg text-sm font-medium transition-all flex items-center gap-2 ${
isCurrentNode ? 'ring-2 ring-primary-500' : ''
}`}
style={{
background: isCurrentNode ? catColors.border : catColors.bg,
color: isCurrentNode ? 'white' : catColors.text,
}}
>
<span>{screen.icon}</span>
{screen.name}
</button>
)
})}
</div>
</div>
)
}

View File

@@ -0,0 +1,95 @@
'use client'
import ReactFlow, {
Node,
Edge,
Controls,
Background,
MiniMap,
BackgroundVariant,
Panel,
NodeChange,
EdgeChange,
} from 'reactflow'
import 'reactflow/dist/style.css'
import type { ScreenDefinition, FlowType } from '../types'
interface FlowDiagramProps {
flowType: FlowType
nodes: Node[]
edges: Edge[]
onNodesChange: (changes: NodeChange[]) => void
onEdgesChange: (changes: EdgeChange[]) => void
onNodeClick: (event: React.MouseEvent, node: Node) => void
onPaneClick: () => void
screens: ScreenDefinition[]
colors: Record<string, { bg: string; border: string; text: string }>
labels: Record<string, string>
categories: string[]
}
export function FlowDiagram({
flowType,
nodes,
edges,
onNodesChange,
onEdgesChange,
onNodeClick,
onPaneClick,
screens,
colors,
labels,
categories,
}: FlowDiagramProps) {
return (
<div className="bg-white rounded-xl border border-slate-200 shadow-sm overflow-hidden" style={{ height: '500px' }}>
<ReactFlow
nodes={nodes}
edges={edges}
onNodesChange={onNodesChange}
onEdgesChange={onEdgesChange}
onNodeClick={onNodeClick}
onPaneClick={onPaneClick}
fitView
fitViewOptions={{ padding: 0.2 }}
attributionPosition="bottom-left"
>
<Controls />
<MiniMap
nodeColor={(node) => {
const screen = screens.find(s => s.id === node.id)
const catColors = screen ? colors[screen.category] : null
return catColors?.border || '#94a3b8'
}}
maskColor="rgba(0, 0, 0, 0.1)"
/>
<Background variant={BackgroundVariant.Dots} gap={12} size={1} />
<Panel position="top-left" className="bg-white/95 p-3 rounded-lg shadow-lg text-xs">
<div className="font-medium text-slate-700 mb-2">
{flowType === 'studio' ? '🎓 Studio' : '⚙️ Admin v2'}
</div>
<div className="space-y-1">
{categories.slice(0, 4).map((key) => {
const catColors = colors[key] || { bg: '#f1f5f9', border: '#94a3b8' }
return (
<div key={key} className="flex items-center gap-2">
<span
className="w-3 h-3 rounded"
style={{ background: catColors.bg, border: `1px solid ${catColors.border}` }}
/>
<span className="text-slate-600">{labels[key]}</span>
</div>
)
})}
</div>
<div className="mt-2 pt-2 border-t text-slate-400">
Klick = Subtree<br/>
Doppelklick = Oeffnen
</div>
</Panel>
</ReactFlow>
</div>
)
}

View File

@@ -0,0 +1,59 @@
'use client'
import type { FlowType } from '../types'
import { STUDIO_SCREENS, ADMIN_SCREENS } from '../data'
interface FlowTypeSelectorProps {
flowType: FlowType
onFlowTypeChange: (type: FlowType) => void
}
export function FlowTypeSelector({ flowType, onFlowTypeChange }: FlowTypeSelectorProps) {
return (
<div className="grid grid-cols-2 gap-4">
<button
onClick={() => onFlowTypeChange('studio')}
className={`p-6 rounded-xl border-2 transition-all ${
flowType === 'studio'
? 'border-green-500 bg-green-50 shadow-lg'
: 'border-slate-200 bg-white hover:border-slate-300'
}`}
>
<div className="flex items-center gap-4">
<div className={`w-14 h-14 rounded-xl flex items-center justify-center text-2xl ${
flowType === 'studio' ? 'bg-green-500 text-white' : 'bg-slate-100'
}`}>
🎓
</div>
<div className="text-left">
<div className="font-bold text-lg">Studio (Port 8000)</div>
<div className="text-sm text-slate-500">Lehrer-Oberflaeche</div>
<div className="text-xs text-slate-400 mt-1">{STUDIO_SCREENS.length} Screens</div>
</div>
</div>
</button>
<button
onClick={() => onFlowTypeChange('admin')}
className={`p-6 rounded-xl border-2 transition-all ${
flowType === 'admin'
? 'border-primary-500 bg-primary-50 shadow-lg'
: 'border-slate-200 bg-white hover:border-slate-300'
}`}
>
<div className="flex items-center gap-4">
<div className={`w-14 h-14 rounded-xl flex items-center justify-center text-2xl ${
flowType === 'admin' ? 'bg-primary-500 text-white' : 'bg-slate-100'
}`}>
</div>
<div className="text-left">
<div className="font-bold text-lg">Admin v2 (Port 3002)</div>
<div className="text-sm text-slate-500">Admin Panel</div>
<div className="text-xs text-slate-400 mt-1">{ADMIN_SCREENS.length} Screens</div>
</div>
</div>
</button>
</div>
)
}

View File

@@ -0,0 +1,65 @@
'use client'
import type { ScreenDefinition } from '../types'
interface ScreenListProps {
screens: ScreenDefinition[]
colors: Record<string, { bg: string; border: string; text: string }>
labels: Record<string, string>
baseUrl: string
selectedCategory: string | null
onSelectScreen: (screen: ScreenDefinition) => void
}
export function ScreenList({
screens,
colors,
labels,
baseUrl,
selectedCategory,
onSelectScreen,
}: ScreenListProps) {
const filtered = selectedCategory
? screens.filter(s => s.category === selectedCategory)
: screens
return (
<div className="bg-white rounded-xl border border-slate-200 shadow-sm overflow-hidden">
<div className="px-4 py-3 bg-slate-50 border-b flex items-center justify-between">
<h3 className="font-medium text-slate-700">
Alle Screens ({screens.length})
</h3>
<span className="text-xs text-slate-400">{baseUrl}</span>
</div>
<div className="divide-y max-h-80 overflow-y-auto">
{filtered.map((screen) => {
const catColors = colors[screen.category] || { bg: '#f1f5f9', border: '#94a3b8', text: '#475569' }
return (
<button
key={screen.id}
onClick={() => onSelectScreen(screen)}
className="w-full flex items-center gap-4 p-3 hover:bg-slate-50 transition-colors text-left"
>
<span
className="w-9 h-9 rounded-lg flex items-center justify-center text-lg"
style={{ background: catColors.bg }}
>
{screen.icon}
</span>
<div className="flex-1 min-w-0">
<div className="font-medium text-slate-800 text-sm">{screen.name}</div>
<div className="text-xs text-slate-500 truncate">{screen.description}</div>
</div>
<span
className="px-2 py-1 rounded text-xs font-medium shrink-0"
style={{ background: catColors.bg, color: catColors.text }}
>
{labels[screen.category]}
</span>
</button>
)
})}
</div>
</div>
)
}

View File

@@ -0,0 +1,57 @@
'use client'
import type { ScreenDefinition } from '../types'
interface StatsBarProps {
totalScreens: number
totalConnections: number
connectedCount: number
selectedNode: string | null
previewScreen: ScreenDefinition | null
onReset: () => void
}
export function StatsBar({
totalScreens,
totalConnections,
connectedCount,
selectedNode,
previewScreen,
onReset,
}: StatsBarProps) {
return (
<div className="grid grid-cols-4 gap-4">
<div className="bg-white rounded-xl border border-slate-200 p-4 shadow-sm">
<div className="text-3xl font-bold text-slate-800">{totalScreens}</div>
<div className="text-sm text-slate-500">Screens</div>
</div>
<div className="bg-white rounded-xl border border-slate-200 p-4 shadow-sm">
<div className="text-3xl font-bold text-primary-600">{totalConnections}</div>
<div className="text-sm text-slate-500">Verbindungen</div>
</div>
<div className="bg-white rounded-xl border border-slate-200 p-4 shadow-sm col-span-2">
{selectedNode ? (
<div className="flex items-center gap-3">
<div className="text-3xl">{previewScreen?.icon}</div>
<div>
<div className="font-bold text-slate-800">{previewScreen?.name}</div>
<div className="text-sm text-slate-500">
{connectedCount} verbundene Screen{connectedCount !== 1 ? 's' : ''}
</div>
</div>
<button
onClick={onReset}
className="ml-auto px-3 py-1 text-sm bg-slate-100 hover:bg-slate-200 rounded-lg"
>
Zuruecksetzen
</button>
</div>
) : (
<div className="text-slate-500 text-sm">
Klicke auf einen Screen um den Subtree zu sehen
</div>
)}
</div>
</div>
)
}

View File

@@ -0,0 +1,6 @@
export { FlowTypeSelector } from './FlowTypeSelector'
export { StatsBar } from './StatsBar'
export { CategoryFilter } from './CategoryFilter'
export { ConnectedScreensList } from './ConnectedScreensList'
export { FlowDiagram } from './FlowDiagram'
export { ScreenList } from './ScreenList'

View File

@@ -0,0 +1,264 @@
/**
* Screen Flow - Screen definitions, connections, colors, labels
*/
import type { ScreenDefinition, ConnectionDef } from './types'
// ============================================
// STUDIO SCREENS (Port 8000)
// ============================================
export const STUDIO_SCREENS: ScreenDefinition[] = [
{ id: 'lehrer-dashboard', name: 'Mein Dashboard', description: 'Hauptuebersicht mit Widgets', category: 'navigation', icon: '🏠', url: '/app#lehrer-dashboard' },
{ id: 'lehrer-onboarding', name: 'Erste Schritte', description: 'Onboarding & Schnellstart', category: 'navigation', icon: '🚀', url: '/app#lehrer-onboarding' },
{ id: 'hilfe', name: 'Dokumentation', description: 'Hilfe & Anleitungen', category: 'navigation', icon: '📚', url: '/app#hilfe' },
{ id: 'worksheets', name: 'Arbeitsblaetter Studio', description: 'Lernmaterialien erstellen', category: 'content', icon: '📝', url: '/app#worksheets' },
{ id: 'content-creator', name: 'Content Creator', description: 'Inhalte erstellen', category: 'content', icon: '✨', url: '/app#content-creator' },
{ id: 'content-feed', name: 'Content Feed', description: 'Inhalte durchsuchen', category: 'content', icon: '📰', url: '/app#content-feed' },
{ id: 'unit-creator', name: 'Unit Creator', description: 'Lerneinheiten erstellen', category: 'content', icon: '📦', url: '/app#unit-creator' },
{ id: 'letters', name: 'Briefe & Vorlagen', description: 'Brief-Generator', category: 'content', icon: '✉️', url: '/app#letters' },
{ id: 'correction', name: 'Korrektur', description: 'Arbeiten korrigieren', category: 'content', icon: '✏️', url: '/app#correction' },
{ id: 'klausur-korrektur', name: 'Abiturklausuren', description: 'KI-gestuetzte Klausurkorrektur', category: 'content', icon: '📋', url: '/app#klausur-korrektur' },
{ id: 'jitsi', name: 'Videokonferenz', description: 'Jitsi Meet Integration', category: 'communication', icon: '🎥', url: '/app#jitsi' },
{ id: 'messenger', name: 'Messenger', description: 'Matrix E2EE Chat', category: 'communication', icon: '💬', url: '/app#messenger' },
{ id: 'mail', name: 'Unified Inbox', description: 'E-Mail Verwaltung', category: 'communication', icon: '📧', url: '/app#mail' },
{ id: 'school-classes', name: 'Klassen', description: 'Klassenverwaltung', category: 'school', icon: '👥', url: '/app#school-classes' },
{ id: 'school-exams', name: 'Pruefungen', description: 'Pruefungsverwaltung', category: 'school', icon: '📝', url: '/app#school-exams' },
{ id: 'school-grades', name: 'Noten', description: 'Notenverwaltung', category: 'school', icon: '📊', url: '/app#school-grades' },
{ id: 'school-gradebook', name: 'Notenbuch', description: 'Digitales Notenbuch', category: 'school', icon: '📖', url: '/app#school-gradebook' },
{ id: 'school-certificates', name: 'Zeugnisse', description: 'Zeugniserstellung', category: 'school', icon: '🎓', url: '/app#school-certificates' },
{ id: 'companion', name: 'Begleiter & Stunde', description: 'KI-Unterrichtsassistent', category: 'ai', icon: '🤖', url: '/app#companion' },
{ id: 'alerts', name: 'Alerts', description: 'News & Benachrichtigungen', category: 'ai', icon: '🔔', url: '/app#alerts' },
{ id: 'admin', name: 'Einstellungen', description: 'Systemeinstellungen', category: 'admin', icon: '⚙️', url: '/app#admin' },
{ id: 'rbac-admin', name: 'Rollen & Rechte', description: 'Berechtigungsverwaltung', category: 'admin', icon: '🔐', url: '/app#rbac-admin' },
{ id: 'abitur-docs-admin', name: 'Abitur Dokumente', description: 'Erwartungshorizonte', category: 'admin', icon: '📄', url: '/app#abitur-docs-admin' },
{ id: 'system-info', name: 'System Info', description: 'Systeminformationen', category: 'admin', icon: '💻', url: '/app#system-info' },
{ id: 'workflow', name: 'Workflow', description: 'Automatisierungen', category: 'admin', icon: '⚡', url: '/app#workflow' },
]
export const STUDIO_CONNECTIONS: ConnectionDef[] = [
{ source: 'lehrer-onboarding', target: 'worksheets', label: 'Arbeitsblaetter' },
{ source: 'lehrer-onboarding', target: 'klausur-korrektur', label: 'Abiturklausuren' },
{ source: 'lehrer-onboarding', target: 'correction', label: 'Korrektur' },
{ source: 'lehrer-onboarding', target: 'letters', label: 'Briefe' },
{ source: 'lehrer-onboarding', target: 'school-classes', label: 'Klassen' },
{ source: 'lehrer-onboarding', target: 'jitsi', label: 'Meet' },
{ source: 'lehrer-onboarding', target: 'hilfe', label: 'Doku' },
{ source: 'lehrer-onboarding', target: 'admin', label: 'Settings' },
{ source: 'lehrer-dashboard', target: 'worksheets' },
{ source: 'lehrer-dashboard', target: 'correction' },
{ source: 'lehrer-dashboard', target: 'jitsi' },
{ source: 'lehrer-dashboard', target: 'letters' },
{ source: 'lehrer-dashboard', target: 'messenger' },
{ source: 'lehrer-dashboard', target: 'klausur-korrektur' },
{ source: 'lehrer-dashboard', target: 'companion' },
{ source: 'lehrer-dashboard', target: 'alerts' },
{ source: 'lehrer-dashboard', target: 'mail' },
{ source: 'lehrer-dashboard', target: 'school-classes' },
{ source: 'lehrer-dashboard', target: 'lehrer-onboarding', label: 'Sidebar' },
{ source: 'school-classes', target: 'school-exams' },
{ source: 'school-classes', target: 'school-grades' },
{ source: 'school-grades', target: 'school-gradebook' },
{ source: 'school-gradebook', target: 'school-certificates' },
{ source: 'worksheets', target: 'content-creator' },
{ source: 'worksheets', target: 'unit-creator' },
{ source: 'content-creator', target: 'content-feed' },
{ source: 'klausur-korrektur', target: 'abitur-docs-admin' },
{ source: 'admin', target: 'rbac-admin' },
{ source: 'admin', target: 'system-info' },
{ source: 'admin', target: 'workflow' },
]
// ============================================
// ADMIN v2 SCREENS (Port 3002)
// Based on navigation.ts - Last updated: 2026-02-03
// ============================================
export const ADMIN_SCREENS: ScreenDefinition[] = [
// === META / OVERVIEW ===
{ id: 'admin-dashboard', name: 'Dashboard', description: 'Uebersicht & Statistiken', category: 'overview', icon: '🏠', url: '/dashboard' },
{ id: 'admin-onboarding', name: 'Onboarding', description: 'Lern-Wizards fuer alle Module', category: 'overview', icon: '📖', url: '/onboarding' },
{ id: 'admin-architecture', name: 'Architektur', description: 'Backend-Module & Datenfluss', category: 'overview', icon: '🏗️', url: '/architecture' },
{ id: 'admin-backlog', name: 'Production Backlog', description: 'Go-Live Checkliste', category: 'overview', icon: '📝', url: '/backlog' },
{ id: 'admin-rbac', name: 'RBAC', description: 'Rollen & Berechtigungen', category: 'overview', icon: '👥', url: '/rbac' },
// === COMPLIANCE SDK (Violet #8b5cf6) ===
{ id: 'admin-consent', name: 'Consent Verwaltung', description: 'Rechtliche Dokumente & Versionen', category: 'sdk', icon: '📄', url: '/sdk/consent-management' },
{ id: 'admin-dsr', name: 'Datenschutzanfragen', description: 'DSGVO Art. 15-21', category: 'sdk', icon: '🔒', url: '/sdk/dsr' },
{ id: 'admin-einwilligungen', name: 'Einwilligungen', description: 'Nutzer-Consent Uebersicht', category: 'sdk', icon: '✅', url: '/sdk/einwilligungen' },
{ id: 'admin-vvt', name: 'VVT', description: 'Verarbeitungsverzeichnis Art. 30', category: 'sdk', icon: '📋', url: '/sdk/vvt' },
{ id: 'admin-dsfa', name: 'DSFA', description: 'Datenschutz-Folgenabschaetzung', category: 'sdk', icon: '⚖️', url: '/sdk/dsfa' },
{ id: 'admin-tom', name: 'TOMs', description: 'Technische & Org. Massnahmen', category: 'sdk', icon: '🛡️', url: '/sdk/tom' },
{ id: 'admin-loeschfristen', name: 'Loeschfristen', description: 'Aufbewahrung & Deadlines', category: 'sdk', icon: '🗑️', url: '/sdk/loeschfristen' },
{ id: 'admin-advisory-board', name: 'Advisory Board', description: 'KI-Use-Case Pruefung', category: 'sdk', icon: '🧑‍⚖️', url: '/sdk/advisory-board' },
{ id: 'admin-escalations', name: 'Eskalations-Queue', description: 'DSB Review & Freigabe', category: 'sdk', icon: '🚨', url: '/sdk/escalations' },
{ id: 'admin-compliance-hub', name: 'Compliance Hub', description: 'Zentrales GRC Dashboard', category: 'sdk', icon: '✅', url: '/sdk/compliance-hub' },
{ id: 'admin-audit-checklist', name: 'Audit Checkliste', description: '476 Anforderungen pruefen', category: 'sdk', icon: '📋', url: '/sdk/audit-checklist' },
{ id: 'admin-requirements', name: 'Requirements', description: '558+ aus 19 Verordnungen', category: 'sdk', icon: '📜', url: '/sdk/requirements' },
{ id: 'admin-controls', name: 'Controls', description: '474 Control-Mappings', category: 'sdk', icon: '🎛️', url: '/sdk/controls' },
{ id: 'admin-evidence', name: 'Evidence', description: 'Nachweise & Dokumentation', category: 'sdk', icon: '📎', url: '/sdk/evidence' },
{ id: 'admin-risks', name: 'Risiken', description: 'Risk Matrix & Register', category: 'sdk', icon: '⚠️', url: '/sdk/risks' },
{ id: 'admin-audit-report', name: 'Audit Report', description: 'PDF Audit-Berichte', category: 'sdk', icon: '📊', url: '/sdk/audit-report' },
{ id: 'admin-modules', name: 'Service Registry', description: '30+ Service-Module', category: 'sdk', icon: '🔧', url: '/sdk/modules' },
{ id: 'admin-dsms', name: 'DSMS', description: 'Datenschutz-Management-System', category: 'sdk', icon: '🏛️', url: '/sdk/dsms' },
{ id: 'admin-compliance-workflow', name: 'Workflow', description: 'Freigabe-Workflows', category: 'sdk', icon: '🔄', url: '/sdk/workflow' },
{ id: 'admin-source-policy', name: 'Quellen-Policy', description: 'Datenquellen & Compliance', category: 'sdk', icon: '📚', url: '/sdk/source-policy' },
{ id: 'admin-ai-act', name: 'EU-AI-Act', description: 'KI-Risikoklassifizierung', category: 'sdk', icon: '🤖', url: '/sdk/ai-act' },
{ id: 'admin-obligations', name: 'Pflichten', description: 'NIS2, DSGVO, AI Act', category: 'sdk', icon: '⚡', url: '/sdk/obligations' },
// === KI & AUTOMATISIERUNG (Teal #14b8a6) ===
{ id: 'admin-rag', name: 'Daten & RAG', description: 'Training Data & RAG', category: 'ai', icon: '🗄️', url: '/ai/rag' },
{ id: 'admin-ocr-labeling', name: 'OCR-Labeling', description: 'Handschrift-Training', category: 'ai', icon: '✍️', url: '/ai/ocr-labeling' },
{ id: 'admin-magic-help', name: 'Magic Help', description: 'TrOCR Handschrift-OCR', category: 'ai', icon: '🪄', url: '/ai/magic-help' },
{ id: 'admin-klausur-korrektur', name: 'Klausur-Korrektur', description: 'Abitur-Korrektur mit KI', category: 'ai', icon: '📝', url: '/ai/klausur-korrektur' },
{ id: 'admin-quality', name: 'Qualitaet & Audit', description: 'Compliance-Audit & Traceability', category: 'ai', icon: '✨', url: '/ai/quality' },
{ id: 'admin-test-quality', name: 'Test Quality (BQAS)', description: 'Golden Suite & Synthetic Tests', category: 'ai', icon: '🧪', url: '/ai/test-quality' },
{ id: 'admin-agents', name: 'Agent Management', description: 'Multi-Agent & SOUL-Editor', category: 'ai', icon: '🧠', url: '/ai/agents' },
// === INFRASTRUKTUR (Orange #f97316) ===
{ id: 'admin-gpu', name: 'GPU Infrastruktur', description: 'vast.ai GPU Management', category: 'infrastructure', icon: '🖥️', url: '/infrastructure/gpu' },
{ id: 'admin-middleware', name: 'Middleware', description: 'Stack & API Gateway', category: 'infrastructure', icon: '🔧', url: '/infrastructure/middleware' },
{ id: 'admin-security', name: 'Security', description: 'DevSecOps & Scans', category: 'infrastructure', icon: '🔐', url: '/infrastructure/security' },
{ id: 'admin-sbom', name: 'SBOM', description: 'Software Bill of Materials', category: 'infrastructure', icon: '📦', url: '/infrastructure/sbom' },
{ id: 'admin-cicd', name: 'CI/CD', description: 'Pipelines & Deployments', category: 'infrastructure', icon: '🔄', url: '/infrastructure/ci-cd' },
{ id: 'admin-tests', name: 'Test Dashboard', description: '195+ Tests & Coverage', category: 'infrastructure', icon: '🧪', url: '/infrastructure/tests' },
// === BILDUNG (Blue #3b82f6) ===
{ id: 'admin-edu-search', name: 'Education Search', description: 'Bildungsquellen & Crawler', category: 'education', icon: '🔍', url: '/education/edu-search' },
{ id: 'admin-zeugnisse', name: 'Zeugnisse-Crawler', description: 'Zeugnis-Daten', category: 'education', icon: '📜', url: '/education/zeugnisse-crawler' },
{ id: 'admin-rag-pipeline', name: 'RAG Pipeline', description: 'Bildungsdokumente indexieren', category: 'ai', icon: '🔗', url: '/ai/rag-pipeline' },
{ id: 'admin-foerderantrag', name: 'Foerderantrag-Wizard', description: 'DigitalPakt & Landesfoerderung', category: 'education', icon: '💰', url: '/education/foerderantrag' },
// === KOMMUNIKATION (Green #22c55e) ===
{ id: 'admin-video', name: 'Video & Chat', description: 'Matrix & Jitsi Monitoring', category: 'communication', icon: '🎥', url: '/communication/video-chat' },
{ id: 'admin-matrix', name: 'Voice Service', description: 'Voice-First Interface', category: 'communication', icon: '🎙️', url: '/communication/matrix' },
{ id: 'admin-mail', name: 'Unified Inbox', description: 'E-Mail & KI-Analyse', category: 'communication', icon: '📧', url: '/communication/mail' },
{ id: 'admin-alerts', name: 'Alerts Monitoring', description: 'Google Alerts & Feeds', category: 'communication', icon: '🔔', url: '/communication/alerts' },
// === ENTWICKLUNG (Slate #64748b) ===
{ id: 'admin-workflow', name: 'Dev Workflow', description: 'Git, CI/CD & Team-Regeln', category: 'development', icon: '⚡', url: '/development/workflow' },
{ id: 'admin-game', name: 'Breakpilot Drive', description: 'Lernspiel Management', category: 'development', icon: '🎮', url: '/development/game' },
{ id: 'admin-unity', name: 'Unity Bridge', description: 'Unity Editor Steuerung', category: 'development', icon: '🎯', url: '/development/unity-bridge' },
{ id: 'admin-companion', name: 'Companion Dev', description: 'Lesson-Modus Entwicklung', category: 'development', icon: '📚', url: '/development/companion' },
{ id: 'admin-docs', name: 'Developer Docs', description: 'API & Architektur', category: 'development', icon: '📖', url: '/development/docs' },
{ id: 'admin-brandbook', name: 'Brandbook', description: 'Corporate Design', category: 'development', icon: '🎨', url: '/development/brandbook' },
{ id: 'admin-screen-flow', name: 'Screen Flow', description: 'UI Screen-Verbindungen', category: 'development', icon: '🔀', url: '/development/screen-flow' },
{ id: 'admin-content', name: 'Uebersetzungen', description: 'Website Content & Sprachen', category: 'development', icon: '🌐', url: '/development/content' },
]
export const ADMIN_CONNECTIONS: ConnectionDef[] = [
// === OVERVIEW/META FLOWS ===
{ source: 'admin-dashboard', target: 'admin-onboarding', label: 'Erste Schritte' },
{ source: 'admin-dashboard', target: 'admin-architecture', label: 'System' },
{ source: 'admin-dashboard', target: 'admin-backlog', label: 'Go-Live' },
{ source: 'admin-dashboard', target: 'admin-compliance-hub', label: 'Compliance' },
{ source: 'admin-onboarding', target: 'admin-consent' },
{ source: 'admin-rbac', target: 'admin-consent' },
// === DSGVO FLOW ===
{ source: 'admin-consent', target: 'admin-einwilligungen', label: 'Nutzer' },
{ source: 'admin-consent', target: 'admin-dsr' },
{ source: 'admin-dsr', target: 'admin-loeschfristen' },
{ source: 'admin-vvt', target: 'admin-tom' },
{ source: 'admin-vvt', target: 'admin-dsfa' },
{ source: 'admin-dsfa', target: 'admin-tom' },
{ source: 'admin-advisory-board', target: 'admin-escalations', label: 'Eskalation' },
{ source: 'admin-advisory-board', target: 'admin-dsfa', label: 'Risiko' },
// === COMPLIANCE FLOW ===
{ source: 'admin-compliance-hub', target: 'admin-audit-checklist', label: 'Audit' },
{ source: 'admin-compliance-hub', target: 'admin-requirements', label: 'Anforderungen' },
{ source: 'admin-compliance-hub', target: 'admin-risks', label: 'Risiken' },
{ source: 'admin-compliance-hub', target: 'admin-ai-act', label: 'AI Act' },
{ source: 'admin-requirements', target: 'admin-controls' },
{ source: 'admin-controls', target: 'admin-evidence' },
{ source: 'admin-audit-checklist', target: 'admin-audit-report', label: 'Report' },
{ source: 'admin-risks', target: 'admin-controls' },
{ source: 'admin-modules', target: 'admin-controls' },
{ source: 'admin-source-policy', target: 'admin-rag' },
{ source: 'admin-obligations', target: 'admin-requirements' },
{ source: 'admin-dsms', target: 'admin-compliance-workflow' },
// === KI & AUTOMATISIERUNG FLOW ===
{ source: 'admin-rag', target: 'admin-quality' },
{ source: 'admin-rag', target: 'admin-agents' },
{ source: 'admin-ocr-labeling', target: 'admin-magic-help', label: 'Training' },
{ source: 'admin-magic-help', target: 'admin-klausur-korrektur', label: 'Korrektur' },
{ source: 'admin-quality', target: 'admin-test-quality' },
{ source: 'admin-agents', target: 'admin-test-quality', label: 'BQAS' },
{ source: 'admin-klausur-korrektur', target: 'admin-quality', label: 'Audit' },
// === INFRASTRUKTUR FLOW ===
{ source: 'admin-security', target: 'admin-sbom', label: 'Dependencies' },
{ source: 'admin-sbom', target: 'admin-tests' },
{ source: 'admin-tests', target: 'admin-cicd', label: 'Pipeline' },
{ source: 'admin-cicd', target: 'admin-middleware' },
{ source: 'admin-middleware', target: 'admin-gpu', label: 'GPU' },
{ source: 'admin-security', target: 'admin-compliance-hub', label: 'Compliance' },
// === BILDUNG FLOW ===
{ source: 'admin-edu-search', target: 'admin-rag', label: 'Quellen' },
{ source: 'admin-edu-search', target: 'admin-zeugnisse' },
{ source: 'admin-training', target: 'admin-onboarding' },
{ source: 'admin-foerderantrag', target: 'admin-docs', label: 'Docs' },
// === KOMMUNIKATION FLOW ===
{ source: 'admin-video', target: 'admin-matrix', label: 'Voice' },
{ source: 'admin-mail', target: 'admin-alerts' },
{ source: 'admin-alerts', target: 'admin-mail', label: 'Inbox' },
// === ENTWICKLUNG FLOW ===
{ source: 'admin-workflow', target: 'admin-cicd', label: 'Pipeline' },
{ source: 'admin-workflow', target: 'admin-docs' },
{ source: 'admin-game', target: 'admin-unity', label: 'Editor' },
{ source: 'admin-companion', target: 'admin-agents', label: 'Agents' },
{ source: 'admin-brandbook', target: 'admin-screen-flow' },
{ source: 'admin-docs', target: 'admin-architecture' },
{ source: 'admin-content', target: 'admin-brandbook' },
]
// ============================================
// CATEGORY COLORS
// ============================================
export const STUDIO_COLORS: Record<string, { bg: string; border: string; text: string }> = {
navigation: { bg: '#dbeafe', border: '#3b82f6', text: '#1e40af' },
content: { bg: '#dcfce7', border: '#22c55e', text: '#166534' },
communication: { bg: '#fef3c7', border: '#f59e0b', text: '#92400e' },
school: { bg: '#fce7f3', border: '#ec4899', text: '#9d174d' },
admin: { bg: '#f3e8ff', border: '#a855f7', text: '#6b21a8' },
ai: { bg: '#cffafe', border: '#06b6d4', text: '#0e7490' },
}
// Colors from navigation.ts
export const ADMIN_COLORS: Record<string, { bg: string; border: string; text: string }> = {
overview: { bg: '#e0f2fe', border: '#0ea5e9', text: '#0369a1' },
dsgvo: { bg: '#ede9fe', border: '#7c3aed', text: '#5b21b6' },
compliance: { bg: '#f3e8ff', border: '#9333ea', text: '#6b21a8' },
ai: { bg: '#ccfbf1', border: '#14b8a6', text: '#0f766e' },
infrastructure: { bg: '#ffedd5', border: '#f97316', text: '#c2410c' },
education: { bg: '#dbeafe', border: '#3b82f6', text: '#1e40af' },
communication: { bg: '#dcfce7', border: '#22c55e', text: '#166534' },
development: { bg: '#f1f5f9', border: '#64748b', text: '#334155' },
}
export const STUDIO_LABELS: Record<string, string> = {
navigation: 'Navigation',
content: 'Content & Tools',
communication: 'Kommunikation',
school: 'Schulverwaltung',
admin: 'Administration',
ai: 'KI & Assistent',
}
// Labels from navigation.ts
export const ADMIN_LABELS: Record<string, string> = {
overview: 'Uebersicht & Meta',
dsgvo: 'DSGVO',
compliance: 'Compliance & GRC',
ai: 'KI & Automatisierung',
infrastructure: 'Infrastruktur & DevOps',
education: 'Bildung & Schule',
communication: 'Kommunikation & Alerts',
development: 'Entwicklung & Produkte',
}

View File

@@ -0,0 +1,83 @@
/**
* Screen Flow - Layout and graph traversal helpers
*/
import type { ScreenDefinition, ConnectionDef, FlowType } from './types'
/**
* BFS traversal to find all connected nodes from a start node.
*/
export function findConnectedNodes(
startNodeId: string,
connections: ConnectionDef[],
direction: 'children' | 'parents' | 'both' = 'children'
): Set<string> {
const connected = new Set<string>()
connected.add(startNodeId)
const queue = [startNodeId]
while (queue.length > 0) {
const current = queue.shift()!
connections.forEach(conn => {
if ((direction === 'children' || direction === 'both') && conn.source === current) {
if (!connected.has(conn.target)) {
connected.add(conn.target)
queue.push(conn.target)
}
}
if ((direction === 'parents' || direction === 'both') && conn.target === current) {
if (!connected.has(conn.source)) {
connected.add(conn.source)
queue.push(conn.source)
}
}
})
}
return connected
}
const STUDIO_POSITIONS: Record<string, { x: number; y: number }> = {
navigation: { x: 400, y: 50 },
content: { x: 50, y: 250 },
communication: { x: 750, y: 250 },
school: { x: 50, y: 500 },
admin: { x: 750, y: 500 },
ai: { x: 400, y: 380 },
}
const ADMIN_POSITIONS: Record<string, { x: number; y: number }> = {
overview: { x: 400, y: 30 },
dsgvo: { x: 50, y: 150 },
compliance: { x: 700, y: 150 },
ai: { x: 50, y: 350 },
communication: { x: 400, y: 350 },
infrastructure: { x: 700, y: 350 },
education: { x: 50, y: 550 },
development: { x: 400, y: 550 },
}
/**
* Compute the x/y position for a node based on its category and index.
*/
export function getNodePosition(
id: string,
category: string,
screens: ScreenDefinition[],
flowType: FlowType
) {
const positions = flowType === 'studio' ? STUDIO_POSITIONS : ADMIN_POSITIONS
const base = positions[category] || { x: 400, y: 300 }
const categoryScreens = screens.filter(s => s.category === category)
const categoryIndex = categoryScreens.findIndex(s => s.id === id)
const cols = Math.ceil(Math.sqrt(categoryScreens.length + 1))
const row = Math.floor(categoryIndex / cols)
const col = categoryIndex % cols
return {
x: base.x + col * 160,
y: base.y + row * 90,
}
}

View File

@@ -8,787 +8,122 @@
* - Admin v2 (Port 3002): Admin Panel
*/
import { useCallback, useState, useMemo, useEffect } from 'react'
import ReactFlow, {
Node,
Edge,
Controls,
Background,
MiniMap,
useNodesState,
useEdgesState,
BackgroundVariant,
MarkerType,
Panel,
} from 'reactflow'
import 'reactflow/dist/style.css'
// ============================================
// TYPES
// ============================================
interface ScreenDefinition {
id: string
name: string
description: string
category: string
icon: string
url?: string
}
interface ConnectionDef {
source: string
target: string
label?: string
}
type FlowType = 'studio' | 'admin'
// ============================================
// STUDIO SCREENS (Port 8000)
// ============================================
const STUDIO_SCREENS: ScreenDefinition[] = [
{ id: 'lehrer-dashboard', name: 'Mein Dashboard', description: 'Hauptuebersicht mit Widgets', category: 'navigation', icon: '🏠', url: '/app#lehrer-dashboard' },
{ id: 'lehrer-onboarding', name: 'Erste Schritte', description: 'Onboarding & Schnellstart', category: 'navigation', icon: '🚀', url: '/app#lehrer-onboarding' },
{ id: 'hilfe', name: 'Dokumentation', description: 'Hilfe & Anleitungen', category: 'navigation', icon: '📚', url: '/app#hilfe' },
{ id: 'worksheets', name: 'Arbeitsblaetter Studio', description: 'Lernmaterialien erstellen', category: 'content', icon: '📝', url: '/app#worksheets' },
{ id: 'content-creator', name: 'Content Creator', description: 'Inhalte erstellen', category: 'content', icon: '✨', url: '/app#content-creator' },
{ id: 'content-feed', name: 'Content Feed', description: 'Inhalte durchsuchen', category: 'content', icon: '📰', url: '/app#content-feed' },
{ id: 'unit-creator', name: 'Unit Creator', description: 'Lerneinheiten erstellen', category: 'content', icon: '📦', url: '/app#unit-creator' },
{ id: 'letters', name: 'Briefe & Vorlagen', description: 'Brief-Generator', category: 'content', icon: '✉️', url: '/app#letters' },
{ id: 'correction', name: 'Korrektur', description: 'Arbeiten korrigieren', category: 'content', icon: '✏️', url: '/app#correction' },
{ id: 'klausur-korrektur', name: 'Abiturklausuren', description: 'KI-gestuetzte Klausurkorrektur', category: 'content', icon: '📋', url: '/app#klausur-korrektur' },
{ id: 'jitsi', name: 'Videokonferenz', description: 'Jitsi Meet Integration', category: 'communication', icon: '🎥', url: '/app#jitsi' },
{ id: 'messenger', name: 'Messenger', description: 'Matrix E2EE Chat', category: 'communication', icon: '💬', url: '/app#messenger' },
{ id: 'mail', name: 'Unified Inbox', description: 'E-Mail Verwaltung', category: 'communication', icon: '📧', url: '/app#mail' },
{ id: 'school-classes', name: 'Klassen', description: 'Klassenverwaltung', category: 'school', icon: '👥', url: '/app#school-classes' },
{ id: 'school-exams', name: 'Pruefungen', description: 'Pruefungsverwaltung', category: 'school', icon: '📝', url: '/app#school-exams' },
{ id: 'school-grades', name: 'Noten', description: 'Notenverwaltung', category: 'school', icon: '📊', url: '/app#school-grades' },
{ id: 'school-gradebook', name: 'Notenbuch', description: 'Digitales Notenbuch', category: 'school', icon: '📖', url: '/app#school-gradebook' },
{ id: 'school-certificates', name: 'Zeugnisse', description: 'Zeugniserstellung', category: 'school', icon: '🎓', url: '/app#school-certificates' },
{ id: 'companion', name: 'Begleiter & Stunde', description: 'KI-Unterrichtsassistent', category: 'ai', icon: '🤖', url: '/app#companion' },
{ id: 'alerts', name: 'Alerts', description: 'News & Benachrichtigungen', category: 'ai', icon: '🔔', url: '/app#alerts' },
{ id: 'admin', name: 'Einstellungen', description: 'Systemeinstellungen', category: 'admin', icon: '⚙️', url: '/app#admin' },
{ id: 'rbac-admin', name: 'Rollen & Rechte', description: 'Berechtigungsverwaltung', category: 'admin', icon: '🔐', url: '/app#rbac-admin' },
{ id: 'abitur-docs-admin', name: 'Abitur Dokumente', description: 'Erwartungshorizonte', category: 'admin', icon: '📄', url: '/app#abitur-docs-admin' },
{ id: 'system-info', name: 'System Info', description: 'Systeminformationen', category: 'admin', icon: '💻', url: '/app#system-info' },
{ id: 'workflow', name: 'Workflow', description: 'Automatisierungen', category: 'admin', icon: '⚡', url: '/app#workflow' },
]
const STUDIO_CONNECTIONS: ConnectionDef[] = [
{ source: 'lehrer-onboarding', target: 'worksheets', label: 'Arbeitsblaetter' },
{ source: 'lehrer-onboarding', target: 'klausur-korrektur', label: 'Abiturklausuren' },
{ source: 'lehrer-onboarding', target: 'correction', label: 'Korrektur' },
{ source: 'lehrer-onboarding', target: 'letters', label: 'Briefe' },
{ source: 'lehrer-onboarding', target: 'school-classes', label: 'Klassen' },
{ source: 'lehrer-onboarding', target: 'jitsi', label: 'Meet' },
{ source: 'lehrer-onboarding', target: 'hilfe', label: 'Doku' },
{ source: 'lehrer-onboarding', target: 'admin', label: 'Settings' },
{ source: 'lehrer-dashboard', target: 'worksheets' },
{ source: 'lehrer-dashboard', target: 'correction' },
{ source: 'lehrer-dashboard', target: 'jitsi' },
{ source: 'lehrer-dashboard', target: 'letters' },
{ source: 'lehrer-dashboard', target: 'messenger' },
{ source: 'lehrer-dashboard', target: 'klausur-korrektur' },
{ source: 'lehrer-dashboard', target: 'companion' },
{ source: 'lehrer-dashboard', target: 'alerts' },
{ source: 'lehrer-dashboard', target: 'mail' },
{ source: 'lehrer-dashboard', target: 'school-classes' },
{ source: 'lehrer-dashboard', target: 'lehrer-onboarding', label: 'Sidebar' },
{ source: 'school-classes', target: 'school-exams' },
{ source: 'school-classes', target: 'school-grades' },
{ source: 'school-grades', target: 'school-gradebook' },
{ source: 'school-gradebook', target: 'school-certificates' },
{ source: 'worksheets', target: 'content-creator' },
{ source: 'worksheets', target: 'unit-creator' },
{ source: 'content-creator', target: 'content-feed' },
{ source: 'klausur-korrektur', target: 'abitur-docs-admin' },
{ source: 'admin', target: 'rbac-admin' },
{ source: 'admin', target: 'system-info' },
{ source: 'admin', target: 'workflow' },
]
// ============================================
// ADMIN v2 SCREENS (Port 3002)
// Based on navigation.ts - Last updated: 2026-02-03
// ============================================
const ADMIN_SCREENS: ScreenDefinition[] = [
// === META / OVERVIEW ===
{ id: 'admin-dashboard', name: 'Dashboard', description: 'Uebersicht & Statistiken', category: 'overview', icon: '🏠', url: '/dashboard' },
{ id: 'admin-onboarding', name: 'Onboarding', description: 'Lern-Wizards fuer alle Module', category: 'overview', icon: '📖', url: '/onboarding' },
{ id: 'admin-architecture', name: 'Architektur', description: 'Backend-Module & Datenfluss', category: 'overview', icon: '🏗️', url: '/architecture' },
{ id: 'admin-backlog', name: 'Production Backlog', description: 'Go-Live Checkliste', category: 'overview', icon: '📝', url: '/backlog' },
{ id: 'admin-rbac', name: 'RBAC', description: 'Rollen & Berechtigungen', category: 'overview', icon: '👥', url: '/rbac' },
// === COMPLIANCE SDK (Violet #8b5cf6) ===
// DSGVO - Datenschutz & Betroffenenrechte
{ id: 'admin-consent', name: 'Consent Verwaltung', description: 'Rechtliche Dokumente & Versionen', category: 'sdk', icon: '📄', url: '/sdk/consent-management' },
{ id: 'admin-dsr', name: 'Datenschutzanfragen', description: 'DSGVO Art. 15-21', category: 'sdk', icon: '🔒', url: '/sdk/dsr' },
{ id: 'admin-einwilligungen', name: 'Einwilligungen', description: 'Nutzer-Consent Uebersicht', category: 'sdk', icon: '✅', url: '/sdk/einwilligungen' },
{ id: 'admin-vvt', name: 'VVT', description: 'Verarbeitungsverzeichnis Art. 30', category: 'sdk', icon: '📋', url: '/sdk/vvt' },
{ id: 'admin-dsfa', name: 'DSFA', description: 'Datenschutz-Folgenabschaetzung', category: 'sdk', icon: '⚖️', url: '/sdk/dsfa' },
{ id: 'admin-tom', name: 'TOMs', description: 'Technische & Org. Massnahmen', category: 'sdk', icon: '🛡️', url: '/sdk/tom' },
{ id: 'admin-loeschfristen', name: 'Loeschfristen', description: 'Aufbewahrung & Deadlines', category: 'sdk', icon: '🗑️', url: '/sdk/loeschfristen' },
{ id: 'admin-advisory-board', name: 'Advisory Board', description: 'KI-Use-Case Pruefung', category: 'sdk', icon: '🧑‍⚖️', url: '/sdk/advisory-board' },
{ id: 'admin-escalations', name: 'Eskalations-Queue', description: 'DSB Review & Freigabe', category: 'sdk', icon: '🚨', url: '/sdk/escalations' },
// Compliance - Audit, GRC & Regulatorik
{ id: 'admin-compliance-hub', name: 'Compliance Hub', description: 'Zentrales GRC Dashboard', category: 'sdk', icon: '✅', url: '/sdk/compliance-hub' },
{ id: 'admin-audit-checklist', name: 'Audit Checkliste', description: '476 Anforderungen pruefen', category: 'sdk', icon: '📋', url: '/sdk/audit-checklist' },
{ id: 'admin-requirements', name: 'Requirements', description: '558+ aus 19 Verordnungen', category: 'sdk', icon: '📜', url: '/sdk/requirements' },
{ id: 'admin-controls', name: 'Controls', description: '474 Control-Mappings', category: 'sdk', icon: '🎛️', url: '/sdk/controls' },
{ id: 'admin-evidence', name: 'Evidence', description: 'Nachweise & Dokumentation', category: 'sdk', icon: '📎', url: '/sdk/evidence' },
{ id: 'admin-risks', name: 'Risiken', description: 'Risk Matrix & Register', category: 'sdk', icon: '⚠️', url: '/sdk/risks' },
{ id: 'admin-audit-report', name: 'Audit Report', description: 'PDF Audit-Berichte', category: 'sdk', icon: '📊', url: '/sdk/audit-report' },
{ id: 'admin-modules', name: 'Service Registry', description: '30+ Service-Module', category: 'sdk', icon: '🔧', url: '/sdk/modules' },
{ id: 'admin-dsms', name: 'DSMS', description: 'Datenschutz-Management-System', category: 'sdk', icon: '🏛️', url: '/sdk/dsms' },
{ id: 'admin-compliance-workflow', name: 'Workflow', description: 'Freigabe-Workflows', category: 'sdk', icon: '🔄', url: '/sdk/workflow' },
{ id: 'admin-source-policy', name: 'Quellen-Policy', description: 'Datenquellen & Compliance', category: 'sdk', icon: '📚', url: '/sdk/source-policy' },
{ id: 'admin-ai-act', name: 'EU-AI-Act', description: 'KI-Risikoklassifizierung', category: 'sdk', icon: '🤖', url: '/sdk/ai-act' },
{ id: 'admin-obligations', name: 'Pflichten', description: 'NIS2, DSGVO, AI Act', category: 'sdk', icon: '⚡', url: '/sdk/obligations' },
// === KI & AUTOMATISIERUNG (Teal #14b8a6) ===
{ id: 'admin-rag', name: 'Daten & RAG', description: 'Training Data & RAG', category: 'ai', icon: '🗄️', url: '/ai/rag' },
{ id: 'admin-ocr-labeling', name: 'OCR-Labeling', description: 'Handschrift-Training', category: 'ai', icon: '✍️', url: '/ai/ocr-labeling' },
{ id: 'admin-magic-help', name: 'Magic Help', description: 'TrOCR Handschrift-OCR', category: 'ai', icon: '🪄', url: '/ai/magic-help' },
{ id: 'admin-klausur-korrektur', name: 'Klausur-Korrektur', description: 'Abitur-Korrektur mit KI', category: 'ai', icon: '📝', url: '/ai/klausur-korrektur' },
{ id: 'admin-quality', name: 'Qualitaet & Audit', description: 'Compliance-Audit & Traceability', category: 'ai', icon: '✨', url: '/ai/quality' },
{ id: 'admin-test-quality', name: 'Test Quality (BQAS)', description: 'Golden Suite & Synthetic Tests', category: 'ai', icon: '🧪', url: '/ai/test-quality' },
{ id: 'admin-agents', name: 'Agent Management', description: 'Multi-Agent & SOUL-Editor', category: 'ai', icon: '🧠', url: '/ai/agents' },
// === INFRASTRUKTUR (Orange #f97316) ===
{ id: 'admin-gpu', name: 'GPU Infrastruktur', description: 'vast.ai GPU Management', category: 'infrastructure', icon: '🖥️', url: '/infrastructure/gpu' },
{ id: 'admin-middleware', name: 'Middleware', description: 'Stack & API Gateway', category: 'infrastructure', icon: '🔧', url: '/infrastructure/middleware' },
{ id: 'admin-security', name: 'Security', description: 'DevSecOps & Scans', category: 'infrastructure', icon: '🔐', url: '/infrastructure/security' },
{ id: 'admin-sbom', name: 'SBOM', description: 'Software Bill of Materials', category: 'infrastructure', icon: '📦', url: '/infrastructure/sbom' },
{ id: 'admin-cicd', name: 'CI/CD', description: 'Pipelines & Deployments', category: 'infrastructure', icon: '🔄', url: '/infrastructure/ci-cd' },
{ id: 'admin-tests', name: 'Test Dashboard', description: '195+ Tests & Coverage', category: 'infrastructure', icon: '🧪', url: '/infrastructure/tests' },
// === BILDUNG (Blue #3b82f6) ===
{ id: 'admin-edu-search', name: 'Education Search', description: 'Bildungsquellen & Crawler', category: 'education', icon: '🔍', url: '/education/edu-search' },
{ id: 'admin-zeugnisse', name: 'Zeugnisse-Crawler', description: 'Zeugnis-Daten', category: 'education', icon: '📜', url: '/education/zeugnisse-crawler' },
{ id: 'admin-rag-pipeline', name: 'RAG Pipeline', description: 'Bildungsdokumente indexieren', category: 'ai', icon: '🔗', url: '/ai/rag-pipeline' },
{ id: 'admin-foerderantrag', name: 'Foerderantrag-Wizard', description: 'DigitalPakt & Landesfoerderung', category: 'education', icon: '💰', url: '/education/foerderantrag' },
// === KOMMUNIKATION (Green #22c55e) ===
{ id: 'admin-video', name: 'Video & Chat', description: 'Matrix & Jitsi Monitoring', category: 'communication', icon: '🎥', url: '/communication/video-chat' },
{ id: 'admin-matrix', name: 'Voice Service', description: 'Voice-First Interface', category: 'communication', icon: '🎙️', url: '/communication/matrix' },
{ id: 'admin-mail', name: 'Unified Inbox', description: 'E-Mail & KI-Analyse', category: 'communication', icon: '📧', url: '/communication/mail' },
{ id: 'admin-alerts', name: 'Alerts Monitoring', description: 'Google Alerts & Feeds', category: 'communication', icon: '🔔', url: '/communication/alerts' },
// === ENTWICKLUNG (Slate #64748b) ===
{ id: 'admin-workflow', name: 'Dev Workflow', description: 'Git, CI/CD & Team-Regeln', category: 'development', icon: '⚡', url: '/development/workflow' },
{ id: 'admin-game', name: 'Breakpilot Drive', description: 'Lernspiel Management', category: 'development', icon: '🎮', url: '/development/game' },
{ id: 'admin-unity', name: 'Unity Bridge', description: 'Unity Editor Steuerung', category: 'development', icon: '🎯', url: '/development/unity-bridge' },
{ id: 'admin-companion', name: 'Companion Dev', description: 'Lesson-Modus Entwicklung', category: 'development', icon: '📚', url: '/development/companion' },
{ id: 'admin-docs', name: 'Developer Docs', description: 'API & Architektur', category: 'development', icon: '📖', url: '/development/docs' },
{ id: 'admin-brandbook', name: 'Brandbook', description: 'Corporate Design', category: 'development', icon: '🎨', url: '/development/brandbook' },
{ id: 'admin-screen-flow', name: 'Screen Flow', description: 'UI Screen-Verbindungen', category: 'development', icon: '🔀', url: '/development/screen-flow' },
{ id: 'admin-content', name: 'Uebersetzungen', description: 'Website Content & Sprachen', category: 'development', icon: '🌐', url: '/development/content' },
]
const ADMIN_CONNECTIONS: ConnectionDef[] = [
// === OVERVIEW/META FLOWS ===
{ source: 'admin-dashboard', target: 'admin-onboarding', label: 'Erste Schritte' },
{ source: 'admin-dashboard', target: 'admin-architecture', label: 'System' },
{ source: 'admin-dashboard', target: 'admin-backlog', label: 'Go-Live' },
{ source: 'admin-dashboard', target: 'admin-compliance-hub', label: 'Compliance' },
{ source: 'admin-onboarding', target: 'admin-consent' },
{ source: 'admin-rbac', target: 'admin-consent' },
// === DSGVO FLOW ===
{ source: 'admin-consent', target: 'admin-einwilligungen', label: 'Nutzer' },
{ source: 'admin-consent', target: 'admin-dsr' },
{ source: 'admin-dsr', target: 'admin-loeschfristen' },
{ source: 'admin-vvt', target: 'admin-tom' },
{ source: 'admin-vvt', target: 'admin-dsfa' },
{ source: 'admin-dsfa', target: 'admin-tom' },
{ source: 'admin-advisory-board', target: 'admin-escalations', label: 'Eskalation' },
{ source: 'admin-advisory-board', target: 'admin-dsfa', label: 'Risiko' },
// === COMPLIANCE FLOW ===
{ source: 'admin-compliance-hub', target: 'admin-audit-checklist', label: 'Audit' },
{ source: 'admin-compliance-hub', target: 'admin-requirements', label: 'Anforderungen' },
{ source: 'admin-compliance-hub', target: 'admin-risks', label: 'Risiken' },
{ source: 'admin-compliance-hub', target: 'admin-ai-act', label: 'AI Act' },
{ source: 'admin-requirements', target: 'admin-controls' },
{ source: 'admin-controls', target: 'admin-evidence' },
{ source: 'admin-audit-checklist', target: 'admin-audit-report', label: 'Report' },
{ source: 'admin-risks', target: 'admin-controls' },
{ source: 'admin-modules', target: 'admin-controls' },
{ source: 'admin-source-policy', target: 'admin-rag' },
{ source: 'admin-obligations', target: 'admin-requirements' },
{ source: 'admin-dsms', target: 'admin-compliance-workflow' },
// === KI & AUTOMATISIERUNG FLOW ===
{ source: 'admin-rag', target: 'admin-quality' },
{ source: 'admin-rag', target: 'admin-agents' },
{ source: 'admin-ocr-labeling', target: 'admin-magic-help', label: 'Training' },
{ source: 'admin-magic-help', target: 'admin-klausur-korrektur', label: 'Korrektur' },
{ source: 'admin-quality', target: 'admin-test-quality' },
{ source: 'admin-agents', target: 'admin-test-quality', label: 'BQAS' },
{ source: 'admin-klausur-korrektur', target: 'admin-quality', label: 'Audit' },
// === INFRASTRUKTUR FLOW ===
{ source: 'admin-security', target: 'admin-sbom', label: 'Dependencies' },
{ source: 'admin-sbom', target: 'admin-tests' },
{ source: 'admin-tests', target: 'admin-cicd', label: 'Pipeline' },
{ source: 'admin-cicd', target: 'admin-middleware' },
{ source: 'admin-middleware', target: 'admin-gpu', label: 'GPU' },
{ source: 'admin-security', target: 'admin-compliance-hub', label: 'Compliance' },
// === BILDUNG FLOW ===
{ source: 'admin-edu-search', target: 'admin-rag', label: 'Quellen' },
{ source: 'admin-edu-search', target: 'admin-zeugnisse' },
{ source: 'admin-training', target: 'admin-onboarding' },
{ source: 'admin-foerderantrag', target: 'admin-docs', label: 'Docs' },
// === KOMMUNIKATION FLOW ===
{ source: 'admin-video', target: 'admin-matrix', label: 'Voice' },
{ source: 'admin-mail', target: 'admin-alerts' },
{ source: 'admin-alerts', target: 'admin-mail', label: 'Inbox' },
// === ENTWICKLUNG FLOW ===
{ source: 'admin-workflow', target: 'admin-cicd', label: 'Pipeline' },
{ source: 'admin-workflow', target: 'admin-docs' },
{ source: 'admin-game', target: 'admin-unity', label: 'Editor' },
{ source: 'admin-companion', target: 'admin-agents', label: 'Agents' },
{ source: 'admin-brandbook', target: 'admin-screen-flow' },
{ source: 'admin-docs', target: 'admin-architecture' },
{ source: 'admin-content', target: 'admin-brandbook' },
]
// ============================================
// CATEGORY COLORS
// ============================================
const STUDIO_COLORS: Record<string, { bg: string; border: string; text: string }> = {
navigation: { bg: '#dbeafe', border: '#3b82f6', text: '#1e40af' },
content: { bg: '#dcfce7', border: '#22c55e', text: '#166534' },
communication: { bg: '#fef3c7', border: '#f59e0b', text: '#92400e' },
school: { bg: '#fce7f3', border: '#ec4899', text: '#9d174d' },
admin: { bg: '#f3e8ff', border: '#a855f7', text: '#6b21a8' },
ai: { bg: '#cffafe', border: '#06b6d4', text: '#0e7490' },
}
// Colors from navigation.ts
const ADMIN_COLORS: Record<string, { bg: string; border: string; text: string }> = {
overview: { bg: '#e0f2fe', border: '#0ea5e9', text: '#0369a1' }, // Sky (Meta)
dsgvo: { bg: '#ede9fe', border: '#7c3aed', text: '#5b21b6' }, // Violet
compliance: { bg: '#f3e8ff', border: '#9333ea', text: '#6b21a8' }, // Purple
ai: { bg: '#ccfbf1', border: '#14b8a6', text: '#0f766e' }, // Teal
infrastructure: { bg: '#ffedd5', border: '#f97316', text: '#c2410c' },// Orange
education: { bg: '#dbeafe', border: '#3b82f6', text: '#1e40af' }, // Blue
communication: { bg: '#dcfce7', border: '#22c55e', text: '#166534' }, // Green
development: { bg: '#f1f5f9', border: '#64748b', text: '#334155' }, // Slate
}
const STUDIO_LABELS: Record<string, string> = {
navigation: 'Navigation',
content: 'Content & Tools',
communication: 'Kommunikation',
school: 'Schulverwaltung',
admin: 'Administration',
ai: 'KI & Assistent',
}
// Labels from navigation.ts
const ADMIN_LABELS: Record<string, string> = {
overview: 'Uebersicht & Meta',
dsgvo: 'DSGVO',
compliance: 'Compliance & GRC',
ai: 'KI & Automatisierung',
infrastructure: 'Infrastruktur & DevOps',
education: 'Bildung & Schule',
communication: 'Kommunikation & Alerts',
development: 'Entwicklung & Produkte',
}
// ============================================
// HELPER: Find all connected nodes (recursive)
// ============================================
function findConnectedNodes(
startNodeId: string,
connections: ConnectionDef[],
direction: 'children' | 'parents' | 'both' = 'children'
): Set<string> {
const connected = new Set<string>()
connected.add(startNodeId)
const queue = [startNodeId]
while (queue.length > 0) {
const current = queue.shift()!
connections.forEach(conn => {
if ((direction === 'children' || direction === 'both') && conn.source === current) {
if (!connected.has(conn.target)) {
connected.add(conn.target)
queue.push(conn.target)
}
}
if ((direction === 'parents' || direction === 'both') && conn.target === current) {
if (!connected.has(conn.source)) {
connected.add(conn.source)
queue.push(conn.source)
}
}
})
}
return connected
}
// ============================================
// LAYOUT HELPERS
// ============================================
const getNodePosition = (
id: string,
category: string,
screens: ScreenDefinition[],
flowType: FlowType
) => {
const studioPositions: Record<string, { x: number; y: number }> = {
navigation: { x: 400, y: 50 },
content: { x: 50, y: 250 },
communication: { x: 750, y: 250 },
school: { x: 50, y: 500 },
admin: { x: 750, y: 500 },
ai: { x: 400, y: 380 },
}
const adminPositions: Record<string, { x: number; y: number }> = {
overview: { x: 400, y: 30 },
dsgvo: { x: 50, y: 150 },
compliance: { x: 700, y: 150 },
ai: { x: 50, y: 350 },
communication: { x: 400, y: 350 },
infrastructure: { x: 700, y: 350 },
education: { x: 50, y: 550 },
development: { x: 400, y: 550 },
}
const positions = flowType === 'studio' ? studioPositions : adminPositions
const base = positions[category] || { x: 400, y: 300 }
const categoryScreens = screens.filter(s => s.category === category)
const categoryIndex = categoryScreens.findIndex(s => s.id === id)
const cols = Math.ceil(Math.sqrt(categoryScreens.length + 1))
const row = Math.floor(categoryIndex / cols)
const col = categoryIndex % cols
return {
x: base.x + col * 160,
y: base.y + row * 90,
}
}
// ============================================
// MAIN COMPONENT
// ============================================
import { useCallback } from 'react'
import { useScreenFlow } from './useScreenFlow'
import {
FlowTypeSelector,
StatsBar,
CategoryFilter,
ConnectedScreensList,
FlowDiagram,
ScreenList,
} from './_components'
import type { ScreenDefinition } from './types'
export default function ScreenFlowPage() {
const [flowType, setFlowType] = useState<FlowType>('admin')
const [selectedCategory, setSelectedCategory] = useState<string | null>(null)
const [selectedNode, setSelectedNode] = useState<string | null>(null)
const [previewScreen, setPreviewScreen] = useState<ScreenDefinition | null>(null)
const {
flowType,
selectedCategory,
setSelectedCategory,
selectedNode,
setSelectedNode,
previewScreen,
setPreviewScreen,
screens,
colors,
labels,
baseUrl,
nodes,
edges,
onNodesChange,
onEdgesChange,
handleFlowTypeChange,
onNodeClick,
onPaneClick,
stats,
categories,
connectedScreens,
} = useScreenFlow()
// Get data based on flow type
const screens = flowType === 'studio' ? STUDIO_SCREENS : ADMIN_SCREENS
const connections = flowType === 'studio' ? STUDIO_CONNECTIONS : ADMIN_CONNECTIONS
const colors = flowType === 'studio' ? STUDIO_COLORS : ADMIN_COLORS
const labels = flowType === 'studio' ? STUDIO_LABELS : ADMIN_LABELS
const baseUrl = flowType === 'studio' ? 'http://macmini:8000' : 'http://macmini:3002'
// Calculate connected nodes
const connectedNodes = useMemo(() => {
if (!selectedNode) return new Set<string>()
return findConnectedNodes(selectedNode, connections, 'children')
}, [selectedNode, connections])
// Create nodes with useMemo
const initialNodes = useMemo((): Node[] => {
return screens.map((screen) => {
const catColors = colors[screen.category] || { bg: '#f1f5f9', border: '#94a3b8', text: '#475569' }
const position = getNodePosition(screen.id, screen.category, screens, flowType)
// Determine opacity
let opacity = 1
if (selectedNode) {
opacity = connectedNodes.has(screen.id) ? 1 : 0.2
} else if (selectedCategory) {
opacity = screen.category === selectedCategory ? 1 : 0.2
}
const isSelected = selectedNode === screen.id
return {
id: screen.id,
type: 'default',
position,
data: {
label: (
<div className="text-center p-1">
<div className="text-lg mb-1">{screen.icon}</div>
<div className="font-medium text-xs leading-tight">{screen.name}</div>
</div>
),
},
style: {
background: isSelected ? catColors.border : catColors.bg,
color: isSelected ? 'white' : catColors.text,
border: `2px solid ${catColors.border}`,
borderRadius: '12px',
padding: '6px',
minWidth: '110px',
opacity,
cursor: 'pointer',
boxShadow: isSelected ? `0 0 20px ${catColors.border}` : 'none',
},
}
})
}, [screens, colors, flowType, selectedCategory, selectedNode, connectedNodes])
// Create edges with useMemo
const initialEdges = useMemo((): Edge[] => {
return connections.map((conn, index) => {
const isHighlighted = selectedNode && (conn.source === selectedNode || conn.target === selectedNode)
const isInSubtree = selectedNode && connectedNodes.has(conn.source) && connectedNodes.has(conn.target)
return {
id: `e-${conn.source}-${conn.target}-${index}`,
source: conn.source,
target: conn.target,
label: conn.label,
type: 'smoothstep',
animated: isHighlighted || false,
style: {
stroke: isHighlighted ? '#3b82f6' : (isInSubtree ? '#94a3b8' : '#e2e8f0'),
strokeWidth: isHighlighted ? 3 : 1.5,
opacity: selectedNode ? (isInSubtree ? 1 : 0.15) : 1,
},
labelStyle: { fontSize: 9, fill: '#64748b' },
labelBgStyle: { fill: '#f8fafc' },
markerEnd: { type: MarkerType.ArrowClosed, color: isHighlighted ? '#3b82f6' : '#94a3b8', width: 15, height: 15 },
}
})
}, [connections, selectedNode, connectedNodes])
const [nodes, setNodes, onNodesChange] = useNodesState([])
const [edges, setEdges, onEdgesChange] = useEdgesState([])
// Update nodes/edges when dependencies change
useEffect(() => {
setNodes(initialNodes)
setEdges(initialEdges)
}, [initialNodes, initialEdges, setNodes, setEdges])
// Reset when flow type changes
const handleFlowTypeChange = useCallback((newType: FlowType) => {
setFlowType(newType)
const handleReset = useCallback(() => {
setSelectedNode(null)
setSelectedCategory(null)
setPreviewScreen(null)
}, [])
}, [setSelectedNode, setPreviewScreen])
// Handle node click
const onNodeClick = useCallback((_event: React.MouseEvent, node: Node) => {
const screen = screens.find(s => s.id === node.id)
if (selectedNode === node.id) {
// Double-click: open in new tab
if (screen?.url) {
window.open(`${baseUrl}${screen.url}`, '_blank')
}
return
}
setSelectedNode(node.id)
const handleCategoryReset = useCallback(() => {
setSelectedCategory(null)
setSelectedNode(null)
setPreviewScreen(null)
}, [setSelectedCategory, setSelectedNode, setPreviewScreen])
if (screen) {
const handleSelectCategory = useCallback((category: string | null) => {
setSelectedCategory(category)
setSelectedNode(null)
setPreviewScreen(null)
}, [setSelectedCategory, setSelectedNode, setPreviewScreen])
const handleSelectScreen = useCallback((screen: ScreenDefinition) => {
setSelectedNode(screen.id)
setSelectedCategory(null)
setPreviewScreen(screen)
}
}, [screens, baseUrl, selectedNode])
// Handle background click - deselect
const onPaneClick = useCallback(() => {
setSelectedNode(null)
setPreviewScreen(null)
}, [])
// Stats
const stats = {
totalScreens: screens.length,
totalConnections: connections.length,
connectedCount: connectedNodes.size,
}
const categories = Object.keys(labels)
// Connected screens list
const connectedScreens = selectedNode
? screens.filter(s => connectedNodes.has(s.id))
: []
}, [setSelectedNode, setSelectedCategory, setPreviewScreen])
return (
<div className="space-y-6">
{/* Flow Type Selector */}
<div className="grid grid-cols-2 gap-4">
<button
onClick={() => handleFlowTypeChange('studio')}
className={`p-6 rounded-xl border-2 transition-all ${
flowType === 'studio'
? 'border-green-500 bg-green-50 shadow-lg'
: 'border-slate-200 bg-white hover:border-slate-300'
}`}
>
<div className="flex items-center gap-4">
<div className={`w-14 h-14 rounded-xl flex items-center justify-center text-2xl ${
flowType === 'studio' ? 'bg-green-500 text-white' : 'bg-slate-100'
}`}>
🎓
</div>
<div className="text-left">
<div className="font-bold text-lg">Studio (Port 8000)</div>
<div className="text-sm text-slate-500">Lehrer-Oberflaeche</div>
<div className="text-xs text-slate-400 mt-1">{STUDIO_SCREENS.length} Screens</div>
</div>
</div>
</button>
<FlowTypeSelector
flowType={flowType}
onFlowTypeChange={handleFlowTypeChange}
/>
<button
onClick={() => handleFlowTypeChange('admin')}
className={`p-6 rounded-xl border-2 transition-all ${
flowType === 'admin'
? 'border-primary-500 bg-primary-50 shadow-lg'
: 'border-slate-200 bg-white hover:border-slate-300'
}`}
>
<div className="flex items-center gap-4">
<div className={`w-14 h-14 rounded-xl flex items-center justify-center text-2xl ${
flowType === 'admin' ? 'bg-primary-500 text-white' : 'bg-slate-100'
}`}>
</div>
<div className="text-left">
<div className="font-bold text-lg">Admin v2 (Port 3002)</div>
<div className="text-sm text-slate-500">Admin Panel</div>
<div className="text-xs text-slate-400 mt-1">{ADMIN_SCREENS.length} Screens</div>
</div>
</div>
</button>
</div>
<StatsBar
totalScreens={stats.totalScreens}
totalConnections={stats.totalConnections}
connectedCount={stats.connectedCount}
selectedNode={selectedNode}
previewScreen={previewScreen}
onReset={handleReset}
/>
{/* Stats & Selection Info */}
<div className="grid grid-cols-4 gap-4">
<div className="bg-white rounded-xl border border-slate-200 p-4 shadow-sm">
<div className="text-3xl font-bold text-slate-800">{stats.totalScreens}</div>
<div className="text-sm text-slate-500">Screens</div>
</div>
<div className="bg-white rounded-xl border border-slate-200 p-4 shadow-sm">
<div className="text-3xl font-bold text-primary-600">{stats.totalConnections}</div>
<div className="text-sm text-slate-500">Verbindungen</div>
</div>
<div className="bg-white rounded-xl border border-slate-200 p-4 shadow-sm col-span-2">
{selectedNode ? (
<div className="flex items-center gap-3">
<div className="text-3xl">{previewScreen?.icon}</div>
<div>
<div className="font-bold text-slate-800">{previewScreen?.name}</div>
<div className="text-sm text-slate-500">
{stats.connectedCount} verbundene Screen{stats.connectedCount !== 1 ? 's' : ''}
</div>
</div>
<button
onClick={() => {
setSelectedNode(null)
setPreviewScreen(null)
}}
className="ml-auto px-3 py-1 text-sm bg-slate-100 hover:bg-slate-200 rounded-lg"
>
Zuruecksetzen
</button>
</div>
) : (
<div className="text-slate-500 text-sm">
Klicke auf einen Screen um den Subtree zu sehen
</div>
)}
</div>
</div>
<CategoryFilter
screens={screens}
categories={categories}
colors={colors}
labels={labels}
selectedCategory={selectedCategory}
selectedNode={selectedNode}
onSelectCategory={handleSelectCategory}
onReset={handleCategoryReset}
/>
{/* Category Filter */}
<div className="bg-white rounded-xl border border-slate-200 p-4 shadow-sm">
<div className="flex flex-wrap gap-2">
<button
onClick={() => {
setSelectedCategory(null)
setSelectedNode(null)
setPreviewScreen(null)
}}
className={`px-4 py-2 rounded-lg text-sm font-medium transition-colors ${
selectedCategory === null && !selectedNode
? 'bg-slate-800 text-white'
: 'bg-slate-100 text-slate-700 hover:bg-slate-200'
}`}
>
Alle ({screens.length})
</button>
{categories.map((key) => {
const count = screens.filter(s => s.category === key).length
const catColors = colors[key] || { bg: '#f1f5f9', border: '#94a3b8', text: '#475569' }
return (
<button
key={key}
onClick={() => {
setSelectedCategory(selectedCategory === key ? null : key)
setSelectedNode(null)
setPreviewScreen(null)
}}
className="px-4 py-2 rounded-lg text-sm font-medium transition-all flex items-center gap-2"
style={{
background: selectedCategory === key ? catColors.border : catColors.bg,
color: selectedCategory === key ? 'white' : catColors.text,
}}
>
<span className="w-3 h-3 rounded-full" style={{ background: catColors.border }} />
{labels[key]} ({count})
</button>
)
})}
</div>
</div>
<ConnectedScreensList
selectedNode={selectedNode}
connectedScreens={connectedScreens}
colors={colors}
baseUrl={baseUrl}
/>
{/* Connected Screens List */}
{selectedNode && connectedScreens.length > 1 && (
<div className="bg-white rounded-xl border border-slate-200 p-4 shadow-sm">
<div className="text-sm font-medium text-slate-700 mb-3">Verbundene Screens:</div>
<div className="flex flex-wrap gap-2">
{connectedScreens.map((screen) => {
const catColors = colors[screen.category] || { bg: '#f1f5f9', border: '#94a3b8', text: '#475569' }
const isCurrentNode = screen.id === selectedNode
return (
<button
key={screen.id}
onClick={() => {
if (screen.url) {
window.open(`${baseUrl}${screen.url}`, '_blank')
}
}}
className={`px-3 py-2 rounded-lg text-sm font-medium transition-all flex items-center gap-2 ${
isCurrentNode ? 'ring-2 ring-primary-500' : ''
}`}
style={{
background: isCurrentNode ? catColors.border : catColors.bg,
color: isCurrentNode ? 'white' : catColors.text,
}}
>
<span>{screen.icon}</span>
{screen.name}
</button>
)
})}
</div>
</div>
)}
{/* Flow Diagram */}
<div className="bg-white rounded-xl border border-slate-200 shadow-sm overflow-hidden" style={{ height: '500px' }}>
<ReactFlow
<FlowDiagram
flowType={flowType}
nodes={nodes}
edges={edges}
onNodesChange={onNodesChange}
onEdgesChange={onEdgesChange}
onNodeClick={onNodeClick}
onPaneClick={onPaneClick}
fitView
fitViewOptions={{ padding: 0.2 }}
attributionPosition="bottom-left"
>
<Controls />
<MiniMap
nodeColor={(node) => {
const screen = screens.find(s => s.id === node.id)
const catColors = screen ? colors[screen.category] : null
return catColors?.border || '#94a3b8'
}}
maskColor="rgba(0, 0, 0, 0.1)"
screens={screens}
colors={colors}
labels={labels}
categories={categories}
/>
<Background variant={BackgroundVariant.Dots} gap={12} size={1} />
<Panel position="top-left" className="bg-white/95 p-3 rounded-lg shadow-lg text-xs">
<div className="font-medium text-slate-700 mb-2">
{flowType === 'studio' ? '🎓 Studio' : '⚙️ Admin v2'}
</div>
<div className="space-y-1">
{categories.slice(0, 4).map((key) => {
const catColors = colors[key] || { bg: '#f1f5f9', border: '#94a3b8' }
return (
<div key={key} className="flex items-center gap-2">
<span
className="w-3 h-3 rounded"
style={{ background: catColors.bg, border: `1px solid ${catColors.border}` }}
<ScreenList
screens={screens}
colors={colors}
labels={labels}
baseUrl={baseUrl}
selectedCategory={selectedCategory}
onSelectScreen={handleSelectScreen}
/>
<span className="text-slate-600">{labels[key]}</span>
</div>
)
})}
</div>
<div className="mt-2 pt-2 border-t text-slate-400">
Klick = Subtree<br/>
Doppelklick = Oeffnen
</div>
</Panel>
</ReactFlow>
</div>
{/* Screen List */}
<div className="bg-white rounded-xl border border-slate-200 shadow-sm overflow-hidden">
<div className="px-4 py-3 bg-slate-50 border-b flex items-center justify-between">
<h3 className="font-medium text-slate-700">
Alle Screens ({screens.length})
</h3>
<span className="text-xs text-slate-400">{baseUrl}</span>
</div>
<div className="divide-y max-h-80 overflow-y-auto">
{screens
.filter(s => !selectedCategory || s.category === selectedCategory)
.map((screen) => {
const catColors = colors[screen.category] || { bg: '#f1f5f9', border: '#94a3b8', text: '#475569' }
return (
<button
key={screen.id}
onClick={() => {
setSelectedNode(screen.id)
setSelectedCategory(null)
setPreviewScreen(screen)
}}
className="w-full flex items-center gap-4 p-3 hover:bg-slate-50 transition-colors text-left"
>
<span
className="w-9 h-9 rounded-lg flex items-center justify-center text-lg"
style={{ background: catColors.bg }}
>
{screen.icon}
</span>
<div className="flex-1 min-w-0">
<div className="font-medium text-slate-800 text-sm">{screen.name}</div>
<div className="text-xs text-slate-500 truncate">{screen.description}</div>
</div>
<span
className="px-2 py-1 rounded text-xs font-medium shrink-0"
style={{ background: catColors.bg, color: catColors.text }}
>
{labels[screen.category]}
</span>
</button>
)
})}
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,20 @@
/**
* Screen Flow - Type definitions
*/
export interface ScreenDefinition {
id: string
name: string
description: string
category: string
icon: string
url?: string
}
export interface ConnectionDef {
source: string
target: string
label?: string
}
export type FlowType = 'studio' | 'admin'

View File

@@ -0,0 +1,182 @@
'use client'
/**
* Screen Flow - State management hook
*/
import { useCallback, useState, useMemo, useEffect } from 'react'
import { Node, Edge, useNodesState, useEdgesState, MarkerType } from 'reactflow'
import type { ScreenDefinition, FlowType } from './types'
import {
STUDIO_SCREENS, ADMIN_SCREENS,
STUDIO_CONNECTIONS, ADMIN_CONNECTIONS,
STUDIO_COLORS, ADMIN_COLORS,
STUDIO_LABELS, ADMIN_LABELS,
} from './data'
import { findConnectedNodes, getNodePosition } from './helpers'
export function useScreenFlow() {
const [flowType, setFlowType] = useState<FlowType>('admin')
const [selectedCategory, setSelectedCategory] = useState<string | null>(null)
const [selectedNode, setSelectedNode] = useState<string | null>(null)
const [previewScreen, setPreviewScreen] = useState<ScreenDefinition | null>(null)
// Derived data based on flow type
const screens = flowType === 'studio' ? STUDIO_SCREENS : ADMIN_SCREENS
const connections = flowType === 'studio' ? STUDIO_CONNECTIONS : ADMIN_CONNECTIONS
const colors = flowType === 'studio' ? STUDIO_COLORS : ADMIN_COLORS
const labels = flowType === 'studio' ? STUDIO_LABELS : ADMIN_LABELS
const baseUrl = flowType === 'studio' ? 'http://macmini:8000' : 'http://macmini:3002'
// BFS connected nodes
const connectedNodes = useMemo(() => {
if (!selectedNode) return new Set<string>()
return findConnectedNodes(selectedNode, connections, 'children')
}, [selectedNode, connections])
// Build ReactFlow nodes
const initialNodes = useMemo((): Node[] => {
return screens.map((screen) => {
const catColors = colors[screen.category] || { bg: '#f1f5f9', border: '#94a3b8', text: '#475569' }
const position = getNodePosition(screen.id, screen.category, screens, flowType)
let opacity = 1
if (selectedNode) {
opacity = connectedNodes.has(screen.id) ? 1 : 0.2
} else if (selectedCategory) {
opacity = screen.category === selectedCategory ? 1 : 0.2
}
const isSelected = selectedNode === screen.id
return {
id: screen.id,
type: 'default',
position,
data: {
label: (
<div className="text-center p-1">
<div className="text-lg mb-1">{screen.icon}</div>
<div className="font-medium text-xs leading-tight">{screen.name}</div>
</div>
),
},
style: {
background: isSelected ? catColors.border : catColors.bg,
color: isSelected ? 'white' : catColors.text,
border: `2px solid ${catColors.border}`,
borderRadius: '12px',
padding: '6px',
minWidth: '110px',
opacity,
cursor: 'pointer',
boxShadow: isSelected ? `0 0 20px ${catColors.border}` : 'none',
},
}
})
}, [screens, colors, flowType, selectedCategory, selectedNode, connectedNodes])
// Build ReactFlow edges
const initialEdges = useMemo((): Edge[] => {
return connections.map((conn, index) => {
const isHighlighted = selectedNode && (conn.source === selectedNode || conn.target === selectedNode)
const isInSubtree = selectedNode && connectedNodes.has(conn.source) && connectedNodes.has(conn.target)
return {
id: `e-${conn.source}-${conn.target}-${index}`,
source: conn.source,
target: conn.target,
label: conn.label,
type: 'smoothstep',
animated: isHighlighted || false,
style: {
stroke: isHighlighted ? '#3b82f6' : (isInSubtree ? '#94a3b8' : '#e2e8f0'),
strokeWidth: isHighlighted ? 3 : 1.5,
opacity: selectedNode ? (isInSubtree ? 1 : 0.15) : 1,
},
labelStyle: { fontSize: 9, fill: '#64748b' },
labelBgStyle: { fill: '#f8fafc' },
markerEnd: { type: MarkerType.ArrowClosed, color: isHighlighted ? '#3b82f6' : '#94a3b8', width: 15, height: 15 },
}
})
}, [connections, selectedNode, connectedNodes])
const [nodes, setNodes, onNodesChange] = useNodesState([])
const [edges, setEdges, onEdgesChange] = useEdgesState([])
// Sync memoized nodes/edges into ReactFlow state
useEffect(() => {
setNodes(initialNodes)
setEdges(initialEdges)
}, [initialNodes, initialEdges, setNodes, setEdges])
// Reset on flow type change
const handleFlowTypeChange = useCallback((newType: FlowType) => {
setFlowType(newType)
setSelectedNode(null)
setSelectedCategory(null)
setPreviewScreen(null)
}, [])
// Node click: select or open on double-click
const onNodeClick = useCallback((_event: React.MouseEvent, node: Node) => {
const screen = screens.find(s => s.id === node.id)
if (selectedNode === node.id) {
if (screen?.url) {
window.open(`${baseUrl}${screen.url}`, '_blank')
}
return
}
setSelectedNode(node.id)
setSelectedCategory(null)
if (screen) {
setPreviewScreen(screen)
}
}, [screens, baseUrl, selectedNode])
// Background click: deselect
const onPaneClick = useCallback(() => {
setSelectedNode(null)
setPreviewScreen(null)
}, [])
const stats = {
totalScreens: screens.length,
totalConnections: connections.length,
connectedCount: connectedNodes.size,
}
const categories = Object.keys(labels)
const connectedScreens = selectedNode
? screens.filter(s => connectedNodes.has(s.id))
: []
return {
flowType,
selectedCategory,
setSelectedCategory,
selectedNode,
setSelectedNode,
previewScreen,
setPreviewScreen,
screens,
colors,
labels,
baseUrl,
connectedNodes,
nodes,
edges,
onNodesChange,
onEdgesChange,
handleFlowTypeChange,
onNodeClick,
onPaneClick,
stats,
categories,
connectedScreens,
}
}

View File

@@ -0,0 +1,214 @@
import {
FileText, ChevronLeft, ChevronRight, Eye, Download, Loader2,
} from 'lucide-react'
import type { AbiturDokument } from '@/lib/education/abitur-docs-types'
import { formatFileSize, FAECHER } from '@/lib/education/abitur-docs-types'
import { DokumentCard } from '../components/DokumentCard'
interface DocumentDisplayProps {
documents: AbiturDokument[]
loading: boolean
error: string | null
viewMode: 'grid' | 'list'
hasActiveFilters: boolean
onClearFilters: () => void
onSelectDocument: (doc: AbiturDokument) => void
onDownload: (doc: AbiturDokument) => void
onAddToKlausur: (doc: AbiturDokument) => void
onRetry: () => void
// Pagination
page: number
totalPages: number
total: number
limit: number
onPageChange: (page: number) => void
}
export function DocumentDisplay({
documents, loading, error, viewMode, hasActiveFilters,
onClearFilters, onSelectDocument, onDownload, onAddToKlausur, onRetry,
page, totalPages, total, limit, onPageChange,
}: DocumentDisplayProps) {
return (
<div className="bg-white rounded-xl border border-slate-200 overflow-hidden">
{loading ? (
<div className="flex items-center justify-center py-16">
<Loader2 className="w-8 h-8 text-blue-600 animate-spin" />
</div>
) : error ? (
<div className="text-center py-16 text-red-600">
<p>{error}</p>
<button
onClick={onRetry}
className="mt-2 text-sm text-blue-600 hover:underline"
>
Erneut versuchen
</button>
</div>
) : documents.length === 0 ? (
<div className="text-center py-16 text-slate-500">
<FileText className="w-12 h-12 mx-auto mb-3 opacity-50" />
<p>Keine Dokumente gefunden</p>
{hasActiveFilters && (
<button
onClick={onClearFilters}
className="mt-2 text-sm text-blue-600 hover:underline"
>
Filter zuruecksetzen
</button>
)}
</div>
) : viewMode === 'grid' ? (
<div className="p-4">
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
{documents.map((doc) => (
<DokumentCard
key={doc.id}
document={doc}
onPreview={onSelectDocument}
onDownload={onDownload}
onAddToKlausur={onAddToKlausur}
/>
))}
</div>
</div>
) : (
<ListView
documents={documents}
onSelectDocument={onSelectDocument}
onDownload={onDownload}
/>
)}
{/* Pagination */}
{documents.length > 0 && (
<div className="flex items-center justify-between px-4 py-3 border-t border-slate-200 bg-slate-50">
<div className="text-sm text-slate-500">
Zeige {(page - 1) * limit + 1}-{Math.min(page * limit, total)} von {total} Dokumenten
</div>
<div className="flex items-center gap-2">
<button
onClick={() => onPageChange(Math.max(1, page - 1))}
disabled={page === 1}
className="p-2 rounded-lg hover:bg-slate-200 disabled:opacity-50 disabled:cursor-not-allowed"
>
<ChevronLeft className="w-4 h-4" />
</button>
<span className="text-sm text-slate-600">
Seite {page} von {totalPages}
</span>
<button
onClick={() => onPageChange(Math.min(totalPages, page + 1))}
disabled={page === totalPages}
className="p-2 rounded-lg hover:bg-slate-200 disabled:opacity-50 disabled:cursor-not-allowed"
>
<ChevronRight className="w-4 h-4" />
</button>
</div>
</div>
)}
</div>
)
}
function ListView({
documents,
onSelectDocument,
onDownload,
}: {
documents: AbiturDokument[]
onSelectDocument: (doc: AbiturDokument) => void
onDownload: (doc: AbiturDokument) => void
}) {
return (
<table className="w-full text-sm">
<thead className="bg-slate-50 border-b border-slate-200">
<tr>
<th className="text-left px-4 py-3 font-medium text-slate-600">Dokument</th>
<th className="text-left px-4 py-3 font-medium text-slate-600">Fach</th>
<th className="text-center px-4 py-3 font-medium text-slate-600">Jahr</th>
<th className="text-center px-4 py-3 font-medium text-slate-600">Niveau</th>
<th className="text-center px-4 py-3 font-medium text-slate-600">Typ</th>
<th className="text-right px-4 py-3 font-medium text-slate-600">Groesse</th>
<th className="text-center px-4 py-3 font-medium text-slate-600">Status</th>
<th className="text-center px-4 py-3 font-medium text-slate-600">Aktion</th>
</tr>
</thead>
<tbody>
{documents.map((doc) => {
const fachLabel = FAECHER.find(f => f.id === doc.fach)?.label || doc.fach
return (
<tr
key={doc.id}
className="border-b border-slate-100 hover:bg-slate-50 cursor-pointer"
onClick={() => onSelectDocument(doc)}
>
<td className="px-4 py-3">
<div className="flex items-center gap-2">
<FileText className="w-4 h-4 text-red-500" />
<span className="font-medium text-slate-900 truncate max-w-[200px]" title={doc.dateiname}>
{doc.dateiname}
</span>
</div>
</td>
<td className="px-4 py-3">
<span className="capitalize">{fachLabel}</span>
</td>
<td className="px-4 py-3 text-center">{doc.jahr}</td>
<td className="px-4 py-3 text-center">
<span className={`px-2 py-0.5 rounded-full text-xs ${
doc.niveau === 'eA'
? 'bg-blue-100 text-blue-700'
: 'bg-slate-100 text-slate-700'
}`}>
{doc.niveau}
</span>
</td>
<td className="px-4 py-3 text-center">
<span className={`px-2 py-0.5 rounded-full text-xs ${
doc.typ === 'erwartungshorizont'
? 'bg-orange-100 text-orange-700'
: 'bg-purple-100 text-purple-700'
}`}>
{doc.typ === 'erwartungshorizont' ? 'EWH' : 'Aufgabe'}
</span>
</td>
<td className="px-4 py-3 text-right text-slate-500">
{formatFileSize(doc.file_size)}
</td>
<td className="px-4 py-3 text-center">
<span className={`px-2 py-0.5 rounded-full text-xs ${
doc.status === 'indexed'
? 'bg-green-100 text-green-700'
: doc.status === 'error'
? 'bg-red-100 text-red-700'
: 'bg-yellow-100 text-yellow-700'
}`}>
{doc.status === 'indexed' ? 'Indexiert' : doc.status === 'error' ? 'Fehler' : 'Ausstehend'}
</span>
</td>
<td className="px-4 py-3 text-center">
<div className="flex items-center justify-center gap-1" onClick={(e) => e.stopPropagation()}>
<button
onClick={() => onSelectDocument(doc)}
className="p-1.5 text-blue-600 hover:bg-blue-50 rounded"
title="Vorschau"
>
<Eye className="w-4 h-4" />
</button>
<button
onClick={() => onDownload(doc)}
className="p-1.5 text-slate-600 hover:bg-slate-100 rounded"
title="Download"
>
<Download className="w-4 h-4" />
</button>
</div>
</td>
</tr>
)
})}
</tbody>
</table>
)
}

View File

@@ -0,0 +1,174 @@
import {
Filter, X, LayoutGrid, List,
} from 'lucide-react'
import {
FAECHER,
JAHRE,
BUNDESLAENDER,
NIVEAUS,
TYPEN,
} from '@/lib/education/abitur-docs-types'
import type { ViewMode } from '@/lib/education/abitur-archiv-types'
interface FilterBarProps {
filterOpen: boolean
onToggleFilter: () => void
hasActiveFilters: boolean
onClearFilters: () => void
total: number
viewMode: ViewMode
onViewModeChange: (mode: ViewMode) => void
filterFach: string
filterJahr: string
filterBundesland: string
filterNiveau: string
filterTyp: string
onFachChange: (v: string) => void
onJahrChange: (v: string) => void
onBundeslandChange: (v: string) => void
onNiveauChange: (v: string) => void
onTypChange: (v: string) => void
onResetPage: () => void
searchQuery: string
}
export function FilterBar({
filterOpen, onToggleFilter, hasActiveFilters, onClearFilters,
total, viewMode, onViewModeChange,
filterFach, filterJahr, filterBundesland, filterNiveau, filterTyp,
onFachChange, onJahrChange, onBundeslandChange, onNiveauChange, onTypChange,
onResetPage, searchQuery,
}: FilterBarProps) {
const activeCount = [filterFach, filterJahr, filterBundesland, filterNiveau, filterTyp, searchQuery].filter(Boolean).length
return (
<div className="bg-white rounded-xl border border-slate-200 p-4">
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-3">
<button
onClick={onToggleFilter}
className={`px-4 py-2 rounded-lg font-medium flex items-center gap-2 transition-colors ${
filterOpen || hasActiveFilters
? 'bg-purple-100 text-purple-700'
: 'bg-slate-100 text-slate-600 hover:bg-slate-200'
}`}
>
<Filter className="w-4 h-4" />
Filter
{hasActiveFilters && (
<span className="bg-purple-600 text-white text-xs px-1.5 py-0.5 rounded-full">
{activeCount}
</span>
)}
</button>
{hasActiveFilters && (
<button
onClick={onClearFilters}
className="text-sm text-slate-500 hover:text-slate-700 flex items-center gap-1"
>
<X className="w-4 h-4" />
Filter zuruecksetzen
</button>
)}
</div>
<div className="flex items-center gap-3">
<span className="text-sm text-slate-500">{total} Treffer</span>
<div className="flex bg-slate-100 rounded-lg p-1">
<button
onClick={() => onViewModeChange('grid')}
className={`p-2 rounded-md transition-colors ${
viewMode === 'grid' ? 'bg-white shadow-sm text-blue-600' : 'text-slate-500 hover:text-slate-700'
}`}
title="Raster-Ansicht"
>
<LayoutGrid className="w-4 h-4" />
</button>
<button
onClick={() => onViewModeChange('list')}
className={`p-2 rounded-md transition-colors ${
viewMode === 'list' ? 'bg-white shadow-sm text-blue-600' : 'text-slate-500 hover:text-slate-700'
}`}
title="Listen-Ansicht"
>
<List className="w-4 h-4" />
</button>
</div>
</div>
</div>
{/* Filter Dropdowns */}
{filterOpen && (
<div className="grid grid-cols-2 md:grid-cols-5 gap-3 pt-4 border-t border-slate-200">
<div>
<label className="block text-xs text-slate-500 mb-1">Fach</label>
<select
value={filterFach}
onChange={(e) => { onFachChange(e.target.value); onResetPage() }}
className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm focus:ring-2 focus:ring-blue-500"
>
<option value="">Alle Faecher</option>
{FAECHER.map(f => (
<option key={f.id} value={f.id}>{f.label}</option>
))}
</select>
</div>
<div>
<label className="block text-xs text-slate-500 mb-1">Jahr</label>
<select
value={filterJahr}
onChange={(e) => { onJahrChange(e.target.value); onResetPage() }}
className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm focus:ring-2 focus:ring-blue-500"
>
<option value="">Alle Jahre</option>
{JAHRE.map(j => (
<option key={j} value={j}>{j}</option>
))}
</select>
</div>
<div>
<label className="block text-xs text-slate-500 mb-1">Bundesland</label>
<select
value={filterBundesland}
onChange={(e) => { onBundeslandChange(e.target.value); onResetPage() }}
className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm focus:ring-2 focus:ring-blue-500"
>
<option value="">Alle Bundeslaender</option>
{BUNDESLAENDER.map(b => (
<option key={b.id} value={b.id}>{b.label}</option>
))}
</select>
</div>
<div>
<label className="block text-xs text-slate-500 mb-1">Niveau</label>
<select
value={filterNiveau}
onChange={(e) => { onNiveauChange(e.target.value); onResetPage() }}
className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm focus:ring-2 focus:ring-blue-500"
>
<option value="">Alle Niveaus</option>
{NIVEAUS.map(n => (
<option key={n.id} value={n.id}>{n.label}</option>
))}
</select>
</div>
<div>
<label className="block text-xs text-slate-500 mb-1">Typ</label>
<select
value={filterTyp}
onChange={(e) => { onTypChange(e.target.value); onResetPage() }}
className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm focus:ring-2 focus:ring-blue-500"
>
<option value="">Alle Typen</option>
{TYPEN.map(t => (
<option key={t.id} value={t.id}>{t.label}</option>
))}
</select>
</div>
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,147 @@
import { useState, useEffect, useCallback } from 'react'
import type { AbiturDokument } from '@/lib/education/abitur-docs-types'
import type { ViewMode, ThemaSuggestion } from '@/lib/education/abitur-archiv-types'
export function useAbiturArchiv() {
// Documents state
const [documents, setDocuments] = useState<AbiturDokument[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
// Pagination
const [page, setPage] = useState(1)
const [totalPages, setTotalPages] = useState(1)
const [total, setTotal] = useState(0)
const limit = 20
// View mode
const [viewMode, setViewMode] = useState<ViewMode>('grid')
// Filters
const [filterOpen, setFilterOpen] = useState(false)
const [filterFach, setFilterFach] = useState<string>('')
const [filterJahr, setFilterJahr] = useState<string>('')
const [filterBundesland, setFilterBundesland] = useState<string>('')
const [filterNiveau, setFilterNiveau] = useState<string>('')
const [filterTyp, setFilterTyp] = useState<string>('')
// Theme search
const [searchQuery, setSearchQuery] = useState<string>('')
const [themes, setThemes] = useState<ThemaSuggestion[]>([])
// Modal
const [selectedDocument, setSelectedDocument] = useState<AbiturDokument | null>(null)
// Stats
const [stats, setStats] = useState({ total: 0, indexed: 0, faecher: 0 })
const fetchDocuments = useCallback(async () => {
setLoading(true)
setError(null)
const params = new URLSearchParams()
params.set('page', page.toString())
params.set('limit', limit.toString())
if (filterFach) params.set('fach', filterFach)
if (filterJahr) params.set('jahr', filterJahr)
if (filterBundesland) params.set('bundesland', filterBundesland)
if (filterNiveau) params.set('niveau', filterNiveau)
if (filterTyp) params.set('typ', filterTyp)
if (searchQuery) params.set('thema', searchQuery)
try {
const response = await fetch(`/api/education/abitur-archiv?${params.toString()}`)
if (!response.ok) throw new Error('Fehler beim Laden der Dokumente')
const data = await response.json()
setDocuments(data.documents || [])
setTotalPages(data.total_pages || 1)
setTotal(data.total || 0)
setThemes(data.themes || [])
const indexed = (data.documents || []).filter((d: AbiturDokument) => d.status === 'indexed').length
const uniqueFaecher = new Set((data.documents || []).map((d: AbiturDokument) => d.fach)).size
setStats({ total: data.total || 0, indexed, faecher: uniqueFaecher })
} catch (err) {
setError(err instanceof Error ? err.message : 'Unbekannter Fehler')
} finally {
setLoading(false)
}
}, [page, filterFach, filterJahr, filterBundesland, filterNiveau, filterTyp, searchQuery])
useEffect(() => {
fetchDocuments()
}, [fetchDocuments])
const clearFilters = () => {
setFilterFach('')
setFilterJahr('')
setFilterBundesland('')
setFilterNiveau('')
setFilterTyp('')
setSearchQuery('')
setPage(1)
}
const handleSearch = (query: string) => {
setSearchQuery(query)
setPage(1)
}
const handleClearSearch = () => {
setSearchQuery('')
setPage(1)
}
const handleDownload = (doc: AbiturDokument) => {
const link = window.document.createElement('a')
link.href = doc.file_path
link.download = doc.dateiname
link.click()
}
const handleAddToKlausur = (doc: AbiturDokument) => {
const params = new URLSearchParams()
params.set('archiv_doc_id', doc.id)
params.set('aufgabentyp', doc.typ === 'erwartungshorizont' ? 'vorlage' : 'aufgabe')
window.location.href = `/education/klausur-korrektur?${params.toString()}`
}
const hasActiveFilters = !!(filterFach || filterJahr || filterBundesland || filterNiveau || filterTyp || searchQuery)
return {
documents,
loading,
error,
page,
setPage,
totalPages,
total,
limit,
viewMode,
setViewMode,
filterOpen,
setFilterOpen,
filterFach,
setFilterFach,
filterJahr,
setFilterJahr,
filterBundesland,
setFilterBundesland,
filterNiveau,
setFilterNiveau,
filterTyp,
setFilterTyp,
searchQuery,
selectedDocument,
setSelectedDocument,
stats,
clearFilters,
handleSearch,
handleClearSearch,
handleDownload,
handleAddToKlausur,
hasActiveFilters,
fetchDocuments,
}
}

View File

@@ -5,134 +5,49 @@
* Zentralabitur-Materialien 2021-2025 mit erweiterter Themensuche
*/
import { useState, useEffect, useCallback } from 'react'
import {
FileText, Filter, ChevronLeft, ChevronRight, Eye, Download, Search,
X, Loader2, Grid, List, LayoutGrid, BarChart3, Archive
} from 'lucide-react'
import type { AbiturDokument, AbiturDocsResponse } from '@/lib/education/abitur-docs-types'
import {
formatFileSize,
FAECHER,
JAHRE,
BUNDESLAENDER,
NIVEAUS,
TYPEN,
} from '@/lib/education/abitur-docs-types'
import type { ViewMode, ThemaSuggestion } from '@/lib/education/abitur-archiv-types'
import { Search, X, Archive } from 'lucide-react'
import { ThemenSuche } from './components/ThemenSuche'
import { DokumentCard } from './components/DokumentCard'
import { FullscreenViewer } from './components/FullscreenViewer'
import { useAbiturArchiv } from './_components/useAbiturArchiv'
import { FilterBar } from './_components/FilterBar'
import { DocumentDisplay } from './_components/DocumentDisplay'
export default function AbiturArchivPage() {
// Documents state
const [documents, setDocuments] = useState<AbiturDokument[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
// Pagination
const [page, setPage] = useState(1)
const [totalPages, setTotalPages] = useState(1)
const [total, setTotal] = useState(0)
const limit = 20
// View mode
const [viewMode, setViewMode] = useState<ViewMode>('grid')
// Filters
const [filterOpen, setFilterOpen] = useState(false)
const [filterFach, setFilterFach] = useState<string>('')
const [filterJahr, setFilterJahr] = useState<string>('')
const [filterBundesland, setFilterBundesland] = useState<string>('')
const [filterNiveau, setFilterNiveau] = useState<string>('')
const [filterTyp, setFilterTyp] = useState<string>('')
// Theme search
const [searchQuery, setSearchQuery] = useState<string>('')
const [themes, setThemes] = useState<ThemaSuggestion[]>([])
// Modal
const [selectedDocument, setSelectedDocument] = useState<AbiturDokument | null>(null)
// Stats
const [stats, setStats] = useState({ total: 0, indexed: 0, faecher: 0 })
// Fetch documents
const fetchDocuments = useCallback(async () => {
setLoading(true)
setError(null)
const params = new URLSearchParams()
params.set('page', page.toString())
params.set('limit', limit.toString())
if (filterFach) params.set('fach', filterFach)
if (filterJahr) params.set('jahr', filterJahr)
if (filterBundesland) params.set('bundesland', filterBundesland)
if (filterNiveau) params.set('niveau', filterNiveau)
if (filterTyp) params.set('typ', filterTyp)
if (searchQuery) params.set('thema', searchQuery)
try {
const response = await fetch(`/api/education/abitur-archiv?${params.toString()}`)
if (!response.ok) throw new Error('Fehler beim Laden der Dokumente')
const data = await response.json()
setDocuments(data.documents || [])
setTotalPages(data.total_pages || 1)
setTotal(data.total || 0)
setThemes(data.themes || [])
// Update stats
const indexed = (data.documents || []).filter((d: AbiturDokument) => d.status === 'indexed').length
const uniqueFaecher = new Set((data.documents || []).map((d: AbiturDokument) => d.fach)).size
setStats({ total: data.total || 0, indexed, faecher: uniqueFaecher })
} catch (err) {
setError(err instanceof Error ? err.message : 'Unbekannter Fehler')
} finally {
setLoading(false)
}
}, [page, filterFach, filterJahr, filterBundesland, filterNiveau, filterTyp, searchQuery])
useEffect(() => {
fetchDocuments()
}, [fetchDocuments])
const clearFilters = () => {
setFilterFach('')
setFilterJahr('')
setFilterBundesland('')
setFilterNiveau('')
setFilterTyp('')
setSearchQuery('')
setPage(1)
}
const handleSearch = (query: string) => {
setSearchQuery(query)
setPage(1)
}
const handleClearSearch = () => {
setSearchQuery('')
setPage(1)
}
const handleDownload = (doc: AbiturDokument) => {
const link = window.document.createElement('a')
link.href = doc.file_path
link.download = doc.dateiname
link.click()
}
const handleAddToKlausur = (doc: AbiturDokument) => {
// Navigate to klausur-korrektur with document reference
const params = new URLSearchParams()
params.set('archiv_doc_id', doc.id)
params.set('aufgabentyp', doc.typ === 'erwartungshorizont' ? 'vorlage' : 'aufgabe')
window.location.href = `/education/klausur-korrektur?${params.toString()}`
}
const hasActiveFilters = filterFach || filterJahr || filterBundesland || filterNiveau || filterTyp || searchQuery
const {
documents,
loading,
error,
page,
setPage,
totalPages,
total,
limit,
viewMode,
setViewMode,
filterOpen,
setFilterOpen,
filterFach,
setFilterFach,
filterJahr,
setFilterJahr,
filterBundesland,
setFilterBundesland,
filterNiveau,
setFilterNiveau,
filterTyp,
setFilterTyp,
searchQuery,
selectedDocument,
setSelectedDocument,
stats,
clearFilters,
handleSearch,
handleClearSearch,
handleDownload,
handleAddToKlausur,
hasActiveFilters,
fetchDocuments,
} = useAbiturArchiv()
return (
<div className="min-h-screen bg-slate-50">
@@ -173,154 +88,30 @@ export default function AbiturArchivPage() {
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6 space-y-6">
{/* Theme Search */}
<div className="bg-white rounded-xl border border-slate-200 p-6">
<ThemenSuche
onSearch={handleSearch}
onClear={handleClearSearch}
<ThemenSuche onSearch={handleSearch} onClear={handleClearSearch} />
</div>
<FilterBar
filterOpen={filterOpen}
onToggleFilter={() => setFilterOpen(!filterOpen)}
hasActiveFilters={hasActiveFilters}
onClearFilters={clearFilters}
total={total}
viewMode={viewMode}
onViewModeChange={setViewMode}
filterFach={filterFach}
filterJahr={filterJahr}
filterBundesland={filterBundesland}
filterNiveau={filterNiveau}
filterTyp={filterTyp}
onFachChange={setFilterFach}
onJahrChange={setFilterJahr}
onBundeslandChange={setFilterBundesland}
onNiveauChange={setFilterNiveau}
onTypChange={setFilterTyp}
onResetPage={() => setPage(1)}
searchQuery={searchQuery}
/>
</div>
{/* Filter Bar */}
<div className="bg-white rounded-xl border border-slate-200 p-4">
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-3">
<button
onClick={() => setFilterOpen(!filterOpen)}
className={`px-4 py-2 rounded-lg font-medium flex items-center gap-2 transition-colors ${
filterOpen || hasActiveFilters
? 'bg-purple-100 text-purple-700'
: 'bg-slate-100 text-slate-600 hover:bg-slate-200'
}`}
>
<Filter className="w-4 h-4" />
Filter
{hasActiveFilters && (
<span className="bg-purple-600 text-white text-xs px-1.5 py-0.5 rounded-full">
{[filterFach, filterJahr, filterBundesland, filterNiveau, filterTyp, searchQuery].filter(Boolean).length}
</span>
)}
</button>
{hasActiveFilters && (
<button
onClick={clearFilters}
className="text-sm text-slate-500 hover:text-slate-700 flex items-center gap-1"
>
<X className="w-4 h-4" />
Filter zuruecksetzen
</button>
)}
</div>
<div className="flex items-center gap-3">
{/* Results count */}
<span className="text-sm text-slate-500">
{total} Treffer
</span>
{/* View Mode Toggle */}
<div className="flex bg-slate-100 rounded-lg p-1">
<button
onClick={() => setViewMode('grid')}
className={`p-2 rounded-md transition-colors ${
viewMode === 'grid' ? 'bg-white shadow-sm text-blue-600' : 'text-slate-500 hover:text-slate-700'
}`}
title="Raster-Ansicht"
>
<LayoutGrid className="w-4 h-4" />
</button>
<button
onClick={() => setViewMode('list')}
className={`p-2 rounded-md transition-colors ${
viewMode === 'list' ? 'bg-white shadow-sm text-blue-600' : 'text-slate-500 hover:text-slate-700'
}`}
title="Listen-Ansicht"
>
<List className="w-4 h-4" />
</button>
</div>
</div>
</div>
{/* Filter Dropdowns */}
{filterOpen && (
<div className="grid grid-cols-2 md:grid-cols-5 gap-3 pt-4 border-t border-slate-200">
{/* Fach */}
<div>
<label className="block text-xs text-slate-500 mb-1">Fach</label>
<select
value={filterFach}
onChange={(e) => { setFilterFach(e.target.value); setPage(1) }}
className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm focus:ring-2 focus:ring-blue-500"
>
<option value="">Alle Faecher</option>
{FAECHER.map(f => (
<option key={f.id} value={f.id}>{f.label}</option>
))}
</select>
</div>
{/* Jahr */}
<div>
<label className="block text-xs text-slate-500 mb-1">Jahr</label>
<select
value={filterJahr}
onChange={(e) => { setFilterJahr(e.target.value); setPage(1) }}
className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm focus:ring-2 focus:ring-blue-500"
>
<option value="">Alle Jahre</option>
{JAHRE.map(j => (
<option key={j} value={j}>{j}</option>
))}
</select>
</div>
{/* Bundesland */}
<div>
<label className="block text-xs text-slate-500 mb-1">Bundesland</label>
<select
value={filterBundesland}
onChange={(e) => { setFilterBundesland(e.target.value); setPage(1) }}
className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm focus:ring-2 focus:ring-blue-500"
>
<option value="">Alle Bundeslaender</option>
{BUNDESLAENDER.map(b => (
<option key={b.id} value={b.id}>{b.label}</option>
))}
</select>
</div>
{/* Niveau */}
<div>
<label className="block text-xs text-slate-500 mb-1">Niveau</label>
<select
value={filterNiveau}
onChange={(e) => { setFilterNiveau(e.target.value); setPage(1) }}
className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm focus:ring-2 focus:ring-blue-500"
>
<option value="">Alle Niveaus</option>
{NIVEAUS.map(n => (
<option key={n.id} value={n.id}>{n.label}</option>
))}
</select>
</div>
{/* Typ */}
<div>
<label className="block text-xs text-slate-500 mb-1">Typ</label>
<select
value={filterTyp}
onChange={(e) => { setFilterTyp(e.target.value); setPage(1) }}
className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm focus:ring-2 focus:ring-blue-500"
>
<option value="">Alle Typen</option>
{TYPEN.map(t => (
<option key={t.id} value={t.id}>{t.label}</option>
))}
</select>
</div>
</div>
)}
</div>
{/* Active Search Query Display */}
{searchQuery && (
@@ -338,171 +129,23 @@ export default function AbiturArchivPage() {
</div>
)}
{/* Document Display */}
<div className="bg-white rounded-xl border border-slate-200 overflow-hidden">
{loading ? (
<div className="flex items-center justify-center py-16">
<Loader2 className="w-8 h-8 text-blue-600 animate-spin" />
</div>
) : error ? (
<div className="text-center py-16 text-red-600">
<p>{error}</p>
<button
onClick={() => fetchDocuments()}
className="mt-2 text-sm text-blue-600 hover:underline"
>
Erneut versuchen
</button>
</div>
) : documents.length === 0 ? (
<div className="text-center py-16 text-slate-500">
<FileText className="w-12 h-12 mx-auto mb-3 opacity-50" />
<p>Keine Dokumente gefunden</p>
{hasActiveFilters && (
<button
onClick={clearFilters}
className="mt-2 text-sm text-blue-600 hover:underline"
>
Filter zuruecksetzen
</button>
)}
</div>
) : viewMode === 'grid' ? (
/* Grid View */
<div className="p-4">
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
{documents.map((doc) => (
<DokumentCard
key={doc.id}
document={doc}
onPreview={setSelectedDocument}
<DocumentDisplay
documents={documents}
loading={loading}
error={error}
viewMode={viewMode}
hasActiveFilters={hasActiveFilters}
onClearFilters={clearFilters}
onSelectDocument={setSelectedDocument}
onDownload={handleDownload}
onAddToKlausur={handleAddToKlausur}
onRetry={fetchDocuments}
page={page}
totalPages={totalPages}
total={total}
limit={limit}
onPageChange={setPage}
/>
))}
</div>
</div>
) : (
/* List View */
<table className="w-full text-sm">
<thead className="bg-slate-50 border-b border-slate-200">
<tr>
<th className="text-left px-4 py-3 font-medium text-slate-600">Dokument</th>
<th className="text-left px-4 py-3 font-medium text-slate-600">Fach</th>
<th className="text-center px-4 py-3 font-medium text-slate-600">Jahr</th>
<th className="text-center px-4 py-3 font-medium text-slate-600">Niveau</th>
<th className="text-center px-4 py-3 font-medium text-slate-600">Typ</th>
<th className="text-right px-4 py-3 font-medium text-slate-600">Groesse</th>
<th className="text-center px-4 py-3 font-medium text-slate-600">Status</th>
<th className="text-center px-4 py-3 font-medium text-slate-600">Aktion</th>
</tr>
</thead>
<tbody>
{documents.map((doc) => {
const fachLabel = FAECHER.find(f => f.id === doc.fach)?.label || doc.fach
return (
<tr
key={doc.id}
className="border-b border-slate-100 hover:bg-slate-50 cursor-pointer"
onClick={() => setSelectedDocument(doc)}
>
<td className="px-4 py-3">
<div className="flex items-center gap-2">
<FileText className="w-4 h-4 text-red-500" />
<span className="font-medium text-slate-900 truncate max-w-[200px]" title={doc.dateiname}>
{doc.dateiname}
</span>
</div>
</td>
<td className="px-4 py-3">
<span className="capitalize">{fachLabel}</span>
</td>
<td className="px-4 py-3 text-center">{doc.jahr}</td>
<td className="px-4 py-3 text-center">
<span className={`px-2 py-0.5 rounded-full text-xs ${
doc.niveau === 'eA'
? 'bg-blue-100 text-blue-700'
: 'bg-slate-100 text-slate-700'
}`}>
{doc.niveau}
</span>
</td>
<td className="px-4 py-3 text-center">
<span className={`px-2 py-0.5 rounded-full text-xs ${
doc.typ === 'erwartungshorizont'
? 'bg-orange-100 text-orange-700'
: 'bg-purple-100 text-purple-700'
}`}>
{doc.typ === 'erwartungshorizont' ? 'EWH' : 'Aufgabe'}
</span>
</td>
<td className="px-4 py-3 text-right text-slate-500">
{formatFileSize(doc.file_size)}
</td>
<td className="px-4 py-3 text-center">
<span className={`px-2 py-0.5 rounded-full text-xs ${
doc.status === 'indexed'
? 'bg-green-100 text-green-700'
: doc.status === 'error'
? 'bg-red-100 text-red-700'
: 'bg-yellow-100 text-yellow-700'
}`}>
{doc.status === 'indexed' ? 'Indexiert' : doc.status === 'error' ? 'Fehler' : 'Ausstehend'}
</span>
</td>
<td className="px-4 py-3 text-center">
<div className="flex items-center justify-center gap-1" onClick={(e) => e.stopPropagation()}>
<button
onClick={() => setSelectedDocument(doc)}
className="p-1.5 text-blue-600 hover:bg-blue-50 rounded"
title="Vorschau"
>
<Eye className="w-4 h-4" />
</button>
<button
onClick={() => handleDownload(doc)}
className="p-1.5 text-slate-600 hover:bg-slate-100 rounded"
title="Download"
>
<Download className="w-4 h-4" />
</button>
</div>
</td>
</tr>
)
})}
</tbody>
</table>
)}
{/* Pagination */}
{documents.length > 0 && (
<div className="flex items-center justify-between px-4 py-3 border-t border-slate-200 bg-slate-50">
<div className="text-sm text-slate-500">
Zeige {(page - 1) * limit + 1}-{Math.min(page * limit, total)} von {total} Dokumenten
</div>
<div className="flex items-center gap-2">
<button
onClick={() => setPage(p => Math.max(1, p - 1))}
disabled={page === 1}
className="p-2 rounded-lg hover:bg-slate-200 disabled:opacity-50 disabled:cursor-not-allowed"
>
<ChevronLeft className="w-4 h-4" />
</button>
<span className="text-sm text-slate-600">
Seite {page} von {totalPages}
</span>
<button
onClick={() => setPage(p => Math.min(totalPages, p + 1))}
disabled={page === totalPages}
className="p-2 rounded-lg hover:bg-slate-200 disabled:opacity-50 disabled:cursor-not-allowed"
>
<ChevronRight className="w-4 h-4" />
</button>
</div>
</div>
)}
</div>
</div>
{/* Fullscreen Viewer Modal */}

View File

@@ -0,0 +1,252 @@
'use client'
/**
* Right panel (1/3 width): Tabs for Kriterien, Annotationen, Gutachten, EH-Vorschlaege.
*/
import { AnnotationPanel, EHSuggestionPanel } from '../../../components'
import type {
Klausur,
Annotation,
AnnotationType,
GradeInfo,
CriteriaScores,
} from '../../../types'
import type { ExaminerWorkflow, ActiveTab, GradeTotals } from './workspace-types'
import { API_BASE } from './workspace-types'
import { CriteriaTab } from './CriteriaTab'
interface CorrectionPanelProps {
// Data
klausur: Klausur | null
klausurId: string
studentId: string
annotations: Annotation[]
gradeInfo: GradeInfo | null
criteriaScores: CriteriaScores
gutachten: string
workflow: ExaminerWorkflow | null
totals: GradeTotals
selectedAnnotation: Annotation | null
// UI flags
activeTab: ActiveTab
generatingGutachten: boolean
saving: boolean
exporting: boolean
submittingWorkflow: boolean
// Actions
onSetActiveTab: (tab: ActiveTab) => void
onCriteriaChange: (criterion: string, value: number) => void
onSelectTool: (tool: AnnotationType) => void
onSelectAnnotation: (ann: Annotation | null) => void
onUpdateAnnotation: (id: string, updates: Partial<Annotation>) => Promise<void>
onDeleteAnnotation: (id: string) => Promise<void>
onSetGutachten: (text: string | ((prev: string) => string)) => void
onGenerateGutachten: () => Promise<void>
onSaveGutachten: () => Promise<void>
onExportGutachtenPDF: () => Promise<void>
onSubmitErstkorrektur: () => Promise<void>
onStartZweitkorrektur: (zkId: string) => Promise<void>
onSubmitZweitkorrektur: () => Promise<void>
onOpenEinigung: () => void
}
export function CorrectionPanel({
klausur,
klausurId,
studentId,
annotations,
gradeInfo,
criteriaScores,
gutachten,
workflow,
totals,
selectedAnnotation,
activeTab,
generatingGutachten,
saving,
exporting,
submittingWorkflow,
onSetActiveTab,
onCriteriaChange,
onSelectTool,
onSelectAnnotation,
onUpdateAnnotation,
onDeleteAnnotation,
onSetGutachten,
onGenerateGutachten,
onSaveGutachten,
onExportGutachtenPDF,
onSubmitErstkorrektur,
onStartZweitkorrektur,
onSubmitZweitkorrektur,
onOpenEinigung,
}: CorrectionPanelProps) {
return (
<div className="w-1/3 bg-white rounded-lg border border-slate-200 overflow-hidden flex flex-col">
{/* Tabs */}
<div className="border-b border-slate-200">
<nav className="flex">
{[
{ id: 'kriterien' as const, label: 'Kriterien' },
{ id: 'annotationen' as const, label: `Notizen (${annotations.length})` },
{ id: 'gutachten' as const, label: 'Gutachten' },
{ id: 'eh-vorschlaege' as const, label: 'EH' },
].map((tab) => (
<button
key={tab.id}
onClick={() => onSetActiveTab(tab.id)}
className={`flex-1 px-2 py-3 text-xs font-medium border-b-2 transition-colors ${
activeTab === tab.id
? 'border-primary-500 text-primary-600'
: 'border-transparent text-slate-500 hover:text-slate-700'
}`}
>
{tab.label}
</button>
))}
</nav>
</div>
{/* Tab content */}
<div className="flex-1 overflow-auto p-4">
{/* Kriterien Tab */}
{activeTab === 'kriterien' && gradeInfo && (
<CriteriaTab
gradeInfo={gradeInfo}
criteriaScores={criteriaScores}
annotations={annotations}
totals={totals}
workflow={workflow}
generatingGutachten={generatingGutachten}
submittingWorkflow={submittingWorkflow}
gutachten={gutachten}
onCriteriaChange={onCriteriaChange}
onSelectTool={onSelectTool}
onGenerateGutachten={onGenerateGutachten}
onSubmitErstkorrektur={onSubmitErstkorrektur}
onStartZweitkorrektur={onStartZweitkorrektur}
onSubmitZweitkorrektur={onSubmitZweitkorrektur}
onOpenEinigung={onOpenEinigung}
/>
)}
{/* Annotationen Tab */}
{activeTab === 'annotationen' && (
<div className="h-full -m-4">
<AnnotationPanel
annotations={annotations}
selectedAnnotation={selectedAnnotation}
onSelectAnnotation={onSelectAnnotation}
onUpdateAnnotation={onUpdateAnnotation}
onDeleteAnnotation={onDeleteAnnotation}
/>
</div>
)}
{/* Gutachten Tab */}
{activeTab === 'gutachten' && (
<GutachtenTab
gutachten={gutachten}
generatingGutachten={generatingGutachten}
saving={saving}
exporting={exporting}
onSetGutachten={onSetGutachten}
onGenerateGutachten={onGenerateGutachten}
onSaveGutachten={onSaveGutachten}
onExportGutachtenPDF={onExportGutachtenPDF}
/>
)}
{/* EH-Vorschlaege Tab */}
{activeTab === 'eh-vorschlaege' && (
<div className="h-full -m-4">
<EHSuggestionPanel
studentId={studentId}
klausurId={klausurId}
hasEH={!!klausur?.eh_id || true}
apiBase={API_BASE}
onInsertSuggestion={(text, criterion) => {
onSetGutachten((prev: string) =>
prev
? `${prev}\n\n[${criterion.toUpperCase()}]: ${text}`
: `[${criterion.toUpperCase()}]: ${text}`,
)
onSetActiveTab('gutachten')
}}
/>
</div>
)}
</div>
</div>
)
}
// ---- Gutachten sub-component ----
interface GutachtenTabProps {
gutachten: string
generatingGutachten: boolean
saving: boolean
exporting: boolean
onSetGutachten: (text: string | ((prev: string) => string)) => void
onGenerateGutachten: () => void
onSaveGutachten: () => void
onExportGutachtenPDF: () => void
}
function GutachtenTab({
gutachten,
generatingGutachten,
saving,
exporting,
onSetGutachten,
onGenerateGutachten,
onSaveGutachten,
onExportGutachtenPDF,
}: GutachtenTabProps) {
return (
<div className="h-full flex flex-col">
<textarea
value={gutachten}
onChange={(e) => onSetGutachten(e.target.value)}
placeholder="Gutachten hier eingeben oder generieren lassen..."
className="flex-1 w-full p-3 border border-slate-300 rounded-lg resize-none focus:ring-2 focus:ring-primary-500 focus:border-transparent text-sm"
/>
<div className="flex gap-2 mt-4">
<button
onClick={onGenerateGutachten}
disabled={generatingGutachten}
className="flex-1 py-2 border border-primary-600 text-primary-600 rounded-lg hover:bg-primary-50 disabled:opacity-50"
>
{generatingGutachten ? 'Generiere...' : 'Neu generieren'}
</button>
<button
onClick={onSaveGutachten}
disabled={saving}
className="flex-1 py-2 bg-primary-600 text-white rounded-lg hover:bg-primary-700 disabled:opacity-50"
>
{saving ? 'Speichern...' : 'Speichern'}
</button>
</div>
{/* PDF Export */}
{gutachten && (
<div className="flex gap-2 mt-2 pt-2 border-t border-slate-200">
<button
onClick={onExportGutachtenPDF}
disabled={exporting}
className="flex-1 py-2 border border-slate-300 text-slate-600 rounded-lg hover:bg-slate-50 disabled:opacity-50 flex items-center justify-center gap-2 text-sm"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 10v6m0 0l-3-3m3 3l3-3m2 8H7a2 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>
{exporting ? 'Exportiere...' : 'Als PDF exportieren'}
</button>
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,384 @@
'use client'
/**
* Kriterien tab content: scoring sliders, annotation counts per criterion,
* totals summary, and workflow action buttons.
*/
import type { Annotation, AnnotationType, GradeInfo, CriteriaScores } from '../../../types'
import { ANNOTATION_COLORS } from '../../../types'
import type { ExaminerWorkflow, GradeTotals } from './workspace-types'
import { GRADE_LABELS } from './workspace-types'
interface CriteriaTabProps {
gradeInfo: GradeInfo
criteriaScores: CriteriaScores
annotations: Annotation[]
totals: GradeTotals
workflow: ExaminerWorkflow | null
generatingGutachten: boolean
submittingWorkflow: boolean
gutachten: string
onCriteriaChange: (criterion: string, value: number) => void
onSelectTool: (tool: AnnotationType) => void
onGenerateGutachten: () => void
onSubmitErstkorrektur: () => void
onStartZweitkorrektur: (zkId: string) => void
onSubmitZweitkorrektur: () => void
onOpenEinigung: () => void
}
export function CriteriaTab({
gradeInfo,
criteriaScores,
annotations,
totals,
workflow,
generatingGutachten,
submittingWorkflow,
gutachten,
onCriteriaChange,
onSelectTool,
onGenerateGutachten,
onSubmitErstkorrektur,
onStartZweitkorrektur,
onSubmitZweitkorrektur,
onOpenEinigung,
}: CriteriaTabProps) {
return (
<div className="space-y-4">
{Object.entries(gradeInfo.criteria || {}).map(([key, criterion]) => {
const score = criteriaScores[key] || 0
const linkedAnnotations = annotations.filter(
(a) => a.linked_criterion === key || a.type === key,
)
const errorCount = linkedAnnotations.length
const severityCounts = {
minor: linkedAnnotations.filter((a) => a.severity === 'minor').length,
major: linkedAnnotations.filter((a) => a.severity === 'major').length,
critical: linkedAnnotations.filter((a) => a.severity === 'critical').length,
}
const criterionColor = ANNOTATION_COLORS[key as AnnotationType] || '#6b7280'
return (
<div key={key} className="bg-slate-50 rounded-lg p-4">
<div className="flex justify-between items-center mb-2">
<div className="flex items-center gap-2">
<div
className="w-3 h-3 rounded-full"
style={{ backgroundColor: criterionColor }}
/>
<span className="font-medium text-slate-800">{criterion.name}</span>
<span className="text-xs text-slate-500">({criterion.weight}%)</span>
</div>
<div className="text-lg font-bold text-slate-800">{score}%</div>
</div>
{/* Annotation count for this criterion */}
{errorCount > 0 && (
<div className="flex items-center gap-2 mb-2 text-xs">
<span className="text-slate-500">{errorCount} Markierungen:</span>
{severityCounts.minor > 0 && (
<span className="px-1.5 py-0.5 bg-yellow-100 text-yellow-700 rounded">
{severityCounts.minor} leicht
</span>
)}
{severityCounts.major > 0 && (
<span className="px-1.5 py-0.5 bg-orange-100 text-orange-700 rounded">
{severityCounts.major} mittel
</span>
)}
{severityCounts.critical > 0 && (
<span className="px-1.5 py-0.5 bg-red-100 text-red-700 rounded">
{severityCounts.critical} schwer
</span>
)}
</div>
)}
{/* Slider */}
<input
type="range"
min="0"
max="100"
value={score}
onChange={(e) => onCriteriaChange(key, parseInt(e.target.value))}
className="w-full h-2 bg-slate-200 rounded-lg appearance-none cursor-pointer"
style={{ accentColor: criterionColor }}
/>
{/* Quick buttons */}
<div className="flex gap-1 mt-2">
{[0, 25, 50, 75, 100].map((val) => (
<button
key={val}
onClick={() => onCriteriaChange(key, val)}
className={`flex-1 py-1 text-xs rounded transition-colors ${
score === val
? 'text-white'
: 'bg-slate-200 text-slate-600 hover:bg-slate-300'
}`}
style={score === val ? { backgroundColor: criterionColor } : undefined}
>
{val}%
</button>
))}
</div>
{/* Quick add annotation button for RS/Grammatik */}
{(key === 'rechtschreibung' || key === 'grammatik') && (
<button
onClick={() => onSelectTool(key as AnnotationType)}
className="mt-2 w-full py-1 text-xs border rounded hover:bg-slate-100 flex items-center justify-center gap-1"
style={{ borderColor: criterionColor, color: criterionColor }}
>
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
</svg>
{key === 'rechtschreibung' ? 'RS-Fehler' : 'Grammatik-Fehler'} markieren
</button>
)}
</div>
)
})}
{/* Total and action buttons */}
<CriteriaTotals
totals={totals}
workflow={workflow}
generatingGutachten={generatingGutachten}
submittingWorkflow={submittingWorkflow}
gutachten={gutachten}
onGenerateGutachten={onGenerateGutachten}
onSubmitErstkorrektur={onSubmitErstkorrektur}
onStartZweitkorrektur={onStartZweitkorrektur}
onSubmitZweitkorrektur={onSubmitZweitkorrektur}
onOpenEinigung={onOpenEinigung}
/>
</div>
)
}
// ---- Sub-component: totals + workflow buttons ----
interface CriteriaTotalsProps {
totals: GradeTotals
workflow: ExaminerWorkflow | null
generatingGutachten: boolean
submittingWorkflow: boolean
gutachten: string
onGenerateGutachten: () => void
onSubmitErstkorrektur: () => void
onStartZweitkorrektur: (zkId: string) => void
onSubmitZweitkorrektur: () => void
onOpenEinigung: () => void
}
function CriteriaTotals({
totals,
workflow,
generatingGutachten,
submittingWorkflow,
gutachten,
onGenerateGutachten,
onSubmitErstkorrektur,
onStartZweitkorrektur,
onSubmitZweitkorrektur,
onOpenEinigung,
}: CriteriaTotalsProps) {
return (
<div className="border-t border-slate-200 pt-4 mt-4">
<div className="flex justify-between items-center mb-4">
<span className="font-semibold text-slate-800">Gesamtergebnis</span>
<div className="text-right">
<div className="text-2xl font-bold text-primary-600">
{totals.gradePoints} Punkte
</div>
<div className="text-sm text-slate-500">
({totals.weighted}%) - Note {GRADE_LABELS[totals.gradePoints]}
</div>
</div>
</div>
<div className="space-y-2">
{/* Generate Gutachten */}
<button
onClick={onGenerateGutachten}
disabled={generatingGutachten}
className="w-full py-2 bg-slate-100 text-slate-700 rounded-lg hover:bg-slate-200 disabled:opacity-50 flex items-center justify-center gap-2 text-sm"
>
{generatingGutachten ? (
<>
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-slate-700"></div>
Generiere Gutachten...
</>
) : (
<>
<svg className="w-4 h-4" 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>
Gutachten generieren
</>
)}
</button>
{/* Workflow buttons */}
<WorkflowButtons
workflow={workflow}
submittingWorkflow={submittingWorkflow}
gutachten={gutachten}
totals={totals}
onSubmitErstkorrektur={onSubmitErstkorrektur}
onStartZweitkorrektur={onStartZweitkorrektur}
onSubmitZweitkorrektur={onSubmitZweitkorrektur}
onOpenEinigung={onOpenEinigung}
/>
</div>
</div>
)
}
// ---- Sub-component: workflow action buttons ----
interface WorkflowButtonsProps {
workflow: ExaminerWorkflow | null
submittingWorkflow: boolean
gutachten: string
totals: GradeTotals
onSubmitErstkorrektur: () => void
onStartZweitkorrektur: (zkId: string) => void
onSubmitZweitkorrektur: () => void
onOpenEinigung: () => void
}
function WorkflowButtons({
workflow,
submittingWorkflow,
gutachten,
totals,
onSubmitErstkorrektur,
onStartZweitkorrektur,
onSubmitZweitkorrektur,
onOpenEinigung,
}: WorkflowButtonsProps) {
return (
<>
{/* EK submit */}
{(!workflow || workflow.workflow_status === 'not_started' || workflow.workflow_status === 'ek_in_progress') && (
<button
onClick={onSubmitErstkorrektur}
disabled={submittingWorkflow || !gutachten}
className="w-full py-3 bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:opacity-50 flex items-center justify-center gap-2"
>
{submittingWorkflow ? (
<>
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white"></div>
Wird abgeschlossen...
</>
) : (
<>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
Erstkorrektur abschliessen
</>
)}
</button>
)}
{/* Forward to ZK */}
{workflow?.workflow_status === 'ek_completed' && workflow.user_role === 'ek' && (
<button
onClick={() => {
const zkId = prompt('Zweitkorrektor-ID eingeben:')
if (zkId) onStartZweitkorrektur(zkId)
}}
disabled={submittingWorkflow}
className="w-full py-3 bg-amber-600 text-white rounded-lg hover:bg-amber-700 disabled:opacity-50 flex items-center justify-center gap-2"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M17 8l4 4m0 0l-4 4m4-4H3" />
</svg>
Zur Zweitkorrektur weiterleiten
</button>
)}
{/* ZK submit */}
{(workflow?.workflow_status === 'zk_assigned' || workflow?.workflow_status === 'zk_in_progress') &&
workflow?.user_role === 'zk' && (
<button
onClick={onSubmitZweitkorrektur}
disabled={submittingWorkflow || !gutachten}
className="w-full py-3 bg-amber-600 text-white rounded-lg hover:bg-amber-700 disabled:opacity-50 flex items-center justify-center gap-2"
>
{submittingWorkflow ? (
<>
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white"></div>
Wird abgeschlossen...
</>
) : (
<>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
Zweitkorrektur abschliessen
</>
)}
</button>
)}
{/* Einigung */}
{workflow?.workflow_status === 'einigung_required' && (
<button
onClick={onOpenEinigung}
className="w-full py-3 bg-orange-600 text-white rounded-lg hover:bg-orange-700 flex items-center justify-center gap-2"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z" />
</svg>
Einigung starten
</button>
)}
{/* Completed */}
{workflow?.workflow_status === 'completed' && (
<div className="bg-green-100 text-green-800 p-4 rounded-lg text-center">
<div className="text-2xl font-bold">
Endnote: {workflow.final_grade} Punkte
</div>
<div className="text-sm mt-1">
({GRADE_LABELS[workflow.final_grade || 0]}) -{' '}
{workflow.consensus_type === 'auto'
? 'Auto-Konsens'
: workflow.consensus_type === 'drittkorrektur'
? 'Drittkorrektur'
: 'Einigung'}
</div>
</div>
)}
{/* EK/ZK comparison */}
{workflow?.first_result && workflow?.second_result && workflow?.workflow_status !== 'completed' && (
<div className="bg-slate-50 rounded-lg p-3 mt-2">
<div className="text-xs text-slate-500 mb-2">Notenvergleich</div>
<div className="flex justify-between">
<div className="text-center">
<div className="text-sm text-slate-500">EK</div>
<div className="font-bold text-blue-600">{workflow.first_result.grade_points}P</div>
</div>
<div className="text-center">
<div className="text-sm text-slate-500">ZK</div>
<div className="font-bold text-amber-600">{workflow.second_result.grade_points}P</div>
</div>
<div className="text-center">
<div className="text-sm text-slate-500">Diff</div>
<div className={`font-bold ${(workflow.grade_difference || 0) >= 4 ? 'text-red-600' : 'text-slate-700'}`}>
{workflow.grade_difference}P
</div>
</div>
</div>
</div>
)}
</>
)
}

View File

@@ -0,0 +1,138 @@
'use client'
/**
* Left panel (2/3 width): Document viewer with annotation overlay,
* toolbar, page navigation, and collapsible OCR text.
*/
import { AnnotationLayer, AnnotationToolbar } from '../../../components'
import type { Annotation, AnnotationType, AnnotationPosition } from '../../../types'
interface DocumentViewerProps {
documentUrl: string | null
filePath?: string
ocrText?: string
zoom: number
currentPage: number
totalPages: number
annotations: Annotation[]
selectedTool: AnnotationType | null
selectedAnnotation: Annotation | null
annotationCounts: Record<AnnotationType, number>
onZoomChange: (zoom: number) => void
onSelectTool: (tool: AnnotationType | null) => void
onCurrentPageChange: (page: number | ((p: number) => number)) => void
onCreateAnnotation: (position: AnnotationPosition, type: AnnotationType) => void
onSelectAnnotation: (ann: Annotation) => void
}
export function DocumentViewer({
documentUrl,
filePath,
ocrText,
zoom,
currentPage,
totalPages,
annotations,
selectedTool,
selectedAnnotation,
annotationCounts,
onZoomChange,
onSelectTool,
onCurrentPageChange,
onCreateAnnotation,
onSelectAnnotation,
}: DocumentViewerProps) {
const pageAnnotations = annotations.filter((ann) => ann.page === currentPage)
return (
<div className="w-2/3 bg-white rounded-lg border border-slate-200 overflow-hidden flex flex-col">
{/* Toolbar */}
<AnnotationToolbar
selectedTool={selectedTool}
onSelectTool={onSelectTool}
zoom={zoom}
onZoomChange={onZoomChange}
annotationCounts={annotationCounts}
/>
{/* Document display with annotation overlay */}
<div className="flex-1 overflow-auto p-4 bg-slate-100">
{documentUrl ? (
<div
className="mx-auto bg-white shadow-lg relative"
style={{ transform: `scale(${zoom / 100})`, transformOrigin: 'top center' }}
>
{filePath?.endsWith('.pdf') ? (
<iframe
src={documentUrl}
className="w-full h-[800px] border-0"
title="Studentenarbeit"
/>
) : (
<div className="relative">
<img
src={documentUrl}
alt="Studentenarbeit"
className="max-w-full"
onError={(e) => {
(e.target as HTMLImageElement).src = '/placeholder-document.png'
}}
/>
{/* Annotation Layer Overlay */}
<AnnotationLayer
annotations={pageAnnotations}
selectedTool={selectedTool}
onCreateAnnotation={onCreateAnnotation}
onSelectAnnotation={onSelectAnnotation}
selectedAnnotationId={selectedAnnotation?.id}
/>
</div>
)}
</div>
) : (
<div className="flex items-center justify-center h-full text-slate-400">
Kein Dokument verfuegbar
</div>
)}
</div>
{/* Page navigation */}
<div className="border-t border-slate-200 p-2 flex items-center justify-center gap-2 bg-slate-50">
<button
onClick={() => onCurrentPageChange((p: number) => Math.max(1, p - 1))}
disabled={currentPage <= 1}
className="p-1 rounded hover:bg-slate-200 disabled:opacity-50"
>
<svg className="w-4 h-4" 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>
<span className="text-sm">
Seite {currentPage} / {totalPages}
</span>
<button
onClick={() => onCurrentPageChange((p: number) => Math.min(totalPages, p + 1))}
disabled={currentPage >= totalPages}
className="p-1 rounded hover:bg-slate-200 disabled:opacity-50"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
</svg>
</button>
</div>
{/* OCR Text (collapsible) */}
{ocrText && (
<details className="border-t border-slate-200">
<summary className="p-3 bg-slate-50 cursor-pointer text-sm font-medium text-slate-600 hover:bg-slate-100">
OCR-Text anzeigen
</summary>
<div className="p-4 max-h-48 overflow-auto text-sm text-slate-700 bg-slate-50">
<pre className="whitespace-pre-wrap font-sans">{ocrText}</pre>
</div>
</details>
)}
</div>
)
}

View File

@@ -0,0 +1,115 @@
'use client'
/**
* Modal for the Einigung (agreement) process between EK and ZK.
*/
import type { ExaminerWorkflow } from './workspace-types'
import { GRADE_LABELS } from './workspace-types'
interface EinigungModalProps {
workflow: ExaminerWorkflow
einigungGrade: number
einigungNotes: string
submittingWorkflow: boolean
onGradeChange: (grade: number) => void
onNotesChange: (notes: string) => void
onSubmit: (type: 'agreed' | 'split' | 'escalated') => void
onClose: () => void
}
export function EinigungModal({
workflow,
einigungGrade,
einigungNotes,
submittingWorkflow,
onGradeChange,
onNotesChange,
onSubmit,
onClose,
}: EinigungModalProps) {
return (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
<div className="bg-white rounded-lg shadow-xl p-6 w-full max-w-lg mx-4">
<h3 className="text-lg font-semibold mb-4">Einigung erforderlich</h3>
{/* Grade comparison */}
<div className="bg-slate-50 rounded-lg p-4 mb-4">
<div className="grid grid-cols-2 gap-4 text-center">
<div>
<div className="text-sm text-slate-500">Erstkorrektor</div>
<div className="text-2xl font-bold text-blue-600">
{workflow.first_result?.grade_points || '-'} P
</div>
</div>
<div>
<div className="text-sm text-slate-500">Zweitkorrektor</div>
<div className="text-2xl font-bold text-amber-600">
{workflow.second_result?.grade_points || '-'} P
</div>
</div>
</div>
<div className="text-center mt-2 text-sm text-slate-500">
Differenz: {workflow.grade_difference} Punkte
</div>
</div>
{/* Final grade selection */}
<div className="mb-4">
<label className="block text-sm font-medium text-slate-700 mb-2">
Endnote festlegen
</label>
<input
type="range"
min={Math.min(workflow.first_result?.grade_points || 0, workflow.second_result?.grade_points || 0) - 1}
max={Math.max(workflow.first_result?.grade_points || 15, workflow.second_result?.grade_points || 15) + 1}
value={einigungGrade}
onChange={(e) => onGradeChange(parseInt(e.target.value))}
className="w-full"
/>
<div className="text-center text-2xl font-bold mt-2">
{einigungGrade} Punkte ({GRADE_LABELS[einigungGrade] || '-'})
</div>
</div>
{/* Notes */}
<div className="mb-4">
<label className="block text-sm font-medium text-slate-700 mb-2">
Begruendung
</label>
<textarea
value={einigungNotes}
onChange={(e) => onNotesChange(e.target.value)}
placeholder="Begruendung fuer die Einigung..."
className="w-full p-2 border border-slate-300 rounded-lg text-sm"
rows={3}
/>
</div>
{/* Actions */}
<div className="flex gap-2">
<button
onClick={() => onSubmit('agreed')}
disabled={submittingWorkflow || !einigungNotes}
className="flex-1 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700 disabled:opacity-50"
>
Einigung bestaetigen
</button>
<button
onClick={() => onSubmit('escalated')}
disabled={submittingWorkflow}
className="py-2 px-4 bg-red-100 text-red-700 rounded-lg hover:bg-red-200"
>
Eskalieren
</button>
<button
onClick={onClose}
className="py-2 px-4 bg-slate-100 text-slate-700 rounded-lg hover:bg-slate-200"
>
Abbrechen
</button>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,114 @@
'use client'
/**
* Top navigation bar with back link, student navigation,
* workflow status badges, and grade display.
*/
import Link from 'next/link'
import type { ExaminerWorkflow, GradeTotals } from './workspace-types'
import { WORKFLOW_STATUS_LABELS, ROLE_LABELS, GRADE_LABELS } from './workspace-types'
interface TopNavigationBarProps {
klausurId: string
currentIndex: number
studentsCount: number
workflow: ExaminerWorkflow | null
saving: boolean
totals: GradeTotals
onGoToStudent: (direction: 'prev' | 'next') => void
}
export function TopNavigationBar({
klausurId,
currentIndex,
studentsCount,
workflow,
saving,
totals,
onGoToStudent,
}: TopNavigationBarProps) {
return (
<div className="bg-white border-b border-slate-200 px-6 py-3 flex items-center justify-between sticky top-0 z-10">
{/* Back link */}
<Link
href={`/education/klausur-korrektur/${klausurId}`}
className="text-primary-600 hover:text-primary-800 flex items-center gap-1 text-sm"
>
<svg className="w-4 h-4" 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
</Link>
{/* Student navigation */}
<div className="flex items-center gap-4">
<button
onClick={() => onGoToStudent('prev')}
disabled={currentIndex <= 0}
className="p-2 rounded-lg hover:bg-slate-100 disabled:opacity-50 disabled:cursor-not-allowed"
>
<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>
</button>
<span className="text-sm font-medium">
{currentIndex + 1} / {studentsCount}
</span>
<button
onClick={() => onGoToStudent('next')}
disabled={currentIndex >= studentsCount - 1}
className="p-2 rounded-lg hover:bg-slate-100 disabled:opacity-50 disabled:cursor-not-allowed"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
</svg>
</button>
</div>
{/* Workflow status and role */}
<div className="flex items-center gap-3">
{workflow && (
<div className="flex items-center gap-2">
<span
className={`px-2 py-1 text-xs font-medium rounded-full text-white ${
ROLE_LABELS[workflow.user_role]?.color || 'bg-slate-500'
}`}
>
{ROLE_LABELS[workflow.user_role]?.label || workflow.user_role}
</span>
<span
className={`px-2 py-1 text-xs font-medium rounded-full ${
WORKFLOW_STATUS_LABELS[workflow.workflow_status]?.color || 'bg-slate-100'
}`}
>
{WORKFLOW_STATUS_LABELS[workflow.workflow_status]?.label || workflow.workflow_status}
</span>
{workflow.user_role === 'zk' && workflow.visibility_mode !== 'full' && (
<span className="px-2 py-1 text-xs bg-purple-100 text-purple-700 rounded-full">
{workflow.visibility_mode === 'blind' ? 'Blind-Modus' : 'Semi-Blind'}
</span>
)}
</div>
)}
{saving && (
<span className="text-sm text-slate-500 flex items-center gap-1">
<div className="animate-spin rounded-full h-3 w-3 border-b-2 border-primary-600"></div>
Speichern...
</span>
)}
<div className="text-right">
<div className="text-lg font-bold text-slate-800">
{totals.gradePoints} Punkte
</div>
<div className="text-sm text-slate-500">
Note: {GRADE_LABELS[totals.gradePoints] || '-'}
</div>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,8 @@
export { useKorrekturWorkspace } from './useKorrekturWorkspace'
export { TopNavigationBar } from './TopNavigationBar'
export { EinigungModal } from './EinigungModal'
export { DocumentViewer } from './DocumentViewer'
export { CorrectionPanel } from './CorrectionPanel'
export { CriteriaTab } from './CriteriaTab'
export type { ExaminerWorkflow, ActiveTab, GradeTotals } from './workspace-types'
export { API_BASE, GRADE_LABELS, WORKFLOW_STATUS_LABELS, ROLE_LABELS } from './workspace-types'

View File

@@ -0,0 +1,454 @@
'use client'
/**
* Custom hook encapsulating all state, data-fetching, CRUD operations,
* and workflow actions for the Korrektur-Workspace page.
*/
import { useState, useEffect, useCallback, useMemo } from 'react'
import type {
Klausur, StudentWork, Annotation, CriteriaScores,
GradeInfo, AnnotationType, AnnotationPosition,
} from '../../../types'
import type { ExaminerWorkflow, ActiveTab, GradeTotals } from './workspace-types'
import { API_BASE } from './workspace-types'
/** Download a blob from url and trigger browser download with given filename. */
async function downloadBlob(url: string, filename: string) {
const res = await fetch(url)
if (!res.ok) throw new Error('Download failed')
const blob = await res.blob()
const blobUrl = window.URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = blobUrl; a.download = filename
document.body.appendChild(a); a.click()
document.body.removeChild(a); window.URL.revokeObjectURL(blobUrl)
}
export function useKorrekturWorkspace(
klausurId: string,
studentId: string,
routerPush: (url: string) => void,
) {
// ---- Core data state ----
const [klausur, setKlausur] = useState<Klausur | null>(null)
const [student, setStudent] = useState<StudentWork | null>(null)
const [students, setStudents] = useState<StudentWork[]>([])
const [annotations, setAnnotations] = useState<Annotation[]>([])
const [gradeInfo, setGradeInfo] = useState<GradeInfo | null>(null)
const [workflow, setWorkflow] = useState<ExaminerWorkflow | null>(null)
// ---- UI flags ----
const [loading, setLoading] = useState(true)
const [saving, setSaving] = useState(false)
const [error, setError] = useState<string | null>(null)
const [activeTab, setActiveTab] = useState<ActiveTab>('kriterien')
const [currentPage, setCurrentPage] = useState(1)
const [totalPages] = useState(1)
const [zoom, setZoom] = useState(100)
const [documentUrl, setDocumentUrl] = useState<string | null>(null)
const [generatingGutachten, setGeneratingGutachten] = useState(false)
const [exporting, setExporting] = useState(false)
// ---- Annotation state ----
const [selectedTool, setSelectedTool] = useState<AnnotationType | null>(null)
const [selectedAnnotation, setSelectedAnnotation] = useState<Annotation | null>(null)
// ---- Form state ----
const [criteriaScores, setCriteriaScores] = useState<CriteriaScores>({})
const [gutachten, setGutachten] = useState('')
// ---- Einigung state ----
const [showEinigungModal, setShowEinigungModal] = useState(false)
const [einigungGrade, setEinigungGrade] = useState<number>(0)
const [einigungNotes, setEinigungNotes] = useState('')
const [submittingWorkflow, setSubmittingWorkflow] = useState(false)
// ---- Derived ----
const currentIndex = students.findIndex(s => s.id === studentId)
const annotationCounts = useMemo(() => {
const counts: Record<AnnotationType, number> = {
rechtschreibung: 0, grammatik: 0, inhalt: 0,
struktur: 0, stil: 0, comment: 0, highlight: 0,
}
annotations.forEach((ann) => {
counts[ann.type] = (counts[ann.type] || 0) + 1
})
return counts
}, [annotations])
// ---- Grade calculation ----
const calculateTotalPoints = useCallback((): GradeTotals => {
if (!gradeInfo?.criteria) return { raw: 0, weighted: 0, gradePoints: 0 }
let totalWeighted = 0
let totalWeight = 0
Object.entries(gradeInfo.criteria).forEach(([key, criterion]) => {
const score = criteriaScores[key] || 0
totalWeighted += score * (criterion.weight / 100)
totalWeight += criterion.weight
})
const percentage = totalWeight > 0 ? (totalWeighted / totalWeight) * 100 : 0
let gradePoints = 0
const thresholds = [
{ points: 15, min: 95 }, { points: 14, min: 90 }, { points: 13, min: 85 },
{ points: 12, min: 80 }, { points: 11, min: 75 }, { points: 10, min: 70 },
{ points: 9, min: 65 }, { points: 8, min: 60 }, { points: 7, min: 55 },
{ points: 6, min: 50 }, { points: 5, min: 45 }, { points: 4, min: 40 },
{ points: 3, min: 33 }, { points: 2, min: 27 }, { points: 1, min: 20 },
]
for (const t of thresholds) {
if (percentage >= t.min) { gradePoints = t.points; break }
}
return { raw: Math.round(totalWeighted), weighted: Math.round(percentage), gradePoints }
}, [gradeInfo, criteriaScores])
const totals = calculateTotalPoints()
// ---- Data fetching ----
const fetchData = useCallback(async () => {
try {
setLoading(true)
const klausurRes = await fetch(`${API_BASE}/api/v1/klausuren/${klausurId}`)
if (klausurRes.ok) setKlausur(await klausurRes.json())
const studentsRes = await fetch(`${API_BASE}/api/v1/klausuren/${klausurId}/students`)
if (studentsRes.ok) {
const data = await studentsRes.json()
setStudents(Array.isArray(data) ? data : data.students || [])
}
const studentRes = await fetch(`${API_BASE}/api/v1/students/${studentId}`)
if (studentRes.ok) {
const studentData = await studentRes.json()
setStudent(studentData)
setCriteriaScores(studentData.criteria_scores || {})
setGutachten(studentData.gutachten || '')
}
const gradeInfoRes = await fetch(`${API_BASE}/api/v1/grade-info`)
if (gradeInfoRes.ok) setGradeInfo(await gradeInfoRes.json())
const workflowRes = await fetch(`${API_BASE}/api/v1/students/${studentId}/examiner-workflow`)
if (workflowRes.ok) {
const workflowData = await workflowRes.json()
setWorkflow(workflowData)
if (workflowData.workflow_status === 'einigung_required' && workflowData.first_result && workflowData.second_result) {
const avgGrade = Math.round((workflowData.first_result.grade_points + workflowData.second_result.grade_points) / 2)
setEinigungGrade(avgGrade)
}
}
const annotationsEndpoint = workflow?.user_role === 'zk'
? `${API_BASE}/api/v1/students/${studentId}/annotations-filtered`
: `${API_BASE}/api/v1/students/${studentId}/annotations`
const annotationsRes = await fetch(annotationsEndpoint)
if (annotationsRes.ok) {
const annotationsData = await annotationsRes.json()
setAnnotations(Array.isArray(annotationsData) ? annotationsData : annotationsData.annotations || [])
}
setDocumentUrl(`${API_BASE}/api/v1/students/${studentId}/file`)
setError(null)
} catch (err) {
console.error('Failed to fetch data:', err)
setError('Fehler beim Laden der Daten')
} finally {
setLoading(false)
}
}, [klausurId, studentId])
useEffect(() => { fetchData() }, [fetchData])
// ---- Annotation CRUD ----
const createAnnotation = useCallback(async (position: AnnotationPosition, type: AnnotationType) => {
try {
const newAnnotation = {
page: currentPage, position, type, text: '',
severity: type === 'rechtschreibung' || type === 'grammatik' ? 'minor' : 'major',
role: 'first_examiner',
linked_criterion: ['rechtschreibung', 'grammatik', 'inhalt', 'struktur', 'stil'].includes(type) ? type : undefined,
}
const res = await fetch(`${API_BASE}/api/v1/students/${studentId}/annotations`, {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(newAnnotation),
})
if (res.ok) {
const created = await res.json()
setAnnotations((prev) => [...prev, created])
setSelectedAnnotation(created)
setActiveTab('annotationen')
} else {
const errorData = await res.json()
setError(errorData.detail || 'Fehler beim Erstellen der Annotation')
}
} catch (err) {
console.error('Failed to create annotation:', err)
setError('Fehler beim Erstellen der Annotation')
}
}, [studentId, currentPage])
const updateAnnotation = useCallback(async (id: string, updates: Partial<Annotation>) => {
try {
const res = await fetch(`${API_BASE}/api/v1/annotations/${id}`, {
method: 'PUT', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(updates),
})
if (res.ok) {
const updated = await res.json()
setAnnotations((prev) => prev.map((ann) => (ann.id === id ? updated : ann)))
if (selectedAnnotation?.id === id) setSelectedAnnotation(updated)
} else {
const errorData = await res.json()
setError(errorData.detail || 'Fehler beim Aktualisieren der Annotation')
}
} catch (err) {
console.error('Failed to update annotation:', err)
setError('Fehler beim Aktualisieren der Annotation')
}
}, [selectedAnnotation?.id])
const deleteAnnotation = useCallback(async (id: string) => {
try {
const res = await fetch(`${API_BASE}/api/v1/annotations/${id}`, { method: 'DELETE' })
if (res.ok) {
setAnnotations((prev) => prev.filter((ann) => ann.id !== id))
if (selectedAnnotation?.id === id) setSelectedAnnotation(null)
} else {
const errorData = await res.json()
setError(errorData.detail || 'Fehler beim Loeschen der Annotation')
}
} catch (err) {
console.error('Failed to delete annotation:', err)
setError('Fehler beim Loeschen der Annotation')
}
}, [selectedAnnotation?.id])
// ---- Criteria ----
const saveCriteriaScores = useCallback(async (newScores: CriteriaScores) => {
try {
setSaving(true)
const res = await fetch(`${API_BASE}/api/v1/students/${studentId}/criteria`, {
method: 'PUT', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ criteria_scores: newScores }),
})
if (res.ok) { setStudent(await res.json()) }
else { setError('Fehler beim Speichern') }
} catch (err) {
console.error('Failed to save criteria:', err)
setError('Fehler beim Speichern')
} finally { setSaving(false) }
}, [studentId])
const handleCriteriaChange = (criterion: string, value: number) => {
const newScores = { ...criteriaScores, [criterion]: value }
setCriteriaScores(newScores)
saveCriteriaScores(newScores)
}
// ---- Gutachten ----
const saveGutachten = useCallback(async () => {
try {
setSaving(true)
const res = await fetch(`${API_BASE}/api/v1/students/${studentId}/gutachten`, {
method: 'PUT', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ gutachten }),
})
if (res.ok) { setStudent(await res.json()) }
else { setError('Fehler beim Speichern') }
} catch (err) {
console.error('Failed to save gutachten:', err)
setError('Fehler beim Speichern')
} finally { setSaving(false) }
}, [studentId, gutachten])
const generateGutachten = useCallback(async () => {
try {
setGeneratingGutachten(true)
setError(null)
const res = await fetch(`${API_BASE}/api/v1/students/${studentId}/gutachten/generate`, {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ criteria_scores: criteriaScores }),
})
if (res.ok) {
const data = await res.json()
const generatedText = [data.einleitung || '', '', data.hauptteil || '', '', data.fazit || '']
.filter(Boolean).join('\n\n')
setGutachten(generatedText)
setActiveTab('gutachten')
} else {
const errorData = await res.json()
setError(errorData.detail || 'Fehler bei der Gutachten-Generierung')
}
} catch (err) {
console.error('Failed to generate gutachten:', err)
setError('Fehler bei der Gutachten-Generierung')
} finally { setGeneratingGutachten(false) }
}, [studentId, criteriaScores])
// ---- PDF Export ----
const exportGutachtenPDF = useCallback(async () => {
try {
setExporting(true); setError(null)
const name = student?.anonym_id?.replace(/\s+/g, '_') || 'Student'
await downloadBlob(
`${API_BASE}/api/v1/students/${studentId}/export/gutachten`,
`Gutachten_${name}_${new Date().toISOString().split('T')[0]}.pdf`,
)
} catch (err) {
console.error('Failed to export PDF:', err)
setError('Fehler beim PDF-Export')
} finally { setExporting(false) }
}, [studentId, student?.anonym_id])
const exportAnnotationsPDF = useCallback(async () => {
try {
setExporting(true); setError(null)
const name = student?.anonym_id?.replace(/\s+/g, '_') || 'Student'
await downloadBlob(
`${API_BASE}/api/v1/students/${studentId}/export/annotations`,
`Anmerkungen_${name}_${new Date().toISOString().split('T')[0]}.pdf`,
)
} catch (err) {
console.error('Failed to export annotations PDF:', err)
setError('Fehler beim PDF-Export')
} finally { setExporting(false) }
}, [studentId, student?.anonym_id])
// ---- Workflow actions ----
const submitErstkorrektur = useCallback(async () => {
try {
setSubmittingWorkflow(true)
const assignRes = await fetch(`${API_BASE}/api/v1/students/${studentId}/examiner`, {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ examiner_id: 'current-user', examiner_role: 'first_examiner' }),
})
if (!assignRes.ok && assignRes.status !== 400) {
const err = await assignRes.json()
throw new Error(err.detail || 'Fehler bei der Zuweisung')
}
const submitRes = await fetch(`${API_BASE}/api/v1/students/${studentId}/examiner/result`, {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ grade_points: totals.gradePoints, notes: gutachten }),
})
if (submitRes.ok) { fetchData() }
else {
const err = await submitRes.json()
setError(err.detail || 'Fehler beim Abschliessen der Erstkorrektur')
}
} catch (err) {
console.error('Failed to submit Erstkorrektur:', err)
setError('Fehler beim Abschliessen der Erstkorrektur')
} finally { setSubmittingWorkflow(false) }
}, [studentId, totals.gradePoints, gutachten, fetchData])
const startZweitkorrektur = useCallback(async (zweitkorrektorId: string) => {
try {
setSubmittingWorkflow(true)
const res = await fetch(`${API_BASE}/api/v1/students/${studentId}/start-zweitkorrektur`, {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ zweitkorrektor_id: zweitkorrektorId }),
})
if (res.ok) { fetchData() }
else {
const err = await res.json()
setError(err.detail || 'Fehler beim Starten der Zweitkorrektur')
}
} catch (err) {
console.error('Failed to start Zweitkorrektur:', err)
setError('Fehler beim Starten der Zweitkorrektur')
} finally { setSubmittingWorkflow(false) }
}, [studentId, fetchData])
const submitZweitkorrektur = useCallback(async () => {
try {
setSubmittingWorkflow(true)
const res = await fetch(`${API_BASE}/api/v1/students/${studentId}/submit-zweitkorrektur`, {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
grade_points: totals.gradePoints, criteria_scores: criteriaScores,
gutachten: gutachten ? { text: gutachten } : null, notes: '',
}),
})
if (res.ok) {
const result = await res.json()
if (result.workflow_status === 'completed') {
alert(`Auto-Konsens erreicht! Endnote: ${result.final_grade} Punkte`)
} else if (result.workflow_status === 'einigung_required') {
setShowEinigungModal(true)
} else if (result.workflow_status === 'drittkorrektur_required') {
alert(`Drittkorrektur erforderlich: Differenz ${result.grade_difference} Punkte`)
}
fetchData()
} else {
const err = await res.json()
setError(err.detail || 'Fehler beim Abschliessen der Zweitkorrektur')
}
} catch (err) {
console.error('Failed to submit Zweitkorrektur:', err)
setError('Fehler beim Abschliessen der Zweitkorrektur')
} finally { setSubmittingWorkflow(false) }
}, [studentId, totals.gradePoints, criteriaScores, gutachten, fetchData])
const submitEinigung = useCallback(async (type: 'agreed' | 'split' | 'escalated') => {
try {
setSubmittingWorkflow(true)
const res = await fetch(`${API_BASE}/api/v1/students/${studentId}/einigung`, {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ final_grade: einigungGrade, einigung_notes: einigungNotes, einigung_type: type }),
})
if (res.ok) {
const result = await res.json()
setShowEinigungModal(false)
if (result.workflow_status === 'drittkorrektur_required') {
alert('Eskaliert zu Drittkorrektur')
} else {
alert(`Einigung abgeschlossen: Endnote ${result.final_grade} Punkte`)
}
fetchData()
} else {
const err = await res.json()
setError(err.detail || 'Fehler bei der Einigung')
}
} catch (err) {
console.error('Failed to submit Einigung:', err)
setError('Fehler bei der Einigung')
} finally { setSubmittingWorkflow(false) }
}, [studentId, einigungGrade, einigungNotes, fetchData])
// ---- Navigation ----
const goToStudent = (direction: 'prev' | 'next') => {
const newIndex = direction === 'prev' ? currentIndex - 1 : currentIndex + 1
if (newIndex >= 0 && newIndex < students.length) {
routerPush(`/education/klausur-korrektur/${klausurId}/${students[newIndex].id}`)
}
}
return {
// State
klausur, student, students, annotations, gradeInfo, workflow,
loading, saving, error, activeTab, currentPage, totalPages,
zoom, documentUrl, generatingGutachten, exporting,
selectedTool, selectedAnnotation,
criteriaScores, gutachten,
showEinigungModal, einigungGrade, einigungNotes, submittingWorkflow,
currentIndex, annotationCounts, totals,
// Actions
setActiveTab, setCurrentPage, setZoom,
setSelectedTool, setSelectedAnnotation,
setGutachten, setError,
setShowEinigungModal, setEinigungGrade, setEinigungNotes,
createAnnotation, updateAnnotation, deleteAnnotation,
handleCriteriaChange, saveGutachten, generateGutachten,
exportGutachtenPDF, exportAnnotationsPDF,
submitErstkorrektur, startZweitkorrektur, submitZweitkorrektur, submitEinigung,
goToStudent,
}
}

View File

@@ -0,0 +1,93 @@
/**
* Types and constants for the Korrektur-Workspace page.
*/
import type { CriteriaScores } from '../../../types'
// ---- Examiner workflow types ----
export interface ExaminerInfo {
id: string
assigned_at: string
notes?: string
}
export interface ExaminerResult {
grade_points: number
criteria_scores?: CriteriaScores
notes?: string
submitted_at: string
}
export interface ExaminerWorkflow {
student_id: string
workflow_status: string
visibility_mode: string
user_role: 'ek' | 'zk' | 'dk' | 'viewer'
first_examiner?: ExaminerInfo
second_examiner?: ExaminerInfo
third_examiner?: ExaminerInfo
first_result?: ExaminerResult
first_result_visible?: boolean
second_result?: ExaminerResult
third_result?: ExaminerResult
grade_difference?: number
final_grade?: number
consensus_reached?: boolean
consensus_type?: string
einigung?: {
final_grade: number
notes: string
type: string
submitted_by: string
submitted_at: string
ek_grade: number
zk_grade: number
}
drittkorrektur_reason?: string
}
// ---- Active tab ----
export type ActiveTab = 'kriterien' | 'gutachten' | 'annotationen' | 'eh-vorschlaege'
// ---- Totals from grade calculation ----
export interface GradeTotals {
raw: number
weighted: number
gradePoints: number
}
// ---- Constants ----
/** Same-origin proxy to avoid CORS issues */
export const API_BASE = '/klausur-api'
export const GRADE_LABELS: Record<number, string> = {
15: '1+', 14: '1', 13: '1-', 12: '2+', 11: '2', 10: '2-',
9: '3+', 8: '3', 7: '3-', 6: '4+', 5: '4', 4: '4-',
3: '5+', 2: '5', 1: '5-', 0: '6',
}
export const WORKFLOW_STATUS_LABELS: Record<string, { label: string; color: string }> = {
not_started: { label: 'Nicht gestartet', color: 'bg-slate-100 text-slate-700' },
ek_in_progress: { label: 'EK in Arbeit', color: 'bg-blue-100 text-blue-700' },
ek_completed: { label: 'EK abgeschlossen', color: 'bg-blue-200 text-blue-800' },
zk_assigned: { label: 'ZK zugewiesen', color: 'bg-amber-100 text-amber-700' },
zk_in_progress: { label: 'ZK in Arbeit', color: 'bg-amber-200 text-amber-800' },
zk_completed: { label: 'ZK abgeschlossen', color: 'bg-amber-300 text-amber-900' },
einigung_required: { label: 'Einigung erforderlich', color: 'bg-orange-100 text-orange-700' },
einigung_completed: { label: 'Einigung abgeschlossen', color: 'bg-green-100 text-green-700' },
drittkorrektur_required: { label: 'DK erforderlich', color: 'bg-red-100 text-red-700' },
drittkorrektur_assigned: { label: 'DK zugewiesen', color: 'bg-red-200 text-red-800' },
drittkorrektur_in_progress: { label: 'DK in Arbeit', color: 'bg-red-300 text-red-900' },
completed: { label: 'Abgeschlossen', color: 'bg-green-200 text-green-800' },
}
export const ROLE_LABELS: Record<string, { label: string; color: string }> = {
ek: { label: 'Erstkorrektor', color: 'bg-blue-500' },
zk: { label: 'Zweitkorrektor', color: 'bg-amber-500' },
dk: { label: 'Drittkorrektor', color: 'bg-purple-500' },
viewer: { label: 'Betrachter', color: 'bg-slate-500' },
}

View File

@@ -0,0 +1,337 @@
'use client'
/**
* DirektuploadTab - 3-step direct upload wizard
*/
import type { TabId } from './constants'
import type { DirektuploadForm } from './types'
interface DirektuploadTabProps {
direktForm: DirektuploadForm
setDirektForm: React.Dispatch<React.SetStateAction<DirektuploadForm>>
direktStep: 1 | 2 | 3
setDirektStep: React.Dispatch<React.SetStateAction<1 | 2 | 3>>
uploading: boolean
onUpload: () => void
onNavigate: (tab: TabId) => void
}
export function DirektuploadTab({
direktForm,
setDirektForm,
direktStep,
setDirektStep,
uploading,
onUpload,
onNavigate,
}: DirektuploadTabProps) {
return (
<div className="max-w-3xl mx-auto">
<div className="bg-white rounded-xl border border-slate-200 shadow-sm overflow-hidden">
<StepHeader
currentStep={direktStep}
onCancel={() => onNavigate('willkommen')}
/>
<div className="p-6">
{direktStep === 1 && (
<FileUploadStep
files={direktForm.files}
onFilesChange={(files) => setDirektForm(prev => ({ ...prev, files }))}
onNext={() => setDirektStep(2)}
/>
)}
{direktStep === 2 && (
<EHStep
aufgabentyp={direktForm.aufgabentyp}
ehText={direktForm.ehText}
onAufgabentypChange={(v) => setDirektForm(prev => ({ ...prev, aufgabentyp: v }))}
onEhTextChange={(v) => setDirektForm(prev => ({ ...prev, ehText: v }))}
onBack={() => setDirektStep(1)}
onNext={() => setDirektStep(3)}
/>
)}
{direktStep === 3 && (
<SummaryStep
direktForm={direktForm}
setDirektForm={setDirektForm}
uploading={uploading}
onBack={() => setDirektStep(2)}
onUpload={onUpload}
/>
)}
</div>
</div>
</div>
)
}
function StepHeader({ currentStep, onCancel }: { currentStep: number; onCancel: () => void }) {
return (
<div className="bg-slate-50 border-b border-slate-200 px-6 py-4">
<div className="flex items-center justify-between mb-2">
<h2 className="text-lg font-semibold text-slate-800">Schnellstart - Direkt Korrigieren</h2>
<button onClick={onCancel} className="text-sm text-slate-500 hover:text-slate-700">
Abbrechen
</button>
</div>
<div className="flex items-center gap-2">
{[1, 2, 3].map((step) => (
<div key={step} className="flex items-center gap-2 flex-1">
<div className={`w-8 h-8 rounded-full flex items-center justify-center text-sm font-medium ${
currentStep >= step ? 'bg-blue-600 text-white' : 'bg-slate-200 text-slate-500'
}`}>
{step}
</div>
<span className={`text-sm ${currentStep >= step ? 'text-slate-800' : 'text-slate-400'}`}>
{step === 1 ? 'Arbeiten' : step === 2 ? 'Erwartungshorizont' : 'Starten'}
</span>
{step < 3 && <div className={`flex-1 h-1 rounded ${currentStep > step ? 'bg-blue-600' : 'bg-slate-200'}`} />}
</div>
))}
</div>
</div>
)
}
function FileUploadStep({
files,
onFilesChange,
onNext,
}: {
files: File[]
onFilesChange: (files: File[]) => void
onNext: () => void
}) {
return (
<div className="space-y-6">
<div>
<h3 className="font-medium text-slate-800 mb-2">Schuelerarbeiten hochladen</h3>
<p className="text-sm text-slate-500 mb-4">
Laden Sie die eingescannten Klausuren hoch. Unterstuetzte Formate: PDF, JPG, PNG.
</p>
<div
className={`border-2 border-dashed rounded-xl p-8 text-center transition-colors ${
files.length > 0 ? 'border-green-300 bg-green-50' : 'border-slate-300 hover:border-blue-400 hover:bg-blue-50'
}`}
onDrop={(e) => {
e.preventDefault()
const dropped = Array.from(e.dataTransfer.files)
onFilesChange([...files, ...dropped])
}}
onDragOver={(e) => e.preventDefault()}
>
<svg className="w-12 h-12 mx-auto text-slate-400 mb-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12" />
</svg>
<p className="text-slate-600 mb-2">Dateien hier ablegen oder</p>
<label className="inline-block px-4 py-2 bg-blue-600 text-white rounded-lg cursor-pointer hover:bg-blue-700">
Dateien auswaehlen
<input
type="file"
multiple
accept=".pdf,.jpg,.jpeg,.png"
className="hidden"
onChange={(e) => {
const selected = Array.from(e.target.files || [])
onFilesChange([...files, ...selected])
}}
/>
</label>
</div>
{files.length > 0 && (
<div className="mt-4 space-y-2">
<div className="flex items-center justify-between text-sm text-slate-600">
<span>{files.length} Datei{files.length !== 1 ? 'en' : ''} ausgewaehlt</span>
<button
onClick={() => onFilesChange([])}
className="text-red-600 hover:text-red-700"
>
Alle entfernen
</button>
</div>
<div className="max-h-40 overflow-y-auto space-y-1">
{files.map((file, idx) => (
<div key={idx} className="flex items-center justify-between bg-slate-50 px-3 py-2 rounded-lg text-sm">
<span className="truncate">{file.name}</span>
<button
onClick={() => onFilesChange(files.filter((_, i) => i !== idx))}
className="text-slate-400 hover:text-red-600"
>
<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>
))}
</div>
</div>
)}
</div>
<div className="flex justify-end">
<button
onClick={onNext}
disabled={files.length === 0}
className="px-6 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed"
>
Weiter
</button>
</div>
</div>
)
}
function EHStep({
aufgabentyp,
ehText,
onAufgabentypChange,
onEhTextChange,
onBack,
onNext,
}: {
aufgabentyp: string
ehText: string
onAufgabentypChange: (v: string) => void
onEhTextChange: (v: string) => void
onBack: () => void
onNext: () => void
}) {
return (
<div className="space-y-6">
<div>
<h3 className="font-medium text-slate-800 mb-2">Erwartungshorizont (optional)</h3>
<p className="text-sm text-slate-500 mb-4">
Beschreiben Sie die Aufgabenstellung fuer bessere KI-Vorschlaege.
</p>
<div className="mb-4">
<label className="block text-sm font-medium text-slate-700 mb-1">Aufgabentyp</label>
<select
value={aufgabentyp}
onChange={(e) => onAufgabentypChange(e.target.value)}
className="w-full px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
>
<option value="">-- Waehlen Sie einen Aufgabentyp --</option>
<option value="textanalyse_pragmatisch">Textanalyse (Sachtexte)</option>
<option value="gedichtanalyse">Gedichtanalyse</option>
<option value="prosaanalyse">Prosaanalyse</option>
<option value="dramenanalyse">Dramenanalyse</option>
<option value="eroerterung_textgebunden">Textgebundene Eroerterung</option>
</select>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">
Aufgabenstellung / Erwartungshorizont
</label>
<textarea
value={ehText}
onChange={(e) => onEhTextChange(e.target.value)}
placeholder="Beschreiben Sie hier die Aufgabenstellung..."
rows={6}
className="w-full px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent resize-none"
/>
</div>
</div>
<div className="flex justify-between">
<button onClick={onBack} className="px-6 py-2 text-slate-600 hover:bg-slate-100 rounded-lg">
Zurueck
</button>
<button onClick={onNext} className="px-6 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700">
Weiter
</button>
</div>
</div>
)
}
function SummaryStep({
direktForm,
setDirektForm,
uploading,
onBack,
onUpload,
}: {
direktForm: DirektuploadForm
setDirektForm: React.Dispatch<React.SetStateAction<DirektuploadForm>>
uploading: boolean
onBack: () => void
onUpload: () => void
}) {
return (
<div className="space-y-6">
<div>
<h3 className="font-medium text-slate-800 mb-2">Zusammenfassung</h3>
<div className="bg-slate-50 rounded-lg p-4 space-y-3">
<div className="flex items-center justify-between">
<span className="text-sm text-slate-600">Titel</span>
<input
type="text"
value={direktForm.klausurTitle}
onChange={(e) => setDirektForm(prev => ({ ...prev, klausurTitle: e.target.value }))}
className="text-sm font-medium text-slate-800 bg-white border border-slate-200 rounded px-2 py-1 text-right"
/>
</div>
<div className="flex items-center justify-between">
<span className="text-sm text-slate-600">Anzahl Arbeiten</span>
<span className="text-sm font-medium text-slate-800">{direktForm.files.length}</span>
</div>
<div className="flex items-center justify-between">
<span className="text-sm text-slate-600">Aufgabentyp</span>
<span className="text-sm font-medium text-slate-800">
{direktForm.aufgabentyp || 'Nicht angegeben'}
</span>
</div>
</div>
<div className="mt-4 p-4 bg-blue-50 border border-blue-200 rounded-lg">
<div className="flex items-start gap-3">
<svg className="w-5 h-5 text-blue-600 mt-0.5" 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>
<div className="text-sm text-blue-800">
<p className="font-medium">Was passiert jetzt?</p>
<ol className="list-decimal list-inside mt-1 space-y-1 text-blue-700">
<li>Eine neue Klausur wird automatisch erstellt</li>
<li>Alle {direktForm.files.length} Arbeiten werden hochgeladen</li>
<li>OCR-Erkennung startet automatisch</li>
<li>Sie werden zur Korrektur-Ansicht weitergeleitet</li>
</ol>
</div>
</div>
</div>
</div>
<div className="flex justify-between">
<button onClick={onBack} className="px-6 py-2 text-slate-600 hover:bg-slate-100 rounded-lg">
Zurueck
</button>
<button
onClick={onUpload}
disabled={uploading}
className="px-6 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700 disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-2"
>
{uploading ? (
<>
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white"></div>
Wird hochgeladen...
</>
) : (
<>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 10V3L4 14h7v7l9-11h-7z" />
</svg>
Korrektur starten
</>
)}
</button>
</div>
</div>
)
}

View File

@@ -0,0 +1,227 @@
'use client'
/**
* ErstellenTab - Form to create a new Klausur
*/
import type { TabId } from './constants'
import type { CreateKlausurForm, VorabiturEHForm, EHTemplate } from './types'
interface ErstellenTabProps {
form: CreateKlausurForm
setForm: React.Dispatch<React.SetStateAction<CreateKlausurForm>>
ehForm: VorabiturEHForm
setEhForm: React.Dispatch<React.SetStateAction<VorabiturEHForm>>
templates: EHTemplate[]
loadingTemplates: boolean
creating: boolean
onSubmit: (e: React.FormEvent) => void
onNavigate: (tab: TabId) => void
}
export function ErstellenTab({
form,
setForm,
ehForm,
setEhForm,
templates,
loadingTemplates,
creating,
onSubmit,
onNavigate,
}: ErstellenTabProps) {
return (
<div className="max-w-2xl mx-auto">
<div className="bg-white rounded-lg border border-slate-200 shadow-sm p-6">
<h2 className="text-lg font-semibold text-slate-800 mb-6">Neue Klausur erstellen</h2>
<form onSubmit={onSubmit} className="space-y-4">
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">
Titel der Klausur *
</label>
<input
type="text"
value={form.title}
onChange={(e) => setForm(prev => ({ ...prev, title: e.target.value }))}
placeholder="z.B. Deutsch LK Abitur 2025 - Kurs D1"
className="w-full px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent"
required
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Fach</label>
<select
value={form.subject}
onChange={(e) => setForm(prev => ({ ...prev, subject: e.target.value }))}
className="w-full px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent"
>
<option value="Deutsch">Deutsch</option>
<option value="Englisch">Englisch</option>
<option value="Mathematik">Mathematik</option>
<option value="Geschichte">Geschichte</option>
<option value="Biologie">Biologie</option>
</select>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Jahr</label>
<input
type="number"
value={form.year}
onChange={(e) => setForm(prev => ({ ...prev, year: parseInt(e.target.value) }))}
min={2020}
max={2030}
className="w-full px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent"
/>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Semester / Pruefung</label>
<select
value={form.semester}
onChange={(e) => setForm(prev => ({ ...prev, semester: e.target.value }))}
className="w-full px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent"
>
<option value="Abitur">Abitur</option>
<option value="Q1">Q1 (11/1)</option>
<option value="Q2">Q2 (11/2)</option>
<option value="Q3">Q3 (12/1)</option>
<option value="Q4">Q4 (12/2)</option>
<option value="Vorabitur">Vorabitur</option>
</select>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Modus</label>
<select
value={form.modus}
onChange={(e) => setForm(prev => ({ ...prev, modus: e.target.value as 'abitur' | 'vorabitur' }))}
className="w-full px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent"
>
<option value="abitur">Abitur (mit offiziellem EH)</option>
<option value="vorabitur">Vorabitur (eigener EH)</option>
</select>
</div>
</div>
{form.modus === 'vorabitur' && (
<VorabiturEHSection
ehForm={ehForm}
setEhForm={setEhForm}
templates={templates}
loadingTemplates={loadingTemplates}
/>
)}
<div className="flex gap-3 pt-4">
<button
type="button"
onClick={() => onNavigate('klausuren')}
className="px-4 py-2 text-slate-600 hover:bg-slate-100 rounded-lg"
>
Abbrechen
</button>
<button
type="submit"
disabled={creating}
className="flex-1 px-4 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2"
>
{creating ? (
<>
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white"></div>
Erstelle...
</>
) : (
'Klausur erstellen'
)}
</button>
</div>
</form>
</div>
</div>
)
}
function VorabiturEHSection({
ehForm,
setEhForm,
templates,
loadingTemplates,
}: {
ehForm: VorabiturEHForm
setEhForm: React.Dispatch<React.SetStateAction<VorabiturEHForm>>
templates: EHTemplate[]
loadingTemplates: boolean
}) {
return (
<div className="p-4 bg-blue-50 border border-blue-200 rounded-lg space-y-4">
<div className="flex items-start gap-3">
<svg className="w-5 h-5 text-blue-600 mt-0.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>
<div className="text-sm text-blue-800">
<p className="font-medium mb-1">Eigenen Erwartungshorizont erstellen</p>
<p>Waehlen Sie einen Aufgabentyp und beschreiben Sie die Aufgabenstellung.</p>
</div>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Aufgabentyp *</label>
{loadingTemplates ? (
<div className="flex items-center gap-2 text-sm text-slate-500">
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-blue-600"></div>
Lade Vorlagen...
</div>
) : (
<select
value={ehForm.aufgabentyp}
onChange={(e) => setEhForm(prev => ({ ...prev, aufgabentyp: e.target.value }))}
className="w-full px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent bg-white"
>
<option value="">-- Aufgabentyp waehlen --</option>
{templates.map(t => (
<option key={t.aufgabentyp} value={t.aufgabentyp}>{t.name}</option>
))}
</select>
)}
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Texttitel (optional)</label>
<input
type="text"
value={ehForm.text_titel}
onChange={(e) => setEhForm(prev => ({ ...prev, text_titel: e.target.value }))}
placeholder="z.B. 'Die Verwandlung'"
className="w-full px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent"
/>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Autor (optional)</label>
<input
type="text"
value={ehForm.text_autor}
onChange={(e) => setEhForm(prev => ({ ...prev, text_autor: e.target.value }))}
placeholder="z.B. 'Franz Kafka'"
className="w-full px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent"
/>
</div>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Aufgabenstellung *</label>
<textarea
value={ehForm.aufgabenstellung}
onChange={(e) => setEhForm(prev => ({ ...prev, aufgabenstellung: e.target.value }))}
placeholder="Beschreiben Sie hier die konkrete Aufgabenstellung..."
rows={4}
className="w-full px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent resize-none"
/>
</div>
</div>
)
}

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