Initial commit: breakpilot-lehrer - Lehrer KI Platform
Services: Admin-Lehrer, Backend-Lehrer, Studio v2, Website, Klausur-Service, School-Service, Voice-Service, Geo-Service, BreakPilot Drive, Agent-Core Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,682 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useParams, useRouter } 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'
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
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'
|
||||
},
|
||||
'compliance-advisor': {
|
||||
id: 'compliance-advisor',
|
||||
name: 'Compliance Advisor',
|
||||
description: 'DSGVO/Compliance-Berater fuer SDK-Nutzer',
|
||||
soulFile: 'compliance-advisor.soul.md',
|
||||
soulContent: `# Compliance Advisor Agent
|
||||
|
||||
## Identitaet
|
||||
Du bist der BreakPilot Compliance-Berater. Du hilfst Nutzern des AI Compliance SDK,
|
||||
Datenschutz- und Compliance-Fragen in verstaendlicher Sprache zu beantworten.
|
||||
Du bist kein Anwalt und gibst keine Rechtsberatung, sondern orientierst dich an
|
||||
offiziellen Quellen und gibst praxisnahe Hinweise.
|
||||
|
||||
## Kernprinzipien
|
||||
- **Quellenbasiert**: Verweise immer auf konkrete Rechtsgrundlagen (DSGVO-Artikel, BDSG-Paragraphen)
|
||||
- **Verstaendlich**: Erklaere rechtliche Konzepte in einfacher, praxisnaher Sprache
|
||||
- **Ehrlich**: Bei Unsicherheit empfehle professionelle Rechtsberatung
|
||||
- **Kontextbewusst**: Nutze das RAG-System fuer aktuelle Rechtstexte und Leitfaeden
|
||||
- **Scope-bewusst**: Nutze alle verfuegbaren RAG-Quellen AUSSER NIBIS-Dokumenten
|
||||
|
||||
## Kompetenzbereich
|
||||
- DSGVO Art. 1-99 + Erwaegsgruende
|
||||
- BDSG (Bundesdatenschutzgesetz)
|
||||
- AI Act (EU KI-Verordnung)
|
||||
- TTDSG, ePrivacy-Richtlinie
|
||||
- DSK-Kurzpapiere (Nr. 1-20)
|
||||
- SDM V3.0, BSI-Grundschutz, BSI-TR-03161
|
||||
- EDPB Guidelines, Bundes-/Laender-Muss-Listen
|
||||
- ISO 27001/27701 (Ueberblick)
|
||||
|
||||
## Kommunikationsstil
|
||||
- Sachlich, aber verstaendlich
|
||||
- Deutsch als Hauptsprache
|
||||
- Strukturierte Antworten mit Quellenangabe
|
||||
- Praxisbeispiele wo hilfreich`,
|
||||
color: '#6366f1',
|
||||
status: 'running',
|
||||
activeSessions: 0,
|
||||
totalProcessed: 0,
|
||||
avgResponseTime: 0,
|
||||
errorRate: 0,
|
||||
lastRestart: new Date().toISOString(),
|
||||
version: '1.0.0',
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString()
|
||||
},
|
||||
'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' },
|
||||
]
|
||||
|
||||
export default function AgentDetailPage() {
|
||||
const params = useParams()
|
||||
const router = useRouter()
|
||||
const agentId = params.agentId as string
|
||||
|
||||
const [agent, setAgent] = useState<AgentDetail | null>(null)
|
||||
const [editedContent, setEditedContent] = useState('')
|
||||
const [isEditing, setIsEditing] = useState(false)
|
||||
const [hasChanges, setHasChanges] = useState(false)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [activeTab, setActiveTab] = useState<'soul' | 'stats' | 'history'>('soul')
|
||||
|
||||
useEffect(() => {
|
||||
// Load agent data
|
||||
const agentData = mockAgentDetails[agentId]
|
||||
if (agentData) {
|
||||
setAgent(agentData)
|
||||
setEditedContent(agentData.soulContent)
|
||||
}
|
||||
}, [agentId])
|
||||
|
||||
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() })
|
||||
}
|
||||
setHasChanges(false)
|
||||
setIsEditing(false)
|
||||
setSaving(false)
|
||||
}
|
||||
|
||||
const handleReset = () => {
|
||||
if (agent) {
|
||||
setEditedContent(agent.soulContent)
|
||||
setHasChanges(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleContentChange = (content: string) => {
|
||||
setEditedContent(content)
|
||||
setHasChanges(content !== agent?.soulContent)
|
||||
}
|
||||
|
||||
if (!agent) {
|
||||
return (
|
||||
<div className="p-6 max-w-7xl mx-auto">
|
||||
<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>
|
||||
<Link href="/ai/agents" className="text-teal-600 hover:text-teal-700">
|
||||
← Zurueck zur Uebersicht
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
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>
|
||||
|
||||
{/* Tabs */}
|
||||
<div className="bg-white border border-gray-200 rounded-xl overflow-hidden">
|
||||
<div className="border-b border-gray-200">
|
||||
<div className="flex">
|
||||
<button
|
||||
onClick={() => setActiveTab('soul')}
|
||||
className={`flex items-center gap-2 px-6 py-4 text-sm font-medium border-b-2 transition-colors ${
|
||||
activeTab === 'soul'
|
||||
? 'border-teal-500 text-teal-600'
|
||||
: 'border-transparent text-gray-500 hover:text-gray-700'
|
||||
}`}
|
||||
>
|
||||
<FileText className="w-4 h-4" />
|
||||
SOUL-File
|
||||
</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>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,779 @@
|
||||
'use client'
|
||||
|
||||
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'
|
||||
|
||||
interface Section {
|
||||
id: string
|
||||
title: string
|
||||
icon: React.ReactNode
|
||||
content: React.ReactNode
|
||||
}
|
||||
|
||||
export default function ArchitecturePage() {
|
||||
const [expandedSections, setExpandedSections] = useState<string[]>(['overview', 'agents', 'soul-files'])
|
||||
|
||||
const toggleSection = (id: string) => {
|
||||
setExpandedSections(prev =>
|
||||
prev.includes(id)
|
||||
? prev.filter(s => s !== id)
|
||||
: [...prev, id]
|
||||
)
|
||||
}
|
||||
|
||||
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 */}
|
||||
<div className="mb-8">
|
||||
<Link
|
||||
href="/ai/agents"
|
||||
className="flex items-center gap-2 text-gray-500 hover:text-gray-700 mb-4"
|
||||
>
|
||||
<ArrowLeft className="w-4 h-4" />
|
||||
Zurueck zur Agent-Verwaltung
|
||||
</Link>
|
||||
<h1 className="text-2xl font-bold text-gray-900 flex items-center gap-3">
|
||||
<div className="p-2 bg-purple-100 rounded-lg">
|
||||
<FileText className="w-6 h-6 text-purple-600" />
|
||||
</div>
|
||||
Multi-Agent Architektur
|
||||
</h1>
|
||||
<p className="text-gray-500 mt-1">
|
||||
Technische Dokumentation des Breakpilot Multi-Agent-Systems
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Table of Contents */}
|
||||
<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 => (
|
||||
<button
|
||||
key={section.id}
|
||||
onClick={() => {
|
||||
if (!expandedSections.includes(section.id)) {
|
||||
setExpandedSections(prev => [...prev, section.id])
|
||||
}
|
||||
document.getElementById(section.id)?.scrollIntoView({ behavior: 'smooth' })
|
||||
}}
|
||||
className="flex items-center gap-2 text-sm text-gray-600 hover:text-teal-600 text-left p-2 rounded-lg hover:bg-gray-100 transition-colors"
|
||||
>
|
||||
{section.icon}
|
||||
<span className="truncate">{section.title}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Sections */}
|
||||
<div className="space-y-4">
|
||||
{sections.map(section => (
|
||||
<div
|
||||
key={section.id}
|
||||
id={section.id}
|
||||
className="bg-white border border-gray-200 rounded-xl overflow-hidden"
|
||||
>
|
||||
<button
|
||||
onClick={() => toggleSection(section.id)}
|
||||
className="w-full flex items-center justify-between p-5 hover:bg-gray-50 transition-colors"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2 bg-gray-100 rounded-lg">
|
||||
{section.icon}
|
||||
</div>
|
||||
<span className="font-semibold text-gray-900">{section.title}</span>
|
||||
</div>
|
||||
{expandedSections.includes(section.id) ? (
|
||||
<ChevronDown className="w-5 h-5 text-gray-400" />
|
||||
) : (
|
||||
<ChevronRight className="w-5 h-5 text-gray-400" />
|
||||
)}
|
||||
</button>
|
||||
{expandedSections.includes(section.id) && (
|
||||
<div className="px-5 pb-5 border-t border-gray-100 pt-4">
|
||||
{section.content}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* 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>
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-3">
|
||||
<Link
|
||||
href="/ai/agents"
|
||||
className="flex items-center gap-2 text-sm text-teal-700 hover:text-teal-900"
|
||||
>
|
||||
<Cpu className="w-4 h-4" />
|
||||
Agent-Uebersicht
|
||||
</Link>
|
||||
<Link
|
||||
href="/ai/agents/sessions"
|
||||
className="flex items-center gap-2 text-sm text-teal-700 hover:text-teal-900"
|
||||
>
|
||||
<Activity className="w-4 h-4" />
|
||||
Aktive Sessions
|
||||
</Link>
|
||||
<Link
|
||||
href="/ai/agents/statistics"
|
||||
className="flex items-center gap-2 text-sm text-teal-700 hover:text-teal-900"
|
||||
>
|
||||
<Database className="w-4 h-4" />
|
||||
Statistiken
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,390 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect } from 'react'
|
||||
import Link from 'next/link'
|
||||
import { Bot, Activity, Brain, Settings, FileText, BarChart3, Clock, AlertTriangle, CheckCircle, Pause, XCircle, ChevronRight, Cpu, MessageSquare, Database, RefreshCw } from 'lucide-react'
|
||||
|
||||
// Agent types
|
||||
interface AgentConfig {
|
||||
id: string
|
||||
name: string
|
||||
description: string
|
||||
soulFile: string
|
||||
color: string
|
||||
icon: 'bot' | 'brain' | 'message' | 'alert' | 'settings'
|
||||
status: 'running' | 'paused' | 'stopped' | 'error'
|
||||
activeSessions: number
|
||||
totalProcessed: number
|
||||
avgResponseTime: number
|
||||
lastActivity: string
|
||||
}
|
||||
|
||||
interface AgentStats {
|
||||
totalSessions: number
|
||||
activeSessions: number
|
||||
totalMessages: number
|
||||
avgLatency: number
|
||||
errorRate: number
|
||||
memoryUsage: number
|
||||
}
|
||||
|
||||
// Mock data - In production, fetch from API
|
||||
const mockAgents: AgentConfig[] = [
|
||||
{
|
||||
id: 'tutor-agent',
|
||||
name: 'TutorAgent',
|
||||
description: 'Lernbegleitung und Fragen beantworten',
|
||||
soulFile: 'tutor-agent.soul.md',
|
||||
color: '#3b82f6',
|
||||
icon: 'brain',
|
||||
status: 'running',
|
||||
activeSessions: 12,
|
||||
totalProcessed: 1847,
|
||||
avgResponseTime: 234,
|
||||
lastActivity: '2 min ago'
|
||||
},
|
||||
{
|
||||
id: 'grader-agent',
|
||||
name: 'GraderAgent',
|
||||
description: 'Klausur-Korrektur und Bewertung',
|
||||
soulFile: 'grader-agent.soul.md',
|
||||
color: '#10b981',
|
||||
icon: 'bot',
|
||||
status: 'running',
|
||||
activeSessions: 3,
|
||||
totalProcessed: 456,
|
||||
avgResponseTime: 1205,
|
||||
lastActivity: '5 min ago'
|
||||
},
|
||||
{
|
||||
id: 'quality-judge',
|
||||
name: 'QualityJudge',
|
||||
description: 'BQAS Qualitaetspruefung',
|
||||
soulFile: 'quality-judge.soul.md',
|
||||
color: '#f59e0b',
|
||||
icon: 'settings',
|
||||
status: 'running',
|
||||
activeSessions: 8,
|
||||
totalProcessed: 3291,
|
||||
avgResponseTime: 89,
|
||||
lastActivity: '1 min ago'
|
||||
},
|
||||
{
|
||||
id: 'alert-agent',
|
||||
name: 'AlertAgent',
|
||||
description: 'Monitoring und Benachrichtigungen',
|
||||
soulFile: 'alert-agent.soul.md',
|
||||
color: '#ef4444',
|
||||
icon: 'alert',
|
||||
status: 'running',
|
||||
activeSessions: 1,
|
||||
totalProcessed: 892,
|
||||
avgResponseTime: 45,
|
||||
lastActivity: '30 sec ago'
|
||||
},
|
||||
{
|
||||
id: 'orchestrator',
|
||||
name: 'Orchestrator',
|
||||
description: 'Task-Koordination und Routing',
|
||||
soulFile: 'orchestrator.soul.md',
|
||||
color: '#8b5cf6',
|
||||
icon: 'message',
|
||||
status: 'running',
|
||||
activeSessions: 24,
|
||||
totalProcessed: 8934,
|
||||
avgResponseTime: 12,
|
||||
lastActivity: 'just now'
|
||||
},
|
||||
{
|
||||
id: 'compliance-advisor',
|
||||
name: 'Compliance Advisor',
|
||||
description: 'DSGVO/Compliance-Berater fuer SDK-Nutzer',
|
||||
soulFile: 'compliance-advisor.soul.md',
|
||||
color: '#6366f1',
|
||||
icon: 'message',
|
||||
status: 'running',
|
||||
activeSessions: 0,
|
||||
totalProcessed: 0,
|
||||
avgResponseTime: 0,
|
||||
lastActivity: new Date().toISOString()
|
||||
}
|
||||
]
|
||||
|
||||
const mockStats: AgentStats = {
|
||||
totalSessions: 156,
|
||||
activeSessions: 48,
|
||||
totalMessages: 15420,
|
||||
avgLatency: 156,
|
||||
errorRate: 0.8,
|
||||
memoryUsage: 67
|
||||
}
|
||||
|
||||
function getIconComponent(icon: string, className: string) {
|
||||
switch(icon) {
|
||||
case 'bot': return <Bot className={className} />
|
||||
case 'brain': return <Brain className={className} />
|
||||
case 'message': return <MessageSquare className={className} />
|
||||
case 'alert': return <AlertTriangle className={className} />
|
||||
case 'settings': return <Settings className={className} />
|
||||
default: return <Bot className={className} />
|
||||
}
|
||||
}
|
||||
|
||||
function getStatusIcon(status: string) {
|
||||
switch(status) {
|
||||
case 'running': return <CheckCircle className="w-4 h-4 text-green-500" />
|
||||
case 'paused': return <Pause className="w-4 h-4 text-yellow-500" />
|
||||
case 'stopped': return <XCircle className="w-4 h-4 text-gray-500" />
|
||||
case 'error': return <AlertTriangle className="w-4 h-4 text-red-500" />
|
||||
default: return null
|
||||
}
|
||||
}
|
||||
|
||||
function getStatusColor(status: string) {
|
||||
switch(status) {
|
||||
case 'running': return 'bg-green-500/10 text-green-600 border-green-500/20'
|
||||
case 'paused': return 'bg-yellow-500/10 text-yellow-600 border-yellow-500/20'
|
||||
case 'stopped': return 'bg-gray-500/10 text-gray-600 border-gray-500/20'
|
||||
case 'error': return 'bg-red-500/10 text-red-600 border-red-500/20'
|
||||
default: return 'bg-gray-500/10 text-gray-600 border-gray-500/20'
|
||||
}
|
||||
}
|
||||
|
||||
export default function AgentManagementPage() {
|
||||
const [agents, setAgents] = useState<AgentConfig[]>(mockAgents)
|
||||
const [stats, setStats] = useState<AgentStats>(mockStats)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [lastRefresh, setLastRefresh] = useState(new Date())
|
||||
|
||||
const refreshData = async () => {
|
||||
setLoading(true)
|
||||
// In production, fetch from API
|
||||
// const response = await fetch('/api/admin/agents/status')
|
||||
await new Promise(resolve => setTimeout(resolve, 500))
|
||||
setLastRefresh(new Date())
|
||||
setLoading(false)
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
// Auto-refresh every 30 seconds
|
||||
const interval = setInterval(refreshData, 30000)
|
||||
return () => clearInterval(interval)
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div className="p-6 max-w-7xl mx-auto">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between mb-8">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900 flex items-center gap-3">
|
||||
<div className="p-2 bg-teal-100 rounded-lg">
|
||||
<Bot className="w-6 h-6 text-teal-600" />
|
||||
</div>
|
||||
Agent Management
|
||||
</h1>
|
||||
<p className="text-gray-500 mt-1">
|
||||
Multi-Agent System verwalten, SOUL-Files bearbeiten, Statistiken analysieren
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-sm text-gray-500">
|
||||
Letzte Aktualisierung: {lastRefresh.toLocaleTimeString('de-DE')}
|
||||
</span>
|
||||
<button
|
||||
onClick={refreshData}
|
||||
disabled={loading}
|
||||
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"
|
||||
>
|
||||
<RefreshCw className={`w-4 h-4 ${loading ? 'animate-spin' : ''}`} />
|
||||
Aktualisieren
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Quick Links */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4 mb-8">
|
||||
<Link
|
||||
href="/ai/agents/architecture"
|
||||
className="flex items-center gap-3 p-4 bg-white border border-gray-200 rounded-xl hover:border-teal-300 hover:shadow-md transition-all group"
|
||||
>
|
||||
<div className="p-2 bg-purple-100 rounded-lg group-hover:bg-purple-200 transition-colors">
|
||||
<FileText className="w-5 h-5 text-purple-600" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="font-medium text-gray-900">Architektur</div>
|
||||
<div className="text-sm text-gray-500">Dokumentation & Diagramme</div>
|
||||
</div>
|
||||
<ChevronRight className="w-5 h-5 text-gray-400 ml-auto" />
|
||||
</Link>
|
||||
|
||||
<Link
|
||||
href="/ai/agents/sessions"
|
||||
className="flex items-center gap-3 p-4 bg-white border border-gray-200 rounded-xl hover:border-teal-300 hover:shadow-md transition-all group"
|
||||
>
|
||||
<div className="p-2 bg-blue-100 rounded-lg group-hover:bg-blue-200 transition-colors">
|
||||
<Activity className="w-5 h-5 text-blue-600" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="font-medium text-gray-900">Sessions</div>
|
||||
<div className="text-sm text-gray-500">{stats.activeSessions} aktiv</div>
|
||||
</div>
|
||||
<ChevronRight className="w-5 h-5 text-gray-400 ml-auto" />
|
||||
</Link>
|
||||
|
||||
<Link
|
||||
href="/ai/agents/statistics"
|
||||
className="flex items-center gap-3 p-4 bg-white border border-gray-200 rounded-xl hover:border-teal-300 hover:shadow-md transition-all group"
|
||||
>
|
||||
<div className="p-2 bg-green-100 rounded-lg group-hover:bg-green-200 transition-colors">
|
||||
<BarChart3 className="w-5 h-5 text-green-600" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="font-medium text-gray-900">Statistiken</div>
|
||||
<div className="text-sm text-gray-500">Performance & Trends</div>
|
||||
</div>
|
||||
<ChevronRight className="w-5 h-5 text-gray-400 ml-auto" />
|
||||
</Link>
|
||||
|
||||
<Link
|
||||
href="/ai/test-quality"
|
||||
className="flex items-center gap-3 p-4 bg-white border border-gray-200 rounded-xl hover:border-teal-300 hover:shadow-md transition-all group"
|
||||
>
|
||||
<div className="p-2 bg-amber-100 rounded-lg group-hover:bg-amber-200 transition-colors">
|
||||
<Cpu className="w-5 h-5 text-amber-600" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="font-medium text-gray-900">BQAS</div>
|
||||
<div className="text-sm text-gray-500">Qualitaetssicherung</div>
|
||||
</div>
|
||||
<ChevronRight className="w-5 h-5 text-gray-400 ml-auto" />
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* Stats Overview */}
|
||||
<div className="grid grid-cols-2 md:grid-cols-6 gap-4 mb-8">
|
||||
<div className="bg-white border border-gray-200 rounded-xl p-4">
|
||||
<div className="text-sm text-gray-500 mb-1">Gesamt Sessions</div>
|
||||
<div className="text-2xl font-bold text-gray-900">{stats.totalSessions.toLocaleString()}</div>
|
||||
</div>
|
||||
<div className="bg-white border border-gray-200 rounded-xl p-4">
|
||||
<div className="text-sm text-gray-500 mb-1">Aktive Sessions</div>
|
||||
<div className="text-2xl font-bold text-green-600">{stats.activeSessions}</div>
|
||||
</div>
|
||||
<div className="bg-white border border-gray-200 rounded-xl p-4">
|
||||
<div className="text-sm text-gray-500 mb-1">Nachrichten (24h)</div>
|
||||
<div className="text-2xl font-bold text-gray-900">{stats.totalMessages.toLocaleString()}</div>
|
||||
</div>
|
||||
<div className="bg-white border border-gray-200 rounded-xl p-4">
|
||||
<div className="text-sm text-gray-500 mb-1">Avg. Latenz</div>
|
||||
<div className="text-2xl font-bold text-gray-900">{stats.avgLatency}ms</div>
|
||||
</div>
|
||||
<div className="bg-white border border-gray-200 rounded-xl p-4">
|
||||
<div className="text-sm text-gray-500 mb-1">Fehlerrate</div>
|
||||
<div className="text-2xl font-bold text-amber-600">{stats.errorRate}%</div>
|
||||
</div>
|
||||
<div className="bg-white border border-gray-200 rounded-xl p-4">
|
||||
<div className="text-sm text-gray-500 mb-1">Memory Usage</div>
|
||||
<div className="text-2xl font-bold text-gray-900">{stats.memoryUsage}%</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Agents Grid */}
|
||||
<div className="mb-6">
|
||||
<h2 className="text-lg font-semibold text-gray-900 mb-4">Agents</h2>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{agents.map((agent) => (
|
||||
<Link
|
||||
key={agent.id}
|
||||
href={`/ai/agents/${agent.id}`}
|
||||
className="bg-white border border-gray-200 rounded-xl p-5 hover:border-teal-300 hover:shadow-lg transition-all group"
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex items-start justify-between mb-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div
|
||||
className="p-2.5 rounded-lg"
|
||||
style={{ backgroundColor: `${agent.color}20` }}
|
||||
>
|
||||
{getIconComponent(agent.icon, `w-5 h-5`)}
|
||||
<style jsx>{`
|
||||
svg { color: ${agent.color}; }
|
||||
`}</style>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-semibold text-gray-900 group-hover:text-teal-600 transition-colors">
|
||||
{agent.name}
|
||||
</h3>
|
||||
<p className="text-sm text-gray-500">{agent.description}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className={`flex items-center gap-1.5 px-2 py-1 rounded-full text-xs font-medium border ${getStatusColor(agent.status)}`}>
|
||||
{getStatusIcon(agent.status)}
|
||||
{agent.status}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Stats */}
|
||||
<div className="grid grid-cols-3 gap-3 mb-4">
|
||||
<div className="text-center p-2 bg-gray-50 rounded-lg">
|
||||
<div className="text-lg font-semibold text-gray-900">{agent.activeSessions}</div>
|
||||
<div className="text-xs text-gray-500">Sessions</div>
|
||||
</div>
|
||||
<div className="text-center p-2 bg-gray-50 rounded-lg">
|
||||
<div className="text-lg font-semibold text-gray-900">{agent.totalProcessed}</div>
|
||||
<div className="text-xs text-gray-500">Verarbeitet</div>
|
||||
</div>
|
||||
<div className="text-center p-2 bg-gray-50 rounded-lg">
|
||||
<div className="text-lg font-semibold text-gray-900">{agent.avgResponseTime}ms</div>
|
||||
<div className="text-xs text-gray-500">Avg. Zeit</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="flex items-center justify-between pt-3 border-t border-gray-100">
|
||||
<div className="flex items-center gap-2 text-sm text-gray-500">
|
||||
<FileText className="w-4 h-4" />
|
||||
{agent.soulFile}
|
||||
</div>
|
||||
<div className="flex items-center gap-1 text-sm text-gray-400">
|
||||
<Clock className="w-3.5 h-3.5" />
|
||||
{agent.lastActivity}
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Info Box */}
|
||||
<div className="bg-teal-50 border border-teal-200 rounded-xl p-5">
|
||||
<div className="flex gap-4">
|
||||
<div className="p-2 bg-teal-100 rounded-lg h-fit">
|
||||
<Brain className="w-5 h-5 text-teal-600" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-semibold text-teal-900 mb-2">Multi-Agent Architektur</h3>
|
||||
<p className="text-sm text-teal-700 mb-3">
|
||||
Das Breakpilot Multi-Agent-System basiert auf dem Mission Control Konzept. Jeder Agent hat eine
|
||||
definierte Persoenlichkeit (SOUL-File), die sein Verhalten steuert. Die Agents kommunizieren
|
||||
ueber einen Message Bus und nutzen ein gemeinsames Gedaechtnis (Shared Brain).
|
||||
</p>
|
||||
<div className="flex gap-3">
|
||||
<Link
|
||||
href="/ai/agents/architecture"
|
||||
className="text-sm font-medium text-teal-600 hover:text-teal-800"
|
||||
>
|
||||
Architektur ansehen →
|
||||
</Link>
|
||||
<Link
|
||||
href="/ai/agents/architecture#soul-files"
|
||||
className="text-sm font-medium text-teal-600 hover:text-teal-800"
|
||||
>
|
||||
SOUL-Files verstehen →
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,444 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect } from 'react'
|
||||
import Link from 'next/link'
|
||||
import { ArrowLeft, Activity, Clock, User, Bot, Brain, MessageSquare, AlertTriangle, Settings, CheckCircle, Pause, XCircle, RefreshCw, Filter, Search, ChevronRight, Zap, MoreVertical } from 'lucide-react'
|
||||
|
||||
// Session types
|
||||
interface AgentSession {
|
||||
id: string
|
||||
agentType: string
|
||||
agentId: string
|
||||
userId: string
|
||||
userName: string
|
||||
state: 'active' | 'paused' | 'completed' | 'failed'
|
||||
createdAt: string
|
||||
lastActivity: string
|
||||
checkpointCount: number
|
||||
messagesProcessed: number
|
||||
currentTask: string | null
|
||||
avgResponseTime: number
|
||||
}
|
||||
|
||||
// Mock data
|
||||
const mockSessions: AgentSession[] = [
|
||||
{
|
||||
id: 'session-001',
|
||||
agentType: 'tutor-agent',
|
||||
agentId: 'tutor-1',
|
||||
userId: 'user-123',
|
||||
userName: 'Max Mustermann',
|
||||
state: 'active',
|
||||
createdAt: '2026-02-03T14:30:00Z',
|
||||
lastActivity: '2026-02-03T15:45:23Z',
|
||||
checkpointCount: 5,
|
||||
messagesProcessed: 23,
|
||||
currentTask: 'Erklaere Quadratische Funktionen',
|
||||
avgResponseTime: 245
|
||||
},
|
||||
{
|
||||
id: 'session-002',
|
||||
agentType: 'tutor-agent',
|
||||
agentId: 'tutor-2',
|
||||
userId: 'user-456',
|
||||
userName: 'Anna Schmidt',
|
||||
state: 'active',
|
||||
createdAt: '2026-02-03T15:00:00Z',
|
||||
lastActivity: '2026-02-03T15:44:12Z',
|
||||
checkpointCount: 3,
|
||||
messagesProcessed: 12,
|
||||
currentTask: 'Hilfe bei Gedichtanalyse',
|
||||
avgResponseTime: 312
|
||||
},
|
||||
{
|
||||
id: 'session-003',
|
||||
agentType: 'grader-agent',
|
||||
agentId: 'grader-1',
|
||||
userId: 'user-789',
|
||||
userName: 'Frau Mueller (Lehrerin)',
|
||||
state: 'active',
|
||||
createdAt: '2026-02-03T14:00:00Z',
|
||||
lastActivity: '2026-02-03T15:42:00Z',
|
||||
checkpointCount: 12,
|
||||
messagesProcessed: 45,
|
||||
currentTask: 'Korrektur Klausur 10b - Arbeit 7/24',
|
||||
avgResponseTime: 1205
|
||||
},
|
||||
{
|
||||
id: 'session-004',
|
||||
agentType: 'quality-judge',
|
||||
agentId: 'judge-1',
|
||||
userId: 'system',
|
||||
userName: 'System (BQAS)',
|
||||
state: 'active',
|
||||
createdAt: '2026-02-03T08:00:00Z',
|
||||
lastActivity: '2026-02-03T15:45:01Z',
|
||||
checkpointCount: 156,
|
||||
messagesProcessed: 892,
|
||||
currentTask: 'Quality Check Queue Processing',
|
||||
avgResponseTime: 89
|
||||
},
|
||||
{
|
||||
id: 'session-005',
|
||||
agentType: 'orchestrator',
|
||||
agentId: 'orchestrator-main',
|
||||
userId: 'system',
|
||||
userName: 'System',
|
||||
state: 'active',
|
||||
createdAt: '2026-02-03T00:00:00Z',
|
||||
lastActivity: '2026-02-03T15:45:30Z',
|
||||
checkpointCount: 2341,
|
||||
messagesProcessed: 8934,
|
||||
currentTask: 'Routing incoming requests',
|
||||
avgResponseTime: 12
|
||||
},
|
||||
{
|
||||
id: 'session-006',
|
||||
agentType: 'tutor-agent',
|
||||
agentId: 'tutor-3',
|
||||
userId: 'user-101',
|
||||
userName: 'Tim Berger',
|
||||
state: 'paused',
|
||||
createdAt: '2026-02-03T13:00:00Z',
|
||||
lastActivity: '2026-02-03T14:30:00Z',
|
||||
checkpointCount: 8,
|
||||
messagesProcessed: 34,
|
||||
currentTask: null,
|
||||
avgResponseTime: 278
|
||||
},
|
||||
{
|
||||
id: 'session-007',
|
||||
agentType: 'grader-agent',
|
||||
agentId: 'grader-2',
|
||||
userId: 'user-202',
|
||||
userName: 'Herr Weber (Lehrer)',
|
||||
state: 'completed',
|
||||
createdAt: '2026-02-03T10:00:00Z',
|
||||
lastActivity: '2026-02-03T12:00:00Z',
|
||||
checkpointCount: 24,
|
||||
messagesProcessed: 120,
|
||||
currentTask: null,
|
||||
avgResponseTime: 1102
|
||||
},
|
||||
{
|
||||
id: 'session-008',
|
||||
agentType: 'alert-agent',
|
||||
agentId: 'alert-1',
|
||||
userId: 'system',
|
||||
userName: 'System (Monitoring)',
|
||||
state: 'active',
|
||||
createdAt: '2026-02-03T00:00:00Z',
|
||||
lastActivity: '2026-02-03T15:45:28Z',
|
||||
checkpointCount: 48,
|
||||
messagesProcessed: 256,
|
||||
currentTask: 'Monitoring System Health',
|
||||
avgResponseTime: 45
|
||||
}
|
||||
]
|
||||
|
||||
function getAgentIcon(agentType: string) {
|
||||
switch (agentType) {
|
||||
case 'tutor-agent': return <Brain className="w-4 h-4" />
|
||||
case 'grader-agent': return <Bot className="w-4 h-4" />
|
||||
case 'quality-judge': return <Settings className="w-4 h-4" />
|
||||
case 'alert-agent': return <AlertTriangle className="w-4 h-4" />
|
||||
case 'orchestrator': return <MessageSquare className="w-4 h-4" />
|
||||
default: return <Bot className="w-4 h-4" />
|
||||
}
|
||||
}
|
||||
|
||||
function getAgentColor(agentType: string) {
|
||||
switch (agentType) {
|
||||
case 'tutor-agent': return { bg: 'bg-blue-100', text: 'text-blue-600', border: 'border-blue-200' }
|
||||
case 'grader-agent': return { bg: 'bg-green-100', text: 'text-green-600', border: 'border-green-200' }
|
||||
case 'quality-judge': return { bg: 'bg-amber-100', text: 'text-amber-600', border: 'border-amber-200' }
|
||||
case 'alert-agent': return { bg: 'bg-red-100', text: 'text-red-600', border: 'border-red-200' }
|
||||
case 'orchestrator': return { bg: 'bg-purple-100', text: 'text-purple-600', border: 'border-purple-200' }
|
||||
default: return { bg: 'bg-gray-100', text: 'text-gray-600', border: 'border-gray-200' }
|
||||
}
|
||||
}
|
||||
|
||||
function getStateConfig(state: string) {
|
||||
switch (state) {
|
||||
case 'active':
|
||||
return { icon: <CheckCircle className="w-4 h-4" />, color: 'bg-green-100 text-green-700 border-green-200', label: 'Aktiv' }
|
||||
case 'paused':
|
||||
return { icon: <Pause className="w-4 h-4" />, color: 'bg-yellow-100 text-yellow-700 border-yellow-200', label: 'Pausiert' }
|
||||
case 'completed':
|
||||
return { icon: <CheckCircle className="w-4 h-4" />, color: 'bg-gray-100 text-gray-600 border-gray-200', label: 'Beendet' }
|
||||
case 'failed':
|
||||
return { icon: <XCircle className="w-4 h-4" />, color: 'bg-red-100 text-red-700 border-red-200', label: 'Fehlgeschlagen' }
|
||||
default:
|
||||
return { icon: null, color: 'bg-gray-100 text-gray-600 border-gray-200', label: state }
|
||||
}
|
||||
}
|
||||
|
||||
function formatDuration(isoDate: string): string {
|
||||
const date = new Date(isoDate)
|
||||
const now = new Date()
|
||||
const diffMs = now.getTime() - date.getTime()
|
||||
const diffMins = Math.floor(diffMs / 60000)
|
||||
const diffHours = Math.floor(diffMins / 60)
|
||||
const diffDays = Math.floor(diffHours / 24)
|
||||
|
||||
if (diffDays > 0) return `${diffDays}d ${diffHours % 24}h`
|
||||
if (diffHours > 0) return `${diffHours}h ${diffMins % 60}m`
|
||||
return `${diffMins}m`
|
||||
}
|
||||
|
||||
function formatTime(isoDate: string): string {
|
||||
return new Date(isoDate).toLocaleTimeString('de-DE', {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit'
|
||||
})
|
||||
}
|
||||
|
||||
export default function SessionsPage() {
|
||||
const [sessions, setSessions] = useState<AgentSession[]>(mockSessions)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [filter, setFilter] = useState<string>('all')
|
||||
const [searchTerm, setSearchTerm] = useState('')
|
||||
const [lastRefresh, setLastRefresh] = useState(new Date())
|
||||
|
||||
const refreshData = async () => {
|
||||
setLoading(true)
|
||||
// In production, fetch from API
|
||||
await new Promise(resolve => setTimeout(resolve, 500))
|
||||
setLastRefresh(new Date())
|
||||
setLoading(false)
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
const interval = setInterval(refreshData, 10000) // Refresh every 10s
|
||||
return () => clearInterval(interval)
|
||||
}, [])
|
||||
|
||||
// Filter sessions
|
||||
const filteredSessions = sessions.filter(session => {
|
||||
if (filter !== 'all' && session.state !== filter) return false
|
||||
if (searchTerm) {
|
||||
const search = searchTerm.toLowerCase()
|
||||
return (
|
||||
session.userName.toLowerCase().includes(search) ||
|
||||
session.agentType.toLowerCase().includes(search) ||
|
||||
session.currentTask?.toLowerCase().includes(search) ||
|
||||
session.id.toLowerCase().includes(search)
|
||||
)
|
||||
}
|
||||
return true
|
||||
})
|
||||
|
||||
// Stats
|
||||
const stats = {
|
||||
total: sessions.length,
|
||||
active: sessions.filter(s => s.state === 'active').length,
|
||||
paused: sessions.filter(s => s.state === 'paused').length,
|
||||
completed: sessions.filter(s => s.state === 'completed').length,
|
||||
failed: sessions.filter(s => s.state === 'failed').length,
|
||||
totalMessages: sessions.reduce((sum, s) => sum + s.messagesProcessed, 0),
|
||||
avgResponseTime: Math.round(sessions.reduce((sum, s) => sum + s.avgResponseTime, 0) / sessions.length)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-6 max-w-7xl mx-auto">
|
||||
{/* Header */}
|
||||
<div className="mb-8">
|
||||
<Link
|
||||
href="/ai/agents"
|
||||
className="flex items-center gap-2 text-gray-500 hover:text-gray-700 mb-4"
|
||||
>
|
||||
<ArrowLeft className="w-4 h-4" />
|
||||
Zurueck zur Agent-Verwaltung
|
||||
</Link>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900 flex items-center gap-3">
|
||||
<div className="p-2 bg-blue-100 rounded-lg">
|
||||
<Activity className="w-6 h-6 text-blue-600" />
|
||||
</div>
|
||||
Aktive Sessions
|
||||
</h1>
|
||||
<p className="text-gray-500 mt-1">
|
||||
Live-Uebersicht aller Agent-Sessions im System
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-sm text-gray-500">
|
||||
Letzte Aktualisierung: {lastRefresh.toLocaleTimeString('de-DE')}
|
||||
</span>
|
||||
<button
|
||||
onClick={refreshData}
|
||||
disabled={loading}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors disabled:opacity-50"
|
||||
>
|
||||
<RefreshCw className={`w-4 h-4 ${loading ? 'animate-spin' : ''}`} />
|
||||
Aktualisieren
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Stats */}
|
||||
<div className="grid grid-cols-2 md:grid-cols-6 gap-4 mb-6">
|
||||
<div className="bg-white border border-gray-200 rounded-xl p-4">
|
||||
<div className="text-sm text-gray-500 mb-1">Gesamt</div>
|
||||
<div className="text-2xl font-bold text-gray-900">{stats.total}</div>
|
||||
</div>
|
||||
<div className="bg-white border border-green-200 rounded-xl p-4">
|
||||
<div className="text-sm text-green-600 mb-1">Aktiv</div>
|
||||
<div className="text-2xl font-bold text-green-600">{stats.active}</div>
|
||||
</div>
|
||||
<div className="bg-white border border-yellow-200 rounded-xl p-4">
|
||||
<div className="text-sm text-yellow-600 mb-1">Pausiert</div>
|
||||
<div className="text-2xl font-bold text-yellow-600">{stats.paused}</div>
|
||||
</div>
|
||||
<div className="bg-white border border-gray-200 rounded-xl p-4">
|
||||
<div className="text-sm text-gray-500 mb-1">Beendet</div>
|
||||
<div className="text-2xl font-bold text-gray-600">{stats.completed}</div>
|
||||
</div>
|
||||
<div className="bg-white border border-gray-200 rounded-xl p-4">
|
||||
<div className="text-sm text-gray-500 mb-1">Messages (24h)</div>
|
||||
<div className="text-2xl font-bold text-gray-900">{stats.totalMessages.toLocaleString()}</div>
|
||||
</div>
|
||||
<div className="bg-white border border-gray-200 rounded-xl p-4">
|
||||
<div className="text-sm text-gray-500 mb-1">Avg. Response</div>
|
||||
<div className="text-2xl font-bold text-gray-900">{stats.avgResponseTime}ms</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Filters */}
|
||||
<div className="flex flex-col md:flex-row gap-4 mb-6">
|
||||
<div className="relative flex-1">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Session, Benutzer oder Task suchen..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
className="w-full pl-10 pr-4 py-2 border border-gray-200 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Filter className="w-4 h-4 text-gray-400" />
|
||||
<select
|
||||
value={filter}
|
||||
onChange={(e) => setFilter(e.target.value)}
|
||||
className="px-4 py-2 border border-gray-200 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
>
|
||||
<option value="all">Alle Status</option>
|
||||
<option value="active">Aktiv</option>
|
||||
<option value="paused">Pausiert</option>
|
||||
<option value="completed">Beendet</option>
|
||||
<option value="failed">Fehlgeschlagen</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Sessions List */}
|
||||
<div className="bg-white border border-gray-200 rounded-xl overflow-hidden">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="min-w-full divide-y divide-gray-200">
|
||||
<thead className="bg-gray-50">
|
||||
<tr>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Agent</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Benutzer</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Status</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Aktueller Task</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Dauer</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Messages</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Letzte Aktivitaet</th>
|
||||
<th className="px-4 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="bg-white divide-y divide-gray-200">
|
||||
{filteredSessions.map(session => {
|
||||
const agentColor = getAgentColor(session.agentType)
|
||||
const stateConfig = getStateConfig(session.state)
|
||||
|
||||
return (
|
||||
<tr key={session.id} className="hover:bg-gray-50">
|
||||
<td className="px-4 py-4 whitespace-nowrap">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className={`p-2 rounded-lg ${agentColor.bg}`}>
|
||||
<span className={agentColor.text}>{getAgentIcon(session.agentType)}</span>
|
||||
</div>
|
||||
<div>
|
||||
<div className="font-medium text-gray-900">{session.agentId}</div>
|
||||
<div className="text-xs text-gray-500">{session.agentType}</div>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-4 py-4 whitespace-nowrap">
|
||||
<div className="flex items-center gap-2">
|
||||
<User className="w-4 h-4 text-gray-400" />
|
||||
<span className="text-sm text-gray-900">{session.userName}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-4 py-4 whitespace-nowrap">
|
||||
<span className={`inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-medium border ${stateConfig.color}`}>
|
||||
{stateConfig.icon}
|
||||
{stateConfig.label}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-4">
|
||||
{session.currentTask ? (
|
||||
<div className="flex items-center gap-2 max-w-xs">
|
||||
<Zap className="w-3.5 h-3.5 text-amber-500 flex-shrink-0" />
|
||||
<span className="text-sm text-gray-700 truncate">{session.currentTask}</span>
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-sm text-gray-400">-</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-4 whitespace-nowrap">
|
||||
<div className="flex items-center gap-1.5 text-sm text-gray-600">
|
||||
<Clock className="w-3.5 h-3.5" />
|
||||
{formatDuration(session.createdAt)}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-4 py-4 whitespace-nowrap">
|
||||
<div className="text-sm">
|
||||
<span className="font-medium text-gray-900">{session.messagesProcessed}</span>
|
||||
<span className="text-gray-500 ml-1">({session.checkpointCount} CP)</span>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-4 py-4 whitespace-nowrap">
|
||||
<div className="text-sm text-gray-600">{formatTime(session.lastActivity)}</div>
|
||||
<div className="text-xs text-gray-400">{session.avgResponseTime}ms avg</div>
|
||||
</td>
|
||||
<td className="px-4 py-4 whitespace-nowrap text-right">
|
||||
<Link
|
||||
href={`/ai/agents/${session.agentType.replace('-agent', '-agent')}`}
|
||||
className="p-2 hover:bg-gray-100 rounded-lg inline-flex items-center gap-1 text-sm text-gray-600 hover:text-gray-900"
|
||||
>
|
||||
Details
|
||||
<ChevronRight className="w-4 h-4" />
|
||||
</Link>
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{filteredSessions.length === 0 && (
|
||||
<div className="text-center py-12">
|
||||
<Activity className="w-12 h-12 text-gray-300 mx-auto mb-3" />
|
||||
<p className="text-gray-500">Keine Sessions gefunden</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Live Activity Indicator */}
|
||||
<div className="mt-6 flex items-center justify-center gap-2 text-sm text-gray-500">
|
||||
<span className="relative flex h-2 w-2">
|
||||
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-green-400 opacity-75"></span>
|
||||
<span className="relative inline-flex rounded-full h-2 w-2 bg-green-500"></span>
|
||||
</span>
|
||||
Live-Daten - Auto-Refresh alle 10 Sekunden
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,491 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect } from 'react'
|
||||
import Link from 'next/link'
|
||||
import { ArrowLeft, BarChart3, TrendingUp, TrendingDown, Clock, Activity, Bot, Brain, MessageSquare, AlertTriangle, Settings, RefreshCw, Calendar, Filter, Download } from 'lucide-react'
|
||||
|
||||
// Types
|
||||
interface AgentMetric {
|
||||
agentType: string
|
||||
name: string
|
||||
color: string
|
||||
sessions: number
|
||||
messagesProcessed: number
|
||||
avgResponseTime: number
|
||||
errorRate: number
|
||||
successRate: number
|
||||
trend: 'up' | 'down' | 'stable'
|
||||
trendValue: number
|
||||
}
|
||||
|
||||
interface TimeSeriesData {
|
||||
timestamp: string
|
||||
value: number
|
||||
}
|
||||
|
||||
interface DailyStats {
|
||||
date: string
|
||||
sessions: number
|
||||
messages: number
|
||||
errors: number
|
||||
avgLatency: number
|
||||
}
|
||||
|
||||
// Mock data
|
||||
const mockAgentMetrics: AgentMetric[] = [
|
||||
{
|
||||
agentType: 'tutor-agent',
|
||||
name: 'TutorAgent',
|
||||
color: '#3b82f6',
|
||||
sessions: 156,
|
||||
messagesProcessed: 4521,
|
||||
avgResponseTime: 234,
|
||||
errorRate: 0.3,
|
||||
successRate: 99.7,
|
||||
trend: 'up',
|
||||
trendValue: 12
|
||||
},
|
||||
{
|
||||
agentType: 'grader-agent',
|
||||
name: 'GraderAgent',
|
||||
color: '#10b981',
|
||||
sessions: 45,
|
||||
messagesProcessed: 1205,
|
||||
avgResponseTime: 1102,
|
||||
errorRate: 0.5,
|
||||
successRate: 99.5,
|
||||
trend: 'stable',
|
||||
trendValue: 2
|
||||
},
|
||||
{
|
||||
agentType: 'quality-judge',
|
||||
name: 'QualityJudge',
|
||||
color: '#f59e0b',
|
||||
sessions: 89,
|
||||
messagesProcessed: 8934,
|
||||
avgResponseTime: 89,
|
||||
errorRate: 0.1,
|
||||
successRate: 99.9,
|
||||
trend: 'up',
|
||||
trendValue: 8
|
||||
},
|
||||
{
|
||||
agentType: 'alert-agent',
|
||||
name: 'AlertAgent',
|
||||
color: '#ef4444',
|
||||
sessions: 12,
|
||||
messagesProcessed: 892,
|
||||
avgResponseTime: 45,
|
||||
errorRate: 0.0,
|
||||
successRate: 100,
|
||||
trend: 'stable',
|
||||
trendValue: 0
|
||||
},
|
||||
{
|
||||
agentType: 'orchestrator',
|
||||
name: 'Orchestrator',
|
||||
color: '#8b5cf6',
|
||||
sessions: 234,
|
||||
messagesProcessed: 15420,
|
||||
avgResponseTime: 12,
|
||||
errorRate: 0.2,
|
||||
successRate: 99.8,
|
||||
trend: 'up',
|
||||
trendValue: 15
|
||||
}
|
||||
]
|
||||
|
||||
const mockDailyStats: DailyStats[] = [
|
||||
{ date: '2026-01-28', sessions: 420, messages: 12500, errors: 15, avgLatency: 156 },
|
||||
{ date: '2026-01-29', sessions: 445, messages: 13200, errors: 12, avgLatency: 148 },
|
||||
{ date: '2026-01-30', sessions: 398, messages: 11800, errors: 18, avgLatency: 162 },
|
||||
{ date: '2026-01-31', sessions: 512, messages: 15600, errors: 10, avgLatency: 145 },
|
||||
{ date: '2026-02-01', sessions: 489, messages: 14200, errors: 8, avgLatency: 139 },
|
||||
{ date: '2026-02-02', sessions: 534, messages: 16100, errors: 11, avgLatency: 142 },
|
||||
{ date: '2026-02-03', sessions: 478, messages: 14800, errors: 9, avgLatency: 151 }
|
||||
]
|
||||
|
||||
const mockHourlyLatency: TimeSeriesData[] = Array.from({ length: 24 }, (_, i) => ({
|
||||
timestamp: `${i.toString().padStart(2, '0')}:00`,
|
||||
value: Math.floor(100 + Math.random() * 100)
|
||||
}))
|
||||
|
||||
function getAgentIcon(agentType: string) {
|
||||
switch (agentType) {
|
||||
case 'tutor-agent': return <Brain className="w-4 h-4" />
|
||||
case 'grader-agent': return <Bot className="w-4 h-4" />
|
||||
case 'quality-judge': return <Settings className="w-4 h-4" />
|
||||
case 'alert-agent': return <AlertTriangle className="w-4 h-4" />
|
||||
case 'orchestrator': return <MessageSquare className="w-4 h-4" />
|
||||
default: return <Bot className="w-4 h-4" />
|
||||
}
|
||||
}
|
||||
|
||||
// Simple bar chart component
|
||||
function BarChart({ data, color, maxValue }: { data: number[], color: string, maxValue: number }) {
|
||||
return (
|
||||
<div className="flex items-end gap-1 h-20">
|
||||
{data.map((value, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="flex-1 rounded-t transition-all hover:opacity-80"
|
||||
style={{
|
||||
height: `${(value / maxValue) * 100}%`,
|
||||
backgroundColor: color,
|
||||
minHeight: '4px'
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Simple line chart visualization
|
||||
function SparkLine({ data, color }: { data: number[], color: string }) {
|
||||
const max = Math.max(...data)
|
||||
const min = Math.min(...data)
|
||||
const range = max - min || 1
|
||||
|
||||
const points = data.map((value, i) => {
|
||||
const x = (i / (data.length - 1)) * 100
|
||||
const y = 100 - ((value - min) / range) * 100
|
||||
return `${x},${y}`
|
||||
}).join(' ')
|
||||
|
||||
return (
|
||||
<svg viewBox="0 0 100 100" className="w-full h-12" preserveAspectRatio="none">
|
||||
<polyline
|
||||
fill="none"
|
||||
stroke={color}
|
||||
strokeWidth="2"
|
||||
points={points}
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export default function StatisticsPage() {
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [timeRange, setTimeRange] = useState<'24h' | '7d' | '30d'>('7d')
|
||||
const [lastRefresh, setLastRefresh] = useState(new Date())
|
||||
|
||||
const refreshData = async () => {
|
||||
setLoading(true)
|
||||
await new Promise(resolve => setTimeout(resolve, 500))
|
||||
setLastRefresh(new Date())
|
||||
setLoading(false)
|
||||
}
|
||||
|
||||
// Calculate totals
|
||||
const totals = {
|
||||
sessions: mockAgentMetrics.reduce((sum, m) => sum + m.sessions, 0),
|
||||
messages: mockAgentMetrics.reduce((sum, m) => sum + m.messagesProcessed, 0),
|
||||
avgLatency: Math.round(mockAgentMetrics.reduce((sum, m) => sum + m.avgResponseTime, 0) / mockAgentMetrics.length),
|
||||
avgErrorRate: (mockAgentMetrics.reduce((sum, m) => sum + m.errorRate, 0) / mockAgentMetrics.length).toFixed(2)
|
||||
}
|
||||
|
||||
// Calculate week stats
|
||||
const weekTotals = {
|
||||
sessions: mockDailyStats.reduce((sum, d) => sum + d.sessions, 0),
|
||||
messages: mockDailyStats.reduce((sum, d) => sum + d.messages, 0),
|
||||
errors: mockDailyStats.reduce((sum, d) => sum + d.errors, 0),
|
||||
avgLatency: Math.round(mockDailyStats.reduce((sum, d) => sum + d.avgLatency, 0) / mockDailyStats.length)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-6 max-w-7xl mx-auto">
|
||||
{/* Header */}
|
||||
<div className="mb-8">
|
||||
<Link
|
||||
href="/ai/agents"
|
||||
className="flex items-center gap-2 text-gray-500 hover:text-gray-700 mb-4"
|
||||
>
|
||||
<ArrowLeft className="w-4 h-4" />
|
||||
Zurueck zur Agent-Verwaltung
|
||||
</Link>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900 flex items-center gap-3">
|
||||
<div className="p-2 bg-green-100 rounded-lg">
|
||||
<BarChart3 className="w-6 h-6 text-green-600" />
|
||||
</div>
|
||||
Agent Statistiken
|
||||
</h1>
|
||||
<p className="text-gray-500 mt-1">
|
||||
Performance-Metriken und Trends des Multi-Agent-Systems
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<select
|
||||
value={timeRange}
|
||||
onChange={(e) => setTimeRange(e.target.value as '24h' | '7d' | '30d')}
|
||||
className="px-4 py-2 border border-gray-200 rounded-lg focus:outline-none focus:ring-2 focus:ring-green-500"
|
||||
>
|
||||
<option value="24h">Letzte 24 Stunden</option>
|
||||
<option value="7d">Letzte 7 Tage</option>
|
||||
<option value="30d">Letzte 30 Tage</option>
|
||||
</select>
|
||||
<button
|
||||
onClick={refreshData}
|
||||
disabled={loading}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700 transition-colors disabled:opacity-50"
|
||||
>
|
||||
<RefreshCw className={`w-4 h-4 ${loading ? 'animate-spin' : ''}`} />
|
||||
Aktualisieren
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Overview Stats */}
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 mb-8">
|
||||
<div className="bg-white border border-gray-200 rounded-xl p-5">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-sm text-gray-500">Sessions (7d)</span>
|
||||
<Activity className="w-4 h-4 text-gray-400" />
|
||||
</div>
|
||||
<div className="text-3xl font-bold text-gray-900">{weekTotals.sessions.toLocaleString()}</div>
|
||||
<div className="flex items-center gap-1 mt-1 text-sm text-green-600">
|
||||
<TrendingUp className="w-3.5 h-3.5" />
|
||||
<span>+12% vs. Vorwoche</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-white border border-gray-200 rounded-xl p-5">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-sm text-gray-500">Messages (7d)</span>
|
||||
<MessageSquare className="w-4 h-4 text-gray-400" />
|
||||
</div>
|
||||
<div className="text-3xl font-bold text-gray-900">{weekTotals.messages.toLocaleString()}</div>
|
||||
<div className="flex items-center gap-1 mt-1 text-sm text-green-600">
|
||||
<TrendingUp className="w-3.5 h-3.5" />
|
||||
<span>+8% vs. Vorwoche</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-white border border-gray-200 rounded-xl p-5">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-sm text-gray-500">Avg. Latenz</span>
|
||||
<Clock className="w-4 h-4 text-gray-400" />
|
||||
</div>
|
||||
<div className="text-3xl font-bold text-gray-900">{weekTotals.avgLatency}ms</div>
|
||||
<div className="flex items-center gap-1 mt-1 text-sm text-green-600">
|
||||
<TrendingDown className="w-3.5 h-3.5" />
|
||||
<span>-5% (verbessert)</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-white border border-gray-200 rounded-xl p-5">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-sm text-gray-500">Fehler (7d)</span>
|
||||
<AlertTriangle className="w-4 h-4 text-gray-400" />
|
||||
</div>
|
||||
<div className="text-3xl font-bold text-gray-900">{weekTotals.errors}</div>
|
||||
<div className="flex items-center gap-1 mt-1 text-sm text-amber-600">
|
||||
<TrendingUp className="w-3.5 h-3.5" />
|
||||
<span>+3 vs. Vorwoche</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Charts Row */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-8">
|
||||
{/* Sessions per Day */}
|
||||
<div className="bg-white border border-gray-200 rounded-xl p-5">
|
||||
<h3 className="font-semibold text-gray-900 mb-4">Sessions pro Tag</h3>
|
||||
<div className="space-y-3">
|
||||
<BarChart
|
||||
data={mockDailyStats.map(d => d.sessions)}
|
||||
color="#3b82f6"
|
||||
maxValue={Math.max(...mockDailyStats.map(d => d.sessions)) * 1.1}
|
||||
/>
|
||||
<div className="flex justify-between text-xs text-gray-500">
|
||||
{mockDailyStats.map(d => (
|
||||
<span key={d.date}>{new Date(d.date).toLocaleDateString('de-DE', { weekday: 'short' })}</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Messages per Day */}
|
||||
<div className="bg-white border border-gray-200 rounded-xl p-5">
|
||||
<h3 className="font-semibold text-gray-900 mb-4">Messages pro Tag</h3>
|
||||
<div className="space-y-3">
|
||||
<BarChart
|
||||
data={mockDailyStats.map(d => d.messages)}
|
||||
color="#10b981"
|
||||
maxValue={Math.max(...mockDailyStats.map(d => d.messages)) * 1.1}
|
||||
/>
|
||||
<div className="flex justify-between text-xs text-gray-500">
|
||||
{mockDailyStats.map(d => (
|
||||
<span key={d.date}>{new Date(d.date).toLocaleDateString('de-DE', { weekday: 'short' })}</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Latency Chart */}
|
||||
<div className="bg-white border border-gray-200 rounded-xl p-5 mb-8">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="font-semibold text-gray-900">Latenz (24h)</h3>
|
||||
<div className="flex items-center gap-2 text-sm text-gray-500">
|
||||
<Clock className="w-4 h-4" />
|
||||
Durchschnitt: {totals.avgLatency}ms
|
||||
</div>
|
||||
</div>
|
||||
<SparkLine
|
||||
data={mockHourlyLatency.map(d => d.value)}
|
||||
color="#8b5cf6"
|
||||
/>
|
||||
<div className="flex justify-between text-xs text-gray-400 mt-2">
|
||||
<span>00:00</span>
|
||||
<span>06:00</span>
|
||||
<span>12:00</span>
|
||||
<span>18:00</span>
|
||||
<span>24:00</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Agent Performance Table */}
|
||||
<div className="bg-white border border-gray-200 rounded-xl overflow-hidden mb-8">
|
||||
<div className="px-5 py-4 border-b border-gray-200">
|
||||
<h3 className="font-semibold text-gray-900">Agent Performance</h3>
|
||||
</div>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="min-w-full divide-y divide-gray-200">
|
||||
<thead className="bg-gray-50">
|
||||
<tr>
|
||||
<th className="px-5 py-3 text-left text-xs font-medium text-gray-500 uppercase">Agent</th>
|
||||
<th className="px-5 py-3 text-right text-xs font-medium text-gray-500 uppercase">Sessions</th>
|
||||
<th className="px-5 py-3 text-right text-xs font-medium text-gray-500 uppercase">Messages</th>
|
||||
<th className="px-5 py-3 text-right text-xs font-medium text-gray-500 uppercase">Avg. Response</th>
|
||||
<th className="px-5 py-3 text-right text-xs font-medium text-gray-500 uppercase">Success Rate</th>
|
||||
<th className="px-5 py-3 text-right text-xs font-medium text-gray-500 uppercase">Error Rate</th>
|
||||
<th className="px-5 py-3 text-center text-xs font-medium text-gray-500 uppercase">Trend</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-200">
|
||||
{mockAgentMetrics.map(metric => (
|
||||
<tr key={metric.agentType} className="hover:bg-gray-50">
|
||||
<td className="px-5 py-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div
|
||||
className="p-2 rounded-lg"
|
||||
style={{ backgroundColor: `${metric.color}20` }}
|
||||
>
|
||||
<span style={{ color: metric.color }}>{getAgentIcon(metric.agentType)}</span>
|
||||
</div>
|
||||
<div>
|
||||
<div className="font-medium text-gray-900">{metric.name}</div>
|
||||
<div className="text-xs text-gray-500">{metric.agentType}</div>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-5 py-4 text-right">
|
||||
<span className="font-medium text-gray-900">{metric.sessions}</span>
|
||||
</td>
|
||||
<td className="px-5 py-4 text-right">
|
||||
<span className="font-medium text-gray-900">{metric.messagesProcessed.toLocaleString()}</span>
|
||||
</td>
|
||||
<td className="px-5 py-4 text-right">
|
||||
<span className="text-gray-900">{metric.avgResponseTime}ms</span>
|
||||
</td>
|
||||
<td className="px-5 py-4 text-right">
|
||||
<span className="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-green-100 text-green-700">
|
||||
{metric.successRate}%
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-5 py-4 text-right">
|
||||
<span className={`inline-flex items-center px-2 py-0.5 rounded text-xs font-medium ${
|
||||
metric.errorRate > 0.5 ? 'bg-red-100 text-red-700' :
|
||||
metric.errorRate > 0 ? 'bg-amber-100 text-amber-700' :
|
||||
'bg-green-100 text-green-700'
|
||||
}`}>
|
||||
{metric.errorRate}%
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-5 py-4 text-center">
|
||||
{metric.trend === 'up' && (
|
||||
<span className="inline-flex items-center gap-1 text-green-600 text-sm">
|
||||
<TrendingUp className="w-4 h-4" />
|
||||
+{metric.trendValue}%
|
||||
</span>
|
||||
)}
|
||||
{metric.trend === 'down' && (
|
||||
<span className="inline-flex items-center gap-1 text-red-600 text-sm">
|
||||
<TrendingDown className="w-4 h-4" />
|
||||
-{metric.trendValue}%
|
||||
</span>
|
||||
)}
|
||||
{metric.trend === 'stable' && (
|
||||
<span className="inline-flex items-center gap-1 text-gray-500 text-sm">
|
||||
<span className="w-4 h-0.5 bg-gray-400 rounded" />
|
||||
{metric.trendValue}%
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Error Distribution */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
{/* Error by Agent */}
|
||||
<div className="bg-white border border-gray-200 rounded-xl p-5">
|
||||
<h3 className="font-semibold text-gray-900 mb-4">Fehlerverteilung nach Agent</h3>
|
||||
<div className="space-y-3">
|
||||
{mockAgentMetrics.filter(m => m.errorRate > 0).map(metric => (
|
||||
<div key={metric.agentType} className="flex items-center gap-3">
|
||||
<div className="w-24 text-sm text-gray-600 truncate">{metric.name}</div>
|
||||
<div className="flex-1 h-4 bg-gray-100 rounded-full overflow-hidden">
|
||||
<div
|
||||
className="h-full rounded-full"
|
||||
style={{
|
||||
width: `${metric.errorRate * 20}%`,
|
||||
backgroundColor: metric.color
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="w-12 text-right text-sm text-gray-600">{metric.errorRate}%</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Message Distribution */}
|
||||
<div className="bg-white border border-gray-200 rounded-xl p-5">
|
||||
<h3 className="font-semibold text-gray-900 mb-4">Message-Verteilung nach Agent</h3>
|
||||
<div className="space-y-3">
|
||||
{mockAgentMetrics.map(metric => {
|
||||
const percentage = (metric.messagesProcessed / totals.messages) * 100
|
||||
return (
|
||||
<div key={metric.agentType} className="flex items-center gap-3">
|
||||
<div className="w-24 text-sm text-gray-600 truncate">{metric.name}</div>
|
||||
<div className="flex-1 h-4 bg-gray-100 rounded-full overflow-hidden">
|
||||
<div
|
||||
className="h-full rounded-full"
|
||||
style={{
|
||||
width: `${percentage}%`,
|
||||
backgroundColor: metric.color
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="w-12 text-right text-sm text-gray-600">{percentage.toFixed(1)}%</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Export Button */}
|
||||
<div className="mt-8 flex justify-end">
|
||||
<button className="flex items-center gap-2 px-4 py-2 border border-gray-200 rounded-lg hover:bg-gray-50 text-gray-600 hover:text-gray-900 transition-colors">
|
||||
<Download className="w-4 h-4" />
|
||||
Statistiken exportieren (CSV)
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,396 @@
|
||||
'use client'
|
||||
|
||||
/**
|
||||
* GPU Infrastructure Admin Page
|
||||
*
|
||||
* vast.ai GPU Management for LLM Processing
|
||||
* Part of KI-Werkzeuge
|
||||
*/
|
||||
|
||||
import { useEffect, useState, useCallback } from 'react'
|
||||
import { PagePurpose } from '@/components/common/PagePurpose'
|
||||
import { AIToolsSidebarResponsive } from '@/components/ai/AIToolsSidebar'
|
||||
|
||||
interface VastStatus {
|
||||
instance_id: number | null
|
||||
status: string
|
||||
gpu_name: string | null
|
||||
dph_total: number | null
|
||||
endpoint_base_url: string | null
|
||||
last_activity: string | null
|
||||
auto_shutdown_in_minutes: number | null
|
||||
total_runtime_hours: number | null
|
||||
total_cost_usd: number | null
|
||||
account_credit: number | null
|
||||
account_total_spend: number | null
|
||||
session_runtime_minutes: number | null
|
||||
session_cost_usd: number | null
|
||||
message: string | null
|
||||
error?: string
|
||||
}
|
||||
|
||||
export default function GPUInfrastructurePage() {
|
||||
const [status, setStatus] = useState<VastStatus | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [actionLoading, setActionLoading] = useState<string | null>(null)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [message, setMessage] = useState<string | null>(null)
|
||||
|
||||
const API_PROXY = '/api/admin/gpu'
|
||||
|
||||
const fetchStatus = useCallback(async () => {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
|
||||
try {
|
||||
const response = await fetch(API_PROXY)
|
||||
const data = await response.json()
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(data.error || `HTTP ${response.status}`)
|
||||
}
|
||||
|
||||
setStatus(data)
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Verbindungsfehler')
|
||||
setStatus({
|
||||
instance_id: null,
|
||||
status: 'error',
|
||||
gpu_name: null,
|
||||
dph_total: null,
|
||||
endpoint_base_url: null,
|
||||
last_activity: null,
|
||||
auto_shutdown_in_minutes: null,
|
||||
total_runtime_hours: null,
|
||||
total_cost_usd: null,
|
||||
account_credit: null,
|
||||
account_total_spend: null,
|
||||
session_runtime_minutes: null,
|
||||
session_cost_usd: null,
|
||||
message: 'Verbindung fehlgeschlagen'
|
||||
})
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
fetchStatus()
|
||||
}, [fetchStatus])
|
||||
|
||||
useEffect(() => {
|
||||
const interval = setInterval(fetchStatus, 30000)
|
||||
return () => clearInterval(interval)
|
||||
}, [fetchStatus])
|
||||
|
||||
const powerOn = async () => {
|
||||
setActionLoading('on')
|
||||
setError(null)
|
||||
setMessage(null)
|
||||
|
||||
try {
|
||||
const response = await fetch(API_PROXY, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ action: 'on' }),
|
||||
})
|
||||
|
||||
const data = await response.json()
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(data.error || data.detail || 'Aktion fehlgeschlagen')
|
||||
}
|
||||
|
||||
setMessage('Start angefordert')
|
||||
setTimeout(fetchStatus, 3000)
|
||||
setTimeout(fetchStatus, 10000)
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Fehler beim Starten')
|
||||
fetchStatus()
|
||||
} finally {
|
||||
setActionLoading(null)
|
||||
}
|
||||
}
|
||||
|
||||
const powerOff = async () => {
|
||||
setActionLoading('off')
|
||||
setError(null)
|
||||
setMessage(null)
|
||||
|
||||
try {
|
||||
const response = await fetch(API_PROXY, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ action: 'off' }),
|
||||
})
|
||||
|
||||
const data = await response.json()
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(data.error || data.detail || 'Aktion fehlgeschlagen')
|
||||
}
|
||||
|
||||
setMessage('Stop angefordert')
|
||||
setTimeout(fetchStatus, 3000)
|
||||
setTimeout(fetchStatus, 10000)
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Fehler beim Stoppen')
|
||||
fetchStatus()
|
||||
} finally {
|
||||
setActionLoading(null)
|
||||
}
|
||||
}
|
||||
|
||||
const getStatusBadge = (s: string) => {
|
||||
const baseClasses = 'px-3 py-1 rounded-full text-sm font-semibold uppercase'
|
||||
switch (s) {
|
||||
case 'running':
|
||||
return `${baseClasses} bg-green-100 text-green-800`
|
||||
case 'stopped':
|
||||
case 'exited':
|
||||
return `${baseClasses} bg-red-100 text-red-800`
|
||||
case 'loading':
|
||||
case 'scheduling':
|
||||
case 'creating':
|
||||
case 'starting...':
|
||||
case 'stopping...':
|
||||
return `${baseClasses} bg-yellow-100 text-yellow-800`
|
||||
default:
|
||||
return `${baseClasses} bg-slate-100 text-slate-600`
|
||||
}
|
||||
}
|
||||
|
||||
const getCreditColor = (credit: number | null) => {
|
||||
if (credit === null) return 'text-slate-500'
|
||||
if (credit < 5) return 'text-red-600'
|
||||
if (credit < 15) return 'text-yellow-600'
|
||||
return 'text-green-600'
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* Page Purpose */}
|
||||
<PagePurpose
|
||||
title="GPU Infrastruktur"
|
||||
purpose="Verwalten Sie die vast.ai GPU-Instanzen fuer LLM-Verarbeitung und OCR. Starten/Stoppen Sie GPUs bei Bedarf und ueberwachen Sie Kosten in Echtzeit."
|
||||
audience={['DevOps', 'Entwickler', 'System-Admins']}
|
||||
architecture={{
|
||||
services: ['vast.ai API', 'Ollama', 'VLLM'],
|
||||
databases: ['PostgreSQL (Logs)'],
|
||||
}}
|
||||
relatedPages={[
|
||||
{ name: 'LLM Vergleich', href: '/ai/llm-compare', description: 'KI-Provider testen' },
|
||||
{ name: 'Test Quality (BQAS)', href: '/ai/test-quality', description: 'Golden Suite & Tests' },
|
||||
{ name: 'Magic Help', href: '/ai/magic-help', description: 'TrOCR Testing' },
|
||||
]}
|
||||
collapsible={true}
|
||||
defaultCollapsed={true}
|
||||
/>
|
||||
|
||||
{/* KI-Werkzeuge Sidebar */}
|
||||
<AIToolsSidebarResponsive currentTool="gpu" />
|
||||
|
||||
{/* Status Cards */}
|
||||
<div className="bg-white rounded-xl border border-slate-200 p-6 mb-6">
|
||||
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-6 gap-6">
|
||||
<div>
|
||||
<div className="text-sm text-slate-500 mb-2">Status</div>
|
||||
{loading ? (
|
||||
<span className="px-3 py-1 rounded-full text-sm font-semibold bg-slate-100 text-slate-600">
|
||||
Laden...
|
||||
</span>
|
||||
) : (
|
||||
<span className={getStatusBadge(
|
||||
actionLoading === 'on' ? 'starting...' :
|
||||
actionLoading === 'off' ? 'stopping...' :
|
||||
status?.status || 'unknown'
|
||||
)}>
|
||||
{actionLoading === 'on' ? 'starting...' :
|
||||
actionLoading === 'off' ? 'stopping...' :
|
||||
status?.status || 'unbekannt'}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="text-sm text-slate-500 mb-2">GPU</div>
|
||||
<div className="font-semibold text-slate-900">
|
||||
{status?.gpu_name || '-'}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="text-sm text-slate-500 mb-2">Kosten/h</div>
|
||||
<div className="font-semibold text-slate-900">
|
||||
{status?.dph_total ? `$${status.dph_total.toFixed(3)}` : '-'}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="text-sm text-slate-500 mb-2">Auto-Stop</div>
|
||||
<div className="font-semibold text-slate-900">
|
||||
{status && status.auto_shutdown_in_minutes !== null
|
||||
? `${status.auto_shutdown_in_minutes} min`
|
||||
: '-'}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="text-sm text-slate-500 mb-2">Budget</div>
|
||||
<div className={`font-bold text-lg ${getCreditColor(status?.account_credit ?? null)}`}>
|
||||
{status && status.account_credit !== null
|
||||
? `$${status.account_credit.toFixed(2)}`
|
||||
: '-'}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="text-sm text-slate-500 mb-2">Session</div>
|
||||
<div className="font-semibold text-slate-900">
|
||||
{status && status.session_runtime_minutes !== null && status.session_cost_usd !== null
|
||||
? `${Math.round(status.session_runtime_minutes)} min / $${status.session_cost_usd.toFixed(3)}`
|
||||
: '-'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Buttons */}
|
||||
<div className="flex items-center gap-4 mt-6 pt-6 border-t border-slate-200">
|
||||
<button
|
||||
onClick={powerOn}
|
||||
disabled={actionLoading !== null || status?.status === 'running'}
|
||||
className="px-6 py-2 bg-orange-600 text-white rounded-lg font-medium hover:bg-orange-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
Starten
|
||||
</button>
|
||||
<button
|
||||
onClick={powerOff}
|
||||
disabled={actionLoading !== null || status?.status !== 'running'}
|
||||
className="px-6 py-2 bg-red-600 text-white rounded-lg font-medium hover:bg-red-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
Stoppen
|
||||
</button>
|
||||
<button
|
||||
onClick={fetchStatus}
|
||||
disabled={loading}
|
||||
className="px-4 py-2 border border-slate-300 text-slate-700 rounded-lg font-medium hover:bg-slate-50 disabled:opacity-50 transition-colors"
|
||||
>
|
||||
{loading ? 'Aktualisiere...' : 'Aktualisieren'}
|
||||
</button>
|
||||
|
||||
{message && (
|
||||
<span className="ml-4 text-sm text-green-600 font-medium">{message}</span>
|
||||
)}
|
||||
{error && (
|
||||
<span className="ml-4 text-sm text-red-600 font-medium">{error}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Extended Stats */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-6">
|
||||
<div className="bg-white rounded-xl border border-slate-200 p-6">
|
||||
<h3 className="font-semibold text-slate-900 mb-4">Kosten-Uebersicht</h3>
|
||||
<div className="space-y-4">
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-slate-600">Session Laufzeit</span>
|
||||
<span className="font-semibold">
|
||||
{status && status.session_runtime_minutes !== null
|
||||
? `${Math.round(status.session_runtime_minutes)} Minuten`
|
||||
: '-'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-slate-600">Session Kosten</span>
|
||||
<span className="font-semibold">
|
||||
{status && status.session_cost_usd !== null
|
||||
? `$${status.session_cost_usd.toFixed(4)}`
|
||||
: '-'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center pt-4 border-t border-slate-100">
|
||||
<span className="text-slate-600">Gesamtlaufzeit</span>
|
||||
<span className="font-semibold">
|
||||
{status && status.total_runtime_hours !== null
|
||||
? `${status.total_runtime_hours.toFixed(1)} Stunden`
|
||||
: '-'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-slate-600">Gesamtkosten</span>
|
||||
<span className="font-semibold">
|
||||
{status && status.total_cost_usd !== null
|
||||
? `$${status.total_cost_usd.toFixed(2)}`
|
||||
: '-'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-slate-600">vast.ai Ausgaben</span>
|
||||
<span className="font-semibold">
|
||||
{status && status.account_total_spend !== null
|
||||
? `$${status.account_total_spend.toFixed(2)}`
|
||||
: '-'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-xl border border-slate-200 p-6">
|
||||
<h3 className="font-semibold text-slate-900 mb-4">Instanz-Details</h3>
|
||||
<div className="space-y-4">
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-slate-600">Instanz ID</span>
|
||||
<span className="font-mono text-sm">
|
||||
{status?.instance_id || '-'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-slate-600">GPU</span>
|
||||
<span className="font-semibold">
|
||||
{status?.gpu_name || '-'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-slate-600">Stundensatz</span>
|
||||
<span className="font-semibold">
|
||||
{status?.dph_total ? `$${status.dph_total.toFixed(4)}/h` : '-'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-slate-600">Letzte Aktivitaet</span>
|
||||
<span className="text-sm">
|
||||
{status?.last_activity
|
||||
? new Date(status.last_activity).toLocaleString('de-DE')
|
||||
: '-'}
|
||||
</span>
|
||||
</div>
|
||||
{status?.endpoint_base_url && status.status === 'running' && (
|
||||
<div className="pt-4 border-t border-slate-100">
|
||||
<div className="text-slate-600 text-sm mb-1">Endpoint</div>
|
||||
<code className="text-xs bg-slate-100 px-2 py-1 rounded block overflow-x-auto">
|
||||
{status.endpoint_base_url}
|
||||
</code>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Info */}
|
||||
<div className="bg-violet-50 border border-violet-200 rounded-xl p-4">
|
||||
<div className="flex gap-3">
|
||||
<svg className="w-5 h-5 text-violet-600 flex-shrink-0 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>
|
||||
<h4 className="font-semibold text-violet-900">Auto-Shutdown</h4>
|
||||
<p className="text-sm text-violet-800 mt-1">
|
||||
Die GPU-Instanz wird automatisch gestoppt, wenn sie laengere Zeit inaktiv ist.
|
||||
Der Status wird alle 30 Sekunden automatisch aktualisiert.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,503 @@
|
||||
'use client'
|
||||
|
||||
/**
|
||||
* LLM Comparison Tool
|
||||
*
|
||||
* Vergleicht Antworten von verschiedenen LLM-Providern:
|
||||
* - OpenAI/ChatGPT
|
||||
* - Claude
|
||||
* - Self-hosted + Tavily
|
||||
* - Self-hosted + EduSearch
|
||||
*/
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { PagePurpose } from '@/components/common/PagePurpose'
|
||||
import { AIToolsSidebarResponsive } from '@/components/ai/AIToolsSidebar'
|
||||
|
||||
interface LLMResponse {
|
||||
provider: string
|
||||
model: string
|
||||
response: string
|
||||
latency_ms: number
|
||||
tokens_used?: number
|
||||
search_results?: Array<{
|
||||
title: string
|
||||
url: string
|
||||
content: string
|
||||
score?: number
|
||||
}>
|
||||
error?: string
|
||||
timestamp: string
|
||||
}
|
||||
|
||||
interface ComparisonResult {
|
||||
comparison_id: string
|
||||
prompt: string
|
||||
system_prompt?: string
|
||||
responses: LLMResponse[]
|
||||
created_at: string
|
||||
}
|
||||
|
||||
const providerColors: Record<string, { bg: string; border: string; text: string }> = {
|
||||
openai: { bg: 'bg-emerald-50', border: 'border-emerald-300', text: 'text-emerald-700' },
|
||||
claude: { bg: 'bg-orange-50', border: 'border-orange-300', text: 'text-orange-700' },
|
||||
selfhosted_tavily: { bg: 'bg-blue-50', border: 'border-blue-300', text: 'text-blue-700' },
|
||||
selfhosted_edusearch: { bg: 'bg-purple-50', border: 'border-purple-300', text: 'text-purple-700' },
|
||||
}
|
||||
|
||||
const providerLabels: Record<string, string> = {
|
||||
openai: 'OpenAI GPT-4o-mini',
|
||||
claude: 'Claude 3.5 Sonnet',
|
||||
selfhosted_tavily: 'Self-hosted + Tavily',
|
||||
selfhosted_edusearch: 'Self-hosted + EduSearch',
|
||||
}
|
||||
|
||||
export default function LLMComparePage() {
|
||||
// State
|
||||
const [prompt, setPrompt] = useState('')
|
||||
const [systemPrompt, setSystemPrompt] = useState('Du bist ein hilfreicher Assistent fuer Lehrkraefte in Deutschland.')
|
||||
|
||||
// Provider toggles
|
||||
const [enableOpenAI, setEnableOpenAI] = useState(true)
|
||||
const [enableClaude, setEnableClaude] = useState(true)
|
||||
const [enableTavily, setEnableTavily] = useState(true)
|
||||
const [enableEduSearch, setEnableEduSearch] = useState(true)
|
||||
|
||||
// Parameters
|
||||
const [model, setModel] = useState('llama3.2:3b')
|
||||
const [temperature, setTemperature] = useState(0.7)
|
||||
const [maxTokens, setMaxTokens] = useState(2048)
|
||||
|
||||
// Results
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const [result, setResult] = useState<ComparisonResult | null>(null)
|
||||
const [history, setHistory] = useState<ComparisonResult[]>([])
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
// UI State
|
||||
const [showSettings, setShowSettings] = useState(false)
|
||||
const [showHistory, setShowHistory] = useState(false)
|
||||
|
||||
// API Base URL
|
||||
const API_URL = process.env.NEXT_PUBLIC_LLM_GATEWAY_URL || 'http://localhost:8082'
|
||||
const API_KEY = process.env.NEXT_PUBLIC_LLM_API_KEY || 'dev-key'
|
||||
|
||||
// Load history
|
||||
const loadHistory = useCallback(async () => {
|
||||
try {
|
||||
const response = await fetch(`${API_URL}/v1/comparison/history?limit=20`, {
|
||||
headers: { Authorization: `Bearer ${API_KEY}` },
|
||||
})
|
||||
if (response.ok) {
|
||||
const data = await response.json()
|
||||
setHistory(data.comparisons || [])
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to load history:', e)
|
||||
}
|
||||
}, [API_URL, API_KEY])
|
||||
|
||||
useEffect(() => {
|
||||
loadHistory()
|
||||
}, [loadHistory])
|
||||
|
||||
const runComparison = async () => {
|
||||
if (!prompt.trim()) {
|
||||
setError('Bitte geben Sie einen Prompt ein')
|
||||
return
|
||||
}
|
||||
|
||||
setIsLoading(true)
|
||||
setError(null)
|
||||
setResult(null)
|
||||
|
||||
try {
|
||||
const response = await fetch(`${API_URL}/v1/comparison/run`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${API_KEY}`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
prompt,
|
||||
system_prompt: systemPrompt || undefined,
|
||||
enable_openai: enableOpenAI,
|
||||
enable_claude: enableClaude,
|
||||
enable_selfhosted_tavily: enableTavily,
|
||||
enable_selfhosted_edusearch: enableEduSearch,
|
||||
selfhosted_model: model,
|
||||
temperature,
|
||||
max_tokens: maxTokens,
|
||||
}),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`API Error: ${response.status}`)
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
setResult(data)
|
||||
loadHistory()
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : 'Unbekannter Fehler')
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const ResponseCard = ({ response }: { response: LLMResponse }) => {
|
||||
const colors = providerColors[response.provider] || {
|
||||
bg: 'bg-slate-50',
|
||||
border: 'border-slate-300',
|
||||
text: 'text-slate-700',
|
||||
}
|
||||
const label = providerLabels[response.provider] || response.provider
|
||||
|
||||
return (
|
||||
<div className={`rounded-xl border-2 ${colors.border} ${colors.bg} overflow-hidden`}>
|
||||
<div className={`px-4 py-3 border-b ${colors.border} flex items-center justify-between`}>
|
||||
<div>
|
||||
<h3 className={`font-semibold ${colors.text}`}>{label}</h3>
|
||||
<p className="text-xs text-slate-500">{response.model}</p>
|
||||
</div>
|
||||
<div className="text-right text-xs text-slate-500">
|
||||
<div>{response.latency_ms}ms</div>
|
||||
{response.tokens_used && <div>{response.tokens_used} tokens</div>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-4">
|
||||
{response.error ? (
|
||||
<div className="text-red-600 text-sm">
|
||||
<strong>Fehler:</strong> {response.error}
|
||||
</div>
|
||||
) : (
|
||||
<pre className="whitespace-pre-wrap text-sm text-slate-700 font-sans">
|
||||
{response.response}
|
||||
</pre>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{response.search_results && response.search_results.length > 0 && (
|
||||
<div className="px-4 pb-4">
|
||||
<details className="text-xs">
|
||||
<summary className="cursor-pointer text-slate-500 hover:text-slate-700">
|
||||
{response.search_results.length} Suchergebnisse anzeigen
|
||||
</summary>
|
||||
<ul className="mt-2 space-y-2">
|
||||
{response.search_results.map((sr, idx) => (
|
||||
<li key={idx} className="bg-white rounded p-2 border border-slate-200">
|
||||
<a
|
||||
href={sr.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-blue-600 hover:underline font-medium"
|
||||
>
|
||||
{sr.title || 'Untitled'}
|
||||
</a>
|
||||
<p className="text-slate-500 truncate">{sr.content}</p>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</details>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* Page Purpose */}
|
||||
<PagePurpose
|
||||
title="LLM Vergleich"
|
||||
purpose="Vergleichen Sie Antworten verschiedener KI-Provider (OpenAI, Claude, Self-hosted) fuer Qualitaetssicherung. Optimieren Sie Parameter und System Prompts fuer beste Ergebnisse. Standalone-Werkzeug ohne direkten Datenfluss zur KI-Pipeline."
|
||||
audience={['Entwickler', 'Data Scientists', 'QA']}
|
||||
architecture={{
|
||||
services: ['llm-gateway (Python)', 'Ollama', 'OpenAI API', 'Claude API'],
|
||||
databases: ['PostgreSQL (History)', 'Qdrant (RAG)'],
|
||||
}}
|
||||
relatedPages={[
|
||||
{ name: 'Test Quality (BQAS)', href: '/ai/test-quality', description: 'Golden Suite & Synthetic Tests' },
|
||||
{ name: 'GPU Infrastruktur', href: '/ai/gpu', description: 'GPU-Ressourcen verwalten' },
|
||||
{ name: 'Agent Management', href: '/ai/agents', description: 'Multi-Agent System' },
|
||||
]}
|
||||
collapsible={true}
|
||||
defaultCollapsed={true}
|
||||
/>
|
||||
|
||||
{/* KI-Werkzeuge Sidebar */}
|
||||
<AIToolsSidebarResponsive currentTool="llm-compare" />
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
{/* Left Column: Input & Settings */}
|
||||
<div className="lg:col-span-1 space-y-4">
|
||||
{/* Prompt Input */}
|
||||
<div className="bg-white rounded-xl border border-slate-200 p-4">
|
||||
<h2 className="font-semibold text-slate-900 mb-3">Prompt</h2>
|
||||
|
||||
{/* System Prompt */}
|
||||
<div className="mb-3">
|
||||
<label className="block text-sm text-slate-600 mb-1">System Prompt</label>
|
||||
<textarea
|
||||
value={systemPrompt}
|
||||
onChange={(e) => setSystemPrompt(e.target.value)}
|
||||
rows={3}
|
||||
className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm resize-none"
|
||||
placeholder="System Prompt (optional)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* User Prompt */}
|
||||
<div className="mb-3">
|
||||
<label className="block text-sm text-slate-600 mb-1">User Prompt</label>
|
||||
<textarea
|
||||
value={prompt}
|
||||
onChange={(e) => setPrompt(e.target.value)}
|
||||
rows={4}
|
||||
className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm resize-none"
|
||||
placeholder="z.B.: Erstelle ein Arbeitsblatt zum Thema Bruchrechnung fuer Klasse 6..."
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Provider Toggles */}
|
||||
<div className="mb-4">
|
||||
<label className="block text-sm text-slate-600 mb-2">Provider</label>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<label className="flex items-center gap-2 text-sm">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={enableOpenAI}
|
||||
onChange={(e) => setEnableOpenAI(e.target.checked)}
|
||||
className="rounded"
|
||||
/>
|
||||
OpenAI
|
||||
</label>
|
||||
<label className="flex items-center gap-2 text-sm">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={enableClaude}
|
||||
onChange={(e) => setEnableClaude(e.target.checked)}
|
||||
className="rounded"
|
||||
/>
|
||||
Claude
|
||||
</label>
|
||||
<label className="flex items-center gap-2 text-sm">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={enableTavily}
|
||||
onChange={(e) => setEnableTavily(e.target.checked)}
|
||||
className="rounded"
|
||||
/>
|
||||
Self + Tavily
|
||||
</label>
|
||||
<label className="flex items-center gap-2 text-sm">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={enableEduSearch}
|
||||
onChange={(e) => setEnableEduSearch(e.target.checked)}
|
||||
className="rounded"
|
||||
/>
|
||||
Self + EduSearch
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Run Button */}
|
||||
<button
|
||||
onClick={runComparison}
|
||||
disabled={isLoading || !prompt.trim()}
|
||||
className="w-full py-3 bg-teal-600 text-white rounded-lg font-medium hover:bg-teal-700 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{isLoading ? (
|
||||
<span className="flex items-center justify-center gap-2">
|
||||
<svg className="animate-spin w-5 h-5" fill="none" viewBox="0 0 24 24">
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
|
||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
|
||||
</svg>
|
||||
Vergleiche...
|
||||
</span>
|
||||
) : (
|
||||
'Vergleich starten'
|
||||
)}
|
||||
</button>
|
||||
|
||||
{error && (
|
||||
<div className="mt-3 p-3 bg-red-50 border border-red-200 rounded-lg text-red-700 text-sm">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Settings Panel */}
|
||||
<div className="bg-white rounded-xl border border-slate-200 overflow-hidden">
|
||||
<button
|
||||
onClick={() => setShowSettings(!showSettings)}
|
||||
className="w-full px-4 py-3 flex items-center justify-between hover:bg-slate-50"
|
||||
>
|
||||
<span className="font-semibold text-slate-900">Parameter</span>
|
||||
<svg
|
||||
className={`w-5 h-5 transition-transform ${showSettings ? 'rotate-180' : ''}`}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{showSettings && (
|
||||
<div className="p-4 border-t border-slate-200 space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm text-slate-600 mb-1">Self-hosted Modell</label>
|
||||
<select
|
||||
value={model}
|
||||
onChange={(e) => setModel(e.target.value)}
|
||||
className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm"
|
||||
>
|
||||
<option value="llama3.2:3b">Llama 3.2 3B</option>
|
||||
<option value="llama3.1:8b">Llama 3.1 8B</option>
|
||||
<option value="mistral:7b">Mistral 7B</option>
|
||||
<option value="qwen2.5:7b">Qwen 2.5 7B</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm text-slate-600 mb-1">
|
||||
Temperature: {temperature.toFixed(2)}
|
||||
</label>
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max="2"
|
||||
step="0.1"
|
||||
value={temperature}
|
||||
onChange={(e) => setTemperature(parseFloat(e.target.value))}
|
||||
className="w-full"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm text-slate-600 mb-1">Max Tokens: {maxTokens}</label>
|
||||
<input
|
||||
type="range"
|
||||
min="256"
|
||||
max="4096"
|
||||
step="256"
|
||||
value={maxTokens}
|
||||
onChange={(e) => setMaxTokens(parseInt(e.target.value))}
|
||||
className="w-full"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* History Panel */}
|
||||
<div className="bg-white rounded-xl border border-slate-200 overflow-hidden">
|
||||
<button
|
||||
onClick={() => setShowHistory(!showHistory)}
|
||||
className="w-full px-4 py-3 flex items-center justify-between hover:bg-slate-50"
|
||||
>
|
||||
<span className="font-semibold text-slate-900">Verlauf ({history.length})</span>
|
||||
<svg
|
||||
className={`w-5 h-5 transition-transform ${showHistory ? 'rotate-180' : ''}`}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{showHistory && history.length > 0 && (
|
||||
<div className="border-t border-slate-200 max-h-64 overflow-y-auto">
|
||||
{history.map((h) => (
|
||||
<button
|
||||
key={h.comparison_id}
|
||||
onClick={() => {
|
||||
setResult(h)
|
||||
setPrompt(h.prompt)
|
||||
if (h.system_prompt) setSystemPrompt(h.system_prompt)
|
||||
}}
|
||||
className="w-full px-4 py-2 text-left hover:bg-slate-50 border-b border-slate-100 last:border-0"
|
||||
>
|
||||
<div className="text-sm text-slate-700 truncate">{h.prompt}</div>
|
||||
<div className="text-xs text-slate-400">
|
||||
{new Date(h.created_at).toLocaleString('de-DE')}
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right Column: Results */}
|
||||
<div className="lg:col-span-2">
|
||||
{result ? (
|
||||
<div className="space-y-4">
|
||||
<div className="bg-white rounded-xl border border-slate-200 p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="font-semibold text-slate-900">Ergebnisse</h2>
|
||||
<p className="text-sm text-slate-500">ID: {result.comparison_id}</p>
|
||||
</div>
|
||||
<div className="text-sm text-slate-500">
|
||||
{new Date(result.created_at).toLocaleString('de-DE')}
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-2 p-3 bg-slate-50 rounded-lg">
|
||||
<p className="text-sm text-slate-700">{result.prompt}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 xl:grid-cols-2 gap-4">
|
||||
{result.responses.map((response, idx) => (
|
||||
<ResponseCard key={`${response.provider}-${idx}`} response={response} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="bg-white rounded-xl border border-slate-200 p-12 text-center">
|
||||
<svg
|
||||
className="w-16 h-16 mx-auto text-slate-300 mb-4"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={1.5}
|
||||
d="M9 3v2m6-2v2M9 19v2m6-2v2M5 9H3m2 6H3m18-6h-2m2 6h-2M7 19h10a2 2 0 002-2V7a2 2 0 00-2-2H7a2 2 0 00-2 2v10a2 2 0 002 2zM9 9h6v6H9V9z"
|
||||
/>
|
||||
</svg>
|
||||
<h3 className="text-lg font-medium text-slate-700 mb-2">LLM-Vergleich starten</h3>
|
||||
<p className="text-slate-500 max-w-md mx-auto">
|
||||
Geben Sie einen Prompt ein und klicken Sie auf "Vergleich starten", um
|
||||
die Antworten verschiedener LLM-Provider zu vergleichen.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Info Box */}
|
||||
<div className="mt-8 bg-teal-50 border border-teal-200 rounded-xl p-6">
|
||||
<div className="flex items-start gap-4">
|
||||
<svg className="w-6 h-6 text-teal-600 flex-shrink-0 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>
|
||||
<h3 className="font-semibold text-teal-900">Qualitaetssicherung</h3>
|
||||
<p className="text-sm text-teal-800 mt-1">
|
||||
Dieses Tool dient zur Qualitaetssicherung der KI-Antworten. Vergleichen Sie verschiedene Provider,
|
||||
um die optimalen Parameter und System Prompts zu finden. Die Ergebnisse werden fuer Audits gespeichert.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,987 @@
|
||||
'use client'
|
||||
|
||||
/**
|
||||
* OCR Labeling Admin Page
|
||||
*
|
||||
* Labeling interface for handwriting training data collection.
|
||||
* DSGVO-konform: Alle Verarbeitung lokal auf Mac Mini (Ollama).
|
||||
*
|
||||
* Teil der KI-Daten-Pipeline:
|
||||
* OCR-Labeling → RAG Pipeline → Daten & RAG
|
||||
*/
|
||||
|
||||
import { useState, useEffect, useCallback, useRef } from 'react'
|
||||
import Link from 'next/link'
|
||||
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>
|
||||
),
|
||||
},
|
||||
]
|
||||
|
||||
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>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-6">
|
||||
{/* Header */}
|
||||
<div className="mb-6">
|
||||
<h1 className="text-2xl font-bold text-gray-900 dark:text-white">OCR-Labeling</h1>
|
||||
<p className="text-gray-600 dark:text-gray-400">Handschrift-Training & Ground Truth Erfassung</p>
|
||||
</div>
|
||||
|
||||
{/* Page Purpose with Related Pages */}
|
||||
<PagePurpose
|
||||
title="OCR-Labeling"
|
||||
purpose="Erstellen Sie Ground Truth Daten für das Training von Handschrift-Erkennungsmodellen. Labeln Sie OCR-Ergebnisse, korrigieren Sie Fehler und exportieren Sie Trainingsdaten für TrOCR, Llama Vision und andere Modelle. Teil der KI-Daten-Pipeline: Gelabelte Daten können zur RAG Pipeline exportiert werden."
|
||||
audience={['Entwickler', 'Data Scientists', 'QA-Team']}
|
||||
architecture={{
|
||||
services: ['klausur-service (Python)'],
|
||||
databases: ['PostgreSQL', 'MinIO (Bilder)'],
|
||||
}}
|
||||
relatedPages={[
|
||||
{ name: 'Magic Help', href: '/ai/magic-help', description: 'TrOCR testen & fine-tunen' },
|
||||
{ name: 'RAG Pipeline', href: '/ai/rag-pipeline', description: 'Trainierte Daten indexieren' },
|
||||
{ name: 'Klausur-Korrektur', href: '/ai/klausur-korrektur', description: 'OCR in Aktion' },
|
||||
{ name: 'Daten & RAG', href: '/ai/rag', description: 'Indexierte Daten durchsuchen' },
|
||||
]}
|
||||
collapsible={true}
|
||||
defaultCollapsed={true}
|
||||
/>
|
||||
|
||||
{/* AI Module Sidebar - Desktop: Fixed, Mobile: FAB + Drawer */}
|
||||
<AIModuleSidebarResponsive currentModule="ocr-labeling" />
|
||||
|
||||
{/* Error Toast */}
|
||||
{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>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Tabs */}
|
||||
<div className="mb-6">
|
||||
<div className="border-b border-slate-200">
|
||||
<nav className="flex space-x-4" aria-label="Tabs">
|
||||
{tabs.map((tab) => (
|
||||
<button
|
||||
key={tab.id}
|
||||
onClick={() => setActiveTab(tab.id)}
|
||||
className={`flex items-center gap-2 px-4 py-3 border-b-2 font-medium text-sm transition-colors ${
|
||||
activeTab === tab.id
|
||||
? 'border-primary-500 text-primary-600'
|
||||
: 'border-transparent text-slate-500 hover:text-slate-700 hover:border-slate-300'
|
||||
}`}
|
||||
>
|
||||
{tab.icon}
|
||||
{tab.name}
|
||||
</button>
|
||||
))}
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tab Content */}
|
||||
{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()}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
/**
|
||||
* TypeScript types for OCR Labeling UI
|
||||
*/
|
||||
|
||||
/**
|
||||
* Available OCR Models
|
||||
*
|
||||
* - llama3.2-vision:11b: Vision LLM, beste Qualitaet bei Handschrift (Standard)
|
||||
* - trocr: Microsoft TrOCR, schnell bei gedrucktem Text
|
||||
* - paddleocr: PaddleOCR + LLM, 4x schneller durch Hybrid-Ansatz
|
||||
* - donut: Document Understanding Transformer, strukturierte Dokumente
|
||||
*/
|
||||
export type OCRModel = 'llama3.2-vision:11b' | 'trocr' | 'paddleocr' | 'donut'
|
||||
|
||||
export const OCR_MODEL_INFO: Record<OCRModel, { label: string; description: string; speed: string }> = {
|
||||
'llama3.2-vision:11b': {
|
||||
label: 'Vision LLM',
|
||||
description: 'Beste Qualitaet bei Handschrift',
|
||||
speed: 'langsam',
|
||||
},
|
||||
trocr: {
|
||||
label: 'Microsoft TrOCR',
|
||||
description: 'Schnell bei gedrucktem Text',
|
||||
speed: 'schnell',
|
||||
},
|
||||
paddleocr: {
|
||||
label: 'PaddleOCR + LLM',
|
||||
description: 'Hybrid-Ansatz: OCR + Strukturierung',
|
||||
speed: 'sehr schnell',
|
||||
},
|
||||
donut: {
|
||||
label: 'Donut',
|
||||
description: 'Document Understanding fuer Tabellen/Formulare',
|
||||
speed: 'mittel',
|
||||
},
|
||||
}
|
||||
|
||||
export interface OCRSession {
|
||||
id: string
|
||||
name: string
|
||||
source_type: 'klausur' | 'handwriting_sample' | 'scan'
|
||||
description?: string
|
||||
ocr_model?: OCRModel
|
||||
total_items: number
|
||||
labeled_items: number
|
||||
confirmed_items: number
|
||||
corrected_items: number
|
||||
skipped_items: number
|
||||
created_at: string
|
||||
}
|
||||
|
||||
export interface OCRItem {
|
||||
id: string
|
||||
session_id: string
|
||||
session_name: string
|
||||
image_path: string
|
||||
image_url?: string
|
||||
ocr_text?: string
|
||||
ocr_confidence?: number
|
||||
ground_truth?: string
|
||||
status: 'pending' | 'confirmed' | 'corrected' | 'skipped'
|
||||
metadata?: Record<string, unknown>
|
||||
created_at: string
|
||||
}
|
||||
|
||||
export interface OCRStats {
|
||||
total_sessions?: number
|
||||
session_id?: string
|
||||
name?: string
|
||||
total_items: number
|
||||
labeled_items: number
|
||||
confirmed_items: number
|
||||
corrected_items: number
|
||||
skipped_items?: number
|
||||
pending_items: number
|
||||
exportable_items?: number
|
||||
accuracy_rate: number
|
||||
avg_label_time_seconds?: number
|
||||
progress_percent?: number
|
||||
}
|
||||
|
||||
export interface TrainingSample {
|
||||
id: string
|
||||
image_path: string
|
||||
ground_truth: string
|
||||
export_format: 'generic' | 'trocr' | 'llama_vision'
|
||||
training_batch: string
|
||||
exported_at?: string
|
||||
}
|
||||
|
||||
export interface CreateSessionRequest {
|
||||
name: string
|
||||
source_type: 'klausur' | 'handwriting_sample' | 'scan'
|
||||
description?: string
|
||||
ocr_model?: OCRModel
|
||||
}
|
||||
|
||||
export interface ConfirmRequest {
|
||||
item_id: string
|
||||
label_time_seconds?: number
|
||||
}
|
||||
|
||||
export interface CorrectRequest {
|
||||
item_id: string
|
||||
ground_truth: string
|
||||
label_time_seconds?: number
|
||||
}
|
||||
|
||||
export interface ExportRequest {
|
||||
export_format: 'generic' | 'trocr' | 'llama_vision'
|
||||
session_id?: string
|
||||
batch_id?: string
|
||||
}
|
||||
|
||||
export interface UploadResult {
|
||||
id: string
|
||||
filename: string
|
||||
image_path: string
|
||||
image_hash: string
|
||||
ocr_text?: string
|
||||
ocr_confidence?: number
|
||||
status: string
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
'use client'
|
||||
|
||||
import { getCategoryById } from '@/lib/navigation'
|
||||
import { ModuleCard } from '@/components/common/ModuleCard'
|
||||
import { PagePurpose } from '@/components/common/PagePurpose'
|
||||
|
||||
export default function AIPage() {
|
||||
const category = getCategoryById('ai')
|
||||
|
||||
if (!category) {
|
||||
return <div>Kategorie nicht gefunden</div>
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* Page Purpose */}
|
||||
<PagePurpose
|
||||
title={category.name}
|
||||
purpose="Diese Kategorie umfasst alle KI- und Machine-Learning-Module. Hier vergleichen Sie LLM-Provider, verwalten RAG-Pipelines, labeln OCR-Daten und nutzen KI-gestuetzte Korrektur-Tools."
|
||||
audience={['Entwickler', 'Data Scientists', 'Lehrer']}
|
||||
architecture={{
|
||||
services: ['klausur-service (Python)', 'embedding-service (Python)', 'backend (Python)'],
|
||||
databases: ['PostgreSQL', 'Qdrant (Vector)', 'MinIO (Object Storage)'],
|
||||
}}
|
||||
relatedPages={[
|
||||
{ name: 'GPU Infrastruktur', href: '/infrastructure/gpu', description: 'GPU-Ressourcen fuer Training' },
|
||||
]}
|
||||
collapsible={true}
|
||||
defaultCollapsed={false}
|
||||
/>
|
||||
|
||||
{/* Modules Grid */}
|
||||
<h2 className="text-lg font-semibold text-slate-900 mb-4">Module</h2>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{category.modules.map((module) => (
|
||||
<ModuleCard key={module.id} module={module} category={category} />
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Info Section */}
|
||||
<div className="mt-8 bg-teal-50 border border-teal-200 rounded-xl p-6">
|
||||
<h3 className="font-semibold text-teal-800 flex items-center gap-2">
|
||||
<span>🧠</span>
|
||||
DSGVO-konforme KI
|
||||
</h3>
|
||||
<p className="text-sm text-teal-700 mt-2">
|
||||
Alle KI-Modelle koennen lokal auf dem Mac Mini mit Ollama ausgefuehrt werden.
|
||||
Keine Daten werden an externe Cloud-Anbieter gesendet, sofern nicht explizit konfiguriert.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,524 @@
|
||||
'use client'
|
||||
|
||||
/**
|
||||
* Quality & Audit Page
|
||||
*
|
||||
* Ermoeglicht Auditoren:
|
||||
* - Chunk-Suche und Stichproben
|
||||
* - 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',
|
||||
}
|
||||
|
||||
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 += `®ulations=${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)}®ulation=${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
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900 dark:text-white">
|
||||
Qualitaet & Audit
|
||||
</h1>
|
||||
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400">
|
||||
Stichproben und Traceability fuer Compliance-Auditoren
|
||||
</p>
|
||||
</div>
|
||||
<Link
|
||||
href="/ai/rag"
|
||||
className="text-sm text-blue-600 hover:text-blue-800 dark:text-blue-400"
|
||||
>
|
||||
← Zurueck zu RAG
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<PagePurpose
|
||||
title="Audit-Werkzeuge"
|
||||
purpose="Pruefen Sie die Qualitaet der Compliance-Datenbank. Suchen Sie gezielt nach Paragraphen, Saetzen oder Begriffen und verfolgen Sie, wie Anforderungen und Controls abgeleitet wurden."
|
||||
audience={['Auditoren', 'Compliance-Beauftragte', 'Qualitaetssicherung']}
|
||||
architecture={{
|
||||
services: ['klausur-service', 'embedding-service', 'qdrant'],
|
||||
databases: ['Qdrant Vector DB']
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* 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"
|
||||
/>
|
||||
</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)
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* 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>
|
||||
)}
|
||||
|
||||
{/* Empty State */}
|
||||
{!searching && searchResults.length === 0 && searchQuery && (
|
||||
<div className="bg-white dark:bg-slate-800 rounded-lg shadow p-12 text-center">
|
||||
<svg className="w-16 h-16 mx-auto mb-4 text-gray-300 dark:text-gray-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9.172 16.172a4 4 0 015.656 0M9 10h.01M15 10h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
<h3 className="text-lg font-medium text-gray-900 dark:text-white mb-2">
|
||||
Keine Ergebnisse gefunden
|
||||
</h3>
|
||||
<p className="text-gray-500 dark:text-gray-400">
|
||||
Versuchen Sie einen anderen Suchbegriff oder waehlen Sie eine andere Regulierung.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Initial State */}
|
||||
{!searching && searchResults.length === 0 && !searchQuery && (
|
||||
<div className="bg-white dark:bg-slate-800 rounded-lg shadow p-12 text-center">
|
||||
<svg className="w-16 h-16 mx-auto mb-4 text-gray-300 dark:text-gray-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
|
||||
</svg>
|
||||
<h3 className="text-lg font-medium text-gray-900 dark:text-white mb-2">
|
||||
Bereit fuer Stichproben
|
||||
</h3>
|
||||
<p className="text-gray-500 dark:text-gray-400 max-w-md mx-auto">
|
||||
Geben Sie einen Suchbegriff ein, um Chunks zu finden. Sie koennen nach Artikeln,
|
||||
Paragraphen oder spezifischen Textpassagen suchen.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 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
|
||||
</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>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,674 @@
|
||||
'use client'
|
||||
|
||||
/**
|
||||
* DSFA Document Manager
|
||||
*
|
||||
* Manages DSFA-related sources and documents for the RAG pipeline.
|
||||
* Features:
|
||||
* - View all registered DSFA sources with license info
|
||||
* - Upload new documents
|
||||
* - Trigger re-indexing
|
||||
* - View corpus statistics
|
||||
*/
|
||||
|
||||
import { useState, useEffect } from 'react'
|
||||
import Link from 'next/link'
|
||||
import {
|
||||
ArrowLeft,
|
||||
RefreshCw,
|
||||
Upload,
|
||||
FileText,
|
||||
Database,
|
||||
Scale,
|
||||
ExternalLink,
|
||||
ChevronDown,
|
||||
ChevronUp,
|
||||
Search,
|
||||
Filter,
|
||||
CheckCircle,
|
||||
Clock,
|
||||
AlertCircle,
|
||||
BookOpen
|
||||
} from 'lucide-react'
|
||||
import {
|
||||
DSFASource,
|
||||
DSFACorpusStats,
|
||||
DSFASourceStats,
|
||||
DSFALicenseCode,
|
||||
DSFA_LICENSE_LABELS,
|
||||
DSFA_DOCUMENT_TYPE_LABELS
|
||||
} from '@/lib/sdk/types'
|
||||
|
||||
// ============================================================================
|
||||
// TYPES
|
||||
// ============================================================================
|
||||
|
||||
interface APIError {
|
||||
message: string
|
||||
status?: number
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// API FUNCTIONS
|
||||
// ============================================================================
|
||||
|
||||
const API_BASE = process.env.NEXT_PUBLIC_KLAUSUR_SERVICE_URL || 'http://localhost:8086'
|
||||
|
||||
async function fetchSources(): Promise<DSFASource[]> {
|
||||
try {
|
||||
const response = await fetch(`${API_BASE}/api/v1/dsfa-rag/sources`)
|
||||
if (!response.ok) throw new Error('Failed to fetch sources')
|
||||
return await response.json()
|
||||
} catch {
|
||||
// Return mock data for demo
|
||||
return MOCK_SOURCES
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchStats(): Promise<DSFACorpusStats> {
|
||||
try {
|
||||
const response = await fetch(`${API_BASE}/api/v1/dsfa-rag/stats`)
|
||||
if (!response.ok) throw new Error('Failed to fetch stats')
|
||||
return await response.json()
|
||||
} catch {
|
||||
return MOCK_STATS
|
||||
}
|
||||
}
|
||||
|
||||
async function initializeCorpus(): Promise<{ sources_registered: number }> {
|
||||
const response = await fetch(`${API_BASE}/api/v1/dsfa-rag/init`, {
|
||||
method: 'POST',
|
||||
})
|
||||
if (!response.ok) throw new Error('Failed to initialize corpus')
|
||||
return await response.json()
|
||||
}
|
||||
|
||||
async function triggerIngestion(sourceCode: string): Promise<void> {
|
||||
const response = await fetch(`${API_BASE}/api/v1/dsfa-rag/sources/${sourceCode}/ingest`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({}),
|
||||
})
|
||||
if (!response.ok) throw new Error('Failed to trigger ingestion')
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// MOCK DATA
|
||||
// ============================================================================
|
||||
|
||||
const MOCK_SOURCES: DSFASource[] = [
|
||||
{
|
||||
id: '1',
|
||||
sourceCode: 'WP248',
|
||||
name: 'WP248 rev.01 - Leitlinien zur DSFA',
|
||||
fullName: 'Leitlinien zur Datenschutz-Folgenabschaetzung',
|
||||
organization: 'Artikel-29-Datenschutzgruppe / EDPB',
|
||||
sourceUrl: 'https://ec.europa.eu/newsroom/article29/items/611236/en',
|
||||
licenseCode: 'EDPB-LICENSE',
|
||||
licenseName: 'EDPB Document License',
|
||||
attributionRequired: true,
|
||||
attributionText: 'Quelle: WP248 rev.01, Artikel-29-Datenschutzgruppe (2017)',
|
||||
documentType: 'guideline',
|
||||
language: 'de',
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
sourceCode: 'DSK_KP5',
|
||||
name: 'Kurzpapier Nr. 5 - DSFA nach Art. 35 DS-GVO',
|
||||
organization: 'Datenschutzkonferenz (DSK)',
|
||||
sourceUrl: 'https://www.datenschutzkonferenz-online.de/media/kp/dsk_kpnr_5.pdf',
|
||||
licenseCode: 'DL-DE-BY-2.0',
|
||||
licenseName: 'Datenlizenz DE – Namensnennung 2.0',
|
||||
licenseUrl: 'https://www.govdata.de/dl-de/by-2-0',
|
||||
attributionRequired: true,
|
||||
attributionText: 'Quelle: DSK Kurzpapier Nr. 5 (Stand: 2018)',
|
||||
documentType: 'guideline',
|
||||
language: 'de',
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
sourceCode: 'BFDI_MUSS_PUBLIC',
|
||||
name: 'BfDI DSFA-Liste (oeffentlicher Bereich)',
|
||||
organization: 'BfDI',
|
||||
sourceUrl: 'https://www.bfdi.bund.de',
|
||||
licenseCode: 'DL-DE-ZERO-2.0',
|
||||
licenseName: 'Datenlizenz DE – Zero 2.0',
|
||||
attributionRequired: false,
|
||||
attributionText: 'Quelle: BfDI, Liste gem. Art. 35 Abs. 4 DSGVO',
|
||||
documentType: 'checklist',
|
||||
language: 'de',
|
||||
},
|
||||
{
|
||||
id: '4',
|
||||
sourceCode: 'NI_MUSS_PRIVATE',
|
||||
name: 'LfD NI DSFA-Liste (nicht-oeffentlich)',
|
||||
organization: 'LfD Niedersachsen',
|
||||
sourceUrl: 'https://www.lfd.niedersachsen.de/download/131098',
|
||||
licenseCode: 'DL-DE-BY-2.0',
|
||||
licenseName: 'Datenlizenz DE – Namensnennung 2.0',
|
||||
attributionRequired: true,
|
||||
attributionText: 'Quelle: LfD Niedersachsen, DSFA-Muss-Liste',
|
||||
documentType: 'checklist',
|
||||
language: 'de',
|
||||
},
|
||||
]
|
||||
|
||||
const MOCK_STATS: DSFACorpusStats = {
|
||||
sources: [
|
||||
{
|
||||
sourceId: '1',
|
||||
sourceCode: 'WP248',
|
||||
name: 'WP248 rev.01',
|
||||
organization: 'EDPB',
|
||||
licenseCode: 'EDPB-LICENSE',
|
||||
documentType: 'guideline',
|
||||
documentCount: 1,
|
||||
chunkCount: 50,
|
||||
lastIndexedAt: '2026-02-09T10:00:00Z',
|
||||
},
|
||||
{
|
||||
sourceId: '2',
|
||||
sourceCode: 'DSK_KP5',
|
||||
name: 'DSK Kurzpapier Nr. 5',
|
||||
organization: 'DSK',
|
||||
licenseCode: 'DL-DE-BY-2.0',
|
||||
documentType: 'guideline',
|
||||
documentCount: 1,
|
||||
chunkCount: 35,
|
||||
lastIndexedAt: '2026-02-09T10:00:00Z',
|
||||
},
|
||||
],
|
||||
totalSources: 45,
|
||||
totalDocuments: 45,
|
||||
totalChunks: 850,
|
||||
qdrantCollection: 'bp_dsfa_corpus',
|
||||
qdrantPointsCount: 850,
|
||||
qdrantStatus: 'green',
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// COMPONENTS
|
||||
// ============================================================================
|
||||
|
||||
function LicenseBadge({ licenseCode }: { licenseCode: DSFALicenseCode }) {
|
||||
const colorMap: Record<DSFALicenseCode, string> = {
|
||||
'DL-DE-BY-2.0': 'bg-blue-100 text-blue-700 border-blue-200',
|
||||
'DL-DE-ZERO-2.0': 'bg-gray-100 text-gray-700 border-gray-200',
|
||||
'CC-BY-4.0': 'bg-green-100 text-green-700 border-green-200',
|
||||
'EDPB-LICENSE': 'bg-purple-100 text-purple-700 border-purple-200',
|
||||
'PUBLIC_DOMAIN': 'bg-gray-100 text-gray-600 border-gray-200',
|
||||
'PROPRIETARY': 'bg-amber-100 text-amber-700 border-amber-200',
|
||||
}
|
||||
|
||||
return (
|
||||
<span className={`inline-flex items-center gap-1 px-2 py-0.5 rounded text-xs border ${colorMap[licenseCode] || 'bg-gray-100 text-gray-700 border-gray-200'}`}>
|
||||
<Scale className="w-3 h-3" />
|
||||
{DSFA_LICENSE_LABELS[licenseCode] || licenseCode}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
function DocumentTypeBadge({ type }: { type?: string }) {
|
||||
if (!type) return null
|
||||
|
||||
const colorMap: Record<string, string> = {
|
||||
guideline: 'bg-indigo-100 text-indigo-700',
|
||||
checklist: 'bg-emerald-100 text-emerald-700',
|
||||
regulation: 'bg-red-100 text-red-700',
|
||||
template: 'bg-orange-100 text-orange-700',
|
||||
}
|
||||
|
||||
return (
|
||||
<span className={`inline-flex items-center px-2 py-0.5 rounded text-xs ${colorMap[type] || 'bg-gray-100 text-gray-700'}`}>
|
||||
{DSFA_DOCUMENT_TYPE_LABELS[type as keyof typeof DSFA_DOCUMENT_TYPE_LABELS] || type}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
function StatusIndicator({ status }: { status: string }) {
|
||||
const statusConfig: Record<string, { color: string; icon: React.ReactNode; label: string }> = {
|
||||
green: { color: 'text-green-500', icon: <CheckCircle className="w-4 h-4" />, label: 'Aktiv' },
|
||||
yellow: { color: 'text-yellow-500', icon: <Clock className="w-4 h-4" />, label: 'Ausstehend' },
|
||||
red: { color: 'text-red-500', icon: <AlertCircle className="w-4 h-4" />, label: 'Fehler' },
|
||||
}
|
||||
|
||||
const config = statusConfig[status] || statusConfig.yellow
|
||||
|
||||
return (
|
||||
<span className={`inline-flex items-center gap-1 ${config.color}`}>
|
||||
{config.icon}
|
||||
<span className="text-sm">{config.label}</span>
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
function SourceCard({
|
||||
source,
|
||||
stats,
|
||||
onIngest,
|
||||
isIngesting
|
||||
}: {
|
||||
source: DSFASource
|
||||
stats?: DSFASourceStats
|
||||
onIngest: () => void
|
||||
isIngesting: boolean
|
||||
}) {
|
||||
const [isExpanded, setIsExpanded] = useState(false)
|
||||
|
||||
return (
|
||||
<div className="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 overflow-hidden">
|
||||
<div className="p-4">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<span className="font-mono text-xs bg-gray-100 dark:bg-gray-700 px-2 py-0.5 rounded">
|
||||
{source.sourceCode}
|
||||
</span>
|
||||
<DocumentTypeBadge type={source.documentType} />
|
||||
</div>
|
||||
<h3 className="font-semibold text-gray-900 dark:text-white truncate">
|
||||
{source.name}
|
||||
</h3>
|
||||
{source.organization && (
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||
{source.organization}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setIsExpanded(!isExpanded)}
|
||||
className="p-1 hover:bg-gray-100 dark:hover:bg-gray-700 rounded"
|
||||
>
|
||||
{isExpanded ? (
|
||||
<ChevronUp className="w-5 h-5 text-gray-400" />
|
||||
) : (
|
||||
<ChevronDown className="w-5 h-5 text-gray-400" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-4 mt-3">
|
||||
<LicenseBadge licenseCode={source.licenseCode} />
|
||||
{stats && (
|
||||
<>
|
||||
<span className="text-sm text-gray-500">
|
||||
{stats.documentCount} Dok.
|
||||
</span>
|
||||
<span className="text-sm text-gray-500">
|
||||
{stats.chunkCount} Chunks
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{source.attributionRequired && (
|
||||
<div className="mt-3 p-2 bg-amber-50 dark:bg-amber-900/20 rounded text-xs text-amber-700 dark:text-amber-300">
|
||||
<strong>Attribution:</strong> {source.attributionText}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isExpanded && (
|
||||
<div className="border-t border-gray-200 dark:border-gray-700 p-4 bg-gray-50 dark:bg-gray-900">
|
||||
<dl className="grid grid-cols-2 gap-3 text-sm">
|
||||
{source.sourceUrl && (
|
||||
<>
|
||||
<dt className="text-gray-500">Quelle:</dt>
|
||||
<dd>
|
||||
<a
|
||||
href={source.sourceUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-blue-600 hover:underline flex items-center gap-1"
|
||||
>
|
||||
Link <ExternalLink className="w-3 h-3" />
|
||||
</a>
|
||||
</dd>
|
||||
</>
|
||||
)}
|
||||
{source.licenseUrl && (
|
||||
<>
|
||||
<dt className="text-gray-500">Lizenz-URL:</dt>
|
||||
<dd>
|
||||
<a
|
||||
href={source.licenseUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-blue-600 hover:underline flex items-center gap-1"
|
||||
>
|
||||
{source.licenseName} <ExternalLink className="w-3 h-3" />
|
||||
</a>
|
||||
</dd>
|
||||
</>
|
||||
)}
|
||||
<dt className="text-gray-500">Sprache:</dt>
|
||||
<dd className="uppercase">{source.language}</dd>
|
||||
{stats?.lastIndexedAt && (
|
||||
<>
|
||||
<dt className="text-gray-500">Zuletzt indexiert:</dt>
|
||||
<dd>{new Date(stats.lastIndexedAt).toLocaleString('de-DE')}</dd>
|
||||
</>
|
||||
)}
|
||||
</dl>
|
||||
|
||||
<div className="mt-4 flex gap-2">
|
||||
<button
|
||||
onClick={onIngest}
|
||||
disabled={isIngesting}
|
||||
className="px-3 py-1.5 text-sm bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:opacity-50 flex items-center gap-1"
|
||||
>
|
||||
{isIngesting ? (
|
||||
<RefreshCw className="w-4 h-4 animate-spin" />
|
||||
) : (
|
||||
<RefreshCw className="w-4 h-4" />
|
||||
)}
|
||||
Neu indexieren
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function StatsOverview({ stats }: { stats: DSFACorpusStats }) {
|
||||
return (
|
||||
<div className="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 p-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h2 className="text-lg font-semibold text-gray-900 dark:text-white flex items-center gap-2">
|
||||
<Database className="w-5 h-5" />
|
||||
Corpus-Statistik
|
||||
</h2>
|
||||
<StatusIndicator status={stats.qdrantStatus} />
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
<div className="text-center p-4 bg-blue-50 dark:bg-blue-900/20 rounded-lg">
|
||||
<p className="text-2xl font-bold text-blue-600 dark:text-blue-400">
|
||||
{stats.totalSources}
|
||||
</p>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">Quellen</p>
|
||||
</div>
|
||||
<div className="text-center p-4 bg-emerald-50 dark:bg-emerald-900/20 rounded-lg">
|
||||
<p className="text-2xl font-bold text-emerald-600 dark:text-emerald-400">
|
||||
{stats.totalDocuments}
|
||||
</p>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">Dokumente</p>
|
||||
</div>
|
||||
<div className="text-center p-4 bg-purple-50 dark:bg-purple-900/20 rounded-lg">
|
||||
<p className="text-2xl font-bold text-purple-600 dark:text-purple-400">
|
||||
{stats.totalChunks.toLocaleString()}
|
||||
</p>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">Chunks</p>
|
||||
</div>
|
||||
<div className="text-center p-4 bg-orange-50 dark:bg-orange-900/20 rounded-lg">
|
||||
<p className="text-2xl font-bold text-orange-600 dark:text-orange-400">
|
||||
{stats.qdrantPointsCount.toLocaleString()}
|
||||
</p>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">Vektoren</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 p-3 bg-gray-50 dark:bg-gray-900 rounded-lg">
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">
|
||||
<strong>Collection:</strong>{' '}
|
||||
<code className="font-mono bg-gray-200 dark:bg-gray-700 px-1 rounded">
|
||||
{stats.qdrantCollection}
|
||||
</code>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// MAIN PAGE
|
||||
// ============================================================================
|
||||
|
||||
export default function DSFADocumentManagerPage() {
|
||||
const [sources, setSources] = useState<DSFASource[]>([])
|
||||
const [stats, setStats] = useState<DSFACorpusStats | null>(null)
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [searchQuery, setSearchQuery] = useState('')
|
||||
const [filterType, setFilterType] = useState<string>('all')
|
||||
const [ingestingSource, setIngestingSource] = useState<string | null>(null)
|
||||
const [isInitializing, setIsInitializing] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
async function loadData() {
|
||||
setIsLoading(true)
|
||||
try {
|
||||
const [sourcesData, statsData] = await Promise.all([
|
||||
fetchSources(),
|
||||
fetchStats(),
|
||||
])
|
||||
setSources(sourcesData)
|
||||
setStats(statsData)
|
||||
setError(null)
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to load data')
|
||||
setSources(MOCK_SOURCES)
|
||||
setStats(MOCK_STATS)
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
loadData()
|
||||
}, [])
|
||||
|
||||
const handleInitialize = async () => {
|
||||
setIsInitializing(true)
|
||||
try {
|
||||
await initializeCorpus()
|
||||
// Reload data
|
||||
const [sourcesData, statsData] = await Promise.all([
|
||||
fetchSources(),
|
||||
fetchStats(),
|
||||
])
|
||||
setSources(sourcesData)
|
||||
setStats(statsData)
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to initialize')
|
||||
} finally {
|
||||
setIsInitializing(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleIngest = async (sourceCode: string) => {
|
||||
setIngestingSource(sourceCode)
|
||||
try {
|
||||
await triggerIngestion(sourceCode)
|
||||
// Reload stats
|
||||
const statsData = await fetchStats()
|
||||
setStats(statsData)
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to ingest')
|
||||
} finally {
|
||||
setIngestingSource(null)
|
||||
}
|
||||
}
|
||||
|
||||
// Filter sources
|
||||
const filteredSources = sources.filter(source => {
|
||||
const matchesSearch = searchQuery === '' ||
|
||||
source.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
source.sourceCode.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
source.organization?.toLowerCase().includes(searchQuery.toLowerCase())
|
||||
|
||||
const matchesType = filterType === 'all' || source.documentType === filterType
|
||||
|
||||
return matchesSearch && matchesType
|
||||
})
|
||||
|
||||
// Get stats by source code
|
||||
const getStatsForSource = (sourceCode: string): DSFASourceStats | undefined => {
|
||||
return stats?.sources.find(s => s.sourceCode === sourceCode)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-gray-50 to-gray-100 dark:from-gray-900 dark:to-gray-800 p-8">
|
||||
<div className="max-w-6xl mx-auto">
|
||||
{/* Header */}
|
||||
<div className="mb-8">
|
||||
<Link
|
||||
href="/ai/rag-pipeline"
|
||||
className="inline-flex items-center gap-2 text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white mb-4"
|
||||
>
|
||||
<ArrowLeft className="w-4 h-4" />
|
||||
Zurueck zur RAG-Pipeline
|
||||
</Link>
|
||||
|
||||
<div className="flex justify-between items-start">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-gray-900 dark:text-white flex items-center gap-3">
|
||||
<BookOpen className="w-8 h-8 text-blue-600" />
|
||||
DSFA-Quellen Manager
|
||||
</h1>
|
||||
<p className="text-gray-500 dark:text-gray-400 mt-1">
|
||||
Verwalten Sie DSFA-Guidance Dokumente mit vollstaendiger Lizenzattribution
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={handleInitialize}
|
||||
disabled={isInitializing}
|
||||
className="px-4 py-2 bg-emerald-600 text-white rounded-lg hover:bg-emerald-700 disabled:opacity-50 flex items-center gap-2"
|
||||
>
|
||||
{isInitializing ? (
|
||||
<RefreshCw className="w-4 h-4 animate-spin" />
|
||||
) : (
|
||||
<Database className="w-4 h-4" />
|
||||
)}
|
||||
Initialisieren
|
||||
</button>
|
||||
<button className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 flex items-center gap-2">
|
||||
<Upload className="w-4 h-4" />
|
||||
Dokument hochladen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Error Banner */}
|
||||
{error && (
|
||||
<div className="mb-6 p-4 bg-red-50 dark:bg-red-900/30 border border-red-200 dark:border-red-700 rounded-xl">
|
||||
<div className="flex items-center gap-3">
|
||||
<AlertCircle className="w-5 h-5 text-red-500" />
|
||||
<span className="text-red-800 dark:text-red-200">{error}</span>
|
||||
<button
|
||||
onClick={() => setError(null)}
|
||||
className="ml-auto text-red-600 hover:text-red-800"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Stats Overview */}
|
||||
{stats && <StatsOverview stats={stats} />}
|
||||
|
||||
{/* Search & Filter */}
|
||||
<div className="mt-6 flex gap-4">
|
||||
<div className="flex-1 relative">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-gray-400" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Quellen durchsuchen..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="w-full pl-10 pr-4 py-2 border border-gray-200 dark:border-gray-700 rounded-lg bg-white dark:bg-gray-800"
|
||||
/>
|
||||
</div>
|
||||
<div className="relative">
|
||||
<Filter className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" />
|
||||
<select
|
||||
value={filterType}
|
||||
onChange={(e) => setFilterType(e.target.value)}
|
||||
className="pl-9 pr-8 py-2 border border-gray-200 dark:border-gray-700 rounded-lg bg-white dark:bg-gray-800 appearance-none"
|
||||
>
|
||||
<option value="all">Alle Typen</option>
|
||||
<option value="guideline">Leitlinien</option>
|
||||
<option value="checklist">Prueflisten</option>
|
||||
<option value="regulation">Verordnungen</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Sources List */}
|
||||
<div className="mt-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h2 className="text-lg font-semibold text-gray-900 dark:text-white">
|
||||
Registrierte Quellen ({filteredSources.length})
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center py-20">
|
||||
<div className="text-center">
|
||||
<div className="w-12 h-12 border-4 border-blue-200 border-t-blue-600 rounded-full animate-spin mx-auto mb-4" />
|
||||
<p className="text-gray-500 dark:text-gray-400">Lade Quellen...</p>
|
||||
</div>
|
||||
</div>
|
||||
) : filteredSources.length === 0 ? (
|
||||
<div className="text-center py-12 bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700">
|
||||
<FileText className="w-12 h-12 text-gray-400 mx-auto mb-4" />
|
||||
<p className="text-gray-500 dark:text-gray-400">
|
||||
{searchQuery || filterType !== 'all'
|
||||
? 'Keine Quellen gefunden'
|
||||
: 'Noch keine Quellen registriert'}
|
||||
</p>
|
||||
{!searchQuery && filterType === 'all' && (
|
||||
<button
|
||||
onClick={handleInitialize}
|
||||
className="mt-4 px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700"
|
||||
>
|
||||
Quellen initialisieren
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid gap-4">
|
||||
{filteredSources.map(source => (
|
||||
<SourceCard
|
||||
key={source.id}
|
||||
source={source}
|
||||
stats={getStatsForSource(source.sourceCode)}
|
||||
onIngest={() => handleIngest(source.sourceCode)}
|
||||
isIngesting={ingestingSource === source.sourceCode}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Info Box */}
|
||||
<div className="mt-8 p-6 bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-xl">
|
||||
<h3 className="font-semibold text-blue-900 dark:text-blue-100 mb-2">
|
||||
Ueber die Lizenzattribution
|
||||
</h3>
|
||||
<p className="text-sm text-blue-800 dark:text-blue-200 mb-4">
|
||||
Alle DSFA-Quellen werden mit vollstaendiger Lizenzinformation gespeichert.
|
||||
Bei der Nutzung der RAG-Suche werden automatisch die korrekten Attributionen angezeigt.
|
||||
</p>
|
||||
<div className="grid grid-cols-2 md:grid-cols-3 gap-3 text-sm">
|
||||
<div className="flex items-center gap-2">
|
||||
<LicenseBadge licenseCode="DL-DE-BY-2.0" />
|
||||
<span className="text-blue-700 dark:text-blue-300">Namensnennung</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<LicenseBadge licenseCode="DL-DE-ZERO-2.0" />
|
||||
<span className="text-blue-700 dark:text-blue-300">Keine Attribution</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<LicenseBadge licenseCode="CC-BY-4.0" />
|
||||
<span className="text-blue-700 dark:text-blue-300">CC Attribution</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,52 @@
|
||||
/**
|
||||
* TypeScript Types for BQAS (Breakpilot Quality Assurance System)
|
||||
*/
|
||||
|
||||
export interface TestResult {
|
||||
test_id: string
|
||||
test_name: string
|
||||
passed: boolean
|
||||
composite_score: number
|
||||
intent_accuracy: number
|
||||
faithfulness: number
|
||||
relevance: number
|
||||
coherence: number
|
||||
safety: string
|
||||
reasoning: string
|
||||
expected_intent: string
|
||||
detected_intent: string
|
||||
}
|
||||
|
||||
export interface TestRun {
|
||||
id: number
|
||||
timestamp: string
|
||||
git_commit: string
|
||||
golden_score: number
|
||||
synthetic_score: number
|
||||
total_tests: number
|
||||
passed_tests: number
|
||||
failed_tests: number
|
||||
duration_seconds: number
|
||||
}
|
||||
|
||||
export interface BQASMetrics {
|
||||
total_tests: number
|
||||
passed_tests: number
|
||||
failed_tests: number
|
||||
avg_intent_accuracy: number
|
||||
avg_faithfulness: number
|
||||
avg_relevance: number
|
||||
avg_coherence: number
|
||||
safety_pass_rate: number
|
||||
avg_composite_score: number
|
||||
scores_by_intent: Record<string, number>
|
||||
failed_test_ids: string[]
|
||||
}
|
||||
|
||||
export interface TrendData {
|
||||
dates: string[]
|
||||
scores: number[]
|
||||
trend: 'improving' | 'stable' | 'declining' | 'insufficient_data'
|
||||
}
|
||||
|
||||
export type TabType = 'overview' | 'golden' | 'rag' | 'synthetic' | 'history' | 'guide'
|
||||
@@ -0,0 +1,152 @@
|
||||
'use client'
|
||||
|
||||
/**
|
||||
* Architecture Overview Page
|
||||
*
|
||||
* Central view of all backend modules and their connections.
|
||||
* Helps track migration progress and ensure no modules are lost.
|
||||
*/
|
||||
|
||||
import { useState } from 'react'
|
||||
import { PagePurpose } from '@/components/common/PagePurpose'
|
||||
import { ArchitectureView } from '@/components/common/ArchitectureView'
|
||||
import { DataFlowDiagram } from '@/components/common/DataFlowDiagram'
|
||||
import { getModuleStats } from '@/lib/module-registry'
|
||||
|
||||
type ViewMode = 'list' | 'diagram'
|
||||
|
||||
export default function ArchitecturePage() {
|
||||
const [viewMode, setViewMode] = useState<ViewMode>('list')
|
||||
const stats = getModuleStats()
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<PagePurpose
|
||||
title="Architektur-Uebersicht"
|
||||
purpose="Zentrale Uebersicht aller Backend-Module und deren Verbindung zum Frontend. Dient zur Sicherstellung, dass bei der Migration keine Module verloren gehen."
|
||||
audience={['Entwickler', 'DevOps', 'Architekten', 'Auditoren']}
|
||||
architecture={{
|
||||
services: ['consent-service', 'python-backend', 'klausur-service', 'voice-service'],
|
||||
databases: ['PostgreSQL', 'Qdrant']
|
||||
}}
|
||||
relatedPages={[
|
||||
{ name: 'Compliance Hub', href: '/sdk/compliance-hub', description: 'Compliance-Module' },
|
||||
{ name: 'AI Hub', href: '/ai', description: 'KI-Module' },
|
||||
]}
|
||||
/>
|
||||
|
||||
{/* Summary Cards */}
|
||||
<div className="grid grid-cols-4 gap-4">
|
||||
<div className="bg-white rounded-xl shadow-sm border p-4">
|
||||
<div className="text-sm text-slate-500 mb-1">Migrations-Fortschritt</div>
|
||||
<div className="text-3xl font-bold text-purple-600">{stats.percentComplete}%</div>
|
||||
<div className="text-sm text-slate-400">{stats.connected} von {stats.total} Modulen</div>
|
||||
</div>
|
||||
<div className="bg-white rounded-xl shadow-sm border p-4">
|
||||
<div className="text-sm text-slate-500 mb-1">Verbunden</div>
|
||||
<div className="text-3xl font-bold text-green-600">{stats.connected}</div>
|
||||
<div className="text-sm text-green-500">Vollstaendig migriert</div>
|
||||
</div>
|
||||
<div className="bg-white rounded-xl shadow-sm border p-4">
|
||||
<div className="text-sm text-slate-500 mb-1">Teilweise verbunden</div>
|
||||
<div className="text-3xl font-bold text-yellow-600">{stats.partial}</div>
|
||||
<div className="text-sm text-yellow-500">In Bearbeitung</div>
|
||||
</div>
|
||||
<div className="bg-white rounded-xl shadow-sm border p-4">
|
||||
<div className="text-sm text-slate-500 mb-1">Nicht verbunden</div>
|
||||
<div className="text-3xl font-bold text-red-600">{stats.notConnected}</div>
|
||||
<div className="text-sm text-red-500">Noch zu migrieren</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* View Toggle */}
|
||||
<div className="bg-white rounded-xl shadow-sm border p-4">
|
||||
<div className="flex items-center gap-4">
|
||||
<span className="text-sm font-medium text-slate-700">Ansicht:</span>
|
||||
<div className="flex rounded-lg border border-slate-200 overflow-hidden">
|
||||
<button
|
||||
onClick={() => setViewMode('list')}
|
||||
className={`px-4 py-2 text-sm font-medium transition-colors ${
|
||||
viewMode === 'list'
|
||||
? 'bg-purple-600 text-white'
|
||||
: 'bg-white text-slate-600 hover:bg-slate-50'
|
||||
}`}
|
||||
>
|
||||
Modul-Liste
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setViewMode('diagram')}
|
||||
className={`px-4 py-2 text-sm font-medium transition-colors ${
|
||||
viewMode === 'diagram'
|
||||
? 'bg-purple-600 text-white'
|
||||
: 'bg-white text-slate-600 hover:bg-slate-50'
|
||||
}`}
|
||||
>
|
||||
Datenfluss-Diagramm
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content based on view mode */}
|
||||
{viewMode === 'list' ? (
|
||||
<ArchitectureView showAllCategories />
|
||||
) : (
|
||||
<DataFlowDiagram />
|
||||
)}
|
||||
|
||||
{/* Migration Checklist */}
|
||||
<div className="bg-white rounded-xl shadow-sm border p-6">
|
||||
<h3 className="text-lg font-semibold text-slate-900 mb-4">Migrations-Checkliste</h3>
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-3 p-3 border border-green-200 bg-green-50 rounded-lg">
|
||||
<input type="checkbox" checked readOnly className="w-4 h-4 text-green-600" />
|
||||
<span className="text-slate-700">Grundgeruest Admin v2 erstellt (Layout, Navigation)</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 p-3 border border-green-200 bg-green-50 rounded-lg">
|
||||
<input type="checkbox" checked readOnly className="w-4 h-4 text-green-600" />
|
||||
<span className="text-slate-700">Compliance Hub migriert (DSR, DSMS, VVT, TOM, DSFA, Controls, Evidence, Risks)</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 p-3 border border-green-200 bg-green-50 rounded-lg">
|
||||
<input type="checkbox" checked readOnly className="w-4 h-4 text-green-600" />
|
||||
<span className="text-slate-700">Consent Verwaltung migriert (inkl. Einwilligungen)</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 p-3 border border-green-200 bg-green-50 rounded-lg">
|
||||
<input type="checkbox" checked readOnly className="w-4 h-4 text-green-600" />
|
||||
<span className="text-slate-700">Workflow (Versionierung) migriert mit Sync-Scroll</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 p-3 border border-green-200 bg-green-50 rounded-lg">
|
||||
<input type="checkbox" checked readOnly className="w-4 h-4 text-green-600" />
|
||||
<span className="text-slate-700">KI-Module migriert (LLM Compare, RAG, AI Quality, Agents)</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 p-3 border border-green-200 bg-green-50 rounded-lg">
|
||||
<input type="checkbox" checked readOnly className="w-4 h-4 text-green-600" />
|
||||
<span className="text-slate-700">Infrastruktur-Module migriert (GPU, Security, SBOM, CI/CD, Middleware)</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 p-3 border border-green-200 bg-green-50 rounded-lg">
|
||||
<input type="checkbox" checked readOnly className="w-4 h-4 text-green-600" />
|
||||
<span className="text-slate-700">Communication-Module migriert (Mail, Alerts, Matrix, Video-Chat)</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 p-3 border border-green-200 bg-green-50 rounded-lg">
|
||||
<input type="checkbox" checked readOnly className="w-4 h-4 text-green-600" />
|
||||
<span className="text-slate-700">Development-Module migriert (Brandbook, Content, Docs, Game, Unity)</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 p-3 border border-yellow-200 bg-yellow-50 rounded-lg">
|
||||
<input type="checkbox" className="w-4 h-4" />
|
||||
<span className="text-slate-700">Klausur-Korrektur migrieren</span>
|
||||
<span className="text-xs text-yellow-600 ml-auto">Bleibt vorerst im alten Admin</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 p-3 border border-yellow-200 bg-yellow-50 rounded-lg">
|
||||
<input type="checkbox" className="w-4 h-4" />
|
||||
<span className="text-slate-700">OCR-Labeling migrieren</span>
|
||||
<span className="text-xs text-yellow-600 ml-auto">Prioritaet: Mittel</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 p-3 border border-slate-200 rounded-lg">
|
||||
<input type="checkbox" className="w-4 h-4" />
|
||||
<span className="text-slate-700">Verwaiste Module identifiziert (voice, training, multiplayer, pca-platform)</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,991 @@
|
||||
'use client'
|
||||
|
||||
/**
|
||||
* Production Readiness Backlog
|
||||
*
|
||||
* Comprehensive checklist of items needed before going live with BreakPilot
|
||||
* Includes CI/CD, Security, RBAC, Data Protection, and Release Workflows
|
||||
*
|
||||
* Migrated from website/app/admin/backlog/page.tsx
|
||||
* Updated: 2026-02-03
|
||||
*/
|
||||
|
||||
import { useState, useEffect } from 'react'
|
||||
import { PagePurpose } from '@/components/common/PagePurpose'
|
||||
import { metaModules } from '@/lib/navigation'
|
||||
import {
|
||||
ChevronRight,
|
||||
Search,
|
||||
Package,
|
||||
RefreshCw,
|
||||
Shield,
|
||||
ClipboardCheck,
|
||||
Users,
|
||||
GitBranch,
|
||||
Tag,
|
||||
Database,
|
||||
FileText,
|
||||
CheckSquare,
|
||||
AlertTriangle,
|
||||
} from 'lucide-react'
|
||||
|
||||
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 }[]
|
||||
}
|
||||
|
||||
interface BacklogCategory {
|
||||
id: string
|
||||
name: string
|
||||
icon: React.ReactNode
|
||||
color: string
|
||||
bgColor: string
|
||||
description: string
|
||||
}
|
||||
|
||||
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',
|
||||
},
|
||||
]
|
||||
|
||||
// UPDATED: 2026-02-03 - Reflects actual project state
|
||||
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 },
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
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' },
|
||||
}
|
||||
|
||||
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' },
|
||||
}
|
||||
|
||||
export default function BacklogPage() {
|
||||
const module = metaModules.find((m) => m.id === 'backlog')
|
||||
const [items, setItems] = useState<BacklogItem[]>(initialBacklogItems)
|
||||
const [selectedCategory, setSelectedCategory] = useState<string | null>(null)
|
||||
const [selectedPriority, setSelectedPriority] = useState<string | null>(null)
|
||||
const [expandedItems, setExpandedItems] = useState<Set<string>>(new Set())
|
||||
const [searchQuery, setSearchQuery] = useState('')
|
||||
|
||||
// Load saved state from localStorage
|
||||
useEffect(() => {
|
||||
const saved = localStorage.getItem('backlogItems-v2')
|
||||
if (saved) {
|
||||
try {
|
||||
setItems(JSON.parse(saved))
|
||||
} catch (e) {
|
||||
console.error('Failed to load backlog items:', e)
|
||||
}
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Save state to localStorage
|
||||
useEffect(() => {
|
||||
localStorage.setItem('backlogItems-v2', JSON.stringify(items))
|
||||
}, [items])
|
||||
|
||||
const filteredItems = items.filter((item) => {
|
||||
if (selectedCategory && item.category !== selectedCategory) return false
|
||||
if (selectedPriority && item.priority !== selectedPriority) return false
|
||||
if (searchQuery) {
|
||||
const query = searchQuery.toLowerCase()
|
||||
return (
|
||||
item.title.toLowerCase().includes(query) ||
|
||||
item.description.toLowerCase().includes(query) ||
|
||||
item.subtasks?.some((st) => st.title.toLowerCase().includes(query))
|
||||
)
|
||||
}
|
||||
return true
|
||||
})
|
||||
|
||||
const toggleExpand = (id: string) => {
|
||||
const newExpanded = new Set(expandedItems)
|
||||
if (newExpanded.has(id)) {
|
||||
newExpanded.delete(id)
|
||||
} else {
|
||||
newExpanded.add(id)
|
||||
}
|
||||
setExpandedItems(newExpanded)
|
||||
}
|
||||
|
||||
const updateItemStatus = (id: string, status: BacklogItem['status']) => {
|
||||
setItems(items.map((item) => (item.id === id ? { ...item, status } : item)))
|
||||
}
|
||||
|
||||
const toggleSubtask = (itemId: string, subtaskId: string) => {
|
||||
setItems(
|
||||
items.map((item) => {
|
||||
if (item.id !== itemId) return item
|
||||
return {
|
||||
...item,
|
||||
subtasks: item.subtasks?.map((st) =>
|
||||
st.id === subtaskId ? { ...st, completed: !st.completed } : st
|
||||
),
|
||||
}
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
const getProgress = () => {
|
||||
const total = items.length
|
||||
const completed = items.filter((i) => i.status === 'completed').length
|
||||
return { total, completed, percentage: Math.round((completed / total) * 100) }
|
||||
}
|
||||
|
||||
const getCategoryProgress = (categoryId: string) => {
|
||||
const categoryItems = items.filter((i) => i.category === categoryId)
|
||||
const completed = categoryItems.filter((i) => i.status === 'completed').length
|
||||
return { total: categoryItems.length, completed }
|
||||
}
|
||||
|
||||
const resetToDefaults = () => {
|
||||
if (confirm('Backlog auf Standardwerte zuruecksetzen? Alle lokalen Aenderungen gehen verloren.')) {
|
||||
setItems(initialBacklogItems)
|
||||
localStorage.removeItem('backlogItems-v2')
|
||||
}
|
||||
}
|
||||
|
||||
const progress = getProgress()
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{module && (
|
||||
<PagePurpose
|
||||
title={module.name}
|
||||
purpose={module.purpose}
|
||||
audience={module.audience}
|
||||
collapsible={true}
|
||||
defaultCollapsed={true}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Overall Progress */}
|
||||
<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={resetToDefaults}
|
||||
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>
|
||||
|
||||
{/* Category Cards */}
|
||||
<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={() => setSelectedCategory(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>
|
||||
|
||||
{/* Filters & Search */}
|
||||
<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) => setSearchQuery(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) => setSelectedPriority(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>
|
||||
{(selectedCategory || selectedPriority || searchQuery) && (
|
||||
<button
|
||||
onClick={() => {
|
||||
setSelectedCategory(null)
|
||||
setSelectedPriority(null)
|
||||
setSearchQuery('')
|
||||
}}
|
||||
className="px-4 py-2 text-sm text-slate-600 hover:text-slate-900"
|
||||
>
|
||||
Filter zuruecksetzen
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Backlog Items */}
|
||||
<div className="space-y-3">
|
||||
{filteredItems.map((item) => {
|
||||
const category = categories.find((c) => c.id === item.category)
|
||||
const isExpanded = expandedItems.has(item.id)
|
||||
const completedSubtasks = item.subtasks?.filter((st) => st.completed).length || 0
|
||||
const totalSubtasks = item.subtasks?.length || 0
|
||||
|
||||
return (
|
||||
<div
|
||||
key={item.id}
|
||||
className="bg-white rounded-xl border border-slate-200 overflow-hidden"
|
||||
>
|
||||
<div
|
||||
className="p-4 cursor-pointer hover:bg-slate-50 transition-colors"
|
||||
onClick={() => toggleExpand(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()
|
||||
updateItemStatus(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={() => toggleSubtask(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>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
{filteredItems.length === 0 && (
|
||||
<div className="text-center py-12 text-slate-500">
|
||||
Keine Aufgaben gefunden. Versuche einen anderen Filter.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Info Box */}
|
||||
<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 "Zuruecksetzen"
|
||||
auf die Standardwerte zurueckgesetzt werden.
|
||||
</p>
|
||||
<p className="text-xs text-amber-700 mt-2">
|
||||
Letzte Aktualisierung: 2026-02-03
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,912 @@
|
||||
'use client'
|
||||
|
||||
/**
|
||||
* Alerts Monitoring Admin Page (migrated from website/admin/alerts)
|
||||
*
|
||||
* Google Alerts & Feed-Ueberwachung Dashboard
|
||||
* Provides inbox management, topic configuration, rule builder, and relevance profiles
|
||||
*/
|
||||
|
||||
import { useEffect, useState, useCallback } from 'react'
|
||||
import { PagePurpose } from '@/components/common/PagePurpose'
|
||||
|
||||
// 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 }[] = [
|
||||
{ id: 'dashboard', label: 'Dashboard' },
|
||||
{ id: 'inbox', label: 'Inbox', badge: stats?.new_alerts || 0 },
|
||||
{ id: 'topics', label: 'Topics' },
|
||||
{ id: 'rules', label: 'Regeln' },
|
||||
{ id: 'profile', label: 'Profil' },
|
||||
{ id: 'audit', label: 'Audit' },
|
||||
{ id: 'documentation', label: 'Dokumentation' },
|
||||
]
|
||||
|
||||
if (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" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
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."
|
||||
audience={['Marketing', 'Admins', 'DSB']}
|
||||
architecture={{
|
||||
services: ['backend (FastAPI)', 'APScheduler', 'LLM Gateway'],
|
||||
databases: ['PostgreSQL', 'Valkey Cache'],
|
||||
}}
|
||||
relatedPages={[
|
||||
{ name: 'Unified Inbox', href: '/communication/mail', description: 'E-Mail-Konten verwalten' },
|
||||
{ name: 'Voice Service', href: '/communication/matrix', description: 'Voice-First Interface' },
|
||||
]}
|
||||
collapsible={true}
|
||||
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>
|
||||
|
||||
{/* 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) => (
|
||||
<button
|
||||
key={tab.id}
|
||||
onClick={() => setActiveTab(tab.id)}
|
||||
className={`pb-3 pt-4 px-1 text-sm font-medium border-b-2 transition-colors flex items-center gap-2 whitespace-nowrap ${
|
||||
activeTab === tab.id
|
||||
? 'border-green-600 text-green-600'
|
||||
: 'border-transparent text-slate-500 hover:text-slate-700'
|
||||
}`}
|
||||
>
|
||||
{tab.label}
|
||||
{tab.badge !== undefined && tab.badge > 0 && (
|
||||
<span className="px-2 py-0.5 rounded-full text-xs font-semibold bg-red-500 text-white">
|
||||
{tab.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>
|
||||
)}
|
||||
</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} "{rule.conditions[0]?.value}"
|
||||
</div>
|
||||
</div>
|
||||
<span className={`px-3 py-1 rounded text-xs font-semibold uppercase ${
|
||||
rule.action_type === 'keep' ? 'bg-green-100 text-green-800' :
|
||||
rule.action_type === 'drop' ? 'bg-red-100 text-red-800' :
|
||||
rule.action_type === 'email' ? 'bg-blue-100 text-blue-800' :
|
||||
'bg-purple-100 text-purple-800'
|
||||
}`}>
|
||||
{rule.action_type}
|
||||
</span>
|
||||
<div
|
||||
className={`w-12 h-6 rounded-full relative cursor-pointer transition-colors ${
|
||||
rule.is_active ? 'bg-green-500' : 'bg-slate-300'
|
||||
}`}
|
||||
>
|
||||
<div
|
||||
className={`absolute w-5 h-5 bg-white rounded-full top-0.5 transition-all 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>
|
||||
)}
|
||||
|
||||
{/* 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 "Inklusion"</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 "Werbung"</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 "new"</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 "\d{4}"</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>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,946 @@
|
||||
'use client'
|
||||
|
||||
/**
|
||||
* Unified Inbox Mail Admin Page
|
||||
* Migrated from website/admin/mail to admin-v2/communication/mail
|
||||
*
|
||||
* Admin interface for managing email accounts, viewing system status,
|
||||
* and configuring AI analysis settings.
|
||||
*/
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import Link from 'next/link'
|
||||
import { PagePurpose } from '@/components/common/PagePurpose'
|
||||
|
||||
// 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])
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* Page Purpose */}
|
||||
<PagePurpose
|
||||
title="Unified Inbox"
|
||||
purpose="Verwalten Sie E-Mail-Konten, synchronisieren Sie Postfaecher und konfigurieren Sie die KI-gestuetzte E-Mail-Analyse fuer automatische Kategorisierung und Aufgabenerkennung."
|
||||
audience={['Admins', 'Schulleitung']}
|
||||
architecture={{
|
||||
services: ['Mailpit (Dev Mail Catcher)', 'IMAP/SMTP Server (Prod)'],
|
||||
databases: ['PostgreSQL', 'Vault (Credentials)'],
|
||||
}}
|
||||
relatedPages={[
|
||||
{ name: 'Mail Wizard', href: '/communication/mail/wizard', description: 'Interaktives Setup und Testing' },
|
||||
{ name: 'Voice Service', href: '/communication/matrix', description: 'Voice-First Interface' },
|
||||
]}
|
||||
collapsible={true}
|
||||
defaultCollapsed={false}
|
||||
/>
|
||||
|
||||
{/* Quick Link to Wizard */}
|
||||
<div className="mb-6">
|
||||
<Link
|
||||
href="/communication/mail/wizard"
|
||||
className="inline-flex items-center gap-2 px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors"
|
||||
>
|
||||
<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>
|
||||
Mail Wizard starten
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* Error Banner */}
|
||||
{error && (
|
||||
<div className="mb-6 bg-red-50 border border-red-200 rounded-lg p-4 flex items-center gap-3">
|
||||
<svg className="w-5 h-5 text-red-500 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
<span className="text-red-700">{error}</span>
|
||||
<button onClick={fetchData} className="ml-auto text-red-600 hover:text-red-800 text-sm font-medium">
|
||||
Erneut versuchen
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Tab Navigation */}
|
||||
<div className="border-b border-slate-200 mb-6">
|
||||
<nav className="-mb-px flex space-x-8">
|
||||
{tabs.map((tab) => (
|
||||
<button
|
||||
key={tab.id}
|
||||
onClick={() => setActiveTab(tab.id)}
|
||||
className={`
|
||||
flex items-center gap-2 py-4 px-1 border-b-2 font-medium text-sm transition-colors
|
||||
${activeTab === tab.id
|
||||
? 'border-blue-500 text-blue-600'
|
||||
: 'border-transparent text-slate-500 hover:text-slate-700 hover:border-slate-300'
|
||||
}
|
||||
`}
|
||||
>
|
||||
{tab.name}
|
||||
</button>
|
||||
))}
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
{/* Tab Content */}
|
||||
{activeTab === 'overview' && (
|
||||
<OverviewTab
|
||||
stats={stats}
|
||||
syncStatus={syncStatus}
|
||||
loading={loading}
|
||||
onRefresh={fetchData}
|
||||
/>
|
||||
)}
|
||||
{activeTab === 'accounts' && (
|
||||
<AccountsTab
|
||||
accounts={accounts}
|
||||
loading={loading}
|
||||
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>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,421 @@
|
||||
'use client'
|
||||
|
||||
/**
|
||||
* Mail Wizard Page
|
||||
* Migrated from website/admin/mail/wizard to admin-v2/communication/mail/wizard
|
||||
*
|
||||
* Interaktives Lernen und Testen der E-Mail Integration
|
||||
*/
|
||||
|
||||
import { useState } from 'react'
|
||||
import Link from 'next/link'
|
||||
import { PagePurpose } from '@/components/common/PagePurpose'
|
||||
import {
|
||||
WizardStepper,
|
||||
WizardNavigation,
|
||||
EducationCard,
|
||||
ArchitectureContext,
|
||||
TestRunner,
|
||||
TestSummary,
|
||||
type WizardStep,
|
||||
type TestCategoryResult,
|
||||
type FullTestResults,
|
||||
type EducationContent,
|
||||
type ArchitectureContextType,
|
||||
} from '@/components/wizard'
|
||||
|
||||
// ==============================================
|
||||
// Constants
|
||||
// ==============================================
|
||||
|
||||
const BACKEND_URL = process.env.NEXT_PUBLIC_BACKEND_URL || 'http://macmini:8000'
|
||||
|
||||
const STEPS: WizardStep[] = [
|
||||
{ id: 'welcome', name: 'Willkommen', icon: '👋', status: 'pending' },
|
||||
{ id: 'smtp', name: 'SMTP', icon: '📤', status: 'pending', category: 'smtp' },
|
||||
{ id: 'imap', name: 'IMAP', icon: '📥', status: 'pending', category: 'imap' },
|
||||
{ id: 'templates', name: 'Templates', icon: '📝', status: 'pending', category: 'templates' },
|
||||
{ id: 'ai-analysis', name: 'KI-Analyse', icon: '🤖', status: 'pending', category: 'ai-analysis' },
|
||||
{ id: 'summary', name: 'Zusammenfassung', icon: '📊', status: 'pending' },
|
||||
]
|
||||
|
||||
const EDUCATION_CONTENT: Record<string, EducationContent> = {
|
||||
'welcome': {
|
||||
title: 'Willkommen zum Mail Wizard',
|
||||
content: [
|
||||
'E-Mail ist nach wie vor der wichtigste Kommunikationskanal mit Eltern.',
|
||||
'',
|
||||
'BreakPilot bietet:',
|
||||
'- SMTP: Versand von System-E-Mails (Benachrichtigungen, Newsletter)',
|
||||
'- IMAP: Empfang und Analyse eingehender E-Mails',
|
||||
'- Templates: Versionierte E-Mail-Vorlagen mit DSB-Freigabe',
|
||||
'- KI-Analyse: Automatische Kategorisierung und GFK-Pruefung',
|
||||
'',
|
||||
'In der Entwicklung nutzen wir Mailpit als Mail-Catcher.',
|
||||
'Alle E-Mails werden abgefangen und koennen inspiziert werden.',
|
||||
],
|
||||
},
|
||||
'smtp': {
|
||||
title: 'SMTP - Ausgehende E-Mails',
|
||||
content: [
|
||||
'SMTP (Simple Mail Transfer Protocol) sendet E-Mails.',
|
||||
'',
|
||||
'Typische Verwendung:',
|
||||
'- Passwort-Reset E-Mails',
|
||||
'- Einwilligungs-Erinnerungen',
|
||||
'- DSR-Kommunikation (Betroffenenanfragen)',
|
||||
'- Elternbriefe und Newsletter',
|
||||
'',
|
||||
'Entwicklungsumgebung:',
|
||||
'- Mailpit faengt alle E-Mails ab',
|
||||
'- Keine echten E-Mails werden versendet',
|
||||
'- Web-UI unter http://macmini:8025',
|
||||
'',
|
||||
'Produktion: Echter SMTP-Server (z.B. Postfix, SES)',
|
||||
],
|
||||
},
|
||||
'imap': {
|
||||
title: 'IMAP - Eingehende E-Mails',
|
||||
content: [
|
||||
'IMAP (Internet Message Access Protocol) empfaengt E-Mails.',
|
||||
'',
|
||||
'Anwendungsfaelle:',
|
||||
'- Eltern-Antworten auf Benachrichtigungen',
|
||||
'- Automatische Ticket-Erstellung aus E-Mails',
|
||||
'- Abwesenheitsmeldungen per E-Mail',
|
||||
'',
|
||||
'Verarbeitung:',
|
||||
'1. E-Mail wird empfangen',
|
||||
'2. KI analysiert Inhalt und Stimmung',
|
||||
'3. Automatische Kategorisierung',
|
||||
'4. Weiterleitung an zustaendige Stelle',
|
||||
'',
|
||||
'DSGVO: E-Mails werden nach Verarbeitung archiviert/geloescht',
|
||||
],
|
||||
},
|
||||
'templates': {
|
||||
title: 'E-Mail Templates - Versionierte Vorlagen',
|
||||
content: [
|
||||
'Alle System-E-Mails nutzen versionierte Templates.',
|
||||
'',
|
||||
'Workflow (wie bei rechtlichen Dokumenten):',
|
||||
'- draft: Entwurf wird erstellt',
|
||||
'- review: DSB/Admin prueft Inhalt',
|
||||
'- approved: Freigabe erteilt',
|
||||
'- published: Aktiv im System',
|
||||
'',
|
||||
'Template-Typen:',
|
||||
'- welcome: Willkommens-E-Mail',
|
||||
'- password_reset: Passwort zuruecksetzen',
|
||||
'- consent_reminder: Einwilligungs-Erinnerung',
|
||||
'- dsr_receipt: DSR-Eingangsbestaetigung',
|
||||
'',
|
||||
'Personalisierung: {{user.name}}, {{deadline}}, etc.',
|
||||
],
|
||||
},
|
||||
'ai-analysis': {
|
||||
title: 'KI-Analyse - LLM & GFK',
|
||||
content: [
|
||||
'KI-gestuetzte Analyse verbessert die Kommunikation.',
|
||||
'',
|
||||
'LLM-Funktionen:',
|
||||
'- Automatische Kategorisierung eingehender E-Mails',
|
||||
'- Sentiment-Analyse (positiv/neutral/negativ)',
|
||||
'- Zusammenfassung langer E-Mails',
|
||||
'- Antwort-Vorschlaege generieren',
|
||||
'',
|
||||
'GFK (Gewaltfreie Kommunikation):',
|
||||
'- Pruefung ausgehender Elternbriefe',
|
||||
'- Erkennung von "Du-Botschaften"',
|
||||
'- Vorschlaege fuer wertschaetzende Formulierung',
|
||||
'- Konfliktvermeidung durch bessere Sprache',
|
||||
'',
|
||||
'Optional: Nur aktiv wenn LLM_GATEWAY_ENABLED=true',
|
||||
],
|
||||
},
|
||||
'summary': {
|
||||
title: 'Test-Zusammenfassung',
|
||||
content: [
|
||||
'Hier sehen Sie eine Uebersicht aller durchgefuehrten Tests:',
|
||||
'- SMTP Server Verfuegbarkeit',
|
||||
'- IMAP Server Status',
|
||||
'- Template-Verwaltung',
|
||||
'- KI-Analyse Bereitschaft',
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
const ARCHITECTURE_CONTEXTS: Record<string, ArchitectureContextType> = {
|
||||
'smtp': {
|
||||
layer: 'service',
|
||||
services: ['backend', 'mailserver'],
|
||||
dependencies: ['Mailpit (Dev)', 'Postfix (Prod)', 'DNS/SPF/DKIM'],
|
||||
dataFlow: ['FastAPI', 'SMTP Client', 'Mailpit/Postfix', 'Recipient'],
|
||||
},
|
||||
'imap': {
|
||||
layer: 'service',
|
||||
services: ['backend', 'mailserver'],
|
||||
dependencies: ['IMAP Server', 'PostgreSQL', 'LLM Gateway'],
|
||||
dataFlow: ['Mailserver', 'IMAP Fetch', 'KI-Analyse', 'PostgreSQL'],
|
||||
},
|
||||
'templates': {
|
||||
layer: 'api',
|
||||
services: ['backend', 'consent-service'],
|
||||
dependencies: ['PostgreSQL', 'Template Engine', 'DSB Workflow'],
|
||||
dataFlow: ['Admin UI', 'FastAPI', 'email_templates', 'PostgreSQL'],
|
||||
},
|
||||
'ai-analysis': {
|
||||
layer: 'service',
|
||||
services: ['backend'],
|
||||
dependencies: ['LLM Gateway', 'OpenAI/Anthropic/Local', 'GFK Rules'],
|
||||
dataFlow: ['E-Mail Text', 'LLM Gateway', 'Analyse Result', 'PostgreSQL'],
|
||||
},
|
||||
}
|
||||
|
||||
// ==============================================
|
||||
// Main Component
|
||||
// ==============================================
|
||||
|
||||
export default function MailWizardPage() {
|
||||
const [currentStep, setCurrentStep] = useState(0)
|
||||
const [steps, setSteps] = useState<WizardStep[]>(STEPS)
|
||||
const [categoryResults, setCategoryResults] = useState<Record<string, TestCategoryResult>>({})
|
||||
const [fullResults, setFullResults] = useState<FullTestResults | null>(null)
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
const currentStepData = steps[currentStep]
|
||||
const isTestStep = currentStepData?.category !== undefined
|
||||
const isWelcome = currentStepData?.id === 'welcome'
|
||||
const isSummary = currentStepData?.id === 'summary'
|
||||
|
||||
const runCategoryTest = async (category: string) => {
|
||||
setIsLoading(true)
|
||||
setError(null)
|
||||
|
||||
try {
|
||||
const response = await fetch(`${BACKEND_URL}/api/admin/mail-tests/${category}`, {
|
||||
method: 'POST',
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}: ${response.statusText}`)
|
||||
}
|
||||
|
||||
const result: TestCategoryResult = await response.json()
|
||||
setCategoryResults((prev) => ({ ...prev, [category]: result }))
|
||||
|
||||
setSteps((prev) =>
|
||||
prev.map((step) =>
|
||||
step.category === category
|
||||
? { ...step, status: result.failed === 0 ? 'completed' : 'failed' }
|
||||
: step
|
||||
)
|
||||
)
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Unbekannter Fehler')
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const runAllTests = async () => {
|
||||
setIsLoading(true)
|
||||
setError(null)
|
||||
|
||||
try {
|
||||
const response = await fetch(`${BACKEND_URL}/api/admin/mail-tests/run-all`, {
|
||||
method: 'POST',
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}: ${response.statusText}`)
|
||||
}
|
||||
|
||||
const results: FullTestResults = await response.json()
|
||||
setFullResults(results)
|
||||
|
||||
setSteps((prev) =>
|
||||
prev.map((step) => {
|
||||
if (step.category) {
|
||||
const catResult = results.categories.find((c) => c.category === step.category)
|
||||
if (catResult) {
|
||||
return { ...step, status: catResult.failed === 0 ? 'completed' : 'failed' }
|
||||
}
|
||||
}
|
||||
return step
|
||||
})
|
||||
)
|
||||
|
||||
const newCategoryResults: Record<string, TestCategoryResult> = {}
|
||||
results.categories.forEach((cat) => {
|
||||
newCategoryResults[cat.category] = cat
|
||||
})
|
||||
setCategoryResults(newCategoryResults)
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Unbekannter Fehler')
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const goToNext = () => {
|
||||
if (currentStep < steps.length - 1) {
|
||||
setSteps((prev) =>
|
||||
prev.map((step, idx) =>
|
||||
idx === currentStep && step.status === 'pending'
|
||||
? { ...step, status: 'completed' }
|
||||
: step
|
||||
)
|
||||
)
|
||||
setCurrentStep((prev) => prev + 1)
|
||||
}
|
||||
}
|
||||
|
||||
const goToPrev = () => {
|
||||
if (currentStep > 0) {
|
||||
setCurrentStep((prev) => prev - 1)
|
||||
}
|
||||
}
|
||||
|
||||
const handleStepClick = (index: number) => {
|
||||
if (index <= currentStep || steps[index - 1]?.status !== 'pending') {
|
||||
setCurrentStep(index)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* Page Purpose */}
|
||||
<PagePurpose
|
||||
title="Mail Wizard"
|
||||
purpose="Interaktives Lernen und Testen der E-Mail Integration. Pruefen Sie SMTP, IMAP, Templates und KI-Analyse Schritt fuer Schritt."
|
||||
audience={['Admins', 'Entwickler']}
|
||||
architecture={{
|
||||
services: ['backend (Python)', 'mailpit (Dev)', 'LLM Gateway'],
|
||||
databases: ['PostgreSQL'],
|
||||
}}
|
||||
relatedPages={[
|
||||
{ name: 'Unified Inbox', href: '/communication/mail', description: 'E-Mail Verwaltung' },
|
||||
]}
|
||||
collapsible={true}
|
||||
defaultCollapsed={true}
|
||||
/>
|
||||
|
||||
{/* Back Link */}
|
||||
<div className="mb-6">
|
||||
<Link href="/communication/mail" className="text-blue-600 hover:text-blue-800 text-sm flex items-center gap-1">
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
|
||||
</svg>
|
||||
Zurueck zu E-Mail Management
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* Header */}
|
||||
<div className="bg-white rounded-lg shadow p-4 mb-6 flex items-center justify-between">
|
||||
<div className="flex items-center">
|
||||
<span className="text-3xl mr-3">📧</span>
|
||||
<div>
|
||||
<h2 className="text-lg font-bold text-gray-800">E-Mail Test Wizard</h2>
|
||||
<p className="text-sm text-gray-600">SMTP, IMAP, Templates & KI-Analyse</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Stepper */}
|
||||
<div className="bg-white rounded-lg shadow p-6 mb-6">
|
||||
<WizardStepper steps={steps} currentStep={currentStep} onStepClick={handleStepClick} />
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="bg-white rounded-lg shadow p-6">
|
||||
<div className="flex items-center mb-6">
|
||||
<span className="text-3xl mr-3">{currentStepData?.icon}</span>
|
||||
<div>
|
||||
<h2 className="text-xl font-bold text-gray-800">
|
||||
Schritt {currentStep + 1}: {currentStepData?.name}
|
||||
</h2>
|
||||
<p className="text-gray-500 text-sm">
|
||||
{currentStep + 1} von {steps.length}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<EducationCard content={EDUCATION_CONTENT[currentStepData?.id || '']} />
|
||||
|
||||
{isTestStep && currentStepData?.category && ARCHITECTURE_CONTEXTS[currentStepData.category] && (
|
||||
<ArchitectureContext
|
||||
context={ARCHITECTURE_CONTEXTS[currentStepData.category]}
|
||||
currentStep={currentStepData.name}
|
||||
/>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div className="bg-red-50 border border-red-200 text-red-700 rounded-lg p-4 mb-6">
|
||||
<strong>Fehler:</strong> {error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isWelcome && (
|
||||
<div className="text-center py-8">
|
||||
<button
|
||||
onClick={goToNext}
|
||||
className="bg-blue-600 text-white px-8 py-3 rounded-lg font-medium hover:bg-blue-700 transition-colors"
|
||||
>
|
||||
Wizard starten
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isTestStep && currentStepData?.category && (
|
||||
<TestRunner
|
||||
category={currentStepData.category}
|
||||
categoryResult={categoryResults[currentStepData.category]}
|
||||
isLoading={isLoading}
|
||||
onRunTests={() => runCategoryTest(currentStepData.category!)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{isSummary && (
|
||||
<div>
|
||||
{!fullResults ? (
|
||||
<div className="text-center py-8">
|
||||
<p className="text-gray-600 mb-4">
|
||||
Fuehren Sie alle Tests aus um eine Zusammenfassung zu sehen.
|
||||
</p>
|
||||
<button
|
||||
onClick={runAllTests}
|
||||
disabled={isLoading}
|
||||
className={`px-6 py-3 rounded-lg font-medium transition-colors ${
|
||||
isLoading
|
||||
? 'bg-gray-400 cursor-not-allowed'
|
||||
: 'bg-blue-600 text-white hover:bg-blue-700'
|
||||
}`}
|
||||
>
|
||||
{isLoading ? 'Alle Tests laufen...' : 'Alle Tests ausfuehren'}
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<TestSummary results={fullResults} />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<WizardNavigation
|
||||
currentStep={currentStep}
|
||||
totalSteps={steps.length}
|
||||
onPrev={goToPrev}
|
||||
onNext={goToNext}
|
||||
showNext={!isSummary}
|
||||
isLoading={isLoading}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="text-center text-gray-500 text-sm mt-6">
|
||||
Diese Tests pruefen die E-Mail-Integration.
|
||||
Bei Fragen wenden Sie sich an das IT-Team.
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,594 @@
|
||||
'use client'
|
||||
|
||||
/**
|
||||
* Voice Service Admin Page (migrated from website/admin/voice)
|
||||
*
|
||||
* Displays:
|
||||
* - Voice-First Architecture Overview
|
||||
* - Developer Guide Content
|
||||
* - Live Voice Demo (embedded from studio-v2)
|
||||
* - Task State Machine Documentation
|
||||
* - DSGVO Compliance Information
|
||||
*/
|
||||
|
||||
import { useState } from 'react'
|
||||
import Link from 'next/link'
|
||||
import { PagePurpose } from '@/components/common/PagePurpose'
|
||||
|
||||
type TabType = 'overview' | 'demo' | 'tasks' | 'intents' | 'dsgvo' | 'api'
|
||||
|
||||
// Task State Machine data
|
||||
const TASK_STATES = [
|
||||
{ state: 'DRAFT', description: 'Task erstellt, noch nicht verarbeitet', color: 'bg-gray-100 text-gray-800', next: ['QUEUED', 'PAUSED'] },
|
||||
{ state: 'QUEUED', description: 'In Warteschlange fuer Verarbeitung', color: 'bg-blue-100 text-blue-800', next: ['RUNNING', 'PAUSED'] },
|
||||
{ state: 'RUNNING', description: 'Wird aktuell verarbeitet', color: 'bg-yellow-100 text-yellow-800', next: ['READY', 'PAUSED'] },
|
||||
{ state: 'READY', description: 'Fertig, wartet auf User-Bestaetigung', color: 'bg-green-100 text-green-800', next: ['APPROVED', 'REJECTED', 'PAUSED'] },
|
||||
{ state: 'APPROVED', description: 'Vom User bestaetigt', color: 'bg-emerald-100 text-emerald-800', next: ['COMPLETED'] },
|
||||
{ state: 'REJECTED', description: 'Vom User abgelehnt', color: 'bg-red-100 text-red-800', next: ['DRAFT'] },
|
||||
{ state: 'COMPLETED', description: 'Erfolgreich abgeschlossen', color: 'bg-teal-100 text-teal-800', next: [] },
|
||||
{ state: 'EXPIRED', description: 'TTL ueberschritten', color: 'bg-orange-100 text-orange-800', next: [] },
|
||||
{ state: 'PAUSED', description: 'Vom User pausiert', color: 'bg-purple-100 text-purple-800', next: ['DRAFT', 'QUEUED', 'RUNNING', 'READY'] },
|
||||
]
|
||||
|
||||
// Intent Types (22 types organized by group)
|
||||
const INTENT_GROUPS = [
|
||||
{
|
||||
group: 'Notizen',
|
||||
color: 'bg-blue-50 border-blue-200',
|
||||
intents: [
|
||||
{ type: 'student_observation', example: 'Notiz zu Max: heute wiederholt gestoert', description: 'Schuelerbeobachtungen' },
|
||||
{ type: 'reminder', example: 'Erinner mich morgen an Konferenz', description: 'Erinnerungen setzen' },
|
||||
{ type: 'homework_check', example: '7b Mathe Hausaufgabe kontrollieren', description: 'Hausaufgaben pruefen' },
|
||||
{ type: 'conference_topic', example: 'Thema Lehrerkonferenz: iPad-Regeln', description: 'Konferenzthemen' },
|
||||
{ type: 'correction_thought', example: 'Aufgabe 3: haeufiger Fehler erklaeren', description: 'Korrekturgedanken' },
|
||||
]
|
||||
},
|
||||
{
|
||||
group: 'Content-Generierung',
|
||||
color: 'bg-green-50 border-green-200',
|
||||
intents: [
|
||||
{ type: 'worksheet_generate', example: 'Erstelle 3 Lueckentexte zu Vokabeln', description: 'Arbeitsblaetter erstellen' },
|
||||
{ type: 'quiz_generate', example: '10-Minuten Vokabeltest mit Loesungen', description: 'Quiz/Tests erstellen' },
|
||||
{ type: 'quick_activity', example: '10 Minuten Einstieg, 5 Aufgaben', description: 'Schnelle Aktivitaeten' },
|
||||
{ type: 'differentiation', example: 'Zwei Schwierigkeitsstufen: Basis und Plus', description: 'Differenzierung' },
|
||||
]
|
||||
},
|
||||
{
|
||||
group: 'Kommunikation',
|
||||
color: 'bg-yellow-50 border-yellow-200',
|
||||
intents: [
|
||||
{ type: 'parent_letter', example: 'Neutraler Elternbrief wegen Stoerungen', description: 'Elternbriefe erstellen' },
|
||||
{ type: 'class_message', example: 'Nachricht an 8a: Hausaufgaben bis Mittwoch', description: 'Klassennachrichten' },
|
||||
]
|
||||
},
|
||||
{
|
||||
group: 'Canvas-Editor',
|
||||
color: 'bg-purple-50 border-purple-200',
|
||||
intents: [
|
||||
{ type: 'canvas_edit', example: 'Ueberschriften groesser, Zeilenabstand kleiner', description: 'Formatierung aendern' },
|
||||
{ type: 'canvas_layout', example: 'Alles auf eine Seite, Drucklayout A4', description: 'Layout anpassen' },
|
||||
{ type: 'canvas_element', example: 'Kasten fuer Merke hinzufuegen', description: 'Elemente hinzufuegen' },
|
||||
{ type: 'canvas_image', example: 'Bild 2 nach links, Pfeil auf Aufgabe 3', description: 'Bilder positionieren' },
|
||||
]
|
||||
},
|
||||
{
|
||||
group: 'RAG & Korrektur',
|
||||
color: 'bg-pink-50 border-pink-200',
|
||||
intents: [
|
||||
{ type: 'operator_checklist', example: 'Operatoren-Checkliste fuer diese Aufgabe', description: 'Operatoren abrufen' },
|
||||
{ type: 'eh_passage', example: 'Erwartungshorizont-Passage zu diesem Thema', description: 'EH-Passagen suchen' },
|
||||
{ type: 'feedback_suggestion', example: 'Kurze Feedbackformulierung vorschlagen', description: 'Feedback vorschlagen' },
|
||||
]
|
||||
},
|
||||
{
|
||||
group: 'Follow-up (TaskOrchestrator)',
|
||||
color: 'bg-teal-50 border-teal-200',
|
||||
intents: [
|
||||
{ type: 'task_summary', example: 'Fasse alle offenen Tasks zusammen', description: 'Task-Uebersicht' },
|
||||
{ type: 'convert_note', example: 'Mach aus der Notiz von gestern einen Elternbrief', description: 'Notizen konvertieren' },
|
||||
{ type: 'schedule_reminder', example: 'Erinner mich morgen an das Gespraech mit Max', description: 'Erinnerungen planen' },
|
||||
]
|
||||
},
|
||||
]
|
||||
|
||||
// DSGVO Data Categories
|
||||
const DSGVO_CATEGORIES = [
|
||||
{ category: 'Audio', processing: 'NUR transient im RAM, NIEMALS persistiert', storage: 'Keine', ttl: '-', icon: '🎤', risk: 'low' },
|
||||
{ category: 'PII (Schuelernamen)', processing: 'NUR auf Lehrergeraet', storage: 'Client-side', ttl: '-', icon: '👤', risk: 'high' },
|
||||
{ category: 'Pseudonyme', processing: 'Server erlaubt (student_ref, class_ref)', storage: 'Valkey Cache', ttl: '24h', icon: '🔢', risk: 'low' },
|
||||
{ category: 'Transkripte', processing: 'NUR verschluesselt (AES-256-GCM)', storage: 'PostgreSQL', ttl: '7 Tage', icon: '📝', risk: 'medium' },
|
||||
{ category: 'Task States', processing: 'TaskOrchestrator', storage: 'Valkey', ttl: '30 Tage', icon: '📋', risk: 'low' },
|
||||
{ category: 'Audit Logs', processing: 'Nur truncated IDs, keine PII', storage: 'PostgreSQL', ttl: '90 Tage', icon: '📊', risk: 'low' },
|
||||
]
|
||||
|
||||
// API Endpoints
|
||||
const API_ENDPOINTS = [
|
||||
{ method: 'POST', path: '/api/v1/sessions', description: 'Voice Session erstellen' },
|
||||
{ method: 'GET', path: '/api/v1/sessions/{id}', description: 'Session Status abrufen' },
|
||||
{ method: 'DELETE', path: '/api/v1/sessions/{id}', description: 'Session beenden' },
|
||||
{ method: 'GET', path: '/api/v1/sessions/{id}/tasks', description: 'Pending Tasks abrufen' },
|
||||
{ method: 'POST', path: '/api/v1/tasks', description: 'Task erstellen' },
|
||||
{ method: 'GET', path: '/api/v1/tasks/{id}', description: 'Task Status abrufen' },
|
||||
{ method: 'PUT', path: '/api/v1/tasks/{id}/transition', description: 'Task State aendern' },
|
||||
{ method: 'DELETE', path: '/api/v1/tasks/{id}', description: 'Task loeschen' },
|
||||
{ method: 'WS', path: '/ws/voice', description: 'Voice Streaming (WebSocket)' },
|
||||
{ method: 'GET', path: '/health', description: 'Health Check' },
|
||||
]
|
||||
|
||||
export default function VoiceMatrixPage() {
|
||||
const [activeTab, setActiveTab] = useState<TabType>('overview')
|
||||
const [demoLoaded, setDemoLoaded] = useState(false)
|
||||
|
||||
const tabs = [
|
||||
{ id: 'overview', name: 'Architektur', icon: '🏗️' },
|
||||
{ id: 'demo', name: 'Live Demo', icon: '🎤' },
|
||||
{ id: 'tasks', name: 'Task States', icon: '📋' },
|
||||
{ id: 'intents', name: 'Intents (22)', icon: '🎯' },
|
||||
{ id: 'dsgvo', name: 'DSGVO', icon: '🔒' },
|
||||
{ id: 'api', name: 'API', icon: '🔌' },
|
||||
]
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* Page Purpose */}
|
||||
<PagePurpose
|
||||
title="Voice Service"
|
||||
purpose="Voice-First Interface mit PersonaPlex-7B & TaskOrchestrator. Konfigurieren und testen Sie den Voice-Service fuer Lehrer-Interaktionen per Sprache."
|
||||
audience={['Entwickler', 'Admins']}
|
||||
architecture={{
|
||||
services: ['voice-service (Python, Port 8091)', 'studio-v2 (Next.js)', 'valkey (Cache)'],
|
||||
databases: ['PostgreSQL', 'Valkey Cache'],
|
||||
}}
|
||||
relatedPages={[
|
||||
{ name: 'Matrix & Jitsi', href: '/communication/matrix', description: 'Kommunikation Monitoring' },
|
||||
{ name: 'LLM Vergleich', href: '/ai/llm-compare', description: 'KI-Provider vergleichen' },
|
||||
{ name: 'GPU Infrastruktur', href: '/infrastructure/gpu', description: 'GPU fuer Voice-Service' },
|
||||
]}
|
||||
collapsible={true}
|
||||
defaultCollapsed={false}
|
||||
/>
|
||||
|
||||
{/* Quick Links */}
|
||||
<div className="mb-6 flex flex-wrap gap-3">
|
||||
<a
|
||||
href="https://macmini:3001/voice-test"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-2 px-4 py-2 bg-teal-600 text-white rounded-lg hover:bg-teal-700 transition-colors"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 11a7 7 0 01-7 7m0 0a7 7 0 01-7-7m7 7v4m0 0H8m4 0h4m-4-8a3 3 0 01-3-3V5a3 3 0 116 0v6a3 3 0 01-3 3z" />
|
||||
</svg>
|
||||
Voice Test (Studio)
|
||||
</a>
|
||||
<a
|
||||
href="https://macmini:8091/health"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-2 px-4 py-2 bg-green-100 text-green-700 rounded-lg hover:bg-green-200 transition-colors"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
Health Check
|
||||
</a>
|
||||
<Link
|
||||
href="/development/docs"
|
||||
className="flex items-center gap-2 px-4 py-2 bg-slate-100 text-slate-700 rounded-lg hover:bg-slate-200 transition-colors"
|
||||
>
|
||||
<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>
|
||||
Developer Docs
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* Stats Overview */}
|
||||
<div className="grid grid-cols-2 md:grid-cols-6 gap-4 mb-6">
|
||||
<div className="bg-white rounded-lg shadow p-4">
|
||||
<div className="text-3xl font-bold text-teal-600">8091</div>
|
||||
<div className="text-sm text-slate-500">Port</div>
|
||||
</div>
|
||||
<div className="bg-white rounded-lg shadow p-4">
|
||||
<div className="text-3xl font-bold text-blue-600">22</div>
|
||||
<div className="text-sm text-slate-500">Task Types</div>
|
||||
</div>
|
||||
<div className="bg-white rounded-lg shadow p-4">
|
||||
<div className="text-3xl font-bold text-purple-600">9</div>
|
||||
<div className="text-sm text-slate-500">Task States</div>
|
||||
</div>
|
||||
<div className="bg-white rounded-lg shadow p-4">
|
||||
<div className="text-3xl font-bold text-green-600">24kHz</div>
|
||||
<div className="text-sm text-slate-500">Audio Rate</div>
|
||||
</div>
|
||||
<div className="bg-white rounded-lg shadow p-4">
|
||||
<div className="text-3xl font-bold text-orange-600">80ms</div>
|
||||
<div className="text-sm text-slate-500">Frame Size</div>
|
||||
</div>
|
||||
<div className="bg-white rounded-lg shadow p-4">
|
||||
<div className="text-3xl font-bold text-red-600">0</div>
|
||||
<div className="text-sm text-slate-500">Audio Persist</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tabs */}
|
||||
<div className="bg-white rounded-lg shadow mb-6">
|
||||
<div className="border-b border-slate-200 px-4">
|
||||
<div className="flex gap-1 overflow-x-auto">
|
||||
{tabs.map((tab) => (
|
||||
<button
|
||||
key={tab.id}
|
||||
onClick={() => setActiveTab(tab.id as TabType)}
|
||||
className={`px-4 py-3 text-sm font-medium whitespace-nowrap transition-colors border-b-2 ${
|
||||
activeTab === tab.id
|
||||
? 'border-teal-600 text-teal-600'
|
||||
: 'border-transparent text-slate-500 hover:text-slate-700'
|
||||
}`}
|
||||
>
|
||||
<span className="mr-2">{tab.icon}</span>
|
||||
{tab.name}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-6">
|
||||
{/* Overview Tab */}
|
||||
{activeTab === 'overview' && (
|
||||
<div className="space-y-6">
|
||||
<h3 className="text-lg font-semibold text-slate-900">Voice-First Architektur</h3>
|
||||
|
||||
{/* Architecture Diagram */}
|
||||
<div className="bg-slate-50 rounded-lg p-6 font-mono text-sm overflow-x-auto">
|
||||
<pre className="text-slate-700">{`
|
||||
┌──────────────────────────────────────────────────────────────────┐
|
||||
│ LEHRERGERAET (PWA / App) │
|
||||
│ ┌────────────────────────────────────────────────────────────┐ │
|
||||
│ │ VoiceCapture.tsx │ voice-encryption.ts │ voice-api.ts │ │
|
||||
│ │ Mikrofon │ AES-256-GCM │ WebSocket Client │ │
|
||||
│ └────────────────────────────────────────────────────────────┘ │
|
||||
└───────────────────────────┬──────────────────────────────────────┘
|
||||
│ WebSocket (wss://)
|
||||
▼
|
||||
┌──────────────────────────────────────────────────────────────────┐
|
||||
│ VOICE SERVICE (Port 8091) │
|
||||
│ ┌────────────────────────────────────────────────────────────┐ │
|
||||
│ │ main.py │ streaming.py │ sessions.py │ tasks.py │ │
|
||||
│ └────────────────────────────────────────────────────────────┘ │
|
||||
│ ┌────────────────────────────────────────────────────────────┐ │
|
||||
│ │ task_orchestrator.py │ intent_router.py │ encryption │ │
|
||||
│ └────────────────────────────────────────────────────────────┘ │
|
||||
└───────────────────────────┬──────────────────────────────────────┘
|
||||
│
|
||||
┌──────────────────┼──────────────────┐
|
||||
▼ ▼ ▼
|
||||
┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐
|
||||
│ PersonaPlex-7B │ │ Ollama Fallback │ │ Valkey Cache │
|
||||
│ (A100 GPU) │ │ (Mac Mini) │ │ (Sessions) │
|
||||
└─────────────────┘ └─────────────────┘ └─────────────────┘
|
||||
`}</pre>
|
||||
</div>
|
||||
|
||||
{/* Technology Stack */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4">
|
||||
<h4 className="font-semibold text-blue-800 mb-2">Voice Model (Produktion)</h4>
|
||||
<p className="text-sm text-blue-700">PersonaPlex-7B (NVIDIA)</p>
|
||||
<p className="text-xs text-blue-600 mt-1">Full-Duplex Speech-to-Speech</p>
|
||||
<p className="text-xs text-blue-500">Lizenz: MIT + NVIDIA Open Model</p>
|
||||
</div>
|
||||
<div className="bg-green-50 border border-green-200 rounded-lg p-4">
|
||||
<h4 className="font-semibold text-green-800 mb-2">Agent Orchestration</h4>
|
||||
<p className="text-sm text-green-700">TaskOrchestrator</p>
|
||||
<p className="text-xs text-green-600 mt-1">Task State Machine</p>
|
||||
<p className="text-xs text-green-500">Lizenz: Proprietary</p>
|
||||
</div>
|
||||
<div className="bg-purple-50 border border-purple-200 rounded-lg p-4">
|
||||
<h4 className="font-semibold text-purple-800 mb-2">Audio Codec</h4>
|
||||
<p className="text-sm text-purple-700">Mimi (24kHz, 80ms)</p>
|
||||
<p className="text-xs text-purple-600 mt-1">Low-Latency Streaming</p>
|
||||
<p className="text-xs text-purple-500">Lizenz: MIT</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Key Files */}
|
||||
<div>
|
||||
<h4 className="font-semibold text-slate-800 mb-3">Wichtige Dateien</h4>
|
||||
<div className="bg-white border border-slate-200 rounded-lg overflow-hidden">
|
||||
<table className="min-w-full divide-y divide-slate-200">
|
||||
<thead className="bg-slate-50">
|
||||
<tr>
|
||||
<th className="px-4 py-2 text-left text-xs font-medium text-slate-500 uppercase">Datei</th>
|
||||
<th className="px-4 py-2 text-left text-xs font-medium text-slate-500 uppercase">Beschreibung</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-slate-200">
|
||||
<tr><td className="px-4 py-2 font-mono text-sm">voice-service/main.py</td><td className="px-4 py-2 text-sm text-slate-600">FastAPI Entry, WebSocket Handler</td></tr>
|
||||
<tr><td className="px-4 py-2 font-mono text-sm">voice-service/services/task_orchestrator.py</td><td className="px-4 py-2 text-sm text-slate-600">Task State Machine</td></tr>
|
||||
<tr><td className="px-4 py-2 font-mono text-sm">voice-service/services/intent_router.py</td><td className="px-4 py-2 text-sm text-slate-600">Intent Detection (22 Types)</td></tr>
|
||||
<tr><td className="px-4 py-2 font-mono text-sm">voice-service/services/encryption_service.py</td><td className="px-4 py-2 text-sm text-slate-600">Namespace Key Management</td></tr>
|
||||
<tr><td className="px-4 py-2 font-mono text-sm">studio-v2/components/voice/VoiceCapture.tsx</td><td className="px-4 py-2 text-sm text-slate-600">Frontend Mikrofon + Crypto</td></tr>
|
||||
<tr><td className="px-4 py-2 font-mono text-sm">studio-v2/lib/voice/voice-encryption.ts</td><td className="px-4 py-2 text-sm text-slate-600">AES-256-GCM Client-side</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Demo Tab */}
|
||||
{activeTab === 'demo' && (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-lg font-semibold text-slate-900">Live Voice Demo</h3>
|
||||
<a
|
||||
href="https://macmini:3001/voice-test"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-sm text-teal-600 hover:text-teal-700 flex items-center gap-1"
|
||||
>
|
||||
In neuem Tab oeffnen
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
|
||||
</svg>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div className="bg-slate-100 rounded-lg p-4 text-sm text-slate-600 mb-4">
|
||||
<p><strong>Hinweis:</strong> Die Demo erfordert, dass der Voice Service (Port 8091) und das Studio-v2 Frontend (Port 3001) laufen.</p>
|
||||
<code className="block mt-2 bg-slate-200 p-2 rounded">docker compose up -d voice-service && cd studio-v2 && npm run dev</code>
|
||||
</div>
|
||||
|
||||
{/* Embedded Demo */}
|
||||
<div className="relative bg-slate-900 rounded-lg overflow-hidden" style={{ height: '600px' }}>
|
||||
{!demoLoaded && (
|
||||
<div className="absolute inset-0 flex items-center justify-center">
|
||||
<button
|
||||
onClick={() => setDemoLoaded(true)}
|
||||
className="px-6 py-3 bg-teal-600 text-white rounded-lg hover:bg-teal-700 transition-colors flex items-center gap-2"
|
||||
>
|
||||
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M14.752 11.168l-3.197-2.132A1 1 0 0010 9.87v4.263a1 1 0 001.555.832l3.197-2.132a1 1 0 000-1.664z" />
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
Voice Demo laden
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{demoLoaded && (
|
||||
<iframe
|
||||
src="https://macmini:3001/voice-test?embed=true"
|
||||
className="w-full h-full border-0"
|
||||
title="Voice Demo"
|
||||
allow="microphone"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Task States Tab */}
|
||||
{activeTab === 'tasks' && (
|
||||
<div className="space-y-6">
|
||||
<h3 className="text-lg font-semibold text-slate-900">Task State Machine (TaskOrchestrator)</h3>
|
||||
|
||||
{/* State Diagram */}
|
||||
<div className="bg-slate-50 rounded-lg p-6 font-mono text-sm overflow-x-auto">
|
||||
<pre className="text-slate-700">{`
|
||||
DRAFT → QUEUED → RUNNING → READY
|
||||
│
|
||||
┌───────────┴───────────┐
|
||||
│ │
|
||||
APPROVED REJECTED
|
||||
│ │
|
||||
COMPLETED DRAFT (revision)
|
||||
|
||||
Any State → EXPIRED (TTL)
|
||||
Any State → PAUSED (User Interrupt)
|
||||
`}</pre>
|
||||
</div>
|
||||
|
||||
{/* States Table */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{TASK_STATES.map((state) => (
|
||||
<div key={state.state} className={`${state.color} rounded-lg p-4`}>
|
||||
<div className="font-semibold text-lg">{state.state}</div>
|
||||
<p className="text-sm mt-1">{state.description}</p>
|
||||
{state.next.length > 0 && (
|
||||
<div className="mt-2 text-xs">
|
||||
<span className="opacity-75">Naechste:</span>{' '}
|
||||
{state.next.join(', ')}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Intents Tab */}
|
||||
{activeTab === 'intents' && (
|
||||
<div className="space-y-6">
|
||||
<h3 className="text-lg font-semibold text-slate-900">Intent Types (22 unterstuetzte Typen)</h3>
|
||||
|
||||
{INTENT_GROUPS.map((group) => (
|
||||
<div key={group.group} className={`${group.color} border rounded-lg p-4`}>
|
||||
<h4 className="font-semibold text-slate-800 mb-3">{group.group}</h4>
|
||||
<div className="space-y-2">
|
||||
{group.intents.map((intent) => (
|
||||
<div key={intent.type} className="bg-white rounded-lg p-3 shadow-sm">
|
||||
<div className="flex items-start justify-between">
|
||||
<div>
|
||||
<code className="text-sm font-mono text-teal-700 bg-teal-50 px-2 py-0.5 rounded">
|
||||
{intent.type}
|
||||
</code>
|
||||
<p className="text-sm text-slate-600 mt-1">{intent.description}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-2 text-xs text-slate-500 italic">
|
||||
Beispiel: "{intent.example}"
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* DSGVO Tab */}
|
||||
{activeTab === 'dsgvo' && (
|
||||
<div className="space-y-6">
|
||||
<h3 className="text-lg font-semibold text-slate-900">DSGVO-Compliance</h3>
|
||||
|
||||
{/* Key Principles */}
|
||||
<div className="bg-green-50 border border-green-200 rounded-lg p-4">
|
||||
<h4 className="font-semibold text-green-800 mb-2">Kernprinzipien</h4>
|
||||
<ul className="list-disc list-inside text-sm text-green-700 space-y-1">
|
||||
<li><strong>Audio NIEMALS persistiert</strong> - Nur transient im RAM</li>
|
||||
<li><strong>Namespace-Verschluesselung</strong> - Key nur auf Lehrergeraet</li>
|
||||
<li><strong>Keine Klartext-PII serverseitig</strong> - Nur verschluesselt oder pseudonymisiert</li>
|
||||
<li><strong>TTL-basierte Auto-Loeschung</strong> - 7/30/90 Tage je nach Kategorie</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
{/* Data Categories Table */}
|
||||
<div className="bg-white border border-slate-200 rounded-lg overflow-hidden">
|
||||
<table className="min-w-full divide-y divide-slate-200">
|
||||
<thead className="bg-slate-50">
|
||||
<tr>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-slate-500 uppercase">Kategorie</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-slate-500 uppercase">Verarbeitung</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-slate-500 uppercase">Speicherort</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-slate-500 uppercase">TTL</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-slate-500 uppercase">Risiko</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-slate-200">
|
||||
{DSGVO_CATEGORIES.map((cat) => (
|
||||
<tr key={cat.category}>
|
||||
<td className="px-4 py-3">
|
||||
<span className="mr-2">{cat.icon}</span>
|
||||
<span className="font-medium">{cat.category}</span>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sm text-slate-600">{cat.processing}</td>
|
||||
<td className="px-4 py-3 text-sm text-slate-600">{cat.storage}</td>
|
||||
<td className="px-4 py-3 text-sm text-slate-600">{cat.ttl}</td>
|
||||
<td className="px-4 py-3">
|
||||
<span className={`px-2 py-1 rounded text-xs font-medium ${
|
||||
cat.risk === 'low' ? 'bg-green-100 text-green-700' :
|
||||
cat.risk === 'medium' ? 'bg-yellow-100 text-yellow-700' :
|
||||
'bg-red-100 text-red-700'
|
||||
}`}>
|
||||
{cat.risk.toUpperCase()}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* Audit Log Info */}
|
||||
<div className="bg-slate-50 border border-slate-200 rounded-lg p-4">
|
||||
<h4 className="font-semibold text-slate-800 mb-2">Audit Logs (ohne PII)</h4>
|
||||
<div className="grid grid-cols-2 gap-4 text-sm">
|
||||
<div>
|
||||
<span className="text-green-600 font-medium">Erlaubt:</span>
|
||||
<ul className="list-disc list-inside text-slate-600 mt-1">
|
||||
<li>ref_id (truncated)</li>
|
||||
<li>content_type</li>
|
||||
<li>size_bytes</li>
|
||||
<li>ttl_hours</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-red-600 font-medium">Verboten:</span>
|
||||
<ul className="list-disc list-inside text-slate-600 mt-1">
|
||||
<li>user_name</li>
|
||||
<li>content / transcript</li>
|
||||
<li>email</li>
|
||||
<li>student_name</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* API Tab */}
|
||||
{activeTab === 'api' && (
|
||||
<div className="space-y-6">
|
||||
<h3 className="text-lg font-semibold text-slate-900">Voice Service API (Port 8091)</h3>
|
||||
|
||||
{/* REST Endpoints */}
|
||||
<div className="bg-white border border-slate-200 rounded-lg overflow-hidden">
|
||||
<table className="min-w-full divide-y divide-slate-200">
|
||||
<thead className="bg-slate-50">
|
||||
<tr>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-slate-500 uppercase">Methode</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-slate-500 uppercase">Endpoint</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-slate-500 uppercase">Beschreibung</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-slate-200">
|
||||
{API_ENDPOINTS.map((ep, idx) => (
|
||||
<tr key={idx}>
|
||||
<td className="px-4 py-3">
|
||||
<span className={`px-2 py-1 rounded text-xs font-medium ${
|
||||
ep.method === 'GET' ? 'bg-green-100 text-green-700' :
|
||||
ep.method === 'POST' ? 'bg-blue-100 text-blue-700' :
|
||||
ep.method === 'PUT' ? 'bg-yellow-100 text-yellow-700' :
|
||||
ep.method === 'DELETE' ? 'bg-red-100 text-red-700' :
|
||||
'bg-purple-100 text-purple-700'
|
||||
}`}>
|
||||
{ep.method}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3 font-mono text-sm">{ep.path}</td>
|
||||
<td className="px-4 py-3 text-sm text-slate-600">{ep.description}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* WebSocket Protocol */}
|
||||
<div className="bg-slate-50 rounded-lg p-4">
|
||||
<h4 className="font-semibold text-slate-800 mb-3">WebSocket Protocol</h4>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 text-sm">
|
||||
<div className="bg-white rounded-lg p-3 border border-slate-200">
|
||||
<div className="font-medium text-slate-700 mb-2">Client → Server</div>
|
||||
<ul className="list-disc list-inside text-slate-600 space-y-1">
|
||||
<li><code className="bg-slate-100 px-1 rounded">Binary</code>: Int16 PCM Audio (24kHz, 80ms)</li>
|
||||
<li><code className="bg-slate-100 px-1 rounded">JSON</code>: {`{type: "config|end_turn|interrupt"}`}</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div className="bg-white rounded-lg p-3 border border-slate-200">
|
||||
<div className="font-medium text-slate-700 mb-2">Server → Client</div>
|
||||
<ul className="list-disc list-inside text-slate-600 space-y-1">
|
||||
<li><code className="bg-slate-100 px-1 rounded">Binary</code>: Audio Response (base64)</li>
|
||||
<li><code className="bg-slate-100 px-1 rounded">JSON</code>: {`{type: "transcript|intent|status|error"}`}</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Example curl commands */}
|
||||
<div className="bg-slate-900 rounded-lg p-4 text-sm">
|
||||
<h4 className="font-semibold text-slate-300 mb-3">Beispiel: Session erstellen</h4>
|
||||
<pre className="text-green-400 overflow-x-auto">{`curl -X POST https://macmini:8091/api/v1/sessions \\
|
||||
-H "Content-Type: application/json" \\
|
||||
-d '{
|
||||
"namespace_id": "ns-12345678abcdef12345678abcdef12",
|
||||
"key_hash": "sha256:dGVzdGtleWhhc2h0ZXN0a2V5aGFzaHRlc3Q=",
|
||||
"device_type": "pwa"
|
||||
}'`}</pre>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
'use client'
|
||||
|
||||
import { getCategoryById } from '@/lib/navigation'
|
||||
import { ModuleCard } from '@/components/common/ModuleCard'
|
||||
import { PagePurpose } from '@/components/common/PagePurpose'
|
||||
|
||||
export default function CommunicationPage() {
|
||||
const category = getCategoryById('communication')
|
||||
|
||||
if (!category) {
|
||||
return <div>Kategorie nicht gefunden</div>
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* Page Purpose */}
|
||||
<PagePurpose
|
||||
title={category.name}
|
||||
purpose="Diese Kategorie umfasst alle Kommunikations- und Benachrichtigungsmodule. Hier ueberwachen Sie Matrix-Raeume, verwalten E-Mail-Konten und konfigurieren Alert-Feeds."
|
||||
audience={['Admins', 'Support', 'Marketing']}
|
||||
architecture={{
|
||||
services: ['synapse (Matrix)', 'mailpit (Dev SMTP)', 'backend (Python)'],
|
||||
databases: ['PostgreSQL', 'synapse-db'],
|
||||
}}
|
||||
collapsible={true}
|
||||
defaultCollapsed={false}
|
||||
/>
|
||||
|
||||
{/* Modules Grid */}
|
||||
<h2 className="text-lg font-semibold text-slate-900 mb-4">Module</h2>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{category.modules.map((module) => (
|
||||
<ModuleCard key={module.id} module={module} category={category} />
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Info Section */}
|
||||
<div className="mt-8 bg-green-50 border border-green-200 rounded-xl p-6">
|
||||
<h3 className="font-semibold text-green-800 flex items-center gap-2">
|
||||
<span>📬</span>
|
||||
Ende-zu-Ende-Verschluesselung
|
||||
</h3>
|
||||
<p className="text-sm text-green-700 mt-2">
|
||||
Matrix-Kommunikation ist standardmaessig Ende-zu-Ende verschluesselt.
|
||||
Jitsi-Konferenzen werden nicht auf dem Server gespeichert (optional: Aufnahme mit Jibri).
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,635 @@
|
||||
'use client'
|
||||
|
||||
/**
|
||||
* Video & Chat Admin Page
|
||||
*
|
||||
* Matrix & Jitsi Monitoring Dashboard
|
||||
* Provides system statistics, active calls, user metrics, and service health
|
||||
* Migrated from website/app/admin/communication
|
||||
*/
|
||||
|
||||
import { useEffect, useState, useCallback } from 'react'
|
||||
import Link from 'next/link'
|
||||
import { PagePurpose } from '@/components/common/PagePurpose'
|
||||
import { getModuleByHref } from '@/lib/navigation'
|
||||
|
||||
interface MatrixStats {
|
||||
total_users: number
|
||||
active_users: number
|
||||
total_rooms: number
|
||||
active_rooms: number
|
||||
messages_today: number
|
||||
messages_this_week: number
|
||||
status: 'online' | 'offline' | 'degraded'
|
||||
}
|
||||
|
||||
interface JitsiStats {
|
||||
active_meetings: number
|
||||
total_participants: number
|
||||
meetings_today: number
|
||||
average_duration_minutes: number
|
||||
peak_concurrent_users: number
|
||||
total_minutes_today: number
|
||||
status: 'online' | 'offline' | 'degraded'
|
||||
}
|
||||
|
||||
interface TrafficStats {
|
||||
matrix: {
|
||||
bandwidth_in_mb: number
|
||||
bandwidth_out_mb: number
|
||||
messages_per_minute: number
|
||||
media_uploads_today: number
|
||||
media_size_mb: number
|
||||
}
|
||||
jitsi: {
|
||||
bandwidth_in_mb: number
|
||||
bandwidth_out_mb: number
|
||||
video_streams_active: number
|
||||
audio_streams_active: number
|
||||
estimated_hourly_gb: number
|
||||
}
|
||||
total: {
|
||||
bandwidth_in_mb: number
|
||||
bandwidth_out_mb: number
|
||||
estimated_monthly_gb: number
|
||||
}
|
||||
}
|
||||
|
||||
interface CommunicationStats {
|
||||
matrix: MatrixStats
|
||||
jitsi: JitsiStats
|
||||
traffic?: TrafficStats
|
||||
last_updated: string
|
||||
}
|
||||
|
||||
interface ActiveMeeting {
|
||||
room_name: string
|
||||
display_name: string
|
||||
participants: number
|
||||
started_at: string
|
||||
duration_minutes: number
|
||||
}
|
||||
|
||||
interface RecentRoom {
|
||||
room_id: string
|
||||
name: string
|
||||
member_count: number
|
||||
last_activity: string
|
||||
room_type: 'class' | 'parent' | 'staff' | 'general'
|
||||
}
|
||||
|
||||
export default function VideoChatPage() {
|
||||
const [stats, setStats] = useState<CommunicationStats | null>(null)
|
||||
const [activeMeetings, setActiveMeetings] = useState<ActiveMeeting[]>([])
|
||||
const [recentRooms, setRecentRooms] = useState<RecentRoom[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
const moduleInfo = getModuleByHref('/communication/video-chat')
|
||||
|
||||
// Use local API proxy
|
||||
const fetchStats = useCallback(async () => {
|
||||
try {
|
||||
const response = await fetch('/api/admin/communication/stats')
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}`)
|
||||
}
|
||||
const data = await response.json()
|
||||
setStats(data)
|
||||
setActiveMeetings(data.active_meetings || [])
|
||||
setRecentRooms(data.recent_rooms || [])
|
||||
setError(null)
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Verbindungsfehler')
|
||||
// Set mock data for display purposes when API unavailable
|
||||
setStats({
|
||||
matrix: {
|
||||
total_users: 0,
|
||||
active_users: 0,
|
||||
total_rooms: 0,
|
||||
active_rooms: 0,
|
||||
messages_today: 0,
|
||||
messages_this_week: 0,
|
||||
status: 'offline'
|
||||
},
|
||||
jitsi: {
|
||||
active_meetings: 0,
|
||||
total_participants: 0,
|
||||
meetings_today: 0,
|
||||
average_duration_minutes: 0,
|
||||
peak_concurrent_users: 0,
|
||||
total_minutes_today: 0,
|
||||
status: 'offline'
|
||||
},
|
||||
last_updated: new Date().toISOString()
|
||||
})
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
fetchStats()
|
||||
}, [fetchStats])
|
||||
|
||||
// Auto-refresh every 15 seconds
|
||||
useEffect(() => {
|
||||
const interval = setInterval(fetchStats, 15000)
|
||||
return () => clearInterval(interval)
|
||||
}, [fetchStats])
|
||||
|
||||
const getStatusBadge = (status: string) => {
|
||||
const baseClasses = 'px-3 py-1 rounded-full text-xs font-semibold uppercase'
|
||||
switch (status) {
|
||||
case 'online':
|
||||
return `${baseClasses} bg-green-100 text-green-800`
|
||||
case 'degraded':
|
||||
return `${baseClasses} bg-yellow-100 text-yellow-800`
|
||||
case 'offline':
|
||||
return `${baseClasses} bg-red-100 text-red-800`
|
||||
default:
|
||||
return `${baseClasses} bg-slate-100 text-slate-600`
|
||||
}
|
||||
}
|
||||
|
||||
const getRoomTypeBadge = (type: string) => {
|
||||
const baseClasses = 'px-2 py-0.5 rounded text-xs font-medium'
|
||||
switch (type) {
|
||||
case 'class':
|
||||
return `${baseClasses} bg-blue-100 text-blue-700`
|
||||
case 'parent':
|
||||
return `${baseClasses} bg-purple-100 text-purple-700`
|
||||
case 'staff':
|
||||
return `${baseClasses} bg-orange-100 text-orange-700`
|
||||
default:
|
||||
return `${baseClasses} bg-slate-100 text-slate-600`
|
||||
}
|
||||
}
|
||||
|
||||
const formatDuration = (minutes: number) => {
|
||||
if (minutes < 60) return `${Math.round(minutes)} Min.`
|
||||
const hours = Math.floor(minutes / 60)
|
||||
const mins = Math.round(minutes % 60)
|
||||
return `${hours}h ${mins}m`
|
||||
}
|
||||
|
||||
const formatTimeAgo = (dateStr: string) => {
|
||||
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`
|
||||
}
|
||||
|
||||
// Traffic estimation helpers for SysEleven planning
|
||||
const calculateEstimatedTraffic = (direction: 'in' | 'out'): number => {
|
||||
const messages = stats?.matrix?.messages_today || 0
|
||||
const callMinutes = stats?.jitsi?.total_minutes_today || 0
|
||||
const participants = stats?.jitsi?.total_participants || 0
|
||||
|
||||
const messageTrafficMB = messages * 0.002
|
||||
const videoTrafficMB = callMinutes * participants * 0.011
|
||||
|
||||
if (direction === 'in') {
|
||||
return messageTrafficMB * 0.3 + videoTrafficMB * 0.4
|
||||
}
|
||||
return messageTrafficMB * 0.7 + videoTrafficMB * 0.6
|
||||
}
|
||||
|
||||
const calculateHourlyEstimate = (): number => {
|
||||
const activeParticipants = stats?.jitsi?.total_participants || 0
|
||||
return activeParticipants * 0.675
|
||||
}
|
||||
|
||||
const calculateMonthlyEstimate = (): number => {
|
||||
const dailyCallMinutes = stats?.jitsi?.total_minutes_today || 0
|
||||
const avgParticipants = stats?.jitsi?.peak_concurrent_users || 1
|
||||
const monthlyMinutes = dailyCallMinutes * 22
|
||||
return (monthlyMinutes * avgParticipants * 11) / 1024
|
||||
}
|
||||
|
||||
const getResourceRecommendation = (): string => {
|
||||
const peakUsers = stats?.jitsi?.peak_concurrent_users || 0
|
||||
const monthlyGB = calculateMonthlyEstimate()
|
||||
|
||||
if (monthlyGB < 10 || peakUsers < 5) {
|
||||
return 'Starter (1 vCPU, 2GB RAM, 100GB Traffic)'
|
||||
} else if (monthlyGB < 50 || peakUsers < 20) {
|
||||
return 'Standard (2 vCPU, 4GB RAM, 500GB Traffic)'
|
||||
} else if (monthlyGB < 200 || peakUsers < 50) {
|
||||
return 'Professional (4 vCPU, 8GB RAM, 2TB Traffic)'
|
||||
} else {
|
||||
return 'Enterprise (8+ vCPU, 16GB+ RAM, Unlimited Traffic)'
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* Page Purpose */}
|
||||
<PagePurpose
|
||||
title={moduleInfo?.module.name || 'Video & Chat'}
|
||||
purpose={moduleInfo?.module.purpose || 'Matrix & Jitsi Monitoring Dashboard'}
|
||||
audience={moduleInfo?.module.audience || ['Admins', 'DevOps']}
|
||||
architecture={{
|
||||
services: ['synapse (Matrix)', 'jitsi-meet', 'prosody', 'jvb'],
|
||||
databases: ['PostgreSQL', 'synapse-db'],
|
||||
}}
|
||||
collapsible={true}
|
||||
defaultCollapsed={true}
|
||||
/>
|
||||
|
||||
{/* Quick Actions */}
|
||||
<div className="flex gap-3 mb-6">
|
||||
<Link
|
||||
href="/communication/video-chat/wizard"
|
||||
className="px-4 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700 transition-colors text-sm font-medium"
|
||||
>
|
||||
Test Wizard starten
|
||||
</Link>
|
||||
<button
|
||||
onClick={fetchStats}
|
||||
disabled={loading}
|
||||
className="px-4 py-2 border border-slate-300 rounded-lg hover:bg-slate-50 disabled:opacity-50 text-sm"
|
||||
>
|
||||
{loading ? 'Lade...' : 'Aktualisieren'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Service Status Overview */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6 mb-6">
|
||||
{/* Matrix Status Card */}
|
||||
<div className="bg-white rounded-xl border border-slate-200 p-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 bg-purple-100 rounded-lg flex items-center justify-center">
|
||||
<svg className="w-6 h-6 text-purple-600" 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>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-semibold text-slate-900">Matrix (Synapse)</h3>
|
||||
<p className="text-sm text-slate-500">E2EE Messaging</p>
|
||||
</div>
|
||||
</div>
|
||||
<span className={getStatusBadge(stats?.matrix.status || 'offline')}>
|
||||
{stats?.matrix.status || 'offline'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<div>
|
||||
<div className="text-2xl font-bold text-slate-900">{stats?.matrix.total_users || 0}</div>
|
||||
<div className="text-xs text-slate-500">Benutzer</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-2xl font-bold text-slate-900">{stats?.matrix.active_users || 0}</div>
|
||||
<div className="text-xs text-slate-500">Aktiv</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-2xl font-bold text-slate-900">{stats?.matrix.total_rooms || 0}</div>
|
||||
<div className="text-xs text-slate-500">Raeume</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-4 pt-4 border-t border-slate-100">
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-slate-500">Nachrichten heute</span>
|
||||
<span className="font-medium">{stats?.matrix.messages_today || 0}</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-sm mt-1">
|
||||
<span className="text-slate-500">Diese Woche</span>
|
||||
<span className="font-medium">{stats?.matrix.messages_this_week || 0}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Jitsi Status Card */}
|
||||
<div className="bg-white rounded-xl border border-slate-200 p-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 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="M15 10l4.553-2.276A1 1 0 0121 8.618v6.764a1 1 0 01-1.447.894L15 14M5 18h8a2 2 0 002-2V8a2 2 0 00-2-2H5a2 2 0 00-2 2v8a2 2 0 002 2z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-semibold text-slate-900">Jitsi Meet</h3>
|
||||
<p className="text-sm text-slate-500">Videokonferenzen</p>
|
||||
</div>
|
||||
</div>
|
||||
<span className={getStatusBadge(stats?.jitsi.status || 'offline')}>
|
||||
{stats?.jitsi.status || 'offline'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<div>
|
||||
<div className="text-2xl font-bold text-green-600">{stats?.jitsi.active_meetings || 0}</div>
|
||||
<div className="text-xs text-slate-500">Live Calls</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-2xl font-bold text-slate-900">{stats?.jitsi.total_participants || 0}</div>
|
||||
<div className="text-xs text-slate-500">Teilnehmer</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-2xl font-bold text-slate-900">{stats?.jitsi.meetings_today || 0}</div>
|
||||
<div className="text-xs text-slate-500">Calls heute</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-4 pt-4 border-t border-slate-100">
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-slate-500">Durchschnittliche Dauer</span>
|
||||
<span className="font-medium">{formatDuration(stats?.jitsi.average_duration_minutes || 0)}</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-sm mt-1">
|
||||
<span className="text-slate-500">Peak gleichzeitig</span>
|
||||
<span className="font-medium">{stats?.jitsi.peak_concurrent_users || 0} Nutzer</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Traffic & Bandwidth Statistics */}
|
||||
<div className="bg-white rounded-xl border border-slate-200 p-6 mb-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 bg-emerald-100 rounded-lg flex items-center justify-center">
|
||||
<svg className="w-6 h-6 text-emerald-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 7h8m0 0v8m0-8l-8 8-4-4-6 6" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-semibold text-slate-900">Traffic & Bandbreite</h3>
|
||||
<p className="text-sm text-slate-500">SysEleven Ressourcenplanung</p>
|
||||
</div>
|
||||
</div>
|
||||
<span className="px-3 py-1 rounded-full text-xs font-semibold uppercase bg-emerald-100 text-emerald-800">
|
||||
Live
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 mb-4">
|
||||
<div className="bg-slate-50 rounded-lg p-4">
|
||||
<div className="text-xs text-slate-500 mb-1">Eingehend (heute)</div>
|
||||
<div className="text-2xl font-bold text-slate-900">
|
||||
{stats?.traffic?.total?.bandwidth_in_mb?.toFixed(1) || calculateEstimatedTraffic('in').toFixed(1)} MB
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-slate-50 rounded-lg p-4">
|
||||
<div className="text-xs text-slate-500 mb-1">Ausgehend (heute)</div>
|
||||
<div className="text-2xl font-bold text-slate-900">
|
||||
{stats?.traffic?.total?.bandwidth_out_mb?.toFixed(1) || calculateEstimatedTraffic('out').toFixed(1)} MB
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-slate-50 rounded-lg p-4">
|
||||
<div className="text-xs text-slate-500 mb-1">Geschaetzt/Stunde</div>
|
||||
<div className="text-2xl font-bold text-blue-600">
|
||||
{stats?.traffic?.jitsi?.estimated_hourly_gb?.toFixed(2) || calculateHourlyEstimate().toFixed(2)} GB
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-slate-50 rounded-lg p-4">
|
||||
<div className="text-xs text-slate-500 mb-1">Geschaetzt/Monat</div>
|
||||
<div className="text-2xl font-bold text-emerald-600">
|
||||
{stats?.traffic?.total?.estimated_monthly_gb?.toFixed(1) || calculateMonthlyEstimate().toFixed(1)} GB
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{/* Matrix Traffic */}
|
||||
<div className="border border-slate-200 rounded-lg p-4">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<div className="w-3 h-3 bg-purple-500 rounded-full"></div>
|
||||
<span className="text-sm font-medium text-slate-700">Matrix Messaging</span>
|
||||
</div>
|
||||
<div className="space-y-2 text-sm">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-slate-500">Nachrichten/Min</span>
|
||||
<span className="font-medium">{stats?.traffic?.matrix?.messages_per_minute || Math.round((stats?.matrix?.messages_today || 0) / (new Date().getHours() || 1) / 60)}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-slate-500">Media Uploads heute</span>
|
||||
<span className="font-medium">{stats?.traffic?.matrix?.media_uploads_today || 0}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-slate-500">Media Groesse</span>
|
||||
<span className="font-medium">{stats?.traffic?.matrix?.media_size_mb?.toFixed(1) || '0.0'} MB</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Jitsi Traffic */}
|
||||
<div className="border border-slate-200 rounded-lg p-4">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<div className="w-3 h-3 bg-blue-500 rounded-full"></div>
|
||||
<span className="text-sm font-medium text-slate-700">Jitsi Video</span>
|
||||
</div>
|
||||
<div className="space-y-2 text-sm">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-slate-500">Video Streams aktiv</span>
|
||||
<span className="font-medium">{stats?.traffic?.jitsi?.video_streams_active || (stats?.jitsi?.total_participants || 0)}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-slate-500">Audio Streams aktiv</span>
|
||||
<span className="font-medium">{stats?.traffic?.jitsi?.audio_streams_active || (stats?.jitsi?.total_participants || 0)}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-slate-500">Bitrate geschaetzt</span>
|
||||
<span className="font-medium">{((stats?.jitsi?.total_participants || 0) * 1.5).toFixed(1)} Mbps</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* SysEleven Recommendation */}
|
||||
<div className="mt-4 p-4 bg-emerald-50 border border-emerald-200 rounded-lg">
|
||||
<h4 className="text-sm font-semibold text-emerald-800 mb-2">SysEleven Empfehlung</h4>
|
||||
<div className="text-sm text-emerald-700">
|
||||
<p>Basierend auf aktuellem Traffic: <strong>{getResourceRecommendation()}</strong></p>
|
||||
<p className="mt-1 text-xs text-emerald-600">
|
||||
Peak Teilnehmer: {stats?.jitsi?.peak_concurrent_users || 0} |
|
||||
Durchschnittliche Call-Dauer: {stats?.jitsi?.average_duration_minutes?.toFixed(0) || 0} Min. |
|
||||
Calls heute: {stats?.jitsi?.meetings_today || 0}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Active Meetings */}
|
||||
<div className="bg-white rounded-xl border border-slate-200 p-6 mb-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="font-semibold text-slate-900">Aktive Meetings</h3>
|
||||
</div>
|
||||
|
||||
{activeMeetings.length === 0 ? (
|
||||
<div className="text-center py-8 text-slate-500">
|
||||
<svg className="w-12 h-12 mx-auto mb-3 text-slate-300" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 10l4.553-2.276A1 1 0 0121 8.618v6.764a1 1 0 01-1.447.894L15 14M5 18h8a2 2 0 002-2V8a2 2 0 00-2-2H5a2 2 0 00-2 2v8a2 2 0 002 2z" />
|
||||
</svg>
|
||||
<p>Keine aktiven Meetings</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full">
|
||||
<thead>
|
||||
<tr className="text-left text-xs text-slate-500 uppercase border-b border-slate-200">
|
||||
<th className="pb-3 pr-4">Meeting</th>
|
||||
<th className="pb-3 pr-4">Teilnehmer</th>
|
||||
<th className="pb-3 pr-4">Gestartet</th>
|
||||
<th className="pb-3">Dauer</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-slate-100">
|
||||
{activeMeetings.map((meeting, idx) => (
|
||||
<tr key={idx} className="text-sm">
|
||||
<td className="py-3 pr-4">
|
||||
<div className="font-medium text-slate-900">{meeting.display_name}</div>
|
||||
<div className="text-xs text-slate-500">{meeting.room_name}</div>
|
||||
</td>
|
||||
<td className="py-3 pr-4">
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<span className="w-2 h-2 bg-green-500 rounded-full animate-pulse" />
|
||||
{meeting.participants}
|
||||
</span>
|
||||
</td>
|
||||
<td className="py-3 pr-4 text-slate-500">{formatTimeAgo(meeting.started_at)}</td>
|
||||
<td className="py-3 font-medium">{formatDuration(meeting.duration_minutes)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Recent Chat Rooms & Usage Stats */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-6">
|
||||
<div className="bg-white rounded-xl border border-slate-200 p-6">
|
||||
<h3 className="font-semibold text-slate-900 mb-4">Aktive Chat-Raeume</h3>
|
||||
|
||||
{recentRooms.length === 0 ? (
|
||||
<div className="text-center py-6 text-slate-500">
|
||||
<p>Keine aktiven Raeume</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{recentRooms.slice(0, 5).map((room, idx) => (
|
||||
<div key={idx} className="flex items-center justify-between p-3 bg-slate-50 rounded-lg">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-8 h-8 bg-slate-200 rounded-lg flex items-center justify-center">
|
||||
<svg className="w-4 h-4 text-slate-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<div className="font-medium text-slate-900 text-sm">{room.name}</div>
|
||||
<div className="text-xs text-slate-500">{room.member_count} Mitglieder</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={getRoomTypeBadge(room.room_type)}>{room.room_type}</span>
|
||||
<span className="text-xs text-slate-400">{formatTimeAgo(room.last_activity)}</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Usage Statistics */}
|
||||
<div className="bg-white rounded-xl border border-slate-200 p-6">
|
||||
<h3 className="font-semibold text-slate-900 mb-4">Nutzungsstatistiken</h3>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<div className="flex justify-between text-sm mb-1">
|
||||
<span className="text-slate-600">Call-Minuten heute</span>
|
||||
<span className="font-semibold">{stats?.jitsi.total_minutes_today || 0} Min.</span>
|
||||
</div>
|
||||
<div className="w-full bg-slate-100 rounded-full h-2">
|
||||
<div
|
||||
className="bg-blue-600 h-2 rounded-full transition-all"
|
||||
style={{ width: `${Math.min((stats?.jitsi.total_minutes_today || 0) / 500 * 100, 100)}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="flex justify-between text-sm mb-1">
|
||||
<span className="text-slate-600">Aktive Chat-Raeume</span>
|
||||
<span className="font-semibold">{stats?.matrix.active_rooms || 0} / {stats?.matrix.total_rooms || 0}</span>
|
||||
</div>
|
||||
<div className="w-full bg-slate-100 rounded-full h-2">
|
||||
<div
|
||||
className="bg-purple-600 h-2 rounded-full transition-all"
|
||||
style={{ width: `${stats?.matrix.total_rooms ? ((stats.matrix.active_rooms / stats.matrix.total_rooms) * 100) : 0}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="flex justify-between text-sm mb-1">
|
||||
<span className="text-slate-600">Aktive Nutzer</span>
|
||||
<span className="font-semibold">{stats?.matrix.active_users || 0} / {stats?.matrix.total_users || 0}</span>
|
||||
</div>
|
||||
<div className="w-full bg-slate-100 rounded-full h-2">
|
||||
<div
|
||||
className="bg-green-600 h-2 rounded-full transition-all"
|
||||
style={{ width: `${stats?.matrix.total_users ? ((stats.matrix.active_users / stats.matrix.total_users) * 100) : 0}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Quick Actions */}
|
||||
<div className="mt-6 pt-4 border-t border-slate-100">
|
||||
<h4 className="text-sm font-medium text-slate-700 mb-3">Schnellaktionen</h4>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<a
|
||||
href="http://localhost:8448/_synapse/admin"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="px-3 py-1.5 text-sm bg-purple-100 text-purple-700 rounded-lg hover:bg-purple-200 transition-colors"
|
||||
>
|
||||
Synapse Admin
|
||||
</a>
|
||||
<a
|
||||
href="http://localhost:8443"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="px-3 py-1.5 text-sm bg-blue-100 text-blue-700 rounded-lg hover:bg-blue-200 transition-colors"
|
||||
>
|
||||
Jitsi Meet
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Connection Info */}
|
||||
<div className="bg-blue-50 border border-blue-200 rounded-xl p-4">
|
||||
<div className="flex gap-3">
|
||||
<svg className="w-5 h-5 text-blue-600 flex-shrink-0 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>
|
||||
<h4 className="font-semibold text-blue-900">Service Konfiguration</h4>
|
||||
<p className="text-sm text-blue-800 mt-1">
|
||||
<strong>Matrix Homeserver:</strong> http://localhost:8448 (Synapse)<br />
|
||||
<strong>Jitsi Meet:</strong> http://localhost:8443<br />
|
||||
<strong>Auto-Refresh:</strong> Alle 15 Sekunden
|
||||
</p>
|
||||
{error && (
|
||||
<p className="text-sm text-red-600 mt-2">
|
||||
<strong>Fehler:</strong> {error} - Backend nicht erreichbar
|
||||
</p>
|
||||
)}
|
||||
{stats?.last_updated && (
|
||||
<p className="text-xs text-blue-600 mt-2">
|
||||
Letzte Aktualisierung: {new Date(stats.last_updated).toLocaleString('de-DE')}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,366 @@
|
||||
'use client'
|
||||
|
||||
/**
|
||||
* Video & Chat Wizard Page
|
||||
*
|
||||
* Interactive learning and testing wizard for Matrix & Jitsi integration
|
||||
* Migrated from website/app/admin/communication/wizard
|
||||
*/
|
||||
|
||||
import { useState } from 'react'
|
||||
import Link from 'next/link'
|
||||
import {
|
||||
WizardStepper,
|
||||
WizardNavigation,
|
||||
EducationCard,
|
||||
ArchitectureContext,
|
||||
TestRunner,
|
||||
TestSummary,
|
||||
type WizardStep,
|
||||
type TestCategoryResult,
|
||||
type FullTestResults,
|
||||
type EducationContent,
|
||||
type ArchitectureContextType,
|
||||
} from '@/components/wizard'
|
||||
|
||||
// ==============================================
|
||||
// Constants
|
||||
// ==============================================
|
||||
|
||||
const BACKEND_URL = process.env.NEXT_PUBLIC_BACKEND_URL || 'http://localhost:8000'
|
||||
|
||||
const STEPS: WizardStep[] = [
|
||||
{ id: 'welcome', name: 'Willkommen', icon: '👋', status: 'pending' },
|
||||
{ id: 'api-health', name: 'API Status', icon: '💚', status: 'pending', category: 'api-health' },
|
||||
{ id: 'matrix', name: 'Matrix', icon: '💬', status: 'pending', category: 'matrix' },
|
||||
{ id: 'jitsi', name: 'Jitsi', icon: '📹', status: 'pending', category: 'jitsi' },
|
||||
{ id: 'summary', name: 'Zusammenfassung', icon: '📊', status: 'pending' },
|
||||
]
|
||||
|
||||
const EDUCATION_CONTENT: Record<string, EducationContent> = {
|
||||
'welcome': {
|
||||
title: 'Willkommen zum Video & Chat Wizard',
|
||||
content: [
|
||||
'Sichere Kommunikation ist das Rueckgrat moderner Bildungsplattformen.',
|
||||
'',
|
||||
'BreakPilot nutzt zwei Open-Source Systeme:',
|
||||
'• Matrix Synapse: Dezentraler Messenger (Ende-zu-Ende verschluesselt)',
|
||||
'• Jitsi Meet: Video-Konferenzen (WebRTC-basiert)',
|
||||
'',
|
||||
'Beide Systeme sind DSGVO-konform und self-hosted.',
|
||||
'',
|
||||
'In diesem Wizard testen wir:',
|
||||
'• Matrix Homeserver und Federation',
|
||||
'• Jitsi Video-Konferenz Server',
|
||||
'• Integration mit der Schulverwaltung',
|
||||
],
|
||||
},
|
||||
'api-health': {
|
||||
title: 'Communication API - Backend Integration',
|
||||
content: [
|
||||
'Die Communication API verbindet Matrix und Jitsi mit BreakPilot.',
|
||||
'',
|
||||
'Funktionen:',
|
||||
'• Automatische Raum-Erstellung fuer Klassen',
|
||||
'• Eltern-Lehrer DM-Raeume',
|
||||
'• Meeting-Planung mit Kalender-Integration',
|
||||
'• Benachrichtigungen bei neuen Nachrichten',
|
||||
'',
|
||||
'Endpunkte:',
|
||||
'• /api/v1/communication/admin/stats',
|
||||
'• /api/v1/communication/admin/matrix/users',
|
||||
'• /api/v1/communication/rooms',
|
||||
],
|
||||
},
|
||||
'matrix': {
|
||||
title: 'Matrix Synapse - Dezentraler Messenger',
|
||||
content: [
|
||||
'Matrix ist ein offenes Protokoll fuer sichere Kommunikation.',
|
||||
'',
|
||||
'Vorteile gegenueber WhatsApp/Teams:',
|
||||
'• Ende-zu-Ende Verschluesselung (E2EE)',
|
||||
'• Dezentral: Kein Single Point of Failure',
|
||||
'• Federation: Kommunikation mit anderen Schulen',
|
||||
'• Self-Hosted: Volle Datenkontrolle',
|
||||
'',
|
||||
'Raum-Typen in BreakPilot:',
|
||||
'• Klassen-Info (Ankuendigungen)',
|
||||
'• Elternvertreter-Raum',
|
||||
'• Lehrer-Eltern DM',
|
||||
'• Fachgruppen',
|
||||
],
|
||||
},
|
||||
'jitsi': {
|
||||
title: 'Jitsi Meet - Video-Konferenzen',
|
||||
content: [
|
||||
'Jitsi ist eine Open-Source Alternative zu Zoom/Teams.',
|
||||
'',
|
||||
'Features:',
|
||||
'• WebRTC: Keine Software-Installation noetig',
|
||||
'• Bildschirmfreigabe und Whiteboard',
|
||||
'• Breakout-Raeume fuer Gruppenarbeit',
|
||||
'• Aufzeichnung (optional, lokal)',
|
||||
'',
|
||||
'Anwendungsfaelle:',
|
||||
'• Elternsprechtage (online)',
|
||||
'• Fernunterricht bei Schulausfall',
|
||||
'• Lehrerkonferenzen',
|
||||
'• Foerdergespraeche',
|
||||
],
|
||||
},
|
||||
'summary': {
|
||||
title: 'Test-Zusammenfassung',
|
||||
content: [
|
||||
'Hier sehen Sie eine Uebersicht aller durchgefuehrten Tests:',
|
||||
'• Matrix Homeserver Verfuegbarkeit',
|
||||
'• Jitsi Server Status',
|
||||
'• API-Integration',
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
const ARCHITECTURE_CONTEXTS: Record<string, ArchitectureContextType> = {
|
||||
'api-health': {
|
||||
layer: 'api',
|
||||
services: ['backend', 'consent-service'],
|
||||
dependencies: ['PostgreSQL', 'Matrix Synapse', 'Jitsi'],
|
||||
dataFlow: ['Browser', 'FastAPI', 'Go Service', 'Matrix/Jitsi'],
|
||||
},
|
||||
'matrix': {
|
||||
layer: 'service',
|
||||
services: ['matrix'],
|
||||
dependencies: ['PostgreSQL', 'Federation', 'TURN Server'],
|
||||
dataFlow: ['Element Client', 'Matrix Synapse', 'Federation', 'PostgreSQL'],
|
||||
},
|
||||
'jitsi': {
|
||||
layer: 'service',
|
||||
services: ['jitsi'],
|
||||
dependencies: ['Prosody XMPP', 'JVB', 'TURN/STUN'],
|
||||
dataFlow: ['Browser', 'Nginx', 'Prosody', 'Jitsi Videobridge'],
|
||||
},
|
||||
}
|
||||
|
||||
// ==============================================
|
||||
// Main Component
|
||||
// ==============================================
|
||||
|
||||
export default function VideoChatWizardPage() {
|
||||
const [currentStep, setCurrentStep] = useState(0)
|
||||
const [steps, setSteps] = useState<WizardStep[]>(STEPS)
|
||||
const [categoryResults, setCategoryResults] = useState<Record<string, TestCategoryResult>>({})
|
||||
const [fullResults, setFullResults] = useState<FullTestResults | null>(null)
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
const currentStepData = steps[currentStep]
|
||||
const isTestStep = currentStepData?.category !== undefined
|
||||
const isWelcome = currentStepData?.id === 'welcome'
|
||||
const isSummary = currentStepData?.id === 'summary'
|
||||
|
||||
const runCategoryTest = async (category: string) => {
|
||||
setIsLoading(true)
|
||||
setError(null)
|
||||
|
||||
try {
|
||||
const response = await fetch(`${BACKEND_URL}/api/admin/communication-tests/${category}`, {
|
||||
method: 'POST',
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}: ${response.statusText}`)
|
||||
}
|
||||
|
||||
const result: TestCategoryResult = await response.json()
|
||||
setCategoryResults((prev) => ({ ...prev, [category]: result }))
|
||||
|
||||
setSteps((prev) =>
|
||||
prev.map((step) =>
|
||||
step.category === category
|
||||
? { ...step, status: result.failed === 0 ? 'completed' : 'failed' }
|
||||
: step
|
||||
)
|
||||
)
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Unbekannter Fehler')
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const runAllTests = async () => {
|
||||
setIsLoading(true)
|
||||
setError(null)
|
||||
|
||||
try {
|
||||
const response = await fetch(`${BACKEND_URL}/api/admin/communication-tests/run-all`, {
|
||||
method: 'POST',
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}: ${response.statusText}`)
|
||||
}
|
||||
|
||||
const results: FullTestResults = await response.json()
|
||||
setFullResults(results)
|
||||
|
||||
setSteps((prev) =>
|
||||
prev.map((step) => {
|
||||
if (step.category) {
|
||||
const catResult = results.categories.find((c) => c.category === step.category)
|
||||
if (catResult) {
|
||||
return { ...step, status: catResult.failed === 0 ? 'completed' : 'failed' }
|
||||
}
|
||||
}
|
||||
return step
|
||||
})
|
||||
)
|
||||
|
||||
const newCategoryResults: Record<string, TestCategoryResult> = {}
|
||||
results.categories.forEach((cat) => {
|
||||
newCategoryResults[cat.category] = cat
|
||||
})
|
||||
setCategoryResults(newCategoryResults)
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Unbekannter Fehler')
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const goToNext = () => {
|
||||
if (currentStep < steps.length - 1) {
|
||||
setSteps((prev) =>
|
||||
prev.map((step, idx) =>
|
||||
idx === currentStep && step.status === 'pending'
|
||||
? { ...step, status: 'completed' }
|
||||
: step
|
||||
)
|
||||
)
|
||||
setCurrentStep((prev) => prev + 1)
|
||||
}
|
||||
}
|
||||
|
||||
const goToPrev = () => {
|
||||
if (currentStep > 0) {
|
||||
setCurrentStep((prev) => prev - 1)
|
||||
}
|
||||
}
|
||||
|
||||
const handleStepClick = (index: number) => {
|
||||
if (index <= currentStep || steps[index - 1]?.status !== 'pending') {
|
||||
setCurrentStep(index)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* Header */}
|
||||
<div className="bg-white rounded-lg border border-slate-200 p-4 mb-6 flex items-center justify-between">
|
||||
<div className="flex items-center">
|
||||
<span className="text-3xl mr-3">💬</span>
|
||||
<div>
|
||||
<h2 className="text-lg font-bold text-gray-800">Video & Chat Test Wizard</h2>
|
||||
<p className="text-sm text-gray-600">Matrix Messenger & Jitsi Video</p>
|
||||
</div>
|
||||
</div>
|
||||
<Link href="/communication/video-chat" className="text-blue-600 hover:text-blue-800 text-sm">
|
||||
← Zurueck zu Video & Chat
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* Stepper */}
|
||||
<div className="bg-white rounded-lg border border-slate-200 p-6 mb-6">
|
||||
<WizardStepper steps={steps} currentStep={currentStep} onStepClick={handleStepClick} />
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="bg-white rounded-lg border border-slate-200 p-6">
|
||||
<div className="flex items-center mb-6">
|
||||
<span className="text-3xl mr-3">{currentStepData?.icon}</span>
|
||||
<div>
|
||||
<h2 className="text-xl font-bold text-gray-800">
|
||||
Schritt {currentStep + 1}: {currentStepData?.name}
|
||||
</h2>
|
||||
<p className="text-gray-500 text-sm">
|
||||
{currentStep + 1} von {steps.length}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<EducationCard content={EDUCATION_CONTENT[currentStepData?.id || '']} />
|
||||
|
||||
{isTestStep && currentStepData?.category && ARCHITECTURE_CONTEXTS[currentStepData.category] && (
|
||||
<ArchitectureContext
|
||||
context={ARCHITECTURE_CONTEXTS[currentStepData.category]}
|
||||
currentStep={currentStepData.name}
|
||||
/>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div className="bg-red-50 border border-red-200 text-red-700 rounded-lg p-4 mb-6">
|
||||
<strong>Fehler:</strong> {error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isWelcome && (
|
||||
<div className="text-center py-8">
|
||||
<button
|
||||
onClick={goToNext}
|
||||
className="bg-blue-600 text-white px-8 py-3 rounded-lg font-medium hover:bg-blue-700 transition-colors"
|
||||
>
|
||||
Wizard starten
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isTestStep && currentStepData?.category && (
|
||||
<TestRunner
|
||||
category={currentStepData.category}
|
||||
categoryResult={categoryResults[currentStepData.category]}
|
||||
isLoading={isLoading}
|
||||
onRunTests={() => runCategoryTest(currentStepData.category!)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{isSummary && (
|
||||
<div>
|
||||
{!fullResults ? (
|
||||
<div className="text-center py-8">
|
||||
<p className="text-gray-600 mb-4">
|
||||
Fuehren Sie alle Tests aus um eine Zusammenfassung zu sehen.
|
||||
</p>
|
||||
<button
|
||||
onClick={runAllTests}
|
||||
disabled={isLoading}
|
||||
className={`px-6 py-3 rounded-lg font-medium transition-colors ${
|
||||
isLoading
|
||||
? 'bg-gray-400 cursor-not-allowed'
|
||||
: 'bg-blue-600 text-white hover:bg-blue-700'
|
||||
}`}
|
||||
>
|
||||
{isLoading ? 'Alle Tests laufen...' : 'Alle Tests ausfuehren'}
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<TestSummary results={fullResults} />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<WizardNavigation
|
||||
currentStep={currentStep}
|
||||
totalSteps={steps.length}
|
||||
onPrev={goToPrev}
|
||||
onNext={goToNext}
|
||||
showNext={!isSummary}
|
||||
isLoading={isLoading}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="text-center text-gray-500 text-sm mt-6">
|
||||
Diese Tests pruefen die Matrix- und Jitsi-Integration.
|
||||
Bei Fragen wenden Sie sich an das IT-Team.
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
'use client'
|
||||
|
||||
import { SDKProvider } from '@/lib/sdk/context'
|
||||
import { CatalogManagerContent } from '@/components/catalog-manager/CatalogManagerContent'
|
||||
|
||||
export default function AdminCatalogManagerPage() {
|
||||
return (
|
||||
<SDKProvider>
|
||||
<CatalogManagerContent />
|
||||
</SDKProvider>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { navigation, metaModules } from '@/lib/navigation'
|
||||
import { getStoredRole, isCategoryVisibleForRole, RoleId } from '@/lib/roles'
|
||||
import { CategoryCard } from '@/components/common/ModuleCard'
|
||||
import { InfoNote } from '@/components/common/InfoBox'
|
||||
import { ServiceStatus } from '@/components/common/ServiceStatus'
|
||||
import { NightModeWidget } from '@/components/dashboard/NightModeWidget'
|
||||
import Link from 'next/link'
|
||||
|
||||
interface Stats {
|
||||
activeDocuments: number
|
||||
openDSR: number
|
||||
registeredUsers: number
|
||||
totalConsents: number
|
||||
gpuInstances: number
|
||||
}
|
||||
|
||||
export default function DashboardPage() {
|
||||
const [stats, setStats] = useState<Stats>({
|
||||
activeDocuments: 0,
|
||||
openDSR: 0,
|
||||
registeredUsers: 0,
|
||||
totalConsents: 0,
|
||||
gpuInstances: 0,
|
||||
})
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [currentRole, setCurrentRole] = useState<RoleId | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
const role = getStoredRole()
|
||||
setCurrentRole(role)
|
||||
|
||||
// Load stats
|
||||
const loadStats = async () => {
|
||||
try {
|
||||
const response = await fetch('http://localhost:8081/api/v1/admin/stats')
|
||||
if (response.ok) {
|
||||
const data = await response.json()
|
||||
setStats({
|
||||
activeDocuments: data.documents_count || 0,
|
||||
openDSR: data.open_dsr_count || 0,
|
||||
registeredUsers: data.users_count || 0,
|
||||
totalConsents: data.consents_count || 0,
|
||||
gpuInstances: 0,
|
||||
})
|
||||
}
|
||||
} catch (error) {
|
||||
console.log('Stats not available')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
loadStats()
|
||||
}, [])
|
||||
|
||||
const statCards = [
|
||||
{ label: 'Aktive Dokumente', value: stats.activeDocuments, color: 'text-green-600' },
|
||||
{ label: 'Offene DSR', value: stats.openDSR, color: stats.openDSR > 0 ? 'text-orange-600' : 'text-slate-600' },
|
||||
{ label: 'Registrierte Nutzer', value: stats.registeredUsers, color: 'text-blue-600' },
|
||||
{ label: 'Zustimmungen', value: stats.totalConsents, color: 'text-purple-600' },
|
||||
{ label: 'GPU Instanzen', value: stats.gpuInstances, color: 'text-pink-600' },
|
||||
]
|
||||
|
||||
const visibleCategories = currentRole
|
||||
? navigation.filter(cat => isCategoryVisibleForRole(cat.id, currentRole))
|
||||
: navigation
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* Stats Grid */}
|
||||
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-5 gap-4 mb-8">
|
||||
{statCards.map((stat) => (
|
||||
<div key={stat.label} className="bg-white rounded-xl border border-slate-200 p-4 shadow-sm">
|
||||
<div className={`text-3xl font-bold ${stat.color}`}>
|
||||
{loading ? '-' : stat.value}
|
||||
</div>
|
||||
<div className="text-sm text-slate-500 mt-1">{stat.label}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Categories */}
|
||||
<h2 className="text-lg font-semibold text-slate-900 mb-4">Bereiche</h2>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4 mb-8">
|
||||
{visibleCategories.map((category) => (
|
||||
<CategoryCard key={category.id} category={category} />
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Quick Links */}
|
||||
<h2 className="text-lg font-semibold text-slate-900 mb-4">Schnellzugriff</h2>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4 mb-8">
|
||||
{metaModules.filter(m => m.id !== 'dashboard').map((module) => (
|
||||
<Link
|
||||
key={module.id}
|
||||
href={module.href}
|
||||
className="flex items-center gap-3 p-4 bg-white rounded-xl border border-slate-200 hover:border-primary-300 hover:shadow-md transition-all"
|
||||
>
|
||||
<div className="w-10 h-10 bg-slate-100 rounded-lg flex items-center justify-center">
|
||||
{module.id === 'onboarding' && '📖'}
|
||||
{module.id === 'backlog' && '📋'}
|
||||
{module.id === 'rbac' && '👥'}
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-medium text-slate-900">{module.name}</h3>
|
||||
<p className="text-sm text-slate-500">{module.description}</p>
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Infrastructure & System Status */}
|
||||
<h2 className="text-lg font-semibold text-slate-900 mb-4">Infrastruktur</h2>
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-8">
|
||||
{/* Night Mode Widget */}
|
||||
<NightModeWidget />
|
||||
|
||||
{/* System Status */}
|
||||
<ServiceStatus />
|
||||
</div>
|
||||
|
||||
{/* Recent Activity */}
|
||||
<h2 className="text-lg font-semibold text-slate-900 mb-4">Aktivitaet</h2>
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
{/* Recent DSR */}
|
||||
<div className="bg-white rounded-xl border border-slate-200 shadow-sm">
|
||||
<div className="px-4 py-3 border-b border-slate-200 flex items-center justify-between">
|
||||
<h3 className="font-semibold text-slate-900">Neueste Datenschutzanfragen</h3>
|
||||
<Link href="/sdk/dsr" className="text-sm text-primary-600 hover:text-primary-700">
|
||||
Alle anzeigen
|
||||
</Link>
|
||||
</div>
|
||||
<div className="p-4">
|
||||
<p className="text-sm text-slate-500 text-center py-4">
|
||||
Keine offenen Anfragen
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Info Box */}
|
||||
<div className="mt-8">
|
||||
<InfoNote title="Admin v2 - Neues Frontend">
|
||||
<p>
|
||||
Dieses neue Admin-Frontend bietet eine verbesserte Navigation mit Kategorien und Rollen-basiertem Zugriff.
|
||||
Das alte Admin-Frontend ist weiterhin unter Port 3000 verfuegbar.
|
||||
</p>
|
||||
</InfoNote>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,271 @@
|
||||
import { DevPortalLayout, ApiEndpoint, CodeBlock, ParameterTable, InfoBox } from '@/components/developers/DevPortalLayout'
|
||||
|
||||
export default function ExportApiPage() {
|
||||
return (
|
||||
<DevPortalLayout
|
||||
title="Export API"
|
||||
description="Exportieren Sie Compliance-Daten in verschiedenen Formaten"
|
||||
>
|
||||
<h2>Uebersicht</h2>
|
||||
<p>
|
||||
Die Export API ermoeglicht den Download aller Compliance-Daten in
|
||||
verschiedenen Formaten fuer Audits, Dokumentation und Archivierung.
|
||||
</p>
|
||||
|
||||
<h2>Unterstuetzte Formate</h2>
|
||||
<div className="my-4 overflow-x-auto not-prose">
|
||||
<table className="min-w-full divide-y divide-gray-200">
|
||||
<thead className="bg-gray-50">
|
||||
<tr>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Format</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Beschreibung</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Use Case</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="bg-white divide-y divide-gray-200 text-sm">
|
||||
<tr>
|
||||
<td className="px-4 py-3 font-mono">json</td>
|
||||
<td className="px-4 py-3 text-gray-600">Kompletter State als JSON</td>
|
||||
<td className="px-4 py-3 text-gray-600">Backup, Migration, API-Integration</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="px-4 py-3 font-mono">pdf</td>
|
||||
<td className="px-4 py-3 text-gray-600">Formatierter PDF-Report</td>
|
||||
<td className="px-4 py-3 text-gray-600">Audits, Management-Reports</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="px-4 py-3 font-mono">zip</td>
|
||||
<td className="px-4 py-3 text-gray-600">Alle Dokumente als ZIP-Archiv</td>
|
||||
<td className="px-4 py-3 text-gray-600">Vollstaendige Dokumentation</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<h2>GET /export</h2>
|
||||
<p>Exportiert den aktuellen State im gewuenschten Format.</p>
|
||||
|
||||
<h3>Query-Parameter</h3>
|
||||
<ParameterTable
|
||||
parameters={[
|
||||
{
|
||||
name: 'format',
|
||||
type: 'string',
|
||||
required: true,
|
||||
description: 'Export-Format: json, pdf, zip',
|
||||
},
|
||||
{
|
||||
name: 'tenantId',
|
||||
type: 'string',
|
||||
required: true,
|
||||
description: 'Tenant-ID',
|
||||
},
|
||||
{
|
||||
name: 'sections',
|
||||
type: 'string',
|
||||
required: false,
|
||||
description: 'Kommaseparierte Liste: useCases,risks,controls,dsfa,toms,vvt (default: alle)',
|
||||
},
|
||||
{
|
||||
name: 'phase',
|
||||
type: 'number',
|
||||
required: false,
|
||||
description: 'Nur bestimmte Phase exportieren: 1 oder 2',
|
||||
},
|
||||
{
|
||||
name: 'language',
|
||||
type: 'string',
|
||||
required: false,
|
||||
description: 'Sprache fuer PDF: de, en (default: de)',
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
<h2>JSON Export</h2>
|
||||
|
||||
<h3>Request</h3>
|
||||
<CodeBlock language="bash" filename="cURL">
|
||||
{`curl -X GET "https://api.breakpilot.io/sdk/v1/export?format=json&tenantId=your-tenant-id" \\
|
||||
-H "Authorization: Bearer YOUR_API_KEY" \\
|
||||
-o compliance-export.json`}
|
||||
</CodeBlock>
|
||||
|
||||
<h3>Response</h3>
|
||||
<CodeBlock language="json" filename="compliance-export.json">
|
||||
{`{
|
||||
"exportedAt": "2026-02-04T12:00:00Z",
|
||||
"version": "1.0.0",
|
||||
"tenantId": "your-tenant-id",
|
||||
"state": {
|
||||
"currentPhase": 2,
|
||||
"currentStep": "dsfa",
|
||||
"completedSteps": [...],
|
||||
"useCases": [...],
|
||||
"risks": [...],
|
||||
"controls": [...],
|
||||
"dsfa": {...},
|
||||
"toms": [...],
|
||||
"vvt": [...]
|
||||
},
|
||||
"meta": {
|
||||
"completionPercentage": 75,
|
||||
"lastModified": "2026-02-04T11:55:00Z"
|
||||
}
|
||||
}`}
|
||||
</CodeBlock>
|
||||
|
||||
<h2>PDF Export</h2>
|
||||
|
||||
<h3>Request</h3>
|
||||
<CodeBlock language="bash" filename="cURL">
|
||||
{`curl -X GET "https://api.breakpilot.io/sdk/v1/export?format=pdf&tenantId=your-tenant-id§ions=dsfa,toms" \\
|
||||
-H "Authorization: Bearer YOUR_API_KEY" \\
|
||||
-o compliance-report.pdf`}
|
||||
</CodeBlock>
|
||||
|
||||
<h3>PDF Inhalt</h3>
|
||||
<p>Das generierte PDF enthaelt:</p>
|
||||
<ul>
|
||||
<li>Deckblatt mit Tenant-Info und Exportdatum</li>
|
||||
<li>Inhaltsverzeichnis</li>
|
||||
<li>Executive Summary mit Fortschritt</li>
|
||||
<li>Use Case Uebersicht</li>
|
||||
<li>Risikoanalyse mit Matrix-Visualisierung</li>
|
||||
<li>DSFA (falls generiert)</li>
|
||||
<li>TOM-Katalog</li>
|
||||
<li>VVT-Auszug</li>
|
||||
<li>Checkpoint-Status</li>
|
||||
</ul>
|
||||
|
||||
<InfoBox type="info" title="PDF Styling">
|
||||
Das PDF folgt einem professionellen Audit-Layout mit Corporate Design.
|
||||
Enterprise-Kunden koennen ein Custom-Logo und Farbschema konfigurieren.
|
||||
</InfoBox>
|
||||
|
||||
<h2>ZIP Export</h2>
|
||||
|
||||
<h3>Request</h3>
|
||||
<CodeBlock language="bash" filename="cURL">
|
||||
{`curl -X GET "https://api.breakpilot.io/sdk/v1/export?format=zip&tenantId=your-tenant-id" \\
|
||||
-H "Authorization: Bearer YOUR_API_KEY" \\
|
||||
-o compliance-export.zip`}
|
||||
</CodeBlock>
|
||||
|
||||
<h3>ZIP Struktur</h3>
|
||||
<CodeBlock language="text" filename="compliance-export.zip">
|
||||
{`compliance-export/
|
||||
├── README.md
|
||||
├── state.json # Kompletter State
|
||||
├── summary.pdf # Executive Summary
|
||||
├── use-cases/
|
||||
│ ├── uc-1-ki-analyse.json
|
||||
│ └── uc-2-chatbot.json
|
||||
├── risks/
|
||||
│ ├── risk-matrix.pdf
|
||||
│ └── risks.json
|
||||
├── documents/
|
||||
│ ├── dsfa.pdf
|
||||
│ ├── dsfa.json
|
||||
│ ├── toms.pdf
|
||||
│ ├── toms.json
|
||||
│ ├── vvt.pdf
|
||||
│ └── vvt.json
|
||||
├── checkpoints/
|
||||
│ └── checkpoint-status.json
|
||||
└── audit-trail/
|
||||
└── changes.json`}
|
||||
</CodeBlock>
|
||||
|
||||
<h2>SDK Integration</h2>
|
||||
<CodeBlock language="typescript" filename="export-examples.ts">
|
||||
{`import { useSDK, exportToPDF, exportToZIP, downloadExport } from '@breakpilot/compliance-sdk'
|
||||
|
||||
// Option 1: Ueber den Hook
|
||||
function ExportButton() {
|
||||
const { exportState } = useSDK()
|
||||
|
||||
const handlePDFExport = async () => {
|
||||
const blob = await exportState('pdf')
|
||||
downloadExport(blob, 'compliance-report.pdf')
|
||||
}
|
||||
|
||||
const handleZIPExport = async () => {
|
||||
const blob = await exportState('zip')
|
||||
downloadExport(blob, 'compliance-export.zip')
|
||||
}
|
||||
|
||||
const handleJSONExport = async () => {
|
||||
const blob = await exportState('json')
|
||||
downloadExport(blob, 'compliance-state.json')
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex gap-2">
|
||||
<button onClick={handlePDFExport}>PDF Export</button>
|
||||
<button onClick={handleZIPExport}>ZIP Export</button>
|
||||
<button onClick={handleJSONExport}>JSON Export</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Option 2: Direkte Funktionen
|
||||
async function exportManually(state: SDKState) {
|
||||
// PDF generieren
|
||||
const pdfBlob = await exportToPDF(state)
|
||||
downloadExport(pdfBlob, \`compliance-\${Date.now()}.pdf\`)
|
||||
|
||||
// ZIP generieren
|
||||
const zipBlob = await exportToZIP(state)
|
||||
downloadExport(zipBlob, \`compliance-\${Date.now()}.zip\`)
|
||||
}`}
|
||||
</CodeBlock>
|
||||
|
||||
<h2>Command Bar Integration</h2>
|
||||
<p>
|
||||
Exporte sind auch ueber die Command Bar verfuegbar:
|
||||
</p>
|
||||
<CodeBlock language="text" filename="Command Bar">
|
||||
{`Cmd+K → "pdf" → "Als PDF exportieren"
|
||||
Cmd+K → "zip" → "Als ZIP exportieren"
|
||||
Cmd+K → "json" → "Als JSON exportieren"`}
|
||||
</CodeBlock>
|
||||
|
||||
<h2>Automatisierte Exports</h2>
|
||||
<p>
|
||||
Fuer regelmaessige Backups oder CI/CD-Integration:
|
||||
</p>
|
||||
<CodeBlock language="bash" filename="Cron Job">
|
||||
{`# Taeglicher Backup-Export um 02:00 Uhr
|
||||
0 2 * * * curl -X GET "https://api.breakpilot.io/sdk/v1/export?format=zip&tenantId=my-tenant" \\
|
||||
-H "Authorization: Bearer $API_KEY" \\
|
||||
-o "/backups/compliance-$(date +%Y%m%d).zip"`}
|
||||
</CodeBlock>
|
||||
|
||||
<InfoBox type="warning" title="Dateigröße">
|
||||
ZIP-Exporte koennen bei umfangreichen States mehrere MB gross werden.
|
||||
Die API hat ein Timeout von 60 Sekunden. Bei sehr grossen States
|
||||
verwenden Sie den asynchronen Export-Endpoint (Enterprise).
|
||||
</InfoBox>
|
||||
|
||||
<h2>Fehlerbehandlung</h2>
|
||||
<CodeBlock language="typescript" filename="error-handling.ts">
|
||||
{`import { exportState } from '@breakpilot/compliance-sdk'
|
||||
|
||||
try {
|
||||
const blob = await exportState('pdf')
|
||||
downloadExport(blob, 'report.pdf')
|
||||
} catch (error) {
|
||||
if (error.code === 'EMPTY_STATE') {
|
||||
console.error('Keine Daten zum Exportieren vorhanden')
|
||||
} else if (error.code === 'GENERATION_FAILED') {
|
||||
console.error('PDF-Generierung fehlgeschlagen:', error.message)
|
||||
} else if (error.code === 'TIMEOUT') {
|
||||
console.error('Export-Timeout - versuchen Sie ZIP fuer grosse States')
|
||||
} else {
|
||||
console.error('Unbekannter Fehler:', error)
|
||||
}
|
||||
}`}
|
||||
</CodeBlock>
|
||||
</DevPortalLayout>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,381 @@
|
||||
import { DevPortalLayout, ApiEndpoint, CodeBlock, ParameterTable, InfoBox } from '@/components/developers/DevPortalLayout'
|
||||
|
||||
export default function GenerateApiPage() {
|
||||
return (
|
||||
<DevPortalLayout
|
||||
title="Generation API"
|
||||
description="Automatische Generierung von Compliance-Dokumenten"
|
||||
>
|
||||
<h2>Uebersicht</h2>
|
||||
<p>
|
||||
Die Generation API nutzt LLM-Technologie (Claude) zur automatischen Erstellung
|
||||
von Compliance-Dokumenten basierend auf Ihrem SDK-State:
|
||||
</p>
|
||||
<ul>
|
||||
<li><strong>DSFA</strong> - Datenschutz-Folgenabschaetzung</li>
|
||||
<li><strong>TOM</strong> - Technische und Organisatorische Massnahmen</li>
|
||||
<li><strong>VVT</strong> - Verarbeitungsverzeichnis nach Art. 30 DSGVO</li>
|
||||
</ul>
|
||||
|
||||
<InfoBox type="info" title="LLM-Model">
|
||||
Die Generierung verwendet Claude 3.5 Sonnet fuer optimale Qualitaet
|
||||
bei deutschen Rechtstexten. RAG-Context wird automatisch einbezogen.
|
||||
</InfoBox>
|
||||
|
||||
<h2>POST /generate/dsfa</h2>
|
||||
<p>Generiert eine Datenschutz-Folgenabschaetzung basierend auf dem aktuellen State.</p>
|
||||
|
||||
<h3>Request Body</h3>
|
||||
<ParameterTable
|
||||
parameters={[
|
||||
{
|
||||
name: 'tenantId',
|
||||
type: 'string',
|
||||
required: true,
|
||||
description: 'Tenant-ID fuer State-Zugriff',
|
||||
},
|
||||
{
|
||||
name: 'useCaseId',
|
||||
type: 'string',
|
||||
required: false,
|
||||
description: 'Optional: Nur fuer bestimmten Use Case generieren',
|
||||
},
|
||||
{
|
||||
name: 'includeRisks',
|
||||
type: 'boolean',
|
||||
required: false,
|
||||
description: 'Risiken aus Risk Matrix einbeziehen (default: true)',
|
||||
},
|
||||
{
|
||||
name: 'includeControls',
|
||||
type: 'boolean',
|
||||
required: false,
|
||||
description: 'Bestehende Controls referenzieren (default: true)',
|
||||
},
|
||||
{
|
||||
name: 'language',
|
||||
type: 'string',
|
||||
required: false,
|
||||
description: 'Sprache: de, en (default: de)',
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
<h3>Request</h3>
|
||||
<CodeBlock language="bash" filename="cURL">
|
||||
{`curl -X POST "https://api.breakpilot.io/sdk/v1/generate/dsfa" \\
|
||||
-H "Authorization: Bearer YOUR_API_KEY" \\
|
||||
-H "Content-Type: application/json" \\
|
||||
-d '{
|
||||
"tenantId": "your-tenant-id",
|
||||
"useCaseId": "uc-ki-kundenanalyse",
|
||||
"includeRisks": true,
|
||||
"includeControls": true,
|
||||
"language": "de"
|
||||
}'`}
|
||||
</CodeBlock>
|
||||
|
||||
<h3>Response (200 OK)</h3>
|
||||
<CodeBlock language="json" filename="Response">
|
||||
{`{
|
||||
"success": true,
|
||||
"data": {
|
||||
"dsfa": {
|
||||
"id": "dsfa-2026-02-04-abc123",
|
||||
"version": "1.0",
|
||||
"status": "DRAFT",
|
||||
"createdAt": "2026-02-04T12:00:00Z",
|
||||
"useCase": {
|
||||
"id": "uc-ki-kundenanalyse",
|
||||
"name": "KI-gestuetzte Kundenanalyse",
|
||||
"description": "Analyse von Kundenverhalten mittels ML..."
|
||||
},
|
||||
"sections": {
|
||||
"systematicDescription": {
|
||||
"title": "1. Systematische Beschreibung",
|
||||
"content": "Die geplante Verarbeitungstaetigkeit umfasst..."
|
||||
},
|
||||
"necessityAssessment": {
|
||||
"title": "2. Bewertung der Notwendigkeit",
|
||||
"content": "Die Verarbeitung ist notwendig fuer..."
|
||||
},
|
||||
"riskAssessment": {
|
||||
"title": "3. Risikobewertung",
|
||||
"risks": [
|
||||
{
|
||||
"id": "risk-1",
|
||||
"title": "Unbefugter Datenzugriff",
|
||||
"severity": "HIGH",
|
||||
"likelihood": 3,
|
||||
"impact": 4,
|
||||
"description": "...",
|
||||
"mitigations": ["Verschluesselung", "Zugriffskontrolle"]
|
||||
}
|
||||
]
|
||||
},
|
||||
"mitigationMeasures": {
|
||||
"title": "4. Abhilfemassnahmen",
|
||||
"controls": [...]
|
||||
},
|
||||
"stakeholderConsultation": {
|
||||
"title": "5. Einbeziehung Betroffener",
|
||||
"content": "..."
|
||||
},
|
||||
"dpoOpinion": {
|
||||
"title": "6. Stellungnahme des DSB",
|
||||
"content": "Ausstehend - Freigabe erforderlich"
|
||||
}
|
||||
},
|
||||
"conclusion": {
|
||||
"overallRisk": "MEDIUM",
|
||||
"recommendation": "PROCEED_WITH_CONDITIONS",
|
||||
"conditions": [
|
||||
"Implementierung der TOM-Empfehlungen",
|
||||
"Regelmaessige Ueberpruefung"
|
||||
]
|
||||
}
|
||||
},
|
||||
"generationMeta": {
|
||||
"model": "claude-3.5-sonnet",
|
||||
"ragContextUsed": true,
|
||||
"tokensUsed": 4250,
|
||||
"durationMs": 8500
|
||||
}
|
||||
}
|
||||
}`}
|
||||
</CodeBlock>
|
||||
|
||||
<h2>POST /generate/tom</h2>
|
||||
<p>Generiert technische und organisatorische Massnahmen.</p>
|
||||
|
||||
<h3>Request Body</h3>
|
||||
<ParameterTable
|
||||
parameters={[
|
||||
{
|
||||
name: 'tenantId',
|
||||
type: 'string',
|
||||
required: true,
|
||||
description: 'Tenant-ID',
|
||||
},
|
||||
{
|
||||
name: 'categories',
|
||||
type: 'string[]',
|
||||
required: false,
|
||||
description: 'TOM-Kategorien: access_control, encryption, pseudonymization, etc.',
|
||||
},
|
||||
{
|
||||
name: 'basedOnRisks',
|
||||
type: 'boolean',
|
||||
required: false,
|
||||
description: 'TOMs basierend auf Risk Matrix generieren (default: true)',
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
<h3>Request</h3>
|
||||
<CodeBlock language="bash" filename="cURL">
|
||||
{`curl -X POST "https://api.breakpilot.io/sdk/v1/generate/tom" \\
|
||||
-H "Authorization: Bearer YOUR_API_KEY" \\
|
||||
-H "Content-Type: application/json" \\
|
||||
-d '{
|
||||
"tenantId": "your-tenant-id",
|
||||
"categories": ["access_control", "encryption", "backup"],
|
||||
"basedOnRisks": true
|
||||
}'`}
|
||||
</CodeBlock>
|
||||
|
||||
<h3>Response (200 OK)</h3>
|
||||
<CodeBlock language="json" filename="Response">
|
||||
{`{
|
||||
"success": true,
|
||||
"data": {
|
||||
"toms": [
|
||||
{
|
||||
"id": "tom-1",
|
||||
"category": "access_control",
|
||||
"categoryLabel": "Zugangskontrolle",
|
||||
"title": "Multi-Faktor-Authentifizierung",
|
||||
"description": "Implementierung von MFA fuer alle Systemzugaenge",
|
||||
"technicalMeasures": [
|
||||
"TOTP-basierte 2FA",
|
||||
"Hardware Security Keys (FIDO2)"
|
||||
],
|
||||
"organizationalMeasures": [
|
||||
"Schulung der Mitarbeiter",
|
||||
"Dokumentation der Zugaenge"
|
||||
],
|
||||
"article32Reference": "Art. 32 Abs. 1 lit. b DSGVO",
|
||||
"priority": "HIGH",
|
||||
"implementationStatus": "PLANNED"
|
||||
},
|
||||
{
|
||||
"id": "tom-2",
|
||||
"category": "encryption",
|
||||
"categoryLabel": "Verschluesselung",
|
||||
"title": "Transportverschluesselung",
|
||||
"description": "TLS 1.3 fuer alle Datenuebert\\\\ragungen",
|
||||
"technicalMeasures": [
|
||||
"TLS 1.3 mit PFS",
|
||||
"HSTS Header"
|
||||
],
|
||||
"organizationalMeasures": [
|
||||
"Zertifikatsmanagement",
|
||||
"Regelmaessige Audits"
|
||||
],
|
||||
"article32Reference": "Art. 32 Abs. 1 lit. a DSGVO",
|
||||
"priority": "CRITICAL",
|
||||
"implementationStatus": "IMPLEMENTED"
|
||||
}
|
||||
],
|
||||
"summary": {
|
||||
"totalMeasures": 20,
|
||||
"byCategory": {
|
||||
"access_control": 5,
|
||||
"encryption": 4,
|
||||
"backup": 3,
|
||||
"monitoring": 4,
|
||||
"incident_response": 4
|
||||
},
|
||||
"implementationProgress": {
|
||||
"implemented": 12,
|
||||
"in_progress": 5,
|
||||
"planned": 3
|
||||
}
|
||||
}
|
||||
}
|
||||
}`}
|
||||
</CodeBlock>
|
||||
|
||||
<h2>POST /generate/vvt</h2>
|
||||
<p>Generiert ein Verarbeitungsverzeichnis nach Art. 30 DSGVO.</p>
|
||||
|
||||
<h3>Request Body</h3>
|
||||
<ParameterTable
|
||||
parameters={[
|
||||
{
|
||||
name: 'tenantId',
|
||||
type: 'string',
|
||||
required: true,
|
||||
description: 'Tenant-ID',
|
||||
},
|
||||
{
|
||||
name: 'organizationInfo',
|
||||
type: 'object',
|
||||
required: false,
|
||||
description: 'Organisationsdaten (Name, Anschrift, DSB-Kontakt)',
|
||||
},
|
||||
{
|
||||
name: 'includeRetentionPolicies',
|
||||
type: 'boolean',
|
||||
required: false,
|
||||
description: 'Loeschfristen einbeziehen (default: true)',
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
<h3>Request</h3>
|
||||
<CodeBlock language="bash" filename="cURL">
|
||||
{`curl -X POST "https://api.breakpilot.io/sdk/v1/generate/vvt" \\
|
||||
-H "Authorization: Bearer YOUR_API_KEY" \\
|
||||
-H "Content-Type: application/json" \\
|
||||
-d '{
|
||||
"tenantId": "your-tenant-id",
|
||||
"organizationInfo": {
|
||||
"name": "Beispiel GmbH",
|
||||
"address": "Musterstrasse 1, 10115 Berlin",
|
||||
"dpoContact": "datenschutz@beispiel.de"
|
||||
},
|
||||
"includeRetentionPolicies": true
|
||||
}'`}
|
||||
</CodeBlock>
|
||||
|
||||
<h3>Response (200 OK)</h3>
|
||||
<CodeBlock language="json" filename="Response">
|
||||
{`{
|
||||
"success": true,
|
||||
"data": {
|
||||
"vvt": {
|
||||
"id": "vvt-2026-02-04",
|
||||
"version": "1.0",
|
||||
"organization": {
|
||||
"name": "Beispiel GmbH",
|
||||
"address": "Musterstrasse 1, 10115 Berlin",
|
||||
"dpoContact": "datenschutz@beispiel.de"
|
||||
},
|
||||
"processingActivities": [
|
||||
{
|
||||
"id": "pa-1",
|
||||
"name": "Kundendatenverarbeitung",
|
||||
"purpose": "Vertragserfuellung und Kundenservice",
|
||||
"legalBasis": "Art. 6 Abs. 1 lit. b DSGVO",
|
||||
"dataCategories": ["Kontaktdaten", "Vertragsdaten", "Zahlungsdaten"],
|
||||
"dataSubjects": ["Kunden", "Interessenten"],
|
||||
"recipients": ["Zahlungsdienstleister", "Versanddienstleister"],
|
||||
"thirdCountryTransfers": {
|
||||
"exists": false,
|
||||
"countries": [],
|
||||
"safeguards": null
|
||||
},
|
||||
"retentionPeriod": "10 Jahre nach Vertragsende (HGB)",
|
||||
"technicalMeasures": ["Verschluesselung", "Zugriffskontrolle"]
|
||||
}
|
||||
],
|
||||
"lastUpdated": "2026-02-04T12:00:00Z"
|
||||
}
|
||||
}
|
||||
}`}
|
||||
</CodeBlock>
|
||||
|
||||
<h2>SDK Integration</h2>
|
||||
<CodeBlock language="typescript" filename="document-generation.ts">
|
||||
{`import { getSDKBackendClient } from '@breakpilot/compliance-sdk'
|
||||
|
||||
const client = getSDKBackendClient()
|
||||
|
||||
// DSFA generieren
|
||||
async function generateDSFA(useCaseId: string) {
|
||||
const dsfa = await client.generateDSFA({
|
||||
useCaseId,
|
||||
includeRisks: true,
|
||||
includeControls: true,
|
||||
})
|
||||
|
||||
console.log('DSFA generiert:', dsfa.id)
|
||||
console.log('Gesamtrisiko:', dsfa.conclusion.overallRisk)
|
||||
return dsfa
|
||||
}
|
||||
|
||||
// TOMs generieren
|
||||
async function generateTOMs() {
|
||||
const toms = await client.generateTOM({
|
||||
categories: ['access_control', 'encryption'],
|
||||
basedOnRisks: true,
|
||||
})
|
||||
|
||||
console.log(\`\${toms.length} TOMs generiert\`)
|
||||
return toms
|
||||
}
|
||||
|
||||
// VVT generieren
|
||||
async function generateVVT() {
|
||||
const vvt = await client.generateVVT({
|
||||
organizationInfo: {
|
||||
name: 'Beispiel GmbH',
|
||||
address: 'Musterstrasse 1',
|
||||
dpoContact: 'dpo@beispiel.de',
|
||||
},
|
||||
})
|
||||
|
||||
console.log(\`VVT mit \${vvt.processingActivities.length} Verarbeitungen\`)
|
||||
return vvt
|
||||
}`}
|
||||
</CodeBlock>
|
||||
|
||||
<InfoBox type="warning" title="Kosten">
|
||||
Die Dokumentengenerierung verbraucht LLM-Tokens. Durchschnittliche Kosten:
|
||||
DSFA ~5.000 Tokens, TOMs ~3.000 Tokens, VVT ~4.000 Tokens.
|
||||
Enterprise-Kunden haben unbegrenzte Generierungen.
|
||||
</InfoBox>
|
||||
</DevPortalLayout>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,239 @@
|
||||
import Link from 'next/link'
|
||||
import { DevPortalLayout, ApiEndpoint, InfoBox } from '@/components/developers/DevPortalLayout'
|
||||
|
||||
export default function ApiReferencePage() {
|
||||
return (
|
||||
<DevPortalLayout
|
||||
title="API Reference"
|
||||
description="Vollständige REST API Dokumentation"
|
||||
>
|
||||
<h2>Base URL</h2>
|
||||
<p>
|
||||
Alle API-Endpunkte sind unter folgender Basis-URL erreichbar:
|
||||
</p>
|
||||
<div className="bg-gray-100 p-4 rounded-lg font-mono text-sm my-4">
|
||||
https://api.breakpilot.io/sdk/v1
|
||||
</div>
|
||||
<p>
|
||||
Für Self-Hosted-Installationen verwenden Sie Ihre eigene Domain.
|
||||
</p>
|
||||
|
||||
<h2>Authentifizierung</h2>
|
||||
<p>
|
||||
Alle API-Anfragen erfordern einen gültigen API Key im Header:
|
||||
</p>
|
||||
<div className="bg-gray-100 p-4 rounded-lg font-mono text-sm my-4">
|
||||
Authorization: Bearer YOUR_API_KEY
|
||||
</div>
|
||||
|
||||
<InfoBox type="info" title="Tenant-ID">
|
||||
Die Tenant-ID wird aus dem API Key abgeleitet oder kann explizit
|
||||
als Query-Parameter oder im Request-Body mitgegeben werden.
|
||||
</InfoBox>
|
||||
|
||||
<h2>API Endpoints</h2>
|
||||
|
||||
<h3>State Management</h3>
|
||||
<p>
|
||||
Verwalten Sie den SDK-State für Ihren Tenant.
|
||||
</p>
|
||||
|
||||
<ApiEndpoint
|
||||
method="GET"
|
||||
path="/state/{tenantId}"
|
||||
description="Lädt den aktuellen SDK-State für einen Tenant"
|
||||
/>
|
||||
<ApiEndpoint
|
||||
method="POST"
|
||||
path="/state"
|
||||
description="Speichert den SDK-State (mit Versionierung)"
|
||||
/>
|
||||
<ApiEndpoint
|
||||
method="DELETE"
|
||||
path="/state/{tenantId}"
|
||||
description="Löscht den State für einen Tenant"
|
||||
/>
|
||||
|
||||
<p>
|
||||
<Link href="/developers/api/state" className="text-blue-600 hover:underline">
|
||||
→ Vollständige State API Dokumentation
|
||||
</Link>
|
||||
</p>
|
||||
|
||||
<h3>RAG Search</h3>
|
||||
<p>
|
||||
Durchsuchen Sie den Compliance-Korpus (DSGVO, AI Act, NIS2).
|
||||
</p>
|
||||
|
||||
<ApiEndpoint
|
||||
method="GET"
|
||||
path="/rag/search"
|
||||
description="Semantische Suche im Legal Corpus"
|
||||
/>
|
||||
<ApiEndpoint
|
||||
method="GET"
|
||||
path="/rag/status"
|
||||
description="Status des RAG-Systems und Corpus-Informationen"
|
||||
/>
|
||||
|
||||
<p>
|
||||
<Link href="/developers/api/rag" className="text-blue-600 hover:underline">
|
||||
→ Vollständige RAG API Dokumentation
|
||||
</Link>
|
||||
</p>
|
||||
|
||||
<h3>Document Generation</h3>
|
||||
<p>
|
||||
Generieren Sie Compliance-Dokumente automatisch.
|
||||
</p>
|
||||
|
||||
<ApiEndpoint
|
||||
method="POST"
|
||||
path="/generate/dsfa"
|
||||
description="Generiert eine Datenschutz-Folgenabschätzung"
|
||||
/>
|
||||
<ApiEndpoint
|
||||
method="POST"
|
||||
path="/generate/tom"
|
||||
description="Generiert technische und organisatorische Maßnahmen"
|
||||
/>
|
||||
<ApiEndpoint
|
||||
method="POST"
|
||||
path="/generate/vvt"
|
||||
description="Generiert ein Verarbeitungsverzeichnis"
|
||||
/>
|
||||
|
||||
<p>
|
||||
<Link href="/developers/api/generate" className="text-blue-600 hover:underline">
|
||||
→ Vollständige Generation API Dokumentation
|
||||
</Link>
|
||||
</p>
|
||||
|
||||
<h3>Export</h3>
|
||||
<p>
|
||||
Exportieren Sie den Compliance-Stand in verschiedenen Formaten.
|
||||
</p>
|
||||
|
||||
<ApiEndpoint
|
||||
method="GET"
|
||||
path="/export"
|
||||
description="Exportiert den State (JSON, PDF, ZIP)"
|
||||
/>
|
||||
|
||||
<p>
|
||||
<Link href="/developers/api/export" className="text-blue-600 hover:underline">
|
||||
→ Vollständige Export API Dokumentation
|
||||
</Link>
|
||||
</p>
|
||||
|
||||
<h2>Response Format</h2>
|
||||
<p>
|
||||
Alle Responses folgen einem einheitlichen Format:
|
||||
</p>
|
||||
|
||||
<h3>Erfolgreiche Response</h3>
|
||||
<div className="bg-gray-900 text-gray-100 p-4 rounded-lg font-mono text-sm my-4">
|
||||
{`{
|
||||
"success": true,
|
||||
"data": { ... },
|
||||
"meta": {
|
||||
"version": 1,
|
||||
"timestamp": "2026-02-04T12:00:00Z"
|
||||
}
|
||||
}`}
|
||||
</div>
|
||||
|
||||
<h3>Fehler Response</h3>
|
||||
<div className="bg-gray-900 text-gray-100 p-4 rounded-lg font-mono text-sm my-4">
|
||||
{`{
|
||||
"success": false,
|
||||
"error": {
|
||||
"code": "VALIDATION_ERROR",
|
||||
"message": "Tenant ID is required",
|
||||
"details": { ... }
|
||||
}
|
||||
}`}
|
||||
</div>
|
||||
|
||||
<h2>Error Codes</h2>
|
||||
<div className="my-4 overflow-x-auto not-prose">
|
||||
<table className="min-w-full divide-y divide-gray-200">
|
||||
<thead className="bg-gray-50">
|
||||
<tr>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">HTTP Status</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Code</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Beschreibung</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="bg-white divide-y divide-gray-200 text-sm">
|
||||
<tr>
|
||||
<td className="px-4 py-3">400</td>
|
||||
<td className="px-4 py-3 font-mono text-red-600">VALIDATION_ERROR</td>
|
||||
<td className="px-4 py-3 text-gray-600">Ungültige Request-Daten</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="px-4 py-3">401</td>
|
||||
<td className="px-4 py-3 font-mono text-red-600">UNAUTHORIZED</td>
|
||||
<td className="px-4 py-3 text-gray-600">Fehlender oder ungültiger API Key</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="px-4 py-3">403</td>
|
||||
<td className="px-4 py-3 font-mono text-red-600">FORBIDDEN</td>
|
||||
<td className="px-4 py-3 text-gray-600">Keine Berechtigung für diese Ressource</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="px-4 py-3">404</td>
|
||||
<td className="px-4 py-3 font-mono text-red-600">NOT_FOUND</td>
|
||||
<td className="px-4 py-3 text-gray-600">Ressource nicht gefunden</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="px-4 py-3">409</td>
|
||||
<td className="px-4 py-3 font-mono text-red-600">CONFLICT</td>
|
||||
<td className="px-4 py-3 text-gray-600">Versions-Konflikt (Optimistic Locking)</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="px-4 py-3">429</td>
|
||||
<td className="px-4 py-3 font-mono text-red-600">RATE_LIMITED</td>
|
||||
<td className="px-4 py-3 text-gray-600">Zu viele Anfragen</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="px-4 py-3">500</td>
|
||||
<td className="px-4 py-3 font-mono text-red-600">INTERNAL_ERROR</td>
|
||||
<td className="px-4 py-3 text-gray-600">Interner Server-Fehler</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<h2>Rate Limits</h2>
|
||||
<div className="my-4 overflow-x-auto not-prose">
|
||||
<table className="min-w-full divide-y divide-gray-200">
|
||||
<thead className="bg-gray-50">
|
||||
<tr>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Plan</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Requests/Minute</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Requests/Tag</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="bg-white divide-y divide-gray-200 text-sm">
|
||||
<tr>
|
||||
<td className="px-4 py-3">Starter</td>
|
||||
<td className="px-4 py-3">60</td>
|
||||
<td className="px-4 py-3">10.000</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="px-4 py-3">Professional</td>
|
||||
<td className="px-4 py-3">300</td>
|
||||
<td className="px-4 py-3">100.000</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="px-4 py-3">Enterprise</td>
|
||||
<td className="px-4 py-3">Unbegrenzt</td>
|
||||
<td className="px-4 py-3">Unbegrenzt</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</DevPortalLayout>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,248 @@
|
||||
import { DevPortalLayout, ApiEndpoint, CodeBlock, ParameterTable, InfoBox } from '@/components/developers/DevPortalLayout'
|
||||
|
||||
export default function RAGApiPage() {
|
||||
return (
|
||||
<DevPortalLayout
|
||||
title="RAG API"
|
||||
description="Semantische Suche im Legal Corpus (DSGVO, AI Act, NIS2)"
|
||||
>
|
||||
<h2>Uebersicht</h2>
|
||||
<p>
|
||||
Die RAG (Retrieval-Augmented Generation) API ermoeglicht semantische Suche
|
||||
im Compliance-Korpus. Der Korpus enthaelt:
|
||||
</p>
|
||||
<ul>
|
||||
<li>DSGVO (Datenschutz-Grundverordnung)</li>
|
||||
<li>AI Act (EU KI-Verordnung)</li>
|
||||
<li>NIS2 (Netzwerk- und Informationssicherheit)</li>
|
||||
<li>ePrivacy-Verordnung</li>
|
||||
<li>Bundesdatenschutzgesetz (BDSG)</li>
|
||||
</ul>
|
||||
|
||||
<InfoBox type="info" title="Embedding-Modell">
|
||||
Die Suche verwendet BGE-M3 Embeddings fuer praezise semantische Aehnlichkeit.
|
||||
Die Vektoren werden in Qdrant gespeichert.
|
||||
</InfoBox>
|
||||
|
||||
<h2>GET /rag/search</h2>
|
||||
<p>Durchsucht den Legal Corpus semantisch.</p>
|
||||
|
||||
<h3>Query-Parameter</h3>
|
||||
<ParameterTable
|
||||
parameters={[
|
||||
{
|
||||
name: 'q',
|
||||
type: 'string',
|
||||
required: true,
|
||||
description: 'Die Suchanfrage (z.B. "Einwilligung personenbezogene Daten")',
|
||||
},
|
||||
{
|
||||
name: 'top_k',
|
||||
type: 'number',
|
||||
required: false,
|
||||
description: 'Anzahl der Ergebnisse (default: 5, max: 20)',
|
||||
},
|
||||
{
|
||||
name: 'corpus',
|
||||
type: 'string',
|
||||
required: false,
|
||||
description: 'Einschraenkung auf bestimmten Corpus: dsgvo, ai_act, nis2, all (default: all)',
|
||||
},
|
||||
{
|
||||
name: 'min_score',
|
||||
type: 'number',
|
||||
required: false,
|
||||
description: 'Minimaler Relevanz-Score 0-1 (default: 0.5)',
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
<h3>Request</h3>
|
||||
<CodeBlock language="bash" filename="cURL">
|
||||
{`curl -X GET "https://api.breakpilot.io/sdk/v1/rag/search?q=Einwilligung%20DSGVO&top_k=5" \\
|
||||
-H "Authorization: Bearer YOUR_API_KEY"`}
|
||||
</CodeBlock>
|
||||
|
||||
<h3>Response (200 OK)</h3>
|
||||
<CodeBlock language="json" filename="Response">
|
||||
{`{
|
||||
"success": true,
|
||||
"data": {
|
||||
"query": "Einwilligung DSGVO",
|
||||
"results": [
|
||||
{
|
||||
"id": "dsgvo-art-7",
|
||||
"title": "Art. 7 DSGVO - Bedingungen fuer die Einwilligung",
|
||||
"content": "Beruht die Verarbeitung auf einer Einwilligung, muss der Verantwortliche nachweisen koennen, dass die betroffene Person in die Verarbeitung ihrer personenbezogenen Daten eingewilligt hat...",
|
||||
"corpus": "dsgvo",
|
||||
"article": "Art. 7",
|
||||
"score": 0.92,
|
||||
"metadata": {
|
||||
"chapter": "II",
|
||||
"section": "Einwilligung",
|
||||
"url": "https://dsgvo-gesetz.de/art-7-dsgvo/"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "dsgvo-art-6-1-a",
|
||||
"title": "Art. 6 Abs. 1 lit. a DSGVO - Einwilligung als Rechtsgrundlage",
|
||||
"content": "Die Verarbeitung ist nur rechtmaessig, wenn mindestens eine der nachstehenden Bedingungen erfuellt ist: a) Die betroffene Person hat ihre Einwilligung...",
|
||||
"corpus": "dsgvo",
|
||||
"article": "Art. 6",
|
||||
"score": 0.88,
|
||||
"metadata": {
|
||||
"chapter": "II",
|
||||
"section": "Rechtmaessigkeit",
|
||||
"url": "https://dsgvo-gesetz.de/art-6-dsgvo/"
|
||||
}
|
||||
}
|
||||
],
|
||||
"total_results": 2,
|
||||
"search_time_ms": 45
|
||||
},
|
||||
"meta": {
|
||||
"corpus_version": "2026-01",
|
||||
"embedding_model": "bge-m3"
|
||||
}
|
||||
}`}
|
||||
</CodeBlock>
|
||||
|
||||
<h2>GET /rag/status</h2>
|
||||
<p>Gibt Status-Informationen ueber das RAG-System zurueck.</p>
|
||||
|
||||
<h3>Request</h3>
|
||||
<CodeBlock language="bash" filename="cURL">
|
||||
{`curl -X GET "https://api.breakpilot.io/sdk/v1/rag/status" \\
|
||||
-H "Authorization: Bearer YOUR_API_KEY"`}
|
||||
</CodeBlock>
|
||||
|
||||
<h3>Response (200 OK)</h3>
|
||||
<CodeBlock language="json" filename="Response">
|
||||
{`{
|
||||
"success": true,
|
||||
"data": {
|
||||
"status": "healthy",
|
||||
"corpus": {
|
||||
"dsgvo": {
|
||||
"documents": 99,
|
||||
"chunks": 1250,
|
||||
"last_updated": "2026-01-15T00:00:00Z"
|
||||
},
|
||||
"ai_act": {
|
||||
"documents": 89,
|
||||
"chunks": 980,
|
||||
"last_updated": "2026-01-20T00:00:00Z"
|
||||
},
|
||||
"nis2": {
|
||||
"documents": 46,
|
||||
"chunks": 520,
|
||||
"last_updated": "2026-01-10T00:00:00Z"
|
||||
}
|
||||
},
|
||||
"embedding_service": {
|
||||
"status": "online",
|
||||
"model": "bge-m3",
|
||||
"dimension": 1024
|
||||
},
|
||||
"vector_db": {
|
||||
"type": "qdrant",
|
||||
"collections": 3,
|
||||
"total_vectors": 2750
|
||||
}
|
||||
}
|
||||
}`}
|
||||
</CodeBlock>
|
||||
|
||||
<h2>SDK Integration</h2>
|
||||
<p>
|
||||
Verwenden Sie den SDK-Client fuer einfache RAG-Suche:
|
||||
</p>
|
||||
<CodeBlock language="typescript" filename="rag-search.ts">
|
||||
{`import { getSDKBackendClient, isLegalQuery } from '@breakpilot/compliance-sdk'
|
||||
|
||||
const client = getSDKBackendClient()
|
||||
|
||||
// Pruefen ob die Query rechtliche Inhalte betrifft
|
||||
if (isLegalQuery('Was ist eine Einwilligung?')) {
|
||||
// RAG-Suche durchfuehren
|
||||
const results = await client.search('Einwilligung DSGVO', 5)
|
||||
|
||||
results.forEach(result => {
|
||||
console.log(\`[\${result.corpus}] \${result.title}\`)
|
||||
console.log(\`Score: \${result.score}\`)
|
||||
console.log(\`URL: \${result.metadata.url}\`)
|
||||
console.log('---')
|
||||
})
|
||||
}`}
|
||||
</CodeBlock>
|
||||
|
||||
<h2>Keyword-Erkennung</h2>
|
||||
<p>
|
||||
Die Funktion <code>isLegalQuery</code> erkennt automatisch rechtliche Anfragen:
|
||||
</p>
|
||||
<CodeBlock language="typescript" filename="keyword-detection.ts">
|
||||
{`import { isLegalQuery } from '@breakpilot/compliance-sdk'
|
||||
|
||||
// Gibt true zurueck fuer:
|
||||
isLegalQuery('DSGVO Art. 5') // true - Artikel-Referenz
|
||||
isLegalQuery('Einwilligung') // true - DSGVO-Begriff
|
||||
isLegalQuery('AI Act Hochrisiko') // true - AI Act Begriff
|
||||
isLegalQuery('NIS2 Richtlinie') // true - NIS2 Referenz
|
||||
isLegalQuery('personenbezogene Daten') // true - Datenschutz-Begriff
|
||||
|
||||
// Gibt false zurueck fuer:
|
||||
isLegalQuery('Wie ist das Wetter?') // false - Keine rechtliche Anfrage
|
||||
isLegalQuery('Programmiere mir X') // false - Technische Anfrage`}
|
||||
</CodeBlock>
|
||||
|
||||
<h2>Beispiel: Command Bar Integration</h2>
|
||||
<CodeBlock language="typescript" filename="command-bar-rag.tsx">
|
||||
{`import { useState } from 'react'
|
||||
import { getSDKBackendClient, isLegalQuery } from '@breakpilot/compliance-sdk'
|
||||
|
||||
function CommandBarSearch({ query }: { query: string }) {
|
||||
const [results, setResults] = useState([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (query.length > 3 && isLegalQuery(query)) {
|
||||
setLoading(true)
|
||||
const client = getSDKBackendClient()
|
||||
|
||||
client.search(query, 3).then(data => {
|
||||
setResults(data)
|
||||
setLoading(false)
|
||||
})
|
||||
}
|
||||
}, [query])
|
||||
|
||||
if (!isLegalQuery(query)) return null
|
||||
|
||||
return (
|
||||
<div className="rag-results">
|
||||
{loading ? (
|
||||
<p>Suche im Legal Corpus...</p>
|
||||
) : (
|
||||
results.map(result => (
|
||||
<div key={result.id} className="result-card">
|
||||
<h4>{result.title}</h4>
|
||||
<p>{result.content.slice(0, 200)}...</p>
|
||||
<a href={result.metadata.url} target="_blank">
|
||||
Volltext lesen
|
||||
</a>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}`}
|
||||
</CodeBlock>
|
||||
|
||||
<InfoBox type="warning" title="Rate Limits">
|
||||
Die RAG-Suche ist auf 100 Anfragen/Minute (Professional) bzw.
|
||||
unbegrenzt (Enterprise) limitiert. Implementieren Sie Client-Side
|
||||
Debouncing fuer Echtzeit-Suche.
|
||||
</InfoBox>
|
||||
</DevPortalLayout>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,266 @@
|
||||
import { DevPortalLayout, ApiEndpoint, CodeBlock, ParameterTable, InfoBox } from '@/components/developers/DevPortalLayout'
|
||||
|
||||
export default function StateApiPage() {
|
||||
return (
|
||||
<DevPortalLayout
|
||||
title="State API"
|
||||
description="Verwalten Sie den SDK-State für Ihren Tenant"
|
||||
>
|
||||
<h2>Übersicht</h2>
|
||||
<p>
|
||||
Die State API ermöglicht das Speichern und Abrufen des kompletten SDK-States.
|
||||
Der State enthält alle Compliance-Daten: Use Cases, Risiken, Controls,
|
||||
Checkpoints und mehr.
|
||||
</p>
|
||||
|
||||
<InfoBox type="info" title="Versionierung">
|
||||
Der State wird mit optimistischem Locking gespeichert. Bei jedem Speichern
|
||||
wird die Version erhöht. Bei Konflikten erhalten Sie einen 409-Fehler.
|
||||
</InfoBox>
|
||||
|
||||
<h2>GET /state/{'{tenantId}'}</h2>
|
||||
<p>Lädt den aktuellen SDK-State für einen Tenant.</p>
|
||||
|
||||
<h3>Request</h3>
|
||||
<CodeBlock language="bash" filename="cURL">
|
||||
{`curl -X GET "https://api.breakpilot.io/sdk/v1/state/your-tenant-id" \\
|
||||
-H "Authorization: Bearer YOUR_API_KEY"`}
|
||||
</CodeBlock>
|
||||
|
||||
<h3>Response (200 OK)</h3>
|
||||
<CodeBlock language="json" filename="Response">
|
||||
{`{
|
||||
"success": true,
|
||||
"data": {
|
||||
"version": "1.0.0",
|
||||
"lastModified": "2026-02-04T12:00:00Z",
|
||||
"tenantId": "your-tenant-id",
|
||||
"userId": "user-123",
|
||||
"subscription": "PROFESSIONAL",
|
||||
"currentPhase": 1,
|
||||
"currentStep": "use-case-workshop",
|
||||
"completedSteps": ["use-case-workshop", "screening"],
|
||||
"checkpoints": {
|
||||
"CP-UC": {
|
||||
"checkpointId": "CP-UC",
|
||||
"passed": true,
|
||||
"validatedAt": "2026-02-01T10:00:00Z",
|
||||
"validatedBy": "user-123",
|
||||
"errors": [],
|
||||
"warnings": []
|
||||
}
|
||||
},
|
||||
"useCases": [
|
||||
{
|
||||
"id": "uc-1",
|
||||
"name": "KI-Kundenanalyse",
|
||||
"description": "...",
|
||||
"category": "Marketing",
|
||||
"stepsCompleted": 5,
|
||||
"assessmentResult": {
|
||||
"riskLevel": "HIGH",
|
||||
"dsfaRequired": true,
|
||||
"aiActClassification": "LIMITED"
|
||||
}
|
||||
}
|
||||
],
|
||||
"risks": [...],
|
||||
"controls": [...],
|
||||
"dsfa": {...},
|
||||
"toms": [...],
|
||||
"vvt": [...]
|
||||
},
|
||||
"meta": {
|
||||
"version": 5,
|
||||
"etag": "W/\\"abc123\\""
|
||||
}
|
||||
}`}
|
||||
</CodeBlock>
|
||||
|
||||
<h3>Response (404 Not Found)</h3>
|
||||
<CodeBlock language="json" filename="Response">
|
||||
{`{
|
||||
"success": false,
|
||||
"error": {
|
||||
"code": "NOT_FOUND",
|
||||
"message": "No state found for tenant your-tenant-id"
|
||||
}
|
||||
}`}
|
||||
</CodeBlock>
|
||||
|
||||
<h2>POST /state</h2>
|
||||
<p>Speichert den SDK-State. Unterstützt Versionierung und optimistisches Locking.</p>
|
||||
|
||||
<h3>Request Body</h3>
|
||||
<ParameterTable
|
||||
parameters={[
|
||||
{
|
||||
name: 'tenantId',
|
||||
type: 'string',
|
||||
required: true,
|
||||
description: 'Eindeutige Tenant-ID',
|
||||
},
|
||||
{
|
||||
name: 'userId',
|
||||
type: 'string',
|
||||
required: false,
|
||||
description: 'User-ID für Audit-Trail',
|
||||
},
|
||||
{
|
||||
name: 'state',
|
||||
type: 'SDKState',
|
||||
required: true,
|
||||
description: 'Der komplette SDK-State',
|
||||
},
|
||||
{
|
||||
name: 'expectedVersion',
|
||||
type: 'number',
|
||||
required: false,
|
||||
description: 'Erwartete Version für optimistisches Locking',
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
<h3>Request</h3>
|
||||
<CodeBlock language="bash" filename="cURL">
|
||||
{`curl -X POST "https://api.breakpilot.io/sdk/v1/state" \\
|
||||
-H "Authorization: Bearer YOUR_API_KEY" \\
|
||||
-H "Content-Type: application/json" \\
|
||||
-H "If-Match: W/\\"abc123\\"" \\
|
||||
-d '{
|
||||
"tenantId": "your-tenant-id",
|
||||
"userId": "user-123",
|
||||
"state": {
|
||||
"currentPhase": 1,
|
||||
"currentStep": "risks",
|
||||
"useCases": [...],
|
||||
"risks": [...]
|
||||
}
|
||||
}'`}
|
||||
</CodeBlock>
|
||||
|
||||
<h3>Response (200 OK)</h3>
|
||||
<CodeBlock language="json" filename="Response">
|
||||
{`{
|
||||
"success": true,
|
||||
"data": {
|
||||
"tenantId": "your-tenant-id",
|
||||
"version": 6,
|
||||
"updatedAt": "2026-02-04T12:05:00Z"
|
||||
},
|
||||
"meta": {
|
||||
"etag": "W/\\"def456\\""
|
||||
}
|
||||
}`}
|
||||
</CodeBlock>
|
||||
|
||||
<h3>Response (409 Conflict)</h3>
|
||||
<CodeBlock language="json" filename="Response">
|
||||
{`{
|
||||
"success": false,
|
||||
"error": {
|
||||
"code": "CONFLICT",
|
||||
"message": "Version conflict: expected 5, but current is 6",
|
||||
"details": {
|
||||
"expectedVersion": 5,
|
||||
"currentVersion": 6
|
||||
}
|
||||
}
|
||||
}`}
|
||||
</CodeBlock>
|
||||
|
||||
<InfoBox type="warning" title="Konfliktbehandlung">
|
||||
Bei einem 409-Fehler sollten Sie den State erneut laden, Ihre Änderungen
|
||||
mergen und erneut speichern.
|
||||
</InfoBox>
|
||||
|
||||
<h2>DELETE /state/{'{tenantId}'}</h2>
|
||||
<p>Löscht den kompletten State für einen Tenant.</p>
|
||||
|
||||
<h3>Request</h3>
|
||||
<CodeBlock language="bash" filename="cURL">
|
||||
{`curl -X DELETE "https://api.breakpilot.io/sdk/v1/state/your-tenant-id" \\
|
||||
-H "Authorization: Bearer YOUR_API_KEY"`}
|
||||
</CodeBlock>
|
||||
|
||||
<h3>Response (200 OK)</h3>
|
||||
<CodeBlock language="json" filename="Response">
|
||||
{`{
|
||||
"success": true,
|
||||
"data": {
|
||||
"tenantId": "your-tenant-id",
|
||||
"deleted": true
|
||||
}
|
||||
}`}
|
||||
</CodeBlock>
|
||||
|
||||
<h2>State-Struktur</h2>
|
||||
<p>Der SDKState enthält alle Compliance-Daten:</p>
|
||||
|
||||
<CodeBlock language="typescript" filename="types.ts">
|
||||
{`interface SDKState {
|
||||
// Metadata
|
||||
version: string
|
||||
lastModified: Date
|
||||
|
||||
// Tenant & User
|
||||
tenantId: string
|
||||
userId: string
|
||||
subscription: 'FREE' | 'STARTER' | 'PROFESSIONAL' | 'ENTERPRISE'
|
||||
|
||||
// Progress
|
||||
currentPhase: 1 | 2
|
||||
currentStep: string
|
||||
completedSteps: string[]
|
||||
checkpoints: Record<string, CheckpointStatus>
|
||||
|
||||
// Phase 1 Data
|
||||
useCases: UseCaseAssessment[]
|
||||
activeUseCase: string | null
|
||||
screening: ScreeningResult | null
|
||||
modules: ServiceModule[]
|
||||
requirements: Requirement[]
|
||||
controls: Control[]
|
||||
evidence: Evidence[]
|
||||
checklist: ChecklistItem[]
|
||||
risks: Risk[]
|
||||
|
||||
// Phase 2 Data
|
||||
aiActClassification: AIActResult | null
|
||||
obligations: Obligation[]
|
||||
dsfa: DSFA | null
|
||||
toms: TOM[]
|
||||
retentionPolicies: RetentionPolicy[]
|
||||
vvt: ProcessingActivity[]
|
||||
documents: LegalDocument[]
|
||||
cookieBanner: CookieBannerConfig | null
|
||||
consents: ConsentRecord[]
|
||||
dsrConfig: DSRConfig | null
|
||||
escalationWorkflows: EscalationWorkflow[]
|
||||
|
||||
// UI State
|
||||
preferences: UserPreferences
|
||||
}`}
|
||||
</CodeBlock>
|
||||
|
||||
<h2>Beispiel: SDK Integration</h2>
|
||||
<CodeBlock language="typescript" filename="sdk-client.ts">
|
||||
{`import { getSDKApiClient } from '@breakpilot/compliance-sdk'
|
||||
|
||||
const client = getSDKApiClient('your-tenant-id')
|
||||
|
||||
// State laden
|
||||
const state = await client.getState()
|
||||
console.log('Current step:', state.currentStep)
|
||||
console.log('Use cases:', state.useCases.length)
|
||||
|
||||
// State speichern
|
||||
await client.saveState({
|
||||
...state,
|
||||
currentStep: 'risks',
|
||||
risks: [...state.risks, newRisk],
|
||||
})`}
|
||||
</CodeBlock>
|
||||
</DevPortalLayout>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
import { DevPortalLayout, InfoBox } from '@/components/developers/DevPortalLayout'
|
||||
|
||||
export default function ChangelogPage() {
|
||||
return (
|
||||
<DevPortalLayout
|
||||
title="Changelog"
|
||||
description="Versionshistorie und Aenderungen des AI Compliance SDK"
|
||||
>
|
||||
<h2>Versionierung</h2>
|
||||
<p>
|
||||
Das SDK folgt Semantic Versioning (SemVer):
|
||||
<code className="mx-1">MAJOR.MINOR.PATCH</code>
|
||||
</p>
|
||||
<ul>
|
||||
<li><strong>MAJOR:</strong> Breaking Changes</li>
|
||||
<li><strong>MINOR:</strong> Neue Features, abwaertskompatibel</li>
|
||||
<li><strong>PATCH:</strong> Bugfixes</li>
|
||||
</ul>
|
||||
|
||||
{/* Version 1.2.0 */}
|
||||
<div className="mt-8 border-l-4 border-green-500 pl-4">
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<span className="px-3 py-1 bg-green-100 text-green-800 rounded-full text-sm font-medium">
|
||||
v1.2.0
|
||||
</span>
|
||||
<span className="text-slate-500 text-sm">2026-02-04</span>
|
||||
<span className="px-2 py-0.5 bg-green-500 text-white rounded text-xs">Latest</span>
|
||||
</div>
|
||||
|
||||
<h3 className="text-lg font-semibold text-slate-900 mb-2">Neue Features</h3>
|
||||
<ul className="list-disc list-inside text-slate-700 space-y-1 mb-4">
|
||||
<li>Demo-Daten Seeding ueber API (nicht mehr hardcodiert)</li>
|
||||
<li>Playwright E2E Tests fuer alle 19 SDK-Schritte</li>
|
||||
<li>Command Bar RAG-Integration mit Live-Suche</li>
|
||||
<li>Developer Portal mit API-Dokumentation</li>
|
||||
<li>TOM-Katalog mit 20 vorkonfigurierten Massnahmen</li>
|
||||
<li>VVT-Templates fuer gaengige Verarbeitungstaetigkeiten</li>
|
||||
</ul>
|
||||
|
||||
<h3 className="text-lg font-semibold text-slate-900 mb-2">Verbesserungen</h3>
|
||||
<ul className="list-disc list-inside text-slate-700 space-y-1 mb-4">
|
||||
<li>Performance-Optimierung beim State-Loading</li>
|
||||
<li>Bessere TypeScript-Typen fuer alle Exports</li>
|
||||
<li>Verbesserte Fehlerbehandlung bei API-Calls</li>
|
||||
</ul>
|
||||
|
||||
<h3 className="text-lg font-semibold text-slate-900 mb-2">Bugfixes</h3>
|
||||
<ul className="list-disc list-inside text-slate-700 space-y-1">
|
||||
<li>Fix: Checkpoint-Validierung bei leeren Arrays</li>
|
||||
<li>Fix: Multi-Tab-Sync bei Safari</li>
|
||||
<li>Fix: Export-Dateiname mit Sonderzeichen</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
{/* Version 1.1.0 */}
|
||||
<div className="mt-8 border-l-4 border-blue-500 pl-4">
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<span className="px-3 py-1 bg-blue-100 text-blue-800 rounded-full text-sm font-medium">
|
||||
v1.1.0
|
||||
</span>
|
||||
<span className="text-slate-500 text-sm">2026-01-20</span>
|
||||
</div>
|
||||
|
||||
<h3 className="text-lg font-semibold text-slate-900 mb-2">Neue Features</h3>
|
||||
<ul className="list-disc list-inside text-slate-700 space-y-1 mb-4">
|
||||
<li>Backend-Sync mit PostgreSQL-Persistierung</li>
|
||||
<li>SDK Backend (Go) mit RAG + LLM-Integration</li>
|
||||
<li>Automatische DSFA-Generierung via Claude API</li>
|
||||
<li>Export nach PDF, ZIP, JSON</li>
|
||||
</ul>
|
||||
|
||||
<h3 className="text-lg font-semibold text-slate-900 mb-2">Verbesserungen</h3>
|
||||
<ul className="list-disc list-inside text-slate-700 space-y-1 mb-4">
|
||||
<li>Offline-Support mit localStorage Fallback</li>
|
||||
<li>Optimistic Locking fuer Konfliktbehandlung</li>
|
||||
<li>BroadcastChannel fuer Multi-Tab-Sync</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
{/* Version 1.0.0 */}
|
||||
<div className="mt-8 border-l-4 border-slate-400 pl-4">
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<span className="px-3 py-1 bg-slate-100 text-slate-800 rounded-full text-sm font-medium">
|
||||
v1.0.0
|
||||
</span>
|
||||
<span className="text-slate-500 text-sm">2026-01-01</span>
|
||||
</div>
|
||||
|
||||
<h3 className="text-lg font-semibold text-slate-900 mb-2">Initial Release</h3>
|
||||
<ul className="list-disc list-inside text-slate-700 space-y-1 mb-4">
|
||||
<li>SDKProvider mit React Context</li>
|
||||
<li>useSDK Hook mit vollstaendigem State-Zugriff</li>
|
||||
<li>19-Schritte Compliance-Workflow (Phase 1 + 2)</li>
|
||||
<li>Checkpoint-Validierung</li>
|
||||
<li>Risk Matrix mit Score-Berechnung</li>
|
||||
<li>TypeScript-Support mit allen Types</li>
|
||||
<li>Utility Functions fuer Navigation und Berechnung</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
{/* Breaking Changes Notice */}
|
||||
<InfoBox type="warning" title="Upgrade-Hinweise">
|
||||
<p className="mb-2">
|
||||
Bei Major-Version-Updates (z.B. 1.x → 2.x) koennen Breaking Changes auftreten.
|
||||
Pruefen Sie die Migration Guides vor dem Upgrade.
|
||||
</p>
|
||||
<p>
|
||||
Das SDK speichert die State-Version im localStorage. Bei inkompatiblen
|
||||
Aenderungen wird automatisch eine Migration durchgefuehrt.
|
||||
</p>
|
||||
</InfoBox>
|
||||
|
||||
<h2>Geplante Features</h2>
|
||||
<div className="my-4 overflow-x-auto not-prose">
|
||||
<table className="min-w-full divide-y divide-gray-200">
|
||||
<thead className="bg-gray-50">
|
||||
<tr>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Feature</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Version</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Status</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="bg-white divide-y divide-gray-200 text-sm">
|
||||
<tr>
|
||||
<td className="px-4 py-3">Multi-Tenant-Support</td>
|
||||
<td className="px-4 py-3 font-mono">v1.3.0</td>
|
||||
<td className="px-4 py-3"><span className="px-2 py-1 bg-yellow-100 text-yellow-800 rounded text-xs">In Entwicklung</span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="px-4 py-3">Workflow-Customization</td>
|
||||
<td className="px-4 py-3 font-mono">v1.3.0</td>
|
||||
<td className="px-4 py-3"><span className="px-2 py-1 bg-blue-100 text-blue-800 rounded text-xs">Geplant</span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="px-4 py-3">Audit-Trail Export</td>
|
||||
<td className="px-4 py-3 font-mono">v1.4.0</td>
|
||||
<td className="px-4 py-3"><span className="px-2 py-1 bg-blue-100 text-blue-800 rounded text-xs">Geplant</span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="px-4 py-3">White-Label Branding</td>
|
||||
<td className="px-4 py-3 font-mono">v2.0.0</td>
|
||||
<td className="px-4 py-3"><span className="px-2 py-1 bg-slate-100 text-slate-800 rounded text-xs">Roadmap</span></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<h2>Feedback & Issues</h2>
|
||||
<p>
|
||||
Fuer Bug-Reports und Feature-Requests nutzen Sie bitte:
|
||||
</p>
|
||||
<ul>
|
||||
<li>
|
||||
<strong>GitHub Issues:</strong>{' '}
|
||||
<code>github.com/breakpilot/compliance-sdk/issues</code>
|
||||
</li>
|
||||
<li>
|
||||
<strong>Support:</strong>{' '}
|
||||
<code>support@breakpilot.io</code>
|
||||
</li>
|
||||
</ul>
|
||||
</DevPortalLayout>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,203 @@
|
||||
import Link from 'next/link'
|
||||
import { DevPortalLayout, CodeBlock, InfoBox, ParameterTable } from '@/components/developers/DevPortalLayout'
|
||||
|
||||
export default function GettingStartedPage() {
|
||||
return (
|
||||
<DevPortalLayout
|
||||
title="Quick Start"
|
||||
description="Starten Sie in 5 Minuten mit dem AI Compliance SDK"
|
||||
>
|
||||
<h2>1. Installation</h2>
|
||||
<p>
|
||||
Installieren Sie das SDK über Ihren bevorzugten Paketmanager:
|
||||
</p>
|
||||
<CodeBlock language="bash" filename="Terminal">
|
||||
{`npm install @breakpilot/compliance-sdk
|
||||
# oder
|
||||
yarn add @breakpilot/compliance-sdk
|
||||
# oder
|
||||
pnpm add @breakpilot/compliance-sdk`}
|
||||
</CodeBlock>
|
||||
|
||||
<h2>2. API Key erhalten</h2>
|
||||
<p>
|
||||
Nach dem Abo-Abschluss erhalten Sie Ihren API Key im{' '}
|
||||
<Link href="/settings" className="text-blue-600 hover:underline">
|
||||
Einstellungsbereich
|
||||
</Link>.
|
||||
</p>
|
||||
|
||||
<InfoBox type="warning" title="Sicherheitshinweis">
|
||||
Speichern Sie den API Key niemals im Frontend-Code. Verwenden Sie
|
||||
Umgebungsvariablen auf dem Server.
|
||||
</InfoBox>
|
||||
|
||||
<h2>3. Provider einrichten</h2>
|
||||
<p>
|
||||
Wrappen Sie Ihre App mit dem SDKProvider:
|
||||
</p>
|
||||
<CodeBlock language="typescript" filename="app/layout.tsx">
|
||||
{`import { SDKProvider } from '@breakpilot/compliance-sdk'
|
||||
|
||||
export default function RootLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
return (
|
||||
<html lang="de">
|
||||
<body>
|
||||
<SDKProvider
|
||||
tenantId={process.env.TENANT_ID}
|
||||
apiKey={process.env.BREAKPILOT_API_KEY}
|
||||
enableBackendSync={true}
|
||||
>
|
||||
{children}
|
||||
</SDKProvider>
|
||||
</body>
|
||||
</html>
|
||||
)
|
||||
}`}
|
||||
</CodeBlock>
|
||||
|
||||
<h3>Provider Props</h3>
|
||||
<ParameterTable
|
||||
parameters={[
|
||||
{
|
||||
name: 'tenantId',
|
||||
type: 'string',
|
||||
required: true,
|
||||
description: 'Ihre eindeutige Tenant-ID',
|
||||
},
|
||||
{
|
||||
name: 'apiKey',
|
||||
type: 'string',
|
||||
required: false,
|
||||
description: 'API Key für Backend-Sync (serverseitig)',
|
||||
},
|
||||
{
|
||||
name: 'userId',
|
||||
type: 'string',
|
||||
required: false,
|
||||
description: 'Optional: Benutzer-ID für Audit-Trail',
|
||||
},
|
||||
{
|
||||
name: 'enableBackendSync',
|
||||
type: 'boolean',
|
||||
required: false,
|
||||
description: 'Aktiviert Synchronisation mit dem Backend (default: false)',
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
<h2>4. SDK verwenden</h2>
|
||||
<p>
|
||||
Nutzen Sie den useSDK Hook in Ihren Komponenten:
|
||||
</p>
|
||||
<CodeBlock language="typescript" filename="components/Dashboard.tsx">
|
||||
{`'use client'
|
||||
|
||||
import { useSDK } from '@breakpilot/compliance-sdk'
|
||||
|
||||
export function ComplianceDashboard() {
|
||||
const {
|
||||
state,
|
||||
completionPercentage,
|
||||
goToStep,
|
||||
currentStep,
|
||||
} = useSDK()
|
||||
|
||||
return (
|
||||
<div className="p-6">
|
||||
<h1 className="text-2xl font-bold">
|
||||
Compliance Fortschritt: {completionPercentage}%
|
||||
</h1>
|
||||
|
||||
<div className="mt-4">
|
||||
<p>Aktueller Schritt: {currentStep?.name}</p>
|
||||
<p>Phase: {state.currentPhase}</p>
|
||||
<p>Use Cases: {state.useCases.length}</p>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 flex gap-4">
|
||||
<button
|
||||
onClick={() => goToStep('use-case-workshop')}
|
||||
className="px-4 py-2 bg-blue-600 text-white rounded"
|
||||
>
|
||||
Use Case Workshop
|
||||
</button>
|
||||
<button
|
||||
onClick={() => goToStep('risks')}
|
||||
className="px-4 py-2 bg-blue-600 text-white rounded"
|
||||
>
|
||||
Risikoanalyse
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}`}
|
||||
</CodeBlock>
|
||||
|
||||
<h2>5. Erste Schritte im Workflow</h2>
|
||||
<p>
|
||||
Das SDK führt Sie durch einen 19-Schritte-Workflow in 2 Phasen:
|
||||
</p>
|
||||
|
||||
<div className="my-6 not-prose">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="p-4 border border-gray-200 rounded-lg">
|
||||
<h4 className="font-semibold mb-2">Phase 1: Assessment</h4>
|
||||
<ol className="text-sm text-gray-600 space-y-1 list-decimal list-inside">
|
||||
<li>Use Case Workshop</li>
|
||||
<li>System Screening</li>
|
||||
<li>Compliance Modules</li>
|
||||
<li>Requirements</li>
|
||||
<li>Controls</li>
|
||||
<li>Evidence</li>
|
||||
<li>Audit Checklist</li>
|
||||
<li>Risk Matrix</li>
|
||||
</ol>
|
||||
</div>
|
||||
<div className="p-4 border border-gray-200 rounded-lg">
|
||||
<h4 className="font-semibold mb-2">Phase 2: Dokumentation</h4>
|
||||
<ol className="text-sm text-gray-600 space-y-1 list-decimal list-inside">
|
||||
<li>AI Act Klassifizierung</li>
|
||||
<li>Pflichtenübersicht</li>
|
||||
<li>DSFA</li>
|
||||
<li>TOMs</li>
|
||||
<li>Löschfristen</li>
|
||||
<li>VVT</li>
|
||||
<li>Rechtliche Vorlagen</li>
|
||||
<li>Cookie Banner</li>
|
||||
<li>Einwilligungen</li>
|
||||
<li>DSR Portal</li>
|
||||
<li>Escalations</li>
|
||||
</ol>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h2>6. Nächste Schritte</h2>
|
||||
<ul>
|
||||
<li>
|
||||
<Link href="/developers/sdk/configuration" className="text-blue-600 hover:underline">
|
||||
SDK Konfiguration
|
||||
</Link>
|
||||
{' '}- Alle Konfigurationsoptionen
|
||||
</li>
|
||||
<li>
|
||||
<Link href="/developers/api/state" className="text-blue-600 hover:underline">
|
||||
State API
|
||||
</Link>
|
||||
{' '}- Verstehen Sie das State Management
|
||||
</li>
|
||||
<li>
|
||||
<Link href="/developers/guides/phase1" className="text-blue-600 hover:underline">
|
||||
Phase 1 Guide
|
||||
</Link>
|
||||
{' '}- Kompletter Workflow für das Assessment
|
||||
</li>
|
||||
</ul>
|
||||
</DevPortalLayout>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,227 @@
|
||||
import Link from 'next/link'
|
||||
import { DevPortalLayout, InfoBox } from '@/components/developers/DevPortalLayout'
|
||||
|
||||
export default function GuidesPage() {
|
||||
return (
|
||||
<DevPortalLayout
|
||||
title="Entwickler-Guides"
|
||||
description="Schritt-fuer-Schritt Anleitungen fuer die SDK-Integration"
|
||||
>
|
||||
<h2>Workflow-Guides</h2>
|
||||
<p>
|
||||
Das AI Compliance SDK fuehrt durch einen strukturierten 19-Schritte-Workflow
|
||||
in zwei Phasen. Diese Guides erklaeren jeden Schritt im Detail.
|
||||
</p>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6 my-8">
|
||||
<Link
|
||||
href="/developers/guides/phase1"
|
||||
className="block p-6 bg-blue-50 border border-blue-200 rounded-xl hover:border-blue-400 transition-colors"
|
||||
>
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<div className="w-12 h-12 bg-blue-600 text-white rounded-xl flex items-center justify-center text-xl font-bold">
|
||||
1
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-blue-900">Phase 1: Assessment</h3>
|
||||
<p className="text-sm text-blue-600">8 Schritte</p>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-blue-800 text-sm">
|
||||
Use Case Workshop, System Screening, Module-Auswahl, Requirements,
|
||||
Controls, Evidence, Checkliste, Risk Matrix.
|
||||
</p>
|
||||
</Link>
|
||||
|
||||
<Link
|
||||
href="/developers/guides/phase2"
|
||||
className="block p-6 bg-green-50 border border-green-200 rounded-xl hover:border-green-400 transition-colors"
|
||||
>
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<div className="w-12 h-12 bg-green-600 text-white rounded-xl flex items-center justify-center text-xl font-bold">
|
||||
2
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-green-900">Phase 2: Dokumentation</h3>
|
||||
<p className="text-sm text-green-600">11 Schritte</p>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-green-800 text-sm">
|
||||
AI Act Klassifizierung, Pflichten, DSFA, TOMs, Loeschfristen,
|
||||
VVT, Rechtliche Vorlagen, Cookie Banner, DSR Portal.
|
||||
</p>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<h2>Workflow-Uebersicht</h2>
|
||||
<div className="my-6 not-prose">
|
||||
<div className="bg-slate-50 rounded-xl p-6 border border-slate-200">
|
||||
<h4 className="font-semibold mb-4 text-slate-900">Phase 1: Assessment (8 Schritte)</h4>
|
||||
<ol className="grid grid-cols-2 md:grid-cols-4 gap-3 text-sm">
|
||||
<li className="p-3 bg-white rounded-lg border border-slate-200">
|
||||
<span className="text-blue-600 font-mono">01</span>
|
||||
<p className="font-medium">Use Case Workshop</p>
|
||||
</li>
|
||||
<li className="p-3 bg-white rounded-lg border border-slate-200">
|
||||
<span className="text-blue-600 font-mono">02</span>
|
||||
<p className="font-medium">System Screening</p>
|
||||
</li>
|
||||
<li className="p-3 bg-white rounded-lg border border-slate-200">
|
||||
<span className="text-blue-600 font-mono">03</span>
|
||||
<p className="font-medium">Compliance Modules</p>
|
||||
</li>
|
||||
<li className="p-3 bg-white rounded-lg border border-slate-200">
|
||||
<span className="text-blue-600 font-mono">04</span>
|
||||
<p className="font-medium">Requirements</p>
|
||||
</li>
|
||||
<li className="p-3 bg-white rounded-lg border border-slate-200">
|
||||
<span className="text-blue-600 font-mono">05</span>
|
||||
<p className="font-medium">Controls</p>
|
||||
</li>
|
||||
<li className="p-3 bg-white rounded-lg border border-slate-200">
|
||||
<span className="text-blue-600 font-mono">06</span>
|
||||
<p className="font-medium">Evidence</p>
|
||||
</li>
|
||||
<li className="p-3 bg-white rounded-lg border border-slate-200">
|
||||
<span className="text-blue-600 font-mono">07</span>
|
||||
<p className="font-medium">Audit Checklist</p>
|
||||
</li>
|
||||
<li className="p-3 bg-white rounded-lg border border-slate-200">
|
||||
<span className="text-blue-600 font-mono">08</span>
|
||||
<p className="font-medium">Risk Matrix</p>
|
||||
</li>
|
||||
</ol>
|
||||
</div>
|
||||
|
||||
<div className="bg-slate-50 rounded-xl p-6 border border-slate-200 mt-4">
|
||||
<h4 className="font-semibold mb-4 text-slate-900">Phase 2: Dokumentation (11 Schritte)</h4>
|
||||
<ol className="grid grid-cols-2 md:grid-cols-4 gap-3 text-sm">
|
||||
<li className="p-3 bg-white rounded-lg border border-slate-200">
|
||||
<span className="text-green-600 font-mono">09</span>
|
||||
<p className="font-medium">AI Act Klassifizierung</p>
|
||||
</li>
|
||||
<li className="p-3 bg-white rounded-lg border border-slate-200">
|
||||
<span className="text-green-600 font-mono">10</span>
|
||||
<p className="font-medium">Pflichtenuebersicht</p>
|
||||
</li>
|
||||
<li className="p-3 bg-white rounded-lg border border-slate-200">
|
||||
<span className="text-green-600 font-mono">11</span>
|
||||
<p className="font-medium">DSFA</p>
|
||||
</li>
|
||||
<li className="p-3 bg-white rounded-lg border border-slate-200">
|
||||
<span className="text-green-600 font-mono">12</span>
|
||||
<p className="font-medium">TOMs</p>
|
||||
</li>
|
||||
<li className="p-3 bg-white rounded-lg border border-slate-200">
|
||||
<span className="text-green-600 font-mono">13</span>
|
||||
<p className="font-medium">Loeschfristen</p>
|
||||
</li>
|
||||
<li className="p-3 bg-white rounded-lg border border-slate-200">
|
||||
<span className="text-green-600 font-mono">14</span>
|
||||
<p className="font-medium">VVT</p>
|
||||
</li>
|
||||
<li className="p-3 bg-white rounded-lg border border-slate-200">
|
||||
<span className="text-green-600 font-mono">15</span>
|
||||
<p className="font-medium">Rechtliche Vorlagen</p>
|
||||
</li>
|
||||
<li className="p-3 bg-white rounded-lg border border-slate-200">
|
||||
<span className="text-green-600 font-mono">16</span>
|
||||
<p className="font-medium">Cookie Banner</p>
|
||||
</li>
|
||||
<li className="p-3 bg-white rounded-lg border border-slate-200">
|
||||
<span className="text-green-600 font-mono">17</span>
|
||||
<p className="font-medium">Einwilligungen</p>
|
||||
</li>
|
||||
<li className="p-3 bg-white rounded-lg border border-slate-200">
|
||||
<span className="text-green-600 font-mono">18</span>
|
||||
<p className="font-medium">DSR Portal</p>
|
||||
</li>
|
||||
<li className="p-3 bg-white rounded-lg border border-slate-200">
|
||||
<span className="text-green-600 font-mono">19</span>
|
||||
<p className="font-medium">Escalations</p>
|
||||
</li>
|
||||
</ol>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h2>Checkpoints</h2>
|
||||
<p>
|
||||
Das SDK validiert den Fortschritt an definierten Checkpoints:
|
||||
</p>
|
||||
<div className="my-4 overflow-x-auto not-prose">
|
||||
<table className="min-w-full divide-y divide-gray-200">
|
||||
<thead className="bg-gray-50">
|
||||
<tr>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Checkpoint</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Nach Schritt</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Validierung</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="bg-white divide-y divide-gray-200 text-sm">
|
||||
<tr>
|
||||
<td className="px-4 py-3 font-mono text-blue-600">CP-UC</td>
|
||||
<td className="px-4 py-3">Use Case Workshop</td>
|
||||
<td className="px-4 py-3 text-gray-600">Mind. 1 Use Case angelegt</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="px-4 py-3 font-mono text-blue-600">CP-SCREEN</td>
|
||||
<td className="px-4 py-3">System Screening</td>
|
||||
<td className="px-4 py-3 text-gray-600">Screening abgeschlossen</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="px-4 py-3 font-mono text-blue-600">CP-CTRL</td>
|
||||
<td className="px-4 py-3">Controls</td>
|
||||
<td className="px-4 py-3 text-gray-600">Alle Requirements haben Controls</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="px-4 py-3 font-mono text-blue-600">CP-RISK</td>
|
||||
<td className="px-4 py-3">Risk Matrix</td>
|
||||
<td className="px-4 py-3 text-gray-600">Alle Risiken bewertet</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="px-4 py-3 font-mono text-green-600">CP-DSFA</td>
|
||||
<td className="px-4 py-3">DSFA</td>
|
||||
<td className="px-4 py-3 text-gray-600">DSFA generiert (falls erforderlich)</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="px-4 py-3 font-mono text-green-600">CP-TOM</td>
|
||||
<td className="px-4 py-3">TOMs</td>
|
||||
<td className="px-4 py-3 text-gray-600">Mind. 10 TOMs definiert</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="px-4 py-3 font-mono text-green-600">CP-VVT</td>
|
||||
<td className="px-4 py-3">VVT</td>
|
||||
<td className="px-4 py-3 text-gray-600">VVT vollstaendig</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<InfoBox type="info" title="Checkpoint-Navigation">
|
||||
Nicht bestandene Checkpoints blockieren den Fortschritt zu spaetere Schritte.
|
||||
Verwenden Sie <code>validateCheckpoint()</code> um den Status zu pruefen.
|
||||
</InfoBox>
|
||||
|
||||
<h2>Best Practices</h2>
|
||||
<ul>
|
||||
<li>
|
||||
<strong>Speichern Sie regelmaessig:</strong> Der State wird automatisch
|
||||
im localStorage gespeichert, aber aktivieren Sie Backend-Sync fuer
|
||||
persistente Speicherung.
|
||||
</li>
|
||||
<li>
|
||||
<strong>Nutzen Sie die Command Bar:</strong> Cmd+K oeffnet schnelle
|
||||
Navigation, Export und RAG-Suche.
|
||||
</li>
|
||||
<li>
|
||||
<strong>Arbeiten Sie Use-Case-zentriert:</strong> Bearbeiten Sie
|
||||
einen Use Case vollstaendig, bevor Sie zum naechsten wechseln.
|
||||
</li>
|
||||
<li>
|
||||
<strong>Validieren Sie Checkpoints:</strong> Pruefen Sie vor dem
|
||||
Phasenwechsel, ob alle Checkpoints bestanden sind.
|
||||
</li>
|
||||
</ul>
|
||||
</DevPortalLayout>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,391 @@
|
||||
import { DevPortalLayout, CodeBlock, InfoBox } from '@/components/developers/DevPortalLayout'
|
||||
|
||||
export default function Phase1GuidePage() {
|
||||
return (
|
||||
<DevPortalLayout
|
||||
title="Phase 1: Assessment Guide"
|
||||
description="Schritt-fuer-Schritt durch die Assessment-Phase"
|
||||
>
|
||||
<h2>Uebersicht Phase 1</h2>
|
||||
<p>
|
||||
Phase 1 umfasst die Erfassung und Bewertung Ihrer KI-Anwendungsfaelle.
|
||||
Am Ende haben Sie eine vollstaendige Risikoanalyse und wissen, welche
|
||||
Compliance-Dokumente Sie benoetigen.
|
||||
</p>
|
||||
|
||||
<div className="my-6 p-4 bg-blue-50 border border-blue-200 rounded-xl">
|
||||
<h3 className="text-lg font-semibold text-blue-900 mb-2">Phase 1 Schritte</h3>
|
||||
<ol className="list-decimal list-inside text-blue-800 space-y-1">
|
||||
<li>Use Case Workshop</li>
|
||||
<li>System Screening</li>
|
||||
<li>Compliance Modules</li>
|
||||
<li>Requirements</li>
|
||||
<li>Controls</li>
|
||||
<li>Evidence</li>
|
||||
<li>Audit Checklist</li>
|
||||
<li>Risk Matrix</li>
|
||||
</ol>
|
||||
</div>
|
||||
|
||||
<h2>Schritt 1: Use Case Workshop</h2>
|
||||
<p>
|
||||
Erfassen Sie alle KI-Anwendungsfaelle in Ihrem Unternehmen.
|
||||
</p>
|
||||
|
||||
<h3>Code-Beispiel</h3>
|
||||
<CodeBlock language="typescript" filename="use-case-workshop.tsx">
|
||||
{`import { useSDK } from '@breakpilot/compliance-sdk'
|
||||
|
||||
function UseCaseForm() {
|
||||
const { updateUseCase, state } = useSDK()
|
||||
|
||||
const handleCreateUseCase = async () => {
|
||||
await updateUseCase({
|
||||
id: \`uc-\${Date.now()}\`,
|
||||
name: 'KI-gestuetzte Kundenanalyse',
|
||||
description: 'Analyse von Kundenverhalten mittels ML',
|
||||
category: 'Marketing',
|
||||
department: 'Marketing & Sales',
|
||||
dataTypes: ['Kundendaten', 'Verhaltensdaten', 'Transaktionen'],
|
||||
aiCapabilities: ['Profiling', 'Vorhersage'],
|
||||
stepsCompleted: 0,
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h2>Use Cases: {state.useCases.length}</h2>
|
||||
<button onClick={handleCreateUseCase}>
|
||||
Use Case hinzufuegen
|
||||
</button>
|
||||
|
||||
{state.useCases.map(uc => (
|
||||
<div key={uc.id}>
|
||||
<h3>{uc.name}</h3>
|
||||
<p>{uc.description}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}`}
|
||||
</CodeBlock>
|
||||
|
||||
<InfoBox type="info" title="Checkpoint CP-UC">
|
||||
Nach dem Use Case Workshop muss mindestens ein Use Case angelegt sein,
|
||||
um zum naechsten Schritt zu gelangen.
|
||||
</InfoBox>
|
||||
|
||||
<h2>Schritt 2: System Screening</h2>
|
||||
<p>
|
||||
Das Screening bewertet jeden Use Case hinsichtlich Datenschutz und AI Act.
|
||||
</p>
|
||||
|
||||
<h3>Code-Beispiel</h3>
|
||||
<CodeBlock language="typescript" filename="screening.tsx">
|
||||
{`import { useSDK } from '@breakpilot/compliance-sdk'
|
||||
|
||||
function ScreeningView() {
|
||||
const { state, dispatch } = useSDK()
|
||||
|
||||
const completeScreening = (useCaseId: string, result: ScreeningResult) => {
|
||||
dispatch({
|
||||
type: 'UPDATE_USE_CASE',
|
||||
payload: {
|
||||
id: useCaseId,
|
||||
screeningResult: result,
|
||||
// Ergebnis bestimmt weitere Pflichten
|
||||
assessmentResult: {
|
||||
riskLevel: result.aiActRisk,
|
||||
dsfaRequired: result.dsfaRequired,
|
||||
aiActClassification: result.aiActClassification,
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// Screening-Fragen beantworten
|
||||
const screeningQuestions = [
|
||||
'Werden personenbezogene Daten verarbeitet?',
|
||||
'Erfolgt automatisierte Entscheidungsfindung?',
|
||||
'Werden besondere Datenkategorien verarbeitet?',
|
||||
'Erfolgt Profiling?',
|
||||
'Werden Daten in Drittlaender uebermittelt?',
|
||||
]
|
||||
|
||||
return (
|
||||
<div>
|
||||
{screeningQuestions.map((question, i) => (
|
||||
<label key={i} className="block">
|
||||
<input type="checkbox" />
|
||||
{question}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}`}
|
||||
</CodeBlock>
|
||||
|
||||
<h2>Schritt 3: Compliance Modules</h2>
|
||||
<p>
|
||||
Basierend auf dem Screening werden relevante Compliance-Module aktiviert.
|
||||
</p>
|
||||
|
||||
<div className="my-4 overflow-x-auto not-prose">
|
||||
<table className="min-w-full divide-y divide-gray-200">
|
||||
<thead className="bg-gray-50">
|
||||
<tr>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Modul</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Aktiviert wenn</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="bg-white divide-y divide-gray-200 text-sm">
|
||||
<tr>
|
||||
<td className="px-4 py-3 font-medium">DSGVO Basis</td>
|
||||
<td className="px-4 py-3 text-gray-600">Immer (personenbezogene Daten)</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="px-4 py-3 font-medium">DSFA</td>
|
||||
<td className="px-4 py-3 text-gray-600">Hohes Risiko, Profiling, Art. 9 Daten</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="px-4 py-3 font-medium">AI Act</td>
|
||||
<td className="px-4 py-3 text-gray-600">KI-basierte Entscheidungen</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="px-4 py-3 font-medium">NIS2</td>
|
||||
<td className="px-4 py-3 text-gray-600">Kritische Infrastruktur</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<h2>Schritt 4: Requirements</h2>
|
||||
<p>
|
||||
Fuer jedes aktivierte Modul werden spezifische Anforderungen generiert.
|
||||
</p>
|
||||
|
||||
<CodeBlock language="typescript" filename="requirements.tsx">
|
||||
{`import { useSDK } from '@breakpilot/compliance-sdk'
|
||||
|
||||
function RequirementsView() {
|
||||
const { state } = useSDK()
|
||||
|
||||
// Requirements nach Modul gruppieren
|
||||
const byModule = state.requirements.reduce((acc, req) => {
|
||||
const module = req.module || 'general'
|
||||
if (!acc[module]) acc[module] = []
|
||||
acc[module].push(req)
|
||||
return acc
|
||||
}, {})
|
||||
|
||||
return (
|
||||
<div>
|
||||
{Object.entries(byModule).map(([module, reqs]) => (
|
||||
<div key={module}>
|
||||
<h3>{module}</h3>
|
||||
<ul>
|
||||
{reqs.map(req => (
|
||||
<li key={req.id}>
|
||||
<strong>{req.title}</strong>
|
||||
<p>{req.description}</p>
|
||||
<span>Status: {req.status}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}`}
|
||||
</CodeBlock>
|
||||
|
||||
<h2>Schritt 5: Controls</h2>
|
||||
<p>
|
||||
Definieren Sie Kontrollen fuer jede Anforderung.
|
||||
</p>
|
||||
|
||||
<CodeBlock language="typescript" filename="controls.tsx">
|
||||
{`import { useSDK } from '@breakpilot/compliance-sdk'
|
||||
|
||||
function ControlsView() {
|
||||
const { updateControl, state } = useSDK()
|
||||
|
||||
const addControl = (requirementId: string) => {
|
||||
updateControl({
|
||||
id: \`ctrl-\${Date.now()}\`,
|
||||
requirementId,
|
||||
title: 'Zugriffskontrolle implementieren',
|
||||
description: 'Role-based access control fuer alle Datenzugaenge',
|
||||
type: 'TECHNICAL',
|
||||
status: 'PLANNED',
|
||||
implementationDate: null,
|
||||
owner: 'IT-Abteilung',
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h2>Controls: {state.controls.length}</h2>
|
||||
|
||||
{state.requirements.map(req => (
|
||||
<div key={req.id}>
|
||||
<h3>{req.title}</h3>
|
||||
<p>Controls: {state.controls.filter(c => c.requirementId === req.id).length}</p>
|
||||
<button onClick={() => addControl(req.id)}>
|
||||
Control hinzufuegen
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}`}
|
||||
</CodeBlock>
|
||||
|
||||
<InfoBox type="warning" title="Checkpoint CP-CTRL">
|
||||
Jede Requirement muss mindestens ein Control haben, bevor Sie
|
||||
zur Evidence-Phase uebergehen koennen.
|
||||
</InfoBox>
|
||||
|
||||
<h2>Schritt 6: Evidence</h2>
|
||||
<p>
|
||||
Dokumentieren Sie Nachweise fuer implementierte Controls.
|
||||
</p>
|
||||
|
||||
<CodeBlock language="typescript" filename="evidence.tsx">
|
||||
{`import { useSDK } from '@breakpilot/compliance-sdk'
|
||||
|
||||
function EvidenceUpload({ controlId }: { controlId: string }) {
|
||||
const { dispatch } = useSDK()
|
||||
|
||||
const addEvidence = (file: File) => {
|
||||
dispatch({
|
||||
type: 'ADD_EVIDENCE',
|
||||
payload: {
|
||||
id: \`ev-\${Date.now()}\`,
|
||||
controlId,
|
||||
title: file.name,
|
||||
type: 'DOCUMENT',
|
||||
uploadedAt: new Date().toISOString(),
|
||||
fileType: file.type,
|
||||
// In Produktion: Upload zu Storage
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<input
|
||||
type="file"
|
||||
onChange={(e) => e.target.files?.[0] && addEvidence(e.target.files[0])}
|
||||
/>
|
||||
)
|
||||
}`}
|
||||
</CodeBlock>
|
||||
|
||||
<h2>Schritt 7: Audit Checklist</h2>
|
||||
<p>
|
||||
Die Checkliste fasst alle Compliance-Punkte zusammen.
|
||||
</p>
|
||||
|
||||
<h2>Schritt 8: Risk Matrix</h2>
|
||||
<p>
|
||||
Bewerten Sie alle identifizierten Risiken nach Likelihood und Impact.
|
||||
</p>
|
||||
|
||||
<CodeBlock language="typescript" filename="risk-matrix.tsx">
|
||||
{`import { useSDK, calculateRiskScore, getRiskSeverityFromScore } from '@breakpilot/compliance-sdk'
|
||||
|
||||
function RiskMatrix() {
|
||||
const { addRisk, state } = useSDK()
|
||||
|
||||
const createRisk = () => {
|
||||
const likelihood = 3 // 1-5
|
||||
const impact = 4 // 1-5
|
||||
const score = calculateRiskScore(likelihood, impact) // 12
|
||||
const severity = getRiskSeverityFromScore(score) // 'HIGH'
|
||||
|
||||
addRisk({
|
||||
id: \`risk-\${Date.now()}\`,
|
||||
title: 'Unbefugter Datenzugriff',
|
||||
description: 'Risiko durch unzureichende Zugriffskontrolle',
|
||||
likelihood,
|
||||
impact,
|
||||
inherentScore: score,
|
||||
severity,
|
||||
category: 'Security',
|
||||
mitigations: [],
|
||||
residualScore: null,
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h2>Risiken: {state.risks.length}</h2>
|
||||
|
||||
{/* 5x5 Matrix Visualisierung */}
|
||||
<div className="grid grid-cols-5 gap-1">
|
||||
{[5,4,3,2,1].map(likelihood => (
|
||||
[1,2,3,4,5].map(impact => {
|
||||
const score = likelihood * impact
|
||||
const risksHere = state.risks.filter(
|
||||
r => r.likelihood === likelihood && r.impact === impact
|
||||
)
|
||||
return (
|
||||
<div
|
||||
key={\`\${likelihood}-\${impact}\`}
|
||||
className={\`p-2 \${score >= 15 ? 'bg-red-500' : score >= 8 ? 'bg-yellow-500' : 'bg-green-500'}\`}
|
||||
>
|
||||
{risksHere.length > 0 && (
|
||||
<span className="text-white">{risksHere.length}</span>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})
|
||||
))}
|
||||
</div>
|
||||
|
||||
<button onClick={createRisk}>Risiko hinzufuegen</button>
|
||||
</div>
|
||||
)
|
||||
}`}
|
||||
</CodeBlock>
|
||||
|
||||
<InfoBox type="success" title="Phase 1 abgeschlossen">
|
||||
Nach erfolgreicher Bewertung aller Risiken koennen Sie zu Phase 2
|
||||
uebergehen. Der Checkpoint CP-RISK validiert, dass alle Risiken
|
||||
eine Severity-Bewertung haben.
|
||||
</InfoBox>
|
||||
|
||||
<h2>Navigation nach Phase 2</h2>
|
||||
<CodeBlock language="typescript" filename="phase-transition.tsx">
|
||||
{`import { useSDK } from '@breakpilot/compliance-sdk'
|
||||
|
||||
function PhaseTransition() {
|
||||
const { validateCheckpoint, goToStep, phase1Completion } = useSDK()
|
||||
|
||||
const handleContinueToPhase2 = async () => {
|
||||
// Alle Phase-1-Checkpoints pruefen
|
||||
const cpRisk = await validateCheckpoint('CP-RISK')
|
||||
|
||||
if (cpRisk.passed) {
|
||||
goToStep('ai-act-classification') // Erster Schritt Phase 2
|
||||
} else {
|
||||
console.error('Checkpoint nicht bestanden:', cpRisk.errors)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<p>Phase 1 Fortschritt: {phase1Completion}%</p>
|
||||
|
||||
{phase1Completion === 100 && (
|
||||
<button onClick={handleContinueToPhase2}>
|
||||
Weiter zu Phase 2
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}`}
|
||||
</CodeBlock>
|
||||
</DevPortalLayout>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,377 @@
|
||||
import { DevPortalLayout, CodeBlock, InfoBox } from '@/components/developers/DevPortalLayout'
|
||||
|
||||
export default function Phase2GuidePage() {
|
||||
return (
|
||||
<DevPortalLayout
|
||||
title="Phase 2: Dokumentation Guide"
|
||||
description="Schritt-fuer-Schritt durch die Dokumentations-Phase"
|
||||
>
|
||||
<h2>Uebersicht Phase 2</h2>
|
||||
<p>
|
||||
Phase 2 generiert alle erforderlichen Compliance-Dokumente basierend
|
||||
auf dem Assessment aus Phase 1. Die Dokumente koennen exportiert und
|
||||
fuer Audits verwendet werden.
|
||||
</p>
|
||||
|
||||
<div className="my-6 p-4 bg-green-50 border border-green-200 rounded-xl">
|
||||
<h3 className="text-lg font-semibold text-green-900 mb-2">Phase 2 Schritte</h3>
|
||||
<ol className="list-decimal list-inside text-green-800 space-y-1">
|
||||
<li>AI Act Klassifizierung</li>
|
||||
<li>Pflichtenuebersicht</li>
|
||||
<li>DSFA (Datenschutz-Folgenabschaetzung)</li>
|
||||
<li>TOMs (Technische/Organisatorische Massnahmen)</li>
|
||||
<li>Loeschfristen</li>
|
||||
<li>VVT (Verarbeitungsverzeichnis)</li>
|
||||
<li>Rechtliche Vorlagen</li>
|
||||
<li>Cookie Banner</li>
|
||||
<li>Einwilligungen</li>
|
||||
<li>DSR Portal</li>
|
||||
<li>Escalations</li>
|
||||
</ol>
|
||||
</div>
|
||||
|
||||
<h2>Schritt 9: AI Act Klassifizierung</h2>
|
||||
<p>
|
||||
Klassifizieren Sie jeden Use Case nach dem EU AI Act Risikosystem.
|
||||
</p>
|
||||
|
||||
<div className="my-4 overflow-x-auto not-prose">
|
||||
<table className="min-w-full divide-y divide-gray-200">
|
||||
<thead className="bg-gray-50">
|
||||
<tr>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Risikostufe</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Beschreibung</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Pflichten</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="bg-white divide-y divide-gray-200 text-sm">
|
||||
<tr>
|
||||
<td className="px-4 py-3 font-medium text-red-600">Verboten</td>
|
||||
<td className="px-4 py-3 text-gray-600">Social Scoring, Manipulative KI</td>
|
||||
<td className="px-4 py-3 text-gray-600">Nicht zulaessig</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="px-4 py-3 font-medium text-orange-600">Hochrisiko</td>
|
||||
<td className="px-4 py-3 text-gray-600">Biometrie, Medizin, kritische Infrastruktur</td>
|
||||
<td className="px-4 py-3 text-gray-600">Umfangreiche Dokumentation, Konformitaetsbewertung</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="px-4 py-3 font-medium text-yellow-600">Begrenzt</td>
|
||||
<td className="px-4 py-3 text-gray-600">Chatbots, Empfehlungssysteme</td>
|
||||
<td className="px-4 py-3 text-gray-600">Transparenzpflichten</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="px-4 py-3 font-medium text-green-600">Minimal</td>
|
||||
<td className="px-4 py-3 text-gray-600">Spam-Filter, Spiele</td>
|
||||
<td className="px-4 py-3 text-gray-600">Freiwillige Verhaltenskodizes</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<CodeBlock language="typescript" filename="ai-act-classification.tsx">
|
||||
{`import { useSDK } from '@breakpilot/compliance-sdk'
|
||||
import type { AIActRiskCategory } from '@breakpilot/compliance-sdk'
|
||||
|
||||
function AIActClassification() {
|
||||
const { state, dispatch } = useSDK()
|
||||
|
||||
const classifyUseCase = (useCaseId: string, classification: AIActRiskCategory) => {
|
||||
dispatch({
|
||||
type: 'UPDATE_USE_CASE',
|
||||
payload: {
|
||||
id: useCaseId,
|
||||
assessmentResult: {
|
||||
...state.useCases.find(uc => uc.id === useCaseId)?.assessmentResult,
|
||||
aiActClassification: classification,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
// Wenn Hochrisiko, zusaetzliche Pflichten aktivieren
|
||||
if (classification === 'HIGH_RISK') {
|
||||
dispatch({
|
||||
type: 'SET_AI_ACT_RESULT',
|
||||
payload: {
|
||||
classification,
|
||||
conformityRequired: true,
|
||||
documentationRequired: true,
|
||||
humanOversightRequired: true,
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
{state.useCases.map(uc => (
|
||||
<div key={uc.id}>
|
||||
<h3>{uc.name}</h3>
|
||||
<select
|
||||
value={uc.assessmentResult?.aiActClassification || ''}
|
||||
onChange={(e) => classifyUseCase(uc.id, e.target.value as AIActRiskCategory)}
|
||||
>
|
||||
<option value="">Bitte waehlen...</option>
|
||||
<option value="PROHIBITED">Verboten</option>
|
||||
<option value="HIGH_RISK">Hochrisiko</option>
|
||||
<option value="LIMITED">Begrenzt</option>
|
||||
<option value="MINIMAL">Minimal</option>
|
||||
</select>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}`}
|
||||
</CodeBlock>
|
||||
|
||||
<h2>Schritt 10: Pflichtenuebersicht</h2>
|
||||
<p>
|
||||
Basierend auf der Klassifizierung werden alle anwendbaren Pflichten angezeigt.
|
||||
</p>
|
||||
|
||||
<h2>Schritt 11: DSFA</h2>
|
||||
<p>
|
||||
Die Datenschutz-Folgenabschaetzung wird automatisch generiert.
|
||||
</p>
|
||||
|
||||
<CodeBlock language="typescript" filename="dsfa.tsx">
|
||||
{`import { useSDK, getSDKBackendClient } from '@breakpilot/compliance-sdk'
|
||||
|
||||
function DSFAGeneration() {
|
||||
const { state, dispatch } = useSDK()
|
||||
const [generating, setGenerating] = useState(false)
|
||||
|
||||
const generateDSFA = async () => {
|
||||
setGenerating(true)
|
||||
|
||||
const client = getSDKBackendClient()
|
||||
const dsfa = await client.generateDSFA({
|
||||
useCases: state.useCases,
|
||||
risks: state.risks,
|
||||
controls: state.controls,
|
||||
})
|
||||
|
||||
dispatch({
|
||||
type: 'SET_DSFA',
|
||||
payload: dsfa,
|
||||
})
|
||||
|
||||
setGenerating(false)
|
||||
}
|
||||
|
||||
// DSFA nur anzeigen wenn erforderlich
|
||||
const dsfaRequired = state.useCases.some(
|
||||
uc => uc.assessmentResult?.dsfaRequired
|
||||
)
|
||||
|
||||
if (!dsfaRequired) {
|
||||
return <p>Keine DSFA erforderlich fuer die aktuellen Use Cases.</p>
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
{state.dsfa ? (
|
||||
<div>
|
||||
<h3>DSFA generiert</h3>
|
||||
<p>Status: {state.dsfa.status}</p>
|
||||
<p>Gesamtrisiko: {state.dsfa.conclusion?.overallRisk}</p>
|
||||
|
||||
{/* DSFA-Sektionen anzeigen */}
|
||||
{Object.entries(state.dsfa.sections || {}).map(([key, section]) => (
|
||||
<div key={key}>
|
||||
<h4>{section.title}</h4>
|
||||
<p>{section.content}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<button onClick={generateDSFA} disabled={generating}>
|
||||
{generating ? 'Generiere DSFA...' : 'DSFA generieren'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}`}
|
||||
</CodeBlock>
|
||||
|
||||
<InfoBox type="info" title="Checkpoint CP-DSFA">
|
||||
Wenn eine DSFA erforderlich ist (basierend auf Screening), muss diese
|
||||
generiert werden, bevor Sie fortfahren koennen.
|
||||
</InfoBox>
|
||||
|
||||
<h2>Schritt 12: TOMs</h2>
|
||||
<p>
|
||||
Technische und Organisatorische Massnahmen nach Art. 32 DSGVO.
|
||||
</p>
|
||||
|
||||
<CodeBlock language="typescript" filename="toms.tsx">
|
||||
{`import { useSDK, getSDKBackendClient } from '@breakpilot/compliance-sdk'
|
||||
|
||||
function TOMsView() {
|
||||
const { state, dispatch } = useSDK()
|
||||
|
||||
const generateTOMs = async () => {
|
||||
const client = getSDKBackendClient()
|
||||
const toms = await client.generateTOM({
|
||||
risks: state.risks,
|
||||
controls: state.controls,
|
||||
})
|
||||
|
||||
dispatch({
|
||||
type: 'SET_TOMS',
|
||||
payload: toms,
|
||||
})
|
||||
}
|
||||
|
||||
const tomCategories = [
|
||||
{ id: 'access_control', label: 'Zugangskontrolle' },
|
||||
{ id: 'access_rights', label: 'Zugriffskontrolle' },
|
||||
{ id: 'transfer_control', label: 'Weitergabekontrolle' },
|
||||
{ id: 'input_control', label: 'Eingabekontrolle' },
|
||||
{ id: 'availability', label: 'Verfuegbarkeitskontrolle' },
|
||||
{ id: 'separation', label: 'Trennungsgebot' },
|
||||
]
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h2>TOMs: {state.toms.length}</h2>
|
||||
|
||||
{tomCategories.map(cat => {
|
||||
const tomsInCategory = state.toms.filter(t => t.category === cat.id)
|
||||
return (
|
||||
<div key={cat.id}>
|
||||
<h3>{cat.label} ({tomsInCategory.length})</h3>
|
||||
<ul>
|
||||
{tomsInCategory.map(tom => (
|
||||
<li key={tom.id}>
|
||||
<strong>{tom.title}</strong>
|
||||
<p>{tom.description}</p>
|
||||
<span>Status: {tom.implementationStatus}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
|
||||
<button onClick={generateTOMs}>TOMs generieren</button>
|
||||
</div>
|
||||
)
|
||||
}`}
|
||||
</CodeBlock>
|
||||
|
||||
<h2>Schritt 13: Loeschfristen</h2>
|
||||
<p>
|
||||
Definieren Sie Aufbewahrungsfristen fuer verschiedene Datenkategorien.
|
||||
</p>
|
||||
|
||||
<h2>Schritt 14: VVT</h2>
|
||||
<p>
|
||||
Das Verarbeitungsverzeichnis nach Art. 30 DSGVO.
|
||||
</p>
|
||||
|
||||
<CodeBlock language="typescript" filename="vvt.tsx">
|
||||
{`import { useSDK } from '@breakpilot/compliance-sdk'
|
||||
|
||||
function VVTView() {
|
||||
const { state, dispatch } = useSDK()
|
||||
|
||||
const addProcessingActivity = () => {
|
||||
dispatch({
|
||||
type: 'ADD_PROCESSING_ACTIVITY',
|
||||
payload: {
|
||||
id: \`pa-\${Date.now()}\`,
|
||||
name: 'Kundendatenverarbeitung',
|
||||
purpose: 'Vertragserfuellung',
|
||||
legalBasis: 'Art. 6 Abs. 1 lit. b DSGVO',
|
||||
dataCategories: ['Kontaktdaten', 'Vertragsdaten'],
|
||||
dataSubjects: ['Kunden'],
|
||||
recipients: [],
|
||||
retentionPeriod: '10 Jahre',
|
||||
technicalMeasures: ['Verschluesselung', 'Zugriffskontrolle'],
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h2>Verarbeitungstaetigkeiten: {state.vvt.length}</h2>
|
||||
|
||||
{state.vvt.map(activity => (
|
||||
<div key={activity.id} className="border p-4 rounded mb-4">
|
||||
<h3>{activity.name}</h3>
|
||||
<p><strong>Zweck:</strong> {activity.purpose}</p>
|
||||
<p><strong>Rechtsgrundlage:</strong> {activity.legalBasis}</p>
|
||||
<p><strong>Datenkategorien:</strong> {activity.dataCategories.join(', ')}</p>
|
||||
<p><strong>Betroffene:</strong> {activity.dataSubjects.join(', ')}</p>
|
||||
<p><strong>Loeschfrist:</strong> {activity.retentionPeriod}</p>
|
||||
</div>
|
||||
))}
|
||||
|
||||
<button onClick={addProcessingActivity}>
|
||||
Verarbeitungstaetigkeit hinzufuegen
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}`}
|
||||
</CodeBlock>
|
||||
|
||||
<h2>Schritt 15-19: Weitere Dokumentation</h2>
|
||||
<p>
|
||||
Die verbleibenden Schritte umfassen:
|
||||
</p>
|
||||
<ul>
|
||||
<li><strong>Rechtliche Vorlagen:</strong> AGB, Datenschutzerklaerung, etc.</li>
|
||||
<li><strong>Cookie Banner:</strong> Konfiguration fuer Cookie-Consent</li>
|
||||
<li><strong>Einwilligungen:</strong> Consent-Management fuer Betroffene</li>
|
||||
<li><strong>DSR Portal:</strong> Data Subject Request Handling</li>
|
||||
<li><strong>Escalations:</strong> Eskalationspfade fuer Datenschutzvorfaelle</li>
|
||||
</ul>
|
||||
|
||||
<h2>Export der Dokumentation</h2>
|
||||
<CodeBlock language="typescript" filename="export-all.tsx">
|
||||
{`import { useSDK } from '@breakpilot/compliance-sdk'
|
||||
|
||||
function ExportAll() {
|
||||
const { exportState, completionPercentage } = useSDK()
|
||||
|
||||
const handleExport = async (format: 'pdf' | 'zip' | 'json') => {
|
||||
const blob = await exportState(format)
|
||||
|
||||
// Download ausloesen
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = \`compliance-export.\${format === 'json' ? 'json' : format}\`
|
||||
a.click()
|
||||
URL.revokeObjectURL(url)
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h2>Compliance Fortschritt: {completionPercentage}%</h2>
|
||||
|
||||
<div className="flex gap-4 mt-4">
|
||||
<button onClick={() => handleExport('pdf')}>
|
||||
PDF Export
|
||||
</button>
|
||||
<button onClick={() => handleExport('zip')}>
|
||||
ZIP Export (alle Dokumente)
|
||||
</button>
|
||||
<button onClick={() => handleExport('json')}>
|
||||
JSON Backup
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}`}
|
||||
</CodeBlock>
|
||||
|
||||
<InfoBox type="success" title="Workflow abgeschlossen">
|
||||
Nach Abschluss aller 19 Schritte haben Sie eine vollstaendige
|
||||
Compliance-Dokumentation, die Sie fuer Audits und regulatorische
|
||||
Anforderungen verwenden koennen.
|
||||
</InfoBox>
|
||||
</DevPortalLayout>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { DevPortalLayout } from '@/components/developers/DevPortalLayout'
|
||||
|
||||
export default function DevelopersLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
return <>{children}</>
|
||||
}
|
||||
@@ -0,0 +1,188 @@
|
||||
import Link from 'next/link'
|
||||
import { DevPortalLayout, CodeBlock, InfoBox } from '@/components/developers/DevPortalLayout'
|
||||
import { Zap, Code, Terminal, Book, ArrowRight } from 'lucide-react'
|
||||
|
||||
export default function DevelopersPage() {
|
||||
return (
|
||||
<DevPortalLayout
|
||||
title="AI Compliance SDK"
|
||||
description="Integrieren Sie Compliance-Automation in Ihre Anwendung"
|
||||
>
|
||||
{/* Quick Links */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 mb-12 not-prose">
|
||||
<Link
|
||||
href="/developers/getting-started"
|
||||
className="group p-6 border border-gray-200 rounded-xl hover:border-blue-300 hover:shadow-lg transition-all"
|
||||
>
|
||||
<div className="flex items-center gap-3 mb-3">
|
||||
<div className="w-10 h-10 rounded-lg bg-blue-100 flex items-center justify-center text-blue-600">
|
||||
<Zap className="w-5 h-5" />
|
||||
</div>
|
||||
<h3 className="font-semibold text-gray-900">Quick Start</h3>
|
||||
</div>
|
||||
<p className="text-sm text-gray-600 mb-3">
|
||||
Starten Sie in 5 Minuten mit dem AI Compliance SDK
|
||||
</p>
|
||||
<span className="text-sm text-blue-600 group-hover:underline flex items-center gap-1">
|
||||
Jetzt starten <ArrowRight className="w-4 h-4" />
|
||||
</span>
|
||||
</Link>
|
||||
|
||||
<Link
|
||||
href="/developers/api"
|
||||
className="group p-6 border border-gray-200 rounded-xl hover:border-blue-300 hover:shadow-lg transition-all"
|
||||
>
|
||||
<div className="flex items-center gap-3 mb-3">
|
||||
<div className="w-10 h-10 rounded-lg bg-green-100 flex items-center justify-center text-green-600">
|
||||
<Terminal className="w-5 h-5" />
|
||||
</div>
|
||||
<h3 className="font-semibold text-gray-900">API Reference</h3>
|
||||
</div>
|
||||
<p className="text-sm text-gray-600 mb-3">
|
||||
Vollständige API-Dokumentation aller Endpoints
|
||||
</p>
|
||||
<span className="text-sm text-blue-600 group-hover:underline flex items-center gap-1">
|
||||
API erkunden <ArrowRight className="w-4 h-4" />
|
||||
</span>
|
||||
</Link>
|
||||
|
||||
<Link
|
||||
href="/developers/sdk"
|
||||
className="group p-6 border border-gray-200 rounded-xl hover:border-blue-300 hover:shadow-lg transition-all"
|
||||
>
|
||||
<div className="flex items-center gap-3 mb-3">
|
||||
<div className="w-10 h-10 rounded-lg bg-purple-100 flex items-center justify-center text-purple-600">
|
||||
<Code className="w-5 h-5" />
|
||||
</div>
|
||||
<h3 className="font-semibold text-gray-900">SDK Documentation</h3>
|
||||
</div>
|
||||
<p className="text-sm text-gray-600 mb-3">
|
||||
TypeScript SDK für React und Next.js
|
||||
</p>
|
||||
<span className="text-sm text-blue-600 group-hover:underline flex items-center gap-1">
|
||||
Dokumentation lesen <ArrowRight className="w-4 h-4" />
|
||||
</span>
|
||||
</Link>
|
||||
|
||||
<Link
|
||||
href="/developers/guides"
|
||||
className="group p-6 border border-gray-200 rounded-xl hover:border-blue-300 hover:shadow-lg transition-all"
|
||||
>
|
||||
<div className="flex items-center gap-3 mb-3">
|
||||
<div className="w-10 h-10 rounded-lg bg-orange-100 flex items-center justify-center text-orange-600">
|
||||
<Book className="w-5 h-5" />
|
||||
</div>
|
||||
<h3 className="font-semibold text-gray-900">Guides</h3>
|
||||
</div>
|
||||
<p className="text-sm text-gray-600 mb-3">
|
||||
Schritt-für-Schritt-Anleitungen und Best Practices
|
||||
</p>
|
||||
<span className="text-sm text-blue-600 group-hover:underline flex items-center gap-1">
|
||||
Guides ansehen <ArrowRight className="w-4 h-4" />
|
||||
</span>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* Installation */}
|
||||
<h2>Installation</h2>
|
||||
<CodeBlock language="bash" filename="Terminal">
|
||||
{`npm install @breakpilot/compliance-sdk
|
||||
# oder
|
||||
yarn add @breakpilot/compliance-sdk
|
||||
# oder
|
||||
pnpm add @breakpilot/compliance-sdk`}
|
||||
</CodeBlock>
|
||||
|
||||
{/* Quick Example */}
|
||||
<h2>Schnellstart-Beispiel</h2>
|
||||
<CodeBlock language="typescript" filename="app.tsx">
|
||||
{`import { SDKProvider, useSDK } from '@breakpilot/compliance-sdk'
|
||||
|
||||
function App() {
|
||||
return (
|
||||
<SDKProvider
|
||||
tenantId="your-tenant-id"
|
||||
apiKey={process.env.BREAKPILOT_API_KEY}
|
||||
>
|
||||
<ComplianceDashboard />
|
||||
</SDKProvider>
|
||||
)
|
||||
}
|
||||
|
||||
function ComplianceDashboard() {
|
||||
const { state, goToStep, completionPercentage } = useSDK()
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h1>Compliance Status: {completionPercentage}%</h1>
|
||||
<p>Aktueller Schritt: {state.currentStep}</p>
|
||||
<button onClick={() => goToStep('risks')}>
|
||||
Zur Risikoanalyse
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}`}
|
||||
</CodeBlock>
|
||||
|
||||
<InfoBox type="info" title="Voraussetzungen">
|
||||
<ul className="list-disc list-inside space-y-1">
|
||||
<li>Node.js 18 oder höher</li>
|
||||
<li>React 18 oder höher</li>
|
||||
<li>Breakpilot API Key (erhältlich nach Abo-Abschluss)</li>
|
||||
</ul>
|
||||
</InfoBox>
|
||||
|
||||
{/* Features */}
|
||||
<h2>Hauptfunktionen</h2>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 not-prose">
|
||||
<div className="p-4 border border-gray-200 rounded-lg">
|
||||
<h4 className="font-medium text-gray-900 mb-2">19-Schritt-Workflow</h4>
|
||||
<p className="text-sm text-gray-600">
|
||||
Geführter Compliance-Prozess von Use Case bis DSR-Portal
|
||||
</p>
|
||||
</div>
|
||||
<div className="p-4 border border-gray-200 rounded-lg">
|
||||
<h4 className="font-medium text-gray-900 mb-2">RAG-basierte Suche</h4>
|
||||
<p className="text-sm text-gray-600">
|
||||
Durchsuchen Sie DSGVO, AI Act, NIS2 mit semantischer Suche
|
||||
</p>
|
||||
</div>
|
||||
<div className="p-4 border border-gray-200 rounded-lg">
|
||||
<h4 className="font-medium text-gray-900 mb-2">Dokumentengenerierung</h4>
|
||||
<p className="text-sm text-gray-600">
|
||||
Automatische Erstellung von DSFA, TOMs, VVT
|
||||
</p>
|
||||
</div>
|
||||
<div className="p-4 border border-gray-200 rounded-lg">
|
||||
<h4 className="font-medium text-gray-900 mb-2">Export</h4>
|
||||
<p className="text-sm text-gray-600">
|
||||
PDF, JSON, ZIP-Export für Audits und Dokumentation
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Next Steps */}
|
||||
<h2>Nächste Schritte</h2>
|
||||
<ol>
|
||||
<li>
|
||||
<Link href="/developers/getting-started" className="text-blue-600 hover:underline">
|
||||
Quick Start Guide
|
||||
</Link>
|
||||
{' '}- Erste Integration in 5 Minuten
|
||||
</li>
|
||||
<li>
|
||||
<Link href="/developers/api/state" className="text-blue-600 hover:underline">
|
||||
State API
|
||||
</Link>
|
||||
{' '}- Verstehen Sie das State Management
|
||||
</li>
|
||||
<li>
|
||||
<Link href="/developers/guides/phase1" className="text-blue-600 hover:underline">
|
||||
Phase 1 Workflow
|
||||
</Link>
|
||||
{' '}- Durchlaufen Sie den Compliance-Prozess
|
||||
</li>
|
||||
</ol>
|
||||
</DevPortalLayout>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,256 @@
|
||||
import { DevPortalLayout, CodeBlock, InfoBox, ParameterTable } from '@/components/developers/DevPortalLayout'
|
||||
|
||||
export default function SDKConfigurationPage() {
|
||||
return (
|
||||
<DevPortalLayout
|
||||
title="SDK Konfiguration"
|
||||
description="Alle Konfigurationsoptionen des AI Compliance SDK"
|
||||
>
|
||||
<h2>SDKProvider Props</h2>
|
||||
<p>
|
||||
Der SDKProvider akzeptiert folgende Konfigurationsoptionen:
|
||||
</p>
|
||||
<ParameterTable
|
||||
parameters={[
|
||||
{
|
||||
name: 'tenantId',
|
||||
type: 'string',
|
||||
required: true,
|
||||
description: 'Ihre eindeutige Tenant-ID (erhalten nach Abo-Abschluss)',
|
||||
},
|
||||
{
|
||||
name: 'apiKey',
|
||||
type: 'string',
|
||||
required: false,
|
||||
description: 'API Key fuer authentifizierte Anfragen (nur serverseitig verwenden)',
|
||||
},
|
||||
{
|
||||
name: 'userId',
|
||||
type: 'string',
|
||||
required: false,
|
||||
description: 'Benutzer-ID fuer Audit-Trail und Checkpoints',
|
||||
},
|
||||
{
|
||||
name: 'enableBackendSync',
|
||||
type: 'boolean',
|
||||
required: false,
|
||||
description: 'Aktiviert automatische Synchronisation mit dem Backend (default: false)',
|
||||
},
|
||||
{
|
||||
name: 'apiBaseUrl',
|
||||
type: 'string',
|
||||
required: false,
|
||||
description: 'Custom API URL fuer Self-Hosted Installationen',
|
||||
},
|
||||
{
|
||||
name: 'syncInterval',
|
||||
type: 'number',
|
||||
required: false,
|
||||
description: 'Intervall fuer Auto-Sync in Millisekunden (default: 30000)',
|
||||
},
|
||||
{
|
||||
name: 'enableOfflineSupport',
|
||||
type: 'boolean',
|
||||
required: false,
|
||||
description: 'Aktiviert localStorage Fallback bei Offline (default: true)',
|
||||
},
|
||||
{
|
||||
name: 'initialStep',
|
||||
type: 'string',
|
||||
required: false,
|
||||
description: 'Initialer Schritt beim ersten Laden (default: "advisory-board")',
|
||||
},
|
||||
{
|
||||
name: 'onError',
|
||||
type: '(error: Error) => void',
|
||||
required: false,
|
||||
description: 'Callback fuer Fehlerbehandlung',
|
||||
},
|
||||
{
|
||||
name: 'onStateChange',
|
||||
type: '(state: SDKState) => void',
|
||||
required: false,
|
||||
description: 'Callback bei State-Aenderungen',
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
<h2>Vollstaendiges Beispiel</h2>
|
||||
<CodeBlock language="typescript" filename="app/layout.tsx">
|
||||
{`'use client'
|
||||
|
||||
import { SDKProvider } from '@breakpilot/compliance-sdk'
|
||||
import { useRouter } from 'next/navigation'
|
||||
|
||||
export default function SDKLayout({ children }: { children: React.ReactNode }) {
|
||||
const router = useRouter()
|
||||
|
||||
return (
|
||||
<SDKProvider
|
||||
tenantId={process.env.NEXT_PUBLIC_TENANT_ID!}
|
||||
userId="user-123"
|
||||
enableBackendSync={true}
|
||||
syncInterval={60000} // Sync alle 60 Sekunden
|
||||
enableOfflineSupport={true}
|
||||
initialStep="use-case-workshop"
|
||||
onError={(error) => {
|
||||
console.error('SDK Error:', error)
|
||||
// Optional: Sentry oder anderes Error-Tracking
|
||||
}}
|
||||
onStateChange={(state) => {
|
||||
console.log('State changed:', state.currentStep)
|
||||
// Optional: Analytics-Events
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</SDKProvider>
|
||||
)
|
||||
}`}
|
||||
</CodeBlock>
|
||||
|
||||
<h2>Synchronisations-Strategien</h2>
|
||||
|
||||
<h3>1. Nur localStorage (Offline-Only)</h3>
|
||||
<CodeBlock language="typescript" filename="Offline-Only">
|
||||
{`<SDKProvider
|
||||
tenantId="my-tenant"
|
||||
enableBackendSync={false}
|
||||
enableOfflineSupport={true}
|
||||
>
|
||||
{children}
|
||||
</SDKProvider>`}
|
||||
</CodeBlock>
|
||||
<p>
|
||||
Ideal fuer: Lokale Entwicklung, Demos, Privacy-fokussierte Installationen.
|
||||
Daten werden nur im Browser gespeichert.
|
||||
</p>
|
||||
|
||||
<h3>2. Backend-Sync mit Fallback</h3>
|
||||
<CodeBlock language="typescript" filename="Backend + Fallback">
|
||||
{`<SDKProvider
|
||||
tenantId="my-tenant"
|
||||
enableBackendSync={true}
|
||||
enableOfflineSupport={true}
|
||||
syncInterval={30000}
|
||||
>
|
||||
{children}
|
||||
</SDKProvider>`}
|
||||
</CodeBlock>
|
||||
<p>
|
||||
Empfohlen fuer: Produktionsumgebungen. Daten werden mit dem Backend
|
||||
synchronisiert, localStorage dient als Fallback bei Netzwerkproblemen.
|
||||
</p>
|
||||
|
||||
<h3>3. Nur Backend (kein lokaler Cache)</h3>
|
||||
<CodeBlock language="typescript" filename="Backend-Only">
|
||||
{`<SDKProvider
|
||||
tenantId="my-tenant"
|
||||
enableBackendSync={true}
|
||||
enableOfflineSupport={false}
|
||||
>
|
||||
{children}
|
||||
</SDKProvider>`}
|
||||
</CodeBlock>
|
||||
<p>
|
||||
Ideal fuer: Strenge Compliance-Anforderungen, Multi-User-Szenarien.
|
||||
Daten werden nur im Backend gespeichert.
|
||||
</p>
|
||||
|
||||
<InfoBox type="warning" title="Backend-Only Modus">
|
||||
Im Backend-Only Modus ist eine aktive Internetverbindung erforderlich.
|
||||
Bei Netzwerkproblemen koennen Daten verloren gehen.
|
||||
</InfoBox>
|
||||
|
||||
<h2>API URL Konfiguration</h2>
|
||||
|
||||
<h3>Cloud-Version (Standard)</h3>
|
||||
<p>Keine zusaetzliche Konfiguration erforderlich:</p>
|
||||
<CodeBlock language="typescript" filename="Cloud">
|
||||
{`<SDKProvider tenantId="my-tenant">
|
||||
{/* Nutzt automatisch https://api.breakpilot.io/sdk/v1 */}
|
||||
</SDKProvider>`}
|
||||
</CodeBlock>
|
||||
|
||||
<h3>Self-Hosted</h3>
|
||||
<CodeBlock language="typescript" filename="Self-Hosted">
|
||||
{`<SDKProvider
|
||||
tenantId="my-tenant"
|
||||
apiBaseUrl="https://your-server.com/sdk/v1"
|
||||
>
|
||||
{children}
|
||||
</SDKProvider>`}
|
||||
</CodeBlock>
|
||||
|
||||
<h3>Lokale Entwicklung</h3>
|
||||
<CodeBlock language="typescript" filename="Local Dev">
|
||||
{`<SDKProvider
|
||||
tenantId="dev-tenant"
|
||||
apiBaseUrl="http://localhost:8085/sdk/v1"
|
||||
enableBackendSync={true}
|
||||
>
|
||||
{children}
|
||||
</SDKProvider>`}
|
||||
</CodeBlock>
|
||||
|
||||
<h2>Feature Flags</h2>
|
||||
<p>
|
||||
Das SDK unterstuetzt Feature Flags ueber Subscription-Levels:
|
||||
</p>
|
||||
<CodeBlock language="typescript" filename="Feature Checks">
|
||||
{`import { useSDK } from '@breakpilot/compliance-sdk'
|
||||
|
||||
function MyComponent() {
|
||||
const { state } = useSDK()
|
||||
|
||||
// Subscription-basierte Features
|
||||
const isEnterprise = state.subscription === 'ENTERPRISE'
|
||||
const isProfessional = ['PROFESSIONAL', 'ENTERPRISE'].includes(state.subscription)
|
||||
|
||||
// Feature-Gates
|
||||
const canExportPDF = isProfessional
|
||||
const canUseRAG = isProfessional
|
||||
const canCustomizeDSFA = isEnterprise
|
||||
const canUseAPI = isProfessional
|
||||
|
||||
return (
|
||||
<div>
|
||||
{canExportPDF && <button>PDF Export</button>}
|
||||
{canUseRAG && <RAGSearchPanel />}
|
||||
</div>
|
||||
)
|
||||
}`}
|
||||
</CodeBlock>
|
||||
|
||||
<h2>Logging & Debugging</h2>
|
||||
<p>
|
||||
Aktivieren Sie detailliertes Logging fuer die Entwicklung:
|
||||
</p>
|
||||
<CodeBlock language="typescript" filename="Debug Mode">
|
||||
{`// In Ihrer .env.local
|
||||
NEXT_PUBLIC_SDK_DEBUG=true
|
||||
|
||||
// Oder programmatisch
|
||||
<SDKProvider
|
||||
tenantId="my-tenant"
|
||||
onStateChange={(state) => {
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
console.log('[SDK] State Update:', {
|
||||
phase: state.currentPhase,
|
||||
step: state.currentStep,
|
||||
useCases: state.useCases.length,
|
||||
risks: state.risks.length,
|
||||
})
|
||||
}
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</SDKProvider>`}
|
||||
</CodeBlock>
|
||||
|
||||
<InfoBox type="info" title="React DevTools">
|
||||
Der SDK-State ist im React DevTools unter dem SDKProvider-Context sichtbar.
|
||||
Installieren Sie die React Developer Tools Browser-Extension fuer einfaches Debugging.
|
||||
</InfoBox>
|
||||
</DevPortalLayout>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,482 @@
|
||||
'use client'
|
||||
|
||||
import React, { useState } from 'react'
|
||||
import { SDKDocsSidebar } from '@/components/developers/SDKDocsSidebar'
|
||||
import { Copy, Check } from 'lucide-react'
|
||||
|
||||
function CopyButton({ text }: { text: string }) {
|
||||
const [copied, setCopied] = useState(false)
|
||||
|
||||
const handleCopy = async () => {
|
||||
await navigator.clipboard.writeText(text)
|
||||
setCopied(true)
|
||||
setTimeout(() => setCopied(false), 2000)
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={handleCopy}
|
||||
className="p-2 hover:bg-gray-700 rounded transition-colors"
|
||||
title="Kopieren"
|
||||
>
|
||||
{copied ? (
|
||||
<Check className="w-4 h-4 text-green-400" />
|
||||
) : (
|
||||
<Copy className="w-4 h-4 text-gray-400" />
|
||||
)}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
function CodeBlock({ code }: { code: string }) {
|
||||
return (
|
||||
<div className="relative bg-gray-900 rounded-lg overflow-hidden">
|
||||
<div className="absolute top-2 right-2">
|
||||
<CopyButton text={code} />
|
||||
</div>
|
||||
<pre className="p-4 text-sm text-gray-300 font-mono overflow-x-auto">
|
||||
<code>{code}</code>
|
||||
</pre>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function MethodCard({
|
||||
name,
|
||||
signature,
|
||||
description,
|
||||
params,
|
||||
returns,
|
||||
example,
|
||||
}: {
|
||||
name: string
|
||||
signature: string
|
||||
description: string
|
||||
params?: { name: string; type: string; description: string }[]
|
||||
returns?: string
|
||||
example?: string
|
||||
}) {
|
||||
return (
|
||||
<div className="bg-white rounded-xl border border-gray-200 overflow-hidden">
|
||||
<div className="px-6 py-4 border-b border-gray-200 bg-gray-50">
|
||||
<code className="text-violet-600 font-mono font-semibold">{name}</code>
|
||||
</div>
|
||||
<div className="p-6">
|
||||
<div className="bg-gray-100 rounded-lg p-3 mb-4">
|
||||
<code className="text-sm font-mono text-gray-800">{signature}</code>
|
||||
</div>
|
||||
<p className="text-gray-600 mb-4">{description}</p>
|
||||
|
||||
{params && params.length > 0 && (
|
||||
<div className="mb-4">
|
||||
<h4 className="font-medium text-gray-900 mb-2">Parameter</h4>
|
||||
<table className="min-w-full">
|
||||
<tbody className="divide-y divide-gray-200">
|
||||
{params.map((param) => (
|
||||
<tr key={param.name}>
|
||||
<td className="py-2 pr-4">
|
||||
<code className="text-sm text-violet-600">{param.name}</code>
|
||||
</td>
|
||||
<td className="py-2 pr-4">
|
||||
<code className="text-sm text-gray-500">{param.type}</code>
|
||||
</td>
|
||||
<td className="py-2 text-sm text-gray-600">{param.description}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{returns && (
|
||||
<div className="mb-4">
|
||||
<h4 className="font-medium text-gray-900 mb-2">Rueckgabe</h4>
|
||||
<code className="text-sm text-gray-600">{returns}</code>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{example && (
|
||||
<div>
|
||||
<h4 className="font-medium text-gray-900 mb-2">Beispiel</h4>
|
||||
<CodeBlock code={example} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function APIReferencePage() {
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 flex">
|
||||
<SDKDocsSidebar />
|
||||
|
||||
<main className="flex-1 ml-64">
|
||||
<div className="max-w-4xl mx-auto px-8 py-12">
|
||||
<h1 className="text-3xl font-bold text-gray-900 mb-2">API Referenz</h1>
|
||||
<p className="text-lg text-gray-600 mb-8">
|
||||
Vollstaendige Dokumentation aller Methoden und Konfigurationsoptionen des Consent SDK.
|
||||
</p>
|
||||
|
||||
{/* ConsentManager */}
|
||||
<section className="mb-12">
|
||||
<h2 className="text-2xl font-semibold text-gray-900 mb-6">ConsentManager</h2>
|
||||
<p className="text-gray-600 mb-6">
|
||||
Die zentrale Klasse fuer das Consent Management. Verwaltet Einwilligungen, Script-Blocking und Events.
|
||||
</p>
|
||||
|
||||
{/* Constructor */}
|
||||
<div className="space-y-6">
|
||||
<MethodCard
|
||||
name="constructor"
|
||||
signature="new ConsentManager(config: ConsentConfig)"
|
||||
description="Erstellt eine neue Instanz des ConsentManagers mit der angegebenen Konfiguration."
|
||||
params={[
|
||||
{
|
||||
name: 'config',
|
||||
type: 'ConsentConfig',
|
||||
description: 'Konfigurationsobjekt fuer den Manager',
|
||||
},
|
||||
]}
|
||||
example={`const consent = new ConsentManager({
|
||||
apiEndpoint: 'https://api.example.com/consent',
|
||||
siteId: 'my-site',
|
||||
debug: true,
|
||||
});`}
|
||||
/>
|
||||
|
||||
<MethodCard
|
||||
name="init"
|
||||
signature="async init(): Promise<void>"
|
||||
description="Initialisiert das SDK, laedt bestehenden Consent und startet das Script-Blocking. Zeigt den Banner an falls noetig."
|
||||
example={`await consent.init();`}
|
||||
/>
|
||||
|
||||
<MethodCard
|
||||
name="hasConsent"
|
||||
signature="hasConsent(category: ConsentCategory): boolean"
|
||||
description="Prueft ob Einwilligung fuer eine Kategorie vorliegt."
|
||||
params={[
|
||||
{
|
||||
name: 'category',
|
||||
type: 'ConsentCategory',
|
||||
description: 'essential | functional | analytics | marketing | social',
|
||||
},
|
||||
]}
|
||||
returns="boolean - true wenn Einwilligung vorliegt"
|
||||
example={`if (consent.hasConsent('analytics')) {
|
||||
// Analytics laden
|
||||
loadGoogleAnalytics();
|
||||
}`}
|
||||
/>
|
||||
|
||||
<MethodCard
|
||||
name="setConsent"
|
||||
signature="async setConsent(input: ConsentInput): Promise<void>"
|
||||
description="Setzt die Einwilligungen und speichert sie lokal sowie auf dem Server."
|
||||
params={[
|
||||
{
|
||||
name: 'input',
|
||||
type: 'ConsentInput',
|
||||
description: 'Objekt mit Kategorien und optionalen Vendors',
|
||||
},
|
||||
]}
|
||||
example={`await consent.setConsent({
|
||||
essential: true,
|
||||
functional: true,
|
||||
analytics: true,
|
||||
marketing: false,
|
||||
social: false,
|
||||
});`}
|
||||
/>
|
||||
|
||||
<MethodCard
|
||||
name="acceptAll"
|
||||
signature="async acceptAll(): Promise<void>"
|
||||
description="Akzeptiert alle Consent-Kategorien und schliesst den Banner."
|
||||
example={`document.getElementById('accept-all').addEventListener('click', async () => {
|
||||
await consent.acceptAll();
|
||||
});`}
|
||||
/>
|
||||
|
||||
<MethodCard
|
||||
name="rejectAll"
|
||||
signature="async rejectAll(): Promise<void>"
|
||||
description="Lehnt alle nicht-essentiellen Kategorien ab und schliesst den Banner."
|
||||
example={`document.getElementById('reject-all').addEventListener('click', async () => {
|
||||
await consent.rejectAll();
|
||||
});`}
|
||||
/>
|
||||
|
||||
<MethodCard
|
||||
name="revokeAll"
|
||||
signature="async revokeAll(): Promise<void>"
|
||||
description="Widerruft alle Einwilligungen und loescht den gespeicherten Consent."
|
||||
example={`document.getElementById('revoke').addEventListener('click', async () => {
|
||||
await consent.revokeAll();
|
||||
location.reload();
|
||||
});`}
|
||||
/>
|
||||
|
||||
<MethodCard
|
||||
name="on"
|
||||
signature="on<T>(event: ConsentEventType, callback: (data: T) => void): () => void"
|
||||
description="Registriert einen Event-Listener. Gibt eine Unsubscribe-Funktion zurueck."
|
||||
params={[
|
||||
{
|
||||
name: 'event',
|
||||
type: 'ConsentEventType',
|
||||
description: 'init | change | accept_all | reject_all | banner_show | banner_hide | etc.',
|
||||
},
|
||||
{
|
||||
name: 'callback',
|
||||
type: 'function',
|
||||
description: 'Callback-Funktion die bei Event aufgerufen wird',
|
||||
},
|
||||
]}
|
||||
returns="() => void - Funktion zum Entfernen des Listeners"
|
||||
example={`const unsubscribe = consent.on('change', (state) => {
|
||||
console.log('Consent geaendert:', state);
|
||||
});
|
||||
|
||||
// Spaeter: Listener entfernen
|
||||
unsubscribe();`}
|
||||
/>
|
||||
|
||||
<MethodCard
|
||||
name="getConsent"
|
||||
signature="getConsent(): ConsentState | null"
|
||||
description="Gibt den aktuellen Consent-Status zurueck oder null falls kein Consent vorliegt."
|
||||
returns="ConsentState | null"
|
||||
example={`const state = consent.getConsent();
|
||||
if (state) {
|
||||
console.log('Consent ID:', state.consentId);
|
||||
console.log('Kategorien:', state.categories);
|
||||
}`}
|
||||
/>
|
||||
|
||||
<MethodCard
|
||||
name="exportConsent"
|
||||
signature="async exportConsent(): Promise<string>"
|
||||
description="Exportiert alle Consent-Daten als JSON-String (DSGVO Art. 20 Datenportabilitaet)."
|
||||
returns="Promise<string> - JSON-formatierter Export"
|
||||
example={`const exportData = await consent.exportConsent();
|
||||
downloadAsFile(exportData, 'consent-export.json');`}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Configuration */}
|
||||
<section className="mb-12">
|
||||
<h2 className="text-2xl font-semibold text-gray-900 mb-6">Konfiguration</h2>
|
||||
|
||||
<div className="bg-white rounded-xl border border-gray-200 overflow-hidden">
|
||||
<table className="min-w-full divide-y divide-gray-200">
|
||||
<thead className="bg-gray-50">
|
||||
<tr>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">
|
||||
Option
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">
|
||||
Typ
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">
|
||||
Default
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">
|
||||
Beschreibung
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-200">
|
||||
<tr>
|
||||
<td className="px-6 py-4">
|
||||
<code className="text-sm text-violet-600">apiEndpoint</code>
|
||||
</td>
|
||||
<td className="px-6 py-4">
|
||||
<code className="text-sm text-gray-500">string</code>
|
||||
</td>
|
||||
<td className="px-6 py-4 text-sm text-gray-500">erforderlich</td>
|
||||
<td className="px-6 py-4 text-sm text-gray-600">URL des Consent-Backends</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="px-6 py-4">
|
||||
<code className="text-sm text-violet-600">siteId</code>
|
||||
</td>
|
||||
<td className="px-6 py-4">
|
||||
<code className="text-sm text-gray-500">string</code>
|
||||
</td>
|
||||
<td className="px-6 py-4 text-sm text-gray-500">erforderlich</td>
|
||||
<td className="px-6 py-4 text-sm text-gray-600">Eindeutige Site-ID</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="px-6 py-4">
|
||||
<code className="text-sm text-violet-600">debug</code>
|
||||
</td>
|
||||
<td className="px-6 py-4">
|
||||
<code className="text-sm text-gray-500">boolean</code>
|
||||
</td>
|
||||
<td className="px-6 py-4 text-sm text-gray-500">false</td>
|
||||
<td className="px-6 py-4 text-sm text-gray-600">Aktiviert Debug-Logging</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="px-6 py-4">
|
||||
<code className="text-sm text-violet-600">language</code>
|
||||
</td>
|
||||
<td className="px-6 py-4">
|
||||
<code className="text-sm text-gray-500">string</code>
|
||||
</td>
|
||||
<td className="px-6 py-4 text-sm text-gray-500">'de'</td>
|
||||
<td className="px-6 py-4 text-sm text-gray-600">Sprache fuer UI-Texte</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="px-6 py-4">
|
||||
<code className="text-sm text-violet-600">consent.rememberDays</code>
|
||||
</td>
|
||||
<td className="px-6 py-4">
|
||||
<code className="text-sm text-gray-500">number</code>
|
||||
</td>
|
||||
<td className="px-6 py-4 text-sm text-gray-500">365</td>
|
||||
<td className="px-6 py-4 text-sm text-gray-600">Gueltigkeitsdauer in Tagen</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="px-6 py-4">
|
||||
<code className="text-sm text-violet-600">consent.recheckAfterDays</code>
|
||||
</td>
|
||||
<td className="px-6 py-4">
|
||||
<code className="text-sm text-gray-500">number</code>
|
||||
</td>
|
||||
<td className="px-6 py-4 text-sm text-gray-500">180</td>
|
||||
<td className="px-6 py-4 text-sm text-gray-600">Erneute Abfrage nach X Tagen</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Events */}
|
||||
<section className="mb-12">
|
||||
<h2 className="text-2xl font-semibold text-gray-900 mb-6">Events</h2>
|
||||
|
||||
<div className="bg-white rounded-xl border border-gray-200 overflow-hidden">
|
||||
<table className="min-w-full divide-y divide-gray-200">
|
||||
<thead className="bg-gray-50">
|
||||
<tr>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">
|
||||
Event
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">
|
||||
Daten
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">
|
||||
Beschreibung
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-200">
|
||||
<tr>
|
||||
<td className="px-6 py-4">
|
||||
<code className="text-sm text-violet-600">init</code>
|
||||
</td>
|
||||
<td className="px-6 py-4">
|
||||
<code className="text-sm text-gray-500">ConsentState | null</code>
|
||||
</td>
|
||||
<td className="px-6 py-4 text-sm text-gray-600">SDK initialisiert</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="px-6 py-4">
|
||||
<code className="text-sm text-violet-600">change</code>
|
||||
</td>
|
||||
<td className="px-6 py-4">
|
||||
<code className="text-sm text-gray-500">ConsentState</code>
|
||||
</td>
|
||||
<td className="px-6 py-4 text-sm text-gray-600">Consent geaendert</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="px-6 py-4">
|
||||
<code className="text-sm text-violet-600">accept_all</code>
|
||||
</td>
|
||||
<td className="px-6 py-4">
|
||||
<code className="text-sm text-gray-500">ConsentState</code>
|
||||
</td>
|
||||
<td className="px-6 py-4 text-sm text-gray-600">Alle akzeptiert</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="px-6 py-4">
|
||||
<code className="text-sm text-violet-600">reject_all</code>
|
||||
</td>
|
||||
<td className="px-6 py-4">
|
||||
<code className="text-sm text-gray-500">ConsentState</code>
|
||||
</td>
|
||||
<td className="px-6 py-4 text-sm text-gray-600">Alle abgelehnt</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="px-6 py-4">
|
||||
<code className="text-sm text-violet-600">banner_show</code>
|
||||
</td>
|
||||
<td className="px-6 py-4">
|
||||
<code className="text-sm text-gray-500">undefined</code>
|
||||
</td>
|
||||
<td className="px-6 py-4 text-sm text-gray-600">Banner angezeigt</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="px-6 py-4">
|
||||
<code className="text-sm text-violet-600">banner_hide</code>
|
||||
</td>
|
||||
<td className="px-6 py-4">
|
||||
<code className="text-sm text-gray-500">undefined</code>
|
||||
</td>
|
||||
<td className="px-6 py-4 text-sm text-gray-600">Banner versteckt</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="px-6 py-4">
|
||||
<code className="text-sm text-violet-600">error</code>
|
||||
</td>
|
||||
<td className="px-6 py-4">
|
||||
<code className="text-sm text-gray-500">Error</code>
|
||||
</td>
|
||||
<td className="px-6 py-4 text-sm text-gray-600">Fehler aufgetreten</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Types */}
|
||||
<section>
|
||||
<h2 className="text-2xl font-semibold text-gray-900 mb-6">TypeScript Types</h2>
|
||||
<CodeBlock
|
||||
code={`// Consent-Kategorien
|
||||
type ConsentCategory = 'essential' | 'functional' | 'analytics' | 'marketing' | 'social';
|
||||
|
||||
// Consent-Status
|
||||
interface ConsentState {
|
||||
categories: Record<ConsentCategory, boolean>;
|
||||
vendors: Record<string, boolean>;
|
||||
timestamp: string;
|
||||
version: string;
|
||||
consentId?: string;
|
||||
expiresAt?: string;
|
||||
}
|
||||
|
||||
// Konfiguration
|
||||
interface ConsentConfig {
|
||||
apiEndpoint: string;
|
||||
siteId: string;
|
||||
debug?: boolean;
|
||||
language?: string;
|
||||
fallbackLanguage?: string;
|
||||
ui?: ConsentUIConfig;
|
||||
consent?: ConsentBehaviorConfig;
|
||||
onConsentChange?: (state: ConsentState) => void;
|
||||
onBannerShow?: () => void;
|
||||
onBannerHide?: () => void;
|
||||
onError?: (error: Error) => void;
|
||||
}`}
|
||||
/>
|
||||
</section>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,281 @@
|
||||
'use client'
|
||||
|
||||
import React, { useState } from 'react'
|
||||
import { SDKDocsSidebar } from '@/components/developers/SDKDocsSidebar'
|
||||
import { Copy, Check } from 'lucide-react'
|
||||
|
||||
function CopyButton({ text }: { text: string }) {
|
||||
const [copied, setCopied] = useState(false)
|
||||
const handleCopy = async () => {
|
||||
await navigator.clipboard.writeText(text)
|
||||
setCopied(true)
|
||||
setTimeout(() => setCopied(false), 2000)
|
||||
}
|
||||
return (
|
||||
<button onClick={handleCopy} className="p-2 hover:bg-gray-700 rounded transition-colors">
|
||||
{copied ? <Check className="w-4 h-4 text-green-400" /> : <Copy className="w-4 h-4 text-gray-400" />}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
function CodeBlock({ code, filename }: { code: string; filename?: string }) {
|
||||
return (
|
||||
<div className="bg-gray-900 rounded-lg overflow-hidden">
|
||||
{filename && (
|
||||
<div className="px-4 py-2 bg-gray-800 text-gray-400 text-xs border-b border-gray-700">
|
||||
{filename}
|
||||
</div>
|
||||
)}
|
||||
<div className="relative">
|
||||
<div className="absolute top-2 right-2">
|
||||
<CopyButton text={code} />
|
||||
</div>
|
||||
<pre className="p-4 text-sm text-gray-300 font-mono overflow-x-auto">
|
||||
<code>{code}</code>
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function AngularIntegrationPage() {
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 flex">
|
||||
<SDKDocsSidebar />
|
||||
|
||||
<main className="flex-1 ml-64">
|
||||
<div className="max-w-4xl mx-auto px-8 py-12">
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<div className="w-10 h-10 rounded-lg bg-red-500 flex items-center justify-center">
|
||||
<span className="text-white font-bold">A</span>
|
||||
</div>
|
||||
<h1 className="text-3xl font-bold text-gray-900">Angular Integration</h1>
|
||||
</div>
|
||||
<p className="text-lg text-gray-600 mb-8">
|
||||
Service und Module fuer Angular 14+ Projekte.
|
||||
</p>
|
||||
|
||||
{/* Installation */}
|
||||
<section className="mb-12">
|
||||
<h2 className="text-xl font-semibold text-gray-900 mb-4">Installation</h2>
|
||||
<CodeBlock code="npm install @breakpilot/consent-sdk" />
|
||||
</section>
|
||||
|
||||
{/* Module Setup */}
|
||||
<section className="mb-12">
|
||||
<h2 className="text-xl font-semibold text-gray-900 mb-4">Module Setup</h2>
|
||||
<CodeBlock
|
||||
filename="app.module.ts"
|
||||
code={`import { NgModule } from '@angular/core';
|
||||
import { BrowserModule } from '@angular/platform-browser';
|
||||
import { ConsentModule } from '@breakpilot/consent-sdk/angular';
|
||||
import { environment } from '../environments/environment';
|
||||
|
||||
@NgModule({
|
||||
imports: [
|
||||
BrowserModule,
|
||||
ConsentModule.forRoot({
|
||||
apiEndpoint: environment.consentApi,
|
||||
siteId: 'my-site',
|
||||
debug: !environment.production,
|
||||
}),
|
||||
],
|
||||
})
|
||||
export class AppModule {}`}
|
||||
/>
|
||||
</section>
|
||||
|
||||
{/* Standalone Setup */}
|
||||
<section className="mb-12">
|
||||
<h2 className="text-xl font-semibold text-gray-900 mb-4">Standalone Setup (Angular 15+)</h2>
|
||||
<CodeBlock
|
||||
filename="app.config.ts"
|
||||
code={`import { ApplicationConfig } from '@angular/core';
|
||||
import { provideConsent } from '@breakpilot/consent-sdk/angular';
|
||||
import { environment } from '../environments/environment';
|
||||
|
||||
export const appConfig: ApplicationConfig = {
|
||||
providers: [
|
||||
provideConsent({
|
||||
apiEndpoint: environment.consentApi,
|
||||
siteId: 'my-site',
|
||||
debug: !environment.production,
|
||||
}),
|
||||
],
|
||||
};`}
|
||||
/>
|
||||
</section>
|
||||
|
||||
{/* Service Usage */}
|
||||
<section className="mb-12">
|
||||
<h2 className="text-xl font-semibold text-gray-900 mb-4">Service Usage</h2>
|
||||
<CodeBlock
|
||||
filename="components/analytics.component.ts"
|
||||
code={`import { Component, OnInit } from '@angular/core';
|
||||
import { ConsentService } from '@breakpilot/consent-sdk/angular';
|
||||
|
||||
@Component({
|
||||
selector: 'app-analytics',
|
||||
template: \`
|
||||
<div *ngIf="hasAnalyticsConsent$ | async">
|
||||
<!-- Analytics Code hier -->
|
||||
</div>
|
||||
\`,
|
||||
})
|
||||
export class AnalyticsComponent implements OnInit {
|
||||
hasAnalyticsConsent$ = this.consentService.hasConsent$('analytics');
|
||||
|
||||
constructor(private consentService: ConsentService) {}
|
||||
|
||||
async loadAnalytics() {
|
||||
if (await this.consentService.hasConsent('analytics')) {
|
||||
// Load analytics
|
||||
}
|
||||
}
|
||||
}`}
|
||||
/>
|
||||
</section>
|
||||
|
||||
{/* Cookie Banner */}
|
||||
<section className="mb-12">
|
||||
<h2 className="text-xl font-semibold text-gray-900 mb-4">Cookie Banner Component</h2>
|
||||
<CodeBlock
|
||||
filename="components/cookie-banner.component.ts"
|
||||
code={`import { Component } from '@angular/core';
|
||||
import { ConsentService } from '@breakpilot/consent-sdk/angular';
|
||||
|
||||
@Component({
|
||||
selector: 'app-cookie-banner',
|
||||
template: \`
|
||||
<div
|
||||
*ngIf="isBannerVisible$ | async"
|
||||
class="fixed bottom-0 inset-x-0 bg-white border-t shadow-lg p-4 z-50"
|
||||
>
|
||||
<div class="max-w-4xl mx-auto flex items-center justify-between">
|
||||
<p class="text-sm text-gray-600">
|
||||
Wir verwenden Cookies um Ihr Erlebnis zu verbessern.
|
||||
</p>
|
||||
<div class="flex gap-2">
|
||||
<button (click)="rejectAll()" class="px-4 py-2 border rounded">
|
||||
Ablehnen
|
||||
</button>
|
||||
<button (click)="showSettings()" class="px-4 py-2 border rounded">
|
||||
Einstellungen
|
||||
</button>
|
||||
<button (click)="acceptAll()" class="px-4 py-2 bg-blue-600 text-white rounded">
|
||||
Alle akzeptieren
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
\`,
|
||||
})
|
||||
export class CookieBannerComponent {
|
||||
isBannerVisible$ = this.consentService.isBannerVisible$;
|
||||
|
||||
constructor(private consentService: ConsentService) {}
|
||||
|
||||
async acceptAll() {
|
||||
await this.consentService.acceptAll();
|
||||
}
|
||||
|
||||
async rejectAll() {
|
||||
await this.consentService.rejectAll();
|
||||
}
|
||||
|
||||
showSettings() {
|
||||
this.consentService.showSettings();
|
||||
}
|
||||
}`}
|
||||
/>
|
||||
</section>
|
||||
|
||||
{/* Directive */}
|
||||
<section className="mb-12">
|
||||
<h2 className="text-xl font-semibold text-gray-900 mb-4">ConsentGate Directive</h2>
|
||||
<CodeBlock
|
||||
filename="template.html"
|
||||
code={`<!-- Zeigt Element nur wenn Consent vorhanden -->
|
||||
<iframe
|
||||
*consentGate="'social'"
|
||||
src="https://www.youtube.com/embed/VIDEO_ID"
|
||||
width="560"
|
||||
height="315"
|
||||
></iframe>
|
||||
|
||||
<!-- Mit Custom Fallback -->
|
||||
<div *consentGate="'analytics'; else noConsent">
|
||||
<app-analytics-dashboard></app-analytics-dashboard>
|
||||
</div>
|
||||
<ng-template #noConsent>
|
||||
<div class="bg-gray-100 p-4 rounded-lg text-center">
|
||||
<p>Bitte stimmen Sie Statistik-Cookies zu.</p>
|
||||
<button (click)="showSettings()">Einstellungen</button>
|
||||
</div>
|
||||
</ng-template>`}
|
||||
/>
|
||||
</section>
|
||||
|
||||
{/* Service API */}
|
||||
<section>
|
||||
<h2 className="text-xl font-semibold text-gray-900 mb-4">Service API</h2>
|
||||
<div className="bg-white rounded-xl border border-gray-200 overflow-hidden">
|
||||
<table className="min-w-full divide-y divide-gray-200">
|
||||
<thead className="bg-gray-50">
|
||||
<tr>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">
|
||||
Property/Method
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">
|
||||
Typ
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">
|
||||
Beschreibung
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-200">
|
||||
<tr>
|
||||
<td className="px-6 py-4"><code className="text-violet-600">consent$</code></td>
|
||||
<td className="px-6 py-4"><code className="text-gray-500">Observable<ConsentState></code></td>
|
||||
<td className="px-6 py-4 text-sm text-gray-600">Observable des aktuellen Consent</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="px-6 py-4"><code className="text-violet-600">hasConsent$()</code></td>
|
||||
<td className="px-6 py-4"><code className="text-gray-500">Observable<boolean></code></td>
|
||||
<td className="px-6 py-4 text-sm text-gray-600">Reaktive Consent-Pruefung</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="px-6 py-4"><code className="text-violet-600">hasConsent()</code></td>
|
||||
<td className="px-6 py-4"><code className="text-gray-500">Promise<boolean></code></td>
|
||||
<td className="px-6 py-4 text-sm text-gray-600">Async Consent-Pruefung</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="px-6 py-4"><code className="text-violet-600">isBannerVisible$</code></td>
|
||||
<td className="px-6 py-4"><code className="text-gray-500">Observable<boolean></code></td>
|
||||
<td className="px-6 py-4 text-sm text-gray-600">Banner-Sichtbarkeit</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="px-6 py-4"><code className="text-violet-600">acceptAll()</code></td>
|
||||
<td className="px-6 py-4"><code className="text-gray-500">Promise<void></code></td>
|
||||
<td className="px-6 py-4 text-sm text-gray-600">Akzeptiert alle</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="px-6 py-4"><code className="text-violet-600">rejectAll()</code></td>
|
||||
<td className="px-6 py-4"><code className="text-gray-500">Promise<void></code></td>
|
||||
<td className="px-6 py-4 text-sm text-gray-600">Lehnt alle ab</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="px-6 py-4"><code className="text-violet-600">setConsent()</code></td>
|
||||
<td className="px-6 py-4"><code className="text-gray-500">Promise<void></code></td>
|
||||
<td className="px-6 py-4 text-sm text-gray-600">Setzt spezifische Kategorien</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
'use client'
|
||||
|
||||
import React from 'react'
|
||||
import Link from 'next/link'
|
||||
import { SDKDocsSidebar } from '@/components/developers/SDKDocsSidebar'
|
||||
import { ChevronRight } from 'lucide-react'
|
||||
|
||||
const frameworks = [
|
||||
{
|
||||
name: 'React',
|
||||
href: '/developers/sdk/consent/frameworks/react',
|
||||
logo: '/logos/react.svg',
|
||||
description: 'Hooks und Provider fuer React 17+ und Next.js',
|
||||
features: ['ConsentProvider', 'useConsent Hook', 'ConsentGate Component'],
|
||||
color: 'bg-cyan-500',
|
||||
},
|
||||
{
|
||||
name: 'Vue 3',
|
||||
href: '/developers/sdk/consent/frameworks/vue',
|
||||
logo: '/logos/vue.svg',
|
||||
description: 'Composables und Plugin fuer Vue 3 und Nuxt',
|
||||
features: ['Vue Plugin', 'useConsent Composable', 'ConsentGate Component'],
|
||||
color: 'bg-emerald-500',
|
||||
},
|
||||
{
|
||||
name: 'Angular',
|
||||
href: '/developers/sdk/consent/frameworks/angular',
|
||||
logo: '/logos/angular.svg',
|
||||
description: 'Service und Module fuer Angular 14+',
|
||||
features: ['ConsentService', 'ConsentModule', 'Dependency Injection'],
|
||||
color: 'bg-red-500',
|
||||
},
|
||||
]
|
||||
|
||||
export default function FrameworksPage() {
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 flex">
|
||||
<SDKDocsSidebar />
|
||||
|
||||
<main className="flex-1 ml-64">
|
||||
<div className="max-w-4xl mx-auto px-8 py-12">
|
||||
<h1 className="text-3xl font-bold text-gray-900 mb-2">Framework Integration</h1>
|
||||
<p className="text-lg text-gray-600 mb-8">
|
||||
Das Consent SDK bietet native Integrationen fuer alle gaengigen Frontend-Frameworks.
|
||||
</p>
|
||||
|
||||
<div className="space-y-4">
|
||||
{frameworks.map((framework) => (
|
||||
<Link
|
||||
key={framework.name}
|
||||
href={framework.href}
|
||||
className="block bg-white rounded-xl border border-gray-200 p-6 hover:border-violet-300 hover:shadow-md transition-all group"
|
||||
>
|
||||
<div className="flex items-start gap-4">
|
||||
<div className={`w-12 h-12 rounded-xl ${framework.color} flex items-center justify-center shrink-0`}>
|
||||
<span className="text-white font-bold text-lg">{framework.name[0]}</span>
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<h2 className="text-xl font-semibold text-gray-900 group-hover:text-violet-600 transition-colors">
|
||||
{framework.name}
|
||||
</h2>
|
||||
<ChevronRight className="w-5 h-5 text-gray-400 group-hover:text-violet-600 transition-colors" />
|
||||
</div>
|
||||
<p className="text-gray-600 mt-1">{framework.description}</p>
|
||||
<div className="flex flex-wrap gap-2 mt-3">
|
||||
{framework.features.map((feature) => (
|
||||
<span
|
||||
key={feature}
|
||||
className="px-2 py-1 bg-gray-100 text-gray-600 text-xs rounded-md"
|
||||
>
|
||||
{feature}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Vanilla JS Note */}
|
||||
<div className="mt-8 p-4 bg-blue-50 border border-blue-200 rounded-xl">
|
||||
<h3 className="font-medium text-blue-900">Vanilla JavaScript</h3>
|
||||
<p className="text-sm text-blue-700 mt-1">
|
||||
Sie koennen das SDK auch ohne Framework verwenden. Importieren Sie einfach den ConsentManager direkt
|
||||
aus dem Hauptpaket. Siehe{' '}
|
||||
<Link href="/developers/sdk/consent/installation" className="underline">
|
||||
Installation
|
||||
</Link>{' '}
|
||||
fuer Details.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,277 @@
|
||||
'use client'
|
||||
|
||||
import React, { useState } from 'react'
|
||||
import { SDKDocsSidebar } from '@/components/developers/SDKDocsSidebar'
|
||||
import { Copy, Check } from 'lucide-react'
|
||||
|
||||
function CopyButton({ text }: { text: string }) {
|
||||
const [copied, setCopied] = useState(false)
|
||||
const handleCopy = async () => {
|
||||
await navigator.clipboard.writeText(text)
|
||||
setCopied(true)
|
||||
setTimeout(() => setCopied(false), 2000)
|
||||
}
|
||||
return (
|
||||
<button onClick={handleCopy} className="p-2 hover:bg-gray-700 rounded transition-colors">
|
||||
{copied ? <Check className="w-4 h-4 text-green-400" /> : <Copy className="w-4 h-4 text-gray-400" />}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
function CodeBlock({ code, filename }: { code: string; filename?: string }) {
|
||||
return (
|
||||
<div className="bg-gray-900 rounded-lg overflow-hidden">
|
||||
{filename && (
|
||||
<div className="px-4 py-2 bg-gray-800 text-gray-400 text-xs border-b border-gray-700">
|
||||
{filename}
|
||||
</div>
|
||||
)}
|
||||
<div className="relative">
|
||||
<div className="absolute top-2 right-2">
|
||||
<CopyButton text={code} />
|
||||
</div>
|
||||
<pre className="p-4 text-sm text-gray-300 font-mono overflow-x-auto">
|
||||
<code>{code}</code>
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function ReactIntegrationPage() {
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 flex">
|
||||
<SDKDocsSidebar />
|
||||
|
||||
<main className="flex-1 ml-64">
|
||||
<div className="max-w-4xl mx-auto px-8 py-12">
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<div className="w-10 h-10 rounded-lg bg-cyan-500 flex items-center justify-center">
|
||||
<span className="text-white font-bold">R</span>
|
||||
</div>
|
||||
<h1 className="text-3xl font-bold text-gray-900">React Integration</h1>
|
||||
</div>
|
||||
<p className="text-lg text-gray-600 mb-8">
|
||||
Hooks und Provider fuer React 17+ und Next.js Projekte.
|
||||
</p>
|
||||
|
||||
{/* Installation */}
|
||||
<section className="mb-12">
|
||||
<h2 className="text-xl font-semibold text-gray-900 mb-4">Installation</h2>
|
||||
<CodeBlock code="npm install @breakpilot/consent-sdk" />
|
||||
</section>
|
||||
|
||||
{/* Provider Setup */}
|
||||
<section className="mb-12">
|
||||
<h2 className="text-xl font-semibold text-gray-900 mb-4">Provider Setup</h2>
|
||||
<p className="text-gray-600 mb-4">
|
||||
Umschliessen Sie Ihre App mit dem ConsentProvider:
|
||||
</p>
|
||||
<CodeBlock
|
||||
filename="app/layout.tsx"
|
||||
code={`import { ConsentProvider } from '@breakpilot/consent-sdk/react';
|
||||
|
||||
export default function RootLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<html lang="de">
|
||||
<body>
|
||||
<ConsentProvider
|
||||
config={{
|
||||
apiEndpoint: process.env.NEXT_PUBLIC_CONSENT_API!,
|
||||
siteId: 'my-site',
|
||||
debug: process.env.NODE_ENV === 'development',
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</ConsentProvider>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}`}
|
||||
/>
|
||||
</section>
|
||||
|
||||
{/* useConsent Hook */}
|
||||
<section className="mb-12">
|
||||
<h2 className="text-xl font-semibold text-gray-900 mb-4">useConsent Hook</h2>
|
||||
<p className="text-gray-600 mb-4">
|
||||
Verwenden Sie den Hook in jeder Komponente:
|
||||
</p>
|
||||
<CodeBlock
|
||||
filename="components/Analytics.tsx"
|
||||
code={`import { useConsent } from '@breakpilot/consent-sdk/react';
|
||||
|
||||
export function Analytics() {
|
||||
const { hasConsent, acceptAll, rejectAll, showSettings } = useConsent();
|
||||
|
||||
if (!hasConsent('analytics')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Script
|
||||
src="https://www.googletagmanager.com/gtag/js?id=GA_ID"
|
||||
strategy="afterInteractive"
|
||||
/>
|
||||
);
|
||||
}`}
|
||||
/>
|
||||
</section>
|
||||
|
||||
{/* ConsentGate */}
|
||||
<section className="mb-12">
|
||||
<h2 className="text-xl font-semibold text-gray-900 mb-4">ConsentGate Component</h2>
|
||||
<p className="text-gray-600 mb-4">
|
||||
Zeigt Inhalte nur wenn Consent vorhanden ist:
|
||||
</p>
|
||||
<CodeBlock
|
||||
filename="components/YouTubeEmbed.tsx"
|
||||
code={`import { ConsentGate } from '@breakpilot/consent-sdk/react';
|
||||
|
||||
export function YouTubeEmbed({ videoId }: { videoId: string }) {
|
||||
return (
|
||||
<ConsentGate
|
||||
category="social"
|
||||
fallback={
|
||||
<div className="bg-gray-100 p-4 rounded-lg text-center">
|
||||
<p>Video erfordert Ihre Zustimmung.</p>
|
||||
<button onClick={() => showSettings()}>
|
||||
Einstellungen oeffnen
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<iframe
|
||||
src={\`https://www.youtube.com/embed/\${videoId}\`}
|
||||
width="560"
|
||||
height="315"
|
||||
allowFullScreen
|
||||
/>
|
||||
</ConsentGate>
|
||||
);
|
||||
}`}
|
||||
/>
|
||||
</section>
|
||||
|
||||
{/* Custom Banner */}
|
||||
<section className="mb-12">
|
||||
<h2 className="text-xl font-semibold text-gray-900 mb-4">Custom Cookie Banner</h2>
|
||||
<CodeBlock
|
||||
filename="components/CookieBanner.tsx"
|
||||
code={`import { useConsent } from '@breakpilot/consent-sdk/react';
|
||||
|
||||
export function CookieBanner() {
|
||||
const {
|
||||
isBannerVisible,
|
||||
acceptAll,
|
||||
rejectAll,
|
||||
showSettings,
|
||||
} = useConsent();
|
||||
|
||||
if (!isBannerVisible) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed bottom-0 inset-x-0 bg-white border-t shadow-lg p-4">
|
||||
<div className="max-w-4xl mx-auto flex items-center justify-between">
|
||||
<p className="text-sm text-gray-600">
|
||||
Wir verwenden Cookies um Ihr Erlebnis zu verbessern.
|
||||
</p>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={rejectAll}
|
||||
className="px-4 py-2 text-sm border rounded"
|
||||
>
|
||||
Ablehnen
|
||||
</button>
|
||||
<button
|
||||
onClick={showSettings}
|
||||
className="px-4 py-2 text-sm border rounded"
|
||||
>
|
||||
Einstellungen
|
||||
</button>
|
||||
<button
|
||||
onClick={acceptAll}
|
||||
className="px-4 py-2 text-sm bg-blue-600 text-white rounded"
|
||||
>
|
||||
Alle akzeptieren
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}`}
|
||||
/>
|
||||
</section>
|
||||
|
||||
{/* Hook API */}
|
||||
<section>
|
||||
<h2 className="text-xl font-semibold text-gray-900 mb-4">Hook API</h2>
|
||||
<div className="bg-white rounded-xl border border-gray-200 overflow-hidden">
|
||||
<table className="min-w-full divide-y divide-gray-200">
|
||||
<thead className="bg-gray-50">
|
||||
<tr>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">
|
||||
Property
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">
|
||||
Typ
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">
|
||||
Beschreibung
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-200">
|
||||
<tr>
|
||||
<td className="px-6 py-4"><code className="text-violet-600">hasConsent</code></td>
|
||||
<td className="px-6 py-4"><code className="text-gray-500">(category) => boolean</code></td>
|
||||
<td className="px-6 py-4 text-sm text-gray-600">Prueft Consent fuer Kategorie</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="px-6 py-4"><code className="text-violet-600">consent</code></td>
|
||||
<td className="px-6 py-4"><code className="text-gray-500">ConsentState | null</code></td>
|
||||
<td className="px-6 py-4 text-sm text-gray-600">Aktueller Consent-Status</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="px-6 py-4"><code className="text-violet-600">acceptAll</code></td>
|
||||
<td className="px-6 py-4"><code className="text-gray-500">() => Promise</code></td>
|
||||
<td className="px-6 py-4 text-sm text-gray-600">Akzeptiert alle Kategorien</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="px-6 py-4"><code className="text-violet-600">rejectAll</code></td>
|
||||
<td className="px-6 py-4"><code className="text-gray-500">() => Promise</code></td>
|
||||
<td className="px-6 py-4 text-sm text-gray-600">Lehnt alle ab (ausser essential)</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="px-6 py-4"><code className="text-violet-600">setConsent</code></td>
|
||||
<td className="px-6 py-4"><code className="text-gray-500">(input) => Promise</code></td>
|
||||
<td className="px-6 py-4 text-sm text-gray-600">Setzt spezifische Kategorien</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="px-6 py-4"><code className="text-violet-600">isBannerVisible</code></td>
|
||||
<td className="px-6 py-4"><code className="text-gray-500">boolean</code></td>
|
||||
<td className="px-6 py-4 text-sm text-gray-600">Banner sichtbar?</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="px-6 py-4"><code className="text-violet-600">showBanner</code></td>
|
||||
<td className="px-6 py-4"><code className="text-gray-500">() => void</code></td>
|
||||
<td className="px-6 py-4 text-sm text-gray-600">Zeigt den Banner</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="px-6 py-4"><code className="text-violet-600">showSettings</code></td>
|
||||
<td className="px-6 py-4"><code className="text-gray-500">() => void</code></td>
|
||||
<td className="px-6 py-4 text-sm text-gray-600">Oeffnet Einstellungen</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,277 @@
|
||||
'use client'
|
||||
|
||||
import React, { useState } from 'react'
|
||||
import { SDKDocsSidebar } from '@/components/developers/SDKDocsSidebar'
|
||||
import { Copy, Check } from 'lucide-react'
|
||||
|
||||
function CopyButton({ text }: { text: string }) {
|
||||
const [copied, setCopied] = useState(false)
|
||||
const handleCopy = async () => {
|
||||
await navigator.clipboard.writeText(text)
|
||||
setCopied(true)
|
||||
setTimeout(() => setCopied(false), 2000)
|
||||
}
|
||||
return (
|
||||
<button onClick={handleCopy} className="p-2 hover:bg-gray-700 rounded transition-colors">
|
||||
{copied ? <Check className="w-4 h-4 text-green-400" /> : <Copy className="w-4 h-4 text-gray-400" />}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
function CodeBlock({ code, filename }: { code: string; filename?: string }) {
|
||||
return (
|
||||
<div className="bg-gray-900 rounded-lg overflow-hidden">
|
||||
{filename && (
|
||||
<div className="px-4 py-2 bg-gray-800 text-gray-400 text-xs border-b border-gray-700">
|
||||
{filename}
|
||||
</div>
|
||||
)}
|
||||
<div className="relative">
|
||||
<div className="absolute top-2 right-2">
|
||||
<CopyButton text={code} />
|
||||
</div>
|
||||
<pre className="p-4 text-sm text-gray-300 font-mono overflow-x-auto">
|
||||
<code>{code}</code>
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function VueIntegrationPage() {
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 flex">
|
||||
<SDKDocsSidebar />
|
||||
|
||||
<main className="flex-1 ml-64">
|
||||
<div className="max-w-4xl mx-auto px-8 py-12">
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<div className="w-10 h-10 rounded-lg bg-emerald-500 flex items-center justify-center">
|
||||
<span className="text-white font-bold">V</span>
|
||||
</div>
|
||||
<h1 className="text-3xl font-bold text-gray-900">Vue 3 Integration</h1>
|
||||
</div>
|
||||
<p className="text-lg text-gray-600 mb-8">
|
||||
Composables und Plugin fuer Vue 3 und Nuxt Projekte.
|
||||
</p>
|
||||
|
||||
{/* Installation */}
|
||||
<section className="mb-12">
|
||||
<h2 className="text-xl font-semibold text-gray-900 mb-4">Installation</h2>
|
||||
<CodeBlock code="npm install @breakpilot/consent-sdk" />
|
||||
</section>
|
||||
|
||||
{/* Plugin Setup */}
|
||||
<section className="mb-12">
|
||||
<h2 className="text-xl font-semibold text-gray-900 mb-4">Plugin Setup</h2>
|
||||
<CodeBlock
|
||||
filename="main.ts"
|
||||
code={`import { createApp } from 'vue';
|
||||
import App from './App.vue';
|
||||
import { ConsentPlugin } from '@breakpilot/consent-sdk/vue';
|
||||
|
||||
const app = createApp(App);
|
||||
|
||||
app.use(ConsentPlugin, {
|
||||
apiEndpoint: import.meta.env.VITE_CONSENT_API,
|
||||
siteId: 'my-site',
|
||||
debug: import.meta.env.DEV,
|
||||
});
|
||||
|
||||
app.mount('#app');`}
|
||||
/>
|
||||
</section>
|
||||
|
||||
{/* Composable */}
|
||||
<section className="mb-12">
|
||||
<h2 className="text-xl font-semibold text-gray-900 mb-4">useConsent Composable</h2>
|
||||
<CodeBlock
|
||||
filename="components/Analytics.vue"
|
||||
code={`<script setup lang="ts">
|
||||
import { useConsent } from '@breakpilot/consent-sdk/vue';
|
||||
|
||||
const { hasConsent, acceptAll, rejectAll } = useConsent();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="hasConsent('analytics')">
|
||||
<!-- Analytics Code hier -->
|
||||
</div>
|
||||
</template>`}
|
||||
/>
|
||||
</section>
|
||||
|
||||
{/* Cookie Banner */}
|
||||
<section className="mb-12">
|
||||
<h2 className="text-xl font-semibold text-gray-900 mb-4">Cookie Banner Component</h2>
|
||||
<CodeBlock
|
||||
filename="components/CookieBanner.vue"
|
||||
code={`<script setup lang="ts">
|
||||
import { useConsent } from '@breakpilot/consent-sdk/vue';
|
||||
|
||||
const {
|
||||
isBannerVisible,
|
||||
acceptAll,
|
||||
rejectAll,
|
||||
showSettings,
|
||||
} = useConsent();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Transition name="slide">
|
||||
<div
|
||||
v-if="isBannerVisible"
|
||||
class="fixed bottom-0 inset-x-0 bg-white border-t shadow-lg p-4 z-50"
|
||||
>
|
||||
<div class="max-w-4xl mx-auto flex items-center justify-between">
|
||||
<p class="text-sm text-gray-600">
|
||||
Wir verwenden Cookies um Ihr Erlebnis zu verbessern.
|
||||
</p>
|
||||
<div class="flex gap-2">
|
||||
<button
|
||||
@click="rejectAll"
|
||||
class="px-4 py-2 text-sm border rounded hover:bg-gray-50"
|
||||
>
|
||||
Ablehnen
|
||||
</button>
|
||||
<button
|
||||
@click="showSettings"
|
||||
class="px-4 py-2 text-sm border rounded hover:bg-gray-50"
|
||||
>
|
||||
Einstellungen
|
||||
</button>
|
||||
<button
|
||||
@click="acceptAll"
|
||||
class="px-4 py-2 text-sm bg-blue-600 text-white rounded hover:bg-blue-700"
|
||||
>
|
||||
Alle akzeptieren
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.slide-enter-active,
|
||||
.slide-leave-active {
|
||||
transition: transform 0.3s ease;
|
||||
}
|
||||
.slide-enter-from,
|
||||
.slide-leave-to {
|
||||
transform: translateY(100%);
|
||||
}
|
||||
</style>`}
|
||||
/>
|
||||
</section>
|
||||
|
||||
{/* ConsentGate */}
|
||||
<section className="mb-12">
|
||||
<h2 className="text-xl font-semibold text-gray-900 mb-4">ConsentGate Component</h2>
|
||||
<CodeBlock
|
||||
filename="components/YouTubeEmbed.vue"
|
||||
code={`<script setup lang="ts">
|
||||
import { ConsentGate } from '@breakpilot/consent-sdk/vue';
|
||||
|
||||
defineProps<{
|
||||
videoId: string;
|
||||
}>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ConsentGate category="social">
|
||||
<template #default>
|
||||
<iframe
|
||||
:src="\`https://www.youtube.com/embed/\${videoId}\`"
|
||||
width="560"
|
||||
height="315"
|
||||
allowfullscreen
|
||||
/>
|
||||
</template>
|
||||
<template #fallback>
|
||||
<div class="bg-gray-100 p-4 rounded-lg text-center">
|
||||
<p>Video erfordert Ihre Zustimmung.</p>
|
||||
<button class="mt-2 px-4 py-2 bg-blue-600 text-white rounded">
|
||||
Zustimmen
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
</ConsentGate>
|
||||
</template>`}
|
||||
/>
|
||||
</section>
|
||||
|
||||
{/* Nuxt */}
|
||||
<section className="mb-12">
|
||||
<h2 className="text-xl font-semibold text-gray-900 mb-4">Nuxt 3 Setup</h2>
|
||||
<CodeBlock
|
||||
filename="plugins/consent.client.ts"
|
||||
code={`import { ConsentPlugin } from '@breakpilot/consent-sdk/vue';
|
||||
|
||||
export default defineNuxtPlugin((nuxtApp) => {
|
||||
nuxtApp.vueApp.use(ConsentPlugin, {
|
||||
apiEndpoint: useRuntimeConfig().public.consentApi,
|
||||
siteId: 'my-site',
|
||||
});
|
||||
});`}
|
||||
/>
|
||||
</section>
|
||||
|
||||
{/* Composable API */}
|
||||
<section>
|
||||
<h2 className="text-xl font-semibold text-gray-900 mb-4">Composable API</h2>
|
||||
<div className="bg-white rounded-xl border border-gray-200 overflow-hidden">
|
||||
<table className="min-w-full divide-y divide-gray-200">
|
||||
<thead className="bg-gray-50">
|
||||
<tr>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">
|
||||
Property
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">
|
||||
Typ
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">
|
||||
Beschreibung
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-200">
|
||||
<tr>
|
||||
<td className="px-6 py-4"><code className="text-violet-600">hasConsent</code></td>
|
||||
<td className="px-6 py-4"><code className="text-gray-500">(category) => boolean</code></td>
|
||||
<td className="px-6 py-4 text-sm text-gray-600">Reaktive Consent-Pruefung</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="px-6 py-4"><code className="text-violet-600">consent</code></td>
|
||||
<td className="px-6 py-4"><code className="text-gray-500">Ref<ConsentState></code></td>
|
||||
<td className="px-6 py-4 text-sm text-gray-600">Reaktiver Consent-Status</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="px-6 py-4"><code className="text-violet-600">isBannerVisible</code></td>
|
||||
<td className="px-6 py-4"><code className="text-gray-500">Ref<boolean></code></td>
|
||||
<td className="px-6 py-4 text-sm text-gray-600">Reaktive Banner-Sichtbarkeit</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="px-6 py-4"><code className="text-violet-600">acceptAll</code></td>
|
||||
<td className="px-6 py-4"><code className="text-gray-500">() => Promise</code></td>
|
||||
<td className="px-6 py-4 text-sm text-gray-600">Akzeptiert alle</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="px-6 py-4"><code className="text-violet-600">rejectAll</code></td>
|
||||
<td className="px-6 py-4"><code className="text-gray-500">() => Promise</code></td>
|
||||
<td className="px-6 py-4 text-sm text-gray-600">Lehnt alle ab</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="px-6 py-4"><code className="text-violet-600">setConsent</code></td>
|
||||
<td className="px-6 py-4"><code className="text-gray-500">(input) => Promise</code></td>
|
||||
<td className="px-6 py-4 text-sm text-gray-600">Setzt spezifische Kategorien</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,303 @@
|
||||
'use client'
|
||||
|
||||
import React, { useState } from 'react'
|
||||
import { SDKDocsSidebar } from '@/components/developers/SDKDocsSidebar'
|
||||
import { Copy, Check, Info, AlertTriangle } from 'lucide-react'
|
||||
|
||||
type PackageManager = 'npm' | 'yarn' | 'pnpm'
|
||||
|
||||
const installCommands: Record<PackageManager, string> = {
|
||||
npm: 'npm install @breakpilot/consent-sdk',
|
||||
yarn: 'yarn add @breakpilot/consent-sdk',
|
||||
pnpm: 'pnpm add @breakpilot/consent-sdk',
|
||||
}
|
||||
|
||||
function CopyButton({ text }: { text: string }) {
|
||||
const [copied, setCopied] = useState(false)
|
||||
|
||||
const handleCopy = async () => {
|
||||
await navigator.clipboard.writeText(text)
|
||||
setCopied(true)
|
||||
setTimeout(() => setCopied(false), 2000)
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={handleCopy}
|
||||
className="p-2 hover:bg-gray-700 rounded transition-colors"
|
||||
title="Kopieren"
|
||||
>
|
||||
{copied ? (
|
||||
<Check className="w-4 h-4 text-green-400" />
|
||||
) : (
|
||||
<Copy className="w-4 h-4 text-gray-400" />
|
||||
)}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
function CodeBlock({ code, language = 'typescript' }: { code: string; language?: string }) {
|
||||
return (
|
||||
<div className="relative bg-gray-900 rounded-lg overflow-hidden">
|
||||
<div className="absolute top-2 right-2">
|
||||
<CopyButton text={code} />
|
||||
</div>
|
||||
<pre className="p-4 text-sm text-gray-300 font-mono overflow-x-auto">
|
||||
<code>{code}</code>
|
||||
</pre>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function InfoBox({ type = 'info', children }: { type?: 'info' | 'warning'; children: React.ReactNode }) {
|
||||
const styles = {
|
||||
info: 'bg-blue-50 border-blue-200 text-blue-800',
|
||||
warning: 'bg-yellow-50 border-yellow-200 text-yellow-800',
|
||||
}
|
||||
const Icon = type === 'warning' ? AlertTriangle : Info
|
||||
|
||||
return (
|
||||
<div className={`p-4 border rounded-lg ${styles[type]} flex items-start gap-3`}>
|
||||
<Icon className="w-5 h-5 shrink-0 mt-0.5" />
|
||||
<div className="text-sm">{children}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function InstallationPage() {
|
||||
const [selectedPM, setSelectedPM] = useState<PackageManager>('npm')
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 flex">
|
||||
<SDKDocsSidebar />
|
||||
|
||||
<main className="flex-1 ml-64">
|
||||
<div className="max-w-4xl mx-auto px-8 py-12">
|
||||
<h1 className="text-3xl font-bold text-gray-900 mb-2">Installation</h1>
|
||||
<p className="text-lg text-gray-600 mb-8">
|
||||
Installieren Sie das Consent SDK in Ihrem Projekt.
|
||||
</p>
|
||||
|
||||
{/* Package Installation */}
|
||||
<section className="mb-12">
|
||||
<h2 className="text-xl font-semibold text-gray-900 mb-4">NPM Package</h2>
|
||||
<div className="bg-white rounded-xl border border-gray-200 overflow-hidden">
|
||||
<div className="px-4 py-3 border-b border-gray-200 flex gap-1 bg-gray-50">
|
||||
{(['npm', 'yarn', 'pnpm'] as const).map((pm) => (
|
||||
<button
|
||||
key={pm}
|
||||
onClick={() => setSelectedPM(pm)}
|
||||
className={`px-3 py-1.5 text-sm rounded-md transition-colors ${
|
||||
selectedPM === pm
|
||||
? 'bg-white text-gray-900 shadow-sm border border-gray-200'
|
||||
: 'text-gray-600 hover:text-gray-900'
|
||||
}`}
|
||||
>
|
||||
{pm}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="bg-gray-900 px-4 py-4 flex items-center justify-between">
|
||||
<code className="text-green-400 font-mono text-sm">
|
||||
$ {installCommands[selectedPM]}
|
||||
</code>
|
||||
<CopyButton text={installCommands[selectedPM]} />
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Framework-specific */}
|
||||
<section className="mb-12">
|
||||
<h2 className="text-xl font-semibold text-gray-900 mb-4">Framework-spezifische Imports</h2>
|
||||
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h3 className="font-medium text-gray-900 mb-2">Vanilla JavaScript</h3>
|
||||
<CodeBlock
|
||||
code={`import { ConsentManager } from '@breakpilot/consent-sdk';
|
||||
|
||||
const consent = new ConsentManager({
|
||||
apiEndpoint: 'https://api.example.com/consent',
|
||||
siteId: 'your-site-id',
|
||||
});
|
||||
|
||||
await consent.init();`}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3 className="font-medium text-gray-900 mb-2">React</h3>
|
||||
<CodeBlock
|
||||
code={`import { ConsentProvider, useConsent } from '@breakpilot/consent-sdk/react';
|
||||
|
||||
function App() {
|
||||
return (
|
||||
<ConsentProvider
|
||||
config={{
|
||||
apiEndpoint: 'https://api.example.com/consent',
|
||||
siteId: 'your-site-id',
|
||||
}}
|
||||
>
|
||||
<YourApp />
|
||||
</ConsentProvider>
|
||||
);
|
||||
}`}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3 className="font-medium text-gray-900 mb-2">Vue 3</h3>
|
||||
<CodeBlock
|
||||
code={`import { createApp } from 'vue';
|
||||
import { ConsentPlugin } from '@breakpilot/consent-sdk/vue';
|
||||
|
||||
const app = createApp(App);
|
||||
|
||||
app.use(ConsentPlugin, {
|
||||
apiEndpoint: 'https://api.example.com/consent',
|
||||
siteId: 'your-site-id',
|
||||
});`}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Script Blocking Setup */}
|
||||
<section className="mb-12">
|
||||
<h2 className="text-xl font-semibold text-gray-900 mb-4">Script Blocking einrichten</h2>
|
||||
<p className="text-gray-600 mb-4">
|
||||
Um Third-Party Scripts automatisch zu blockieren, verwenden Sie das{' '}
|
||||
<code className="px-1.5 py-0.5 bg-gray-100 rounded text-sm">data-consent</code> Attribut:
|
||||
</p>
|
||||
|
||||
<CodeBlock
|
||||
language="html"
|
||||
code={`<!-- Analytics Script (blockiert bis Consent) -->
|
||||
<script
|
||||
data-consent="analytics"
|
||||
data-src="https://www.googletagmanager.com/gtag/js?id=GA_MEASUREMENT_ID"
|
||||
type="text/plain"
|
||||
></script>
|
||||
|
||||
<!-- Marketing Script (blockiert bis Consent) -->
|
||||
<script data-consent="marketing" type="text/plain">
|
||||
fbq('init', 'YOUR_PIXEL_ID');
|
||||
</script>
|
||||
|
||||
<!-- Embedded iFrame (blockiert bis Consent) -->
|
||||
<iframe
|
||||
data-consent="social"
|
||||
data-src="https://www.youtube.com/embed/VIDEO_ID"
|
||||
width="560"
|
||||
height="315"
|
||||
></iframe>`}
|
||||
/>
|
||||
</section>
|
||||
|
||||
{/* Requirements */}
|
||||
<section className="mb-12">
|
||||
<h2 className="text-xl font-semibold text-gray-900 mb-4">Systemvoraussetzungen</h2>
|
||||
<div className="bg-white rounded-xl border border-gray-200 overflow-hidden">
|
||||
<table className="min-w-full divide-y divide-gray-200">
|
||||
<thead className="bg-gray-50">
|
||||
<tr>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">
|
||||
Anforderung
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">
|
||||
Minimum
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-200">
|
||||
<tr>
|
||||
<td className="px-6 py-4 text-sm text-gray-900">Node.js</td>
|
||||
<td className="px-6 py-4 text-sm text-gray-600">>= 18.0.0</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="px-6 py-4 text-sm text-gray-900">React (optional)</td>
|
||||
<td className="px-6 py-4 text-sm text-gray-600">>= 17.0.0</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="px-6 py-4 text-sm text-gray-900">Vue (optional)</td>
|
||||
<td className="px-6 py-4 text-sm text-gray-600">>= 3.0.0</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="px-6 py-4 text-sm text-gray-900">TypeScript (optional)</td>
|
||||
<td className="px-6 py-4 text-sm text-gray-600">>= 4.7.0</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Browser Support */}
|
||||
<section className="mb-12">
|
||||
<h2 className="text-xl font-semibold text-gray-900 mb-4">Browser-Unterstuetzung</h2>
|
||||
<InfoBox type="info">
|
||||
Das SDK unterstuetzt alle modernen Browser mit ES2017+ Unterstuetzung.
|
||||
Fuer aeltere Browser wird ein automatischer Fallback fuer Crypto-Funktionen bereitgestellt.
|
||||
</InfoBox>
|
||||
<div className="mt-4 bg-white rounded-xl border border-gray-200 overflow-hidden">
|
||||
<table className="min-w-full divide-y divide-gray-200">
|
||||
<thead className="bg-gray-50">
|
||||
<tr>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">
|
||||
Browser
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">
|
||||
Minimum Version
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-200">
|
||||
<tr>
|
||||
<td className="px-6 py-4 text-sm text-gray-900">Chrome</td>
|
||||
<td className="px-6 py-4 text-sm text-gray-600">>= 60</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="px-6 py-4 text-sm text-gray-900">Firefox</td>
|
||||
<td className="px-6 py-4 text-sm text-gray-600">>= 55</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="px-6 py-4 text-sm text-gray-900">Safari</td>
|
||||
<td className="px-6 py-4 text-sm text-gray-600">>= 11</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="px-6 py-4 text-sm text-gray-900">Edge</td>
|
||||
<td className="px-6 py-4 text-sm text-gray-600">>= 79 (Chromium)</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Next Steps */}
|
||||
<section>
|
||||
<h2 className="text-xl font-semibold text-gray-900 mb-4">Naechste Schritte</h2>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<a
|
||||
href="/developers/sdk/consent/api-reference"
|
||||
className="p-4 bg-white rounded-xl border border-gray-200 hover:border-violet-300 hover:shadow-md transition-all"
|
||||
>
|
||||
<h3 className="font-medium text-gray-900">API Referenz</h3>
|
||||
<p className="text-sm text-gray-500 mt-1">
|
||||
Vollstaendige Dokumentation aller Methoden und Konfigurationsoptionen.
|
||||
</p>
|
||||
</a>
|
||||
<a
|
||||
href="/developers/sdk/consent/frameworks"
|
||||
className="p-4 bg-white rounded-xl border border-gray-200 hover:border-violet-300 hover:shadow-md transition-all"
|
||||
>
|
||||
<h3 className="font-medium text-gray-900">Framework Integration</h3>
|
||||
<p className="text-sm text-gray-500 mt-1">
|
||||
Detaillierte Anleitungen fuer React, Vue und Angular.
|
||||
</p>
|
||||
</a>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,269 @@
|
||||
'use client'
|
||||
|
||||
import React, { useState } from 'react'
|
||||
import { SDKDocsSidebar } from '@/components/developers/SDKDocsSidebar'
|
||||
import { Copy, Check, Smartphone } from 'lucide-react'
|
||||
|
||||
function CopyButton({ text }: { text: string }) {
|
||||
const [copied, setCopied] = useState(false)
|
||||
const handleCopy = async () => {
|
||||
await navigator.clipboard.writeText(text)
|
||||
setCopied(true)
|
||||
setTimeout(() => setCopied(false), 2000)
|
||||
}
|
||||
return (
|
||||
<button onClick={handleCopy} className="p-2 hover:bg-gray-700 rounded transition-colors">
|
||||
{copied ? <Check className="w-4 h-4 text-green-400" /> : <Copy className="w-4 h-4 text-gray-400" />}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
function CodeBlock({ code, filename }: { code: string; filename?: string }) {
|
||||
return (
|
||||
<div className="bg-gray-900 rounded-lg overflow-hidden">
|
||||
{filename && (
|
||||
<div className="px-4 py-2 bg-gray-800 text-gray-400 text-xs border-b border-gray-700">
|
||||
{filename}
|
||||
</div>
|
||||
)}
|
||||
<div className="relative">
|
||||
<div className="absolute top-2 right-2">
|
||||
<CopyButton text={code} />
|
||||
</div>
|
||||
<pre className="p-4 text-sm text-gray-300 font-mono overflow-x-auto">
|
||||
<code>{code}</code>
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function AndroidSDKPage() {
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 flex">
|
||||
<SDKDocsSidebar />
|
||||
|
||||
<main className="flex-1 ml-64">
|
||||
<div className="max-w-4xl mx-auto px-8 py-12">
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<div className="w-10 h-10 rounded-lg bg-green-600 flex items-center justify-center">
|
||||
<Smartphone className="w-6 h-6 text-white" />
|
||||
</div>
|
||||
<h1 className="text-3xl font-bold text-gray-900">Android SDK (Kotlin)</h1>
|
||||
</div>
|
||||
<p className="text-lg text-gray-600 mb-8">
|
||||
Native Kotlin SDK fuer Android API 26+ mit Jetpack Compose Unterstuetzung.
|
||||
</p>
|
||||
|
||||
{/* Requirements */}
|
||||
<section className="mb-12">
|
||||
<h2 className="text-xl font-semibold text-gray-900 mb-4">Systemvoraussetzungen</h2>
|
||||
<div className="bg-white rounded-xl border border-gray-200 overflow-hidden">
|
||||
<table className="min-w-full divide-y divide-gray-200">
|
||||
<tbody className="divide-y divide-gray-200">
|
||||
<tr>
|
||||
<td className="px-6 py-4 text-sm font-medium text-gray-900">Kotlin Version</td>
|
||||
<td className="px-6 py-4 text-sm text-gray-600">1.9+</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="px-6 py-4 text-sm font-medium text-gray-900">Min SDK</td>
|
||||
<td className="px-6 py-4 text-sm text-gray-600">API 26 (Android 8.0)</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="px-6 py-4 text-sm font-medium text-gray-900">Compile SDK</td>
|
||||
<td className="px-6 py-4 text-sm text-gray-600">34+</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Installation */}
|
||||
<section className="mb-12">
|
||||
<h2 className="text-xl font-semibold text-gray-900 mb-4">Installation</h2>
|
||||
<CodeBlock
|
||||
filename="build.gradle.kts (Module)"
|
||||
code={`dependencies {
|
||||
implementation("com.breakpilot:consent-sdk:1.0.0")
|
||||
|
||||
// Fuer Jetpack Compose
|
||||
implementation("com.breakpilot:consent-sdk-compose:1.0.0")
|
||||
}`}
|
||||
/>
|
||||
</section>
|
||||
|
||||
{/* Basic Setup */}
|
||||
<section className="mb-12">
|
||||
<h2 className="text-xl font-semibold text-gray-900 mb-4">Grundlegende Einrichtung</h2>
|
||||
<CodeBlock
|
||||
filename="MyApplication.kt"
|
||||
code={`import android.app.Application
|
||||
import com.breakpilot.consent.ConsentManager
|
||||
|
||||
class MyApplication : Application() {
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
|
||||
// Consent Manager konfigurieren
|
||||
ConsentManager.configure(
|
||||
context = this,
|
||||
config = ConsentConfig(
|
||||
apiEndpoint = "https://api.example.com/consent",
|
||||
siteId = "my-android-app"
|
||||
)
|
||||
)
|
||||
|
||||
// Initialisieren
|
||||
lifecycleScope.launch {
|
||||
ConsentManager.initialize()
|
||||
}
|
||||
}
|
||||
}`}
|
||||
/>
|
||||
</section>
|
||||
|
||||
{/* Jetpack Compose */}
|
||||
<section className="mb-12">
|
||||
<h2 className="text-xl font-semibold text-gray-900 mb-4">Jetpack Compose Integration</h2>
|
||||
<CodeBlock
|
||||
filename="MainActivity.kt"
|
||||
code={`import androidx.compose.runtime.*
|
||||
import com.breakpilot.consent.compose.*
|
||||
|
||||
@Composable
|
||||
fun MainScreen() {
|
||||
val consent = rememberConsentState()
|
||||
|
||||
Column {
|
||||
// Consent-abhaengige UI
|
||||
if (consent.hasConsent(ConsentCategory.ANALYTICS)) {
|
||||
AnalyticsView()
|
||||
}
|
||||
|
||||
// Buttons
|
||||
Button(onClick = { consent.acceptAll() }) {
|
||||
Text("Alle akzeptieren")
|
||||
}
|
||||
|
||||
Button(onClick = { consent.rejectAll() }) {
|
||||
Text("Alle ablehnen")
|
||||
}
|
||||
}
|
||||
|
||||
// Consent Banner (automatisch angezeigt wenn noetig)
|
||||
ConsentBanner()
|
||||
}
|
||||
|
||||
// ConsentGate Composable
|
||||
@Composable
|
||||
fun ProtectedContent() {
|
||||
ConsentGate(
|
||||
category = ConsentCategory.MARKETING,
|
||||
fallback = {
|
||||
Text("Marketing-Zustimmung erforderlich")
|
||||
}
|
||||
) {
|
||||
MarketingContent()
|
||||
}
|
||||
}`}
|
||||
/>
|
||||
</section>
|
||||
|
||||
{/* Traditional Android */}
|
||||
<section className="mb-12">
|
||||
<h2 className="text-xl font-semibold text-gray-900 mb-4">View-basierte Integration</h2>
|
||||
<CodeBlock
|
||||
filename="MainActivity.kt"
|
||||
code={`import androidx.appcompat.app.AppCompatActivity
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import com.breakpilot.consent.ConsentManager
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.flow.collect
|
||||
|
||||
class MainActivity : AppCompatActivity() {
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
setContentView(R.layout.activity_main)
|
||||
|
||||
// Auf Consent-Aenderungen reagieren
|
||||
lifecycleScope.launch {
|
||||
ConsentManager.consentFlow.collect { state ->
|
||||
updateUI(state)
|
||||
}
|
||||
}
|
||||
|
||||
// Banner anzeigen wenn noetig
|
||||
if (ConsentManager.needsConsent()) {
|
||||
ConsentManager.showBanner(this)
|
||||
}
|
||||
}
|
||||
|
||||
private fun updateUI(state: ConsentState?) {
|
||||
if (state?.hasConsent(ConsentCategory.ANALYTICS) == true) {
|
||||
loadAnalytics()
|
||||
}
|
||||
}
|
||||
|
||||
fun onAcceptAllClick(view: View) {
|
||||
lifecycleScope.launch {
|
||||
ConsentManager.acceptAll()
|
||||
}
|
||||
}
|
||||
}`}
|
||||
/>
|
||||
</section>
|
||||
|
||||
{/* API Reference */}
|
||||
<section>
|
||||
<h2 className="text-xl font-semibold text-gray-900 mb-4">API Referenz</h2>
|
||||
<div className="bg-white rounded-xl border border-gray-200 overflow-hidden">
|
||||
<table className="min-w-full divide-y divide-gray-200">
|
||||
<thead className="bg-gray-50">
|
||||
<tr>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Methode</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Beschreibung</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-200">
|
||||
<tr>
|
||||
<td className="px-6 py-4"><code className="text-violet-600">configure()</code></td>
|
||||
<td className="px-6 py-4 text-sm text-gray-600">SDK konfigurieren</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="px-6 py-4"><code className="text-violet-600">initialize()</code></td>
|
||||
<td className="px-6 py-4 text-sm text-gray-600">SDK initialisieren (suspend)</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="px-6 py-4"><code className="text-violet-600">hasConsent()</code></td>
|
||||
<td className="px-6 py-4 text-sm text-gray-600">Consent fuer Kategorie pruefen</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="px-6 py-4"><code className="text-violet-600">consentFlow</code></td>
|
||||
<td className="px-6 py-4 text-sm text-gray-600">Flow fuer reaktive Updates</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="px-6 py-4"><code className="text-violet-600">acceptAll()</code></td>
|
||||
<td className="px-6 py-4 text-sm text-gray-600">Alle akzeptieren (suspend)</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="px-6 py-4"><code className="text-violet-600">rejectAll()</code></td>
|
||||
<td className="px-6 py-4 text-sm text-gray-600">Alle ablehnen (suspend)</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="px-6 py-4"><code className="text-violet-600">setConsent()</code></td>
|
||||
<td className="px-6 py-4 text-sm text-gray-600">Kategorien setzen (suspend)</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="px-6 py-4"><code className="text-violet-600">showBanner()</code></td>
|
||||
<td className="px-6 py-4 text-sm text-gray-600">Banner als DialogFragment</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,313 @@
|
||||
'use client'
|
||||
|
||||
import React, { useState } from 'react'
|
||||
import { SDKDocsSidebar } from '@/components/developers/SDKDocsSidebar'
|
||||
import { Copy, Check, Smartphone } from 'lucide-react'
|
||||
|
||||
function CopyButton({ text }: { text: string }) {
|
||||
const [copied, setCopied] = useState(false)
|
||||
const handleCopy = async () => {
|
||||
await navigator.clipboard.writeText(text)
|
||||
setCopied(true)
|
||||
setTimeout(() => setCopied(false), 2000)
|
||||
}
|
||||
return (
|
||||
<button onClick={handleCopy} className="p-2 hover:bg-gray-700 rounded transition-colors">
|
||||
{copied ? <Check className="w-4 h-4 text-green-400" /> : <Copy className="w-4 h-4 text-gray-400" />}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
function CodeBlock({ code, filename }: { code: string; filename?: string }) {
|
||||
return (
|
||||
<div className="bg-gray-900 rounded-lg overflow-hidden">
|
||||
{filename && (
|
||||
<div className="px-4 py-2 bg-gray-800 text-gray-400 text-xs border-b border-gray-700">
|
||||
{filename}
|
||||
</div>
|
||||
)}
|
||||
<div className="relative">
|
||||
<div className="absolute top-2 right-2">
|
||||
<CopyButton text={code} />
|
||||
</div>
|
||||
<pre className="p-4 text-sm text-gray-300 font-mono overflow-x-auto">
|
||||
<code>{code}</code>
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function FlutterSDKPage() {
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 flex">
|
||||
<SDKDocsSidebar />
|
||||
|
||||
<main className="flex-1 ml-64">
|
||||
<div className="max-w-4xl mx-auto px-8 py-12">
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<div className="w-10 h-10 rounded-lg bg-blue-500 flex items-center justify-center">
|
||||
<Smartphone className="w-6 h-6 text-white" />
|
||||
</div>
|
||||
<h1 className="text-3xl font-bold text-gray-900">Flutter SDK</h1>
|
||||
</div>
|
||||
<p className="text-lg text-gray-600 mb-8">
|
||||
Cross-Platform SDK fuer Flutter 3.16+ mit iOS, Android und Web Support.
|
||||
</p>
|
||||
|
||||
{/* Requirements */}
|
||||
<section className="mb-12">
|
||||
<h2 className="text-xl font-semibold text-gray-900 mb-4">Systemvoraussetzungen</h2>
|
||||
<div className="bg-white rounded-xl border border-gray-200 overflow-hidden">
|
||||
<table className="min-w-full divide-y divide-gray-200">
|
||||
<tbody className="divide-y divide-gray-200">
|
||||
<tr>
|
||||
<td className="px-6 py-4 text-sm font-medium text-gray-900">Dart Version</td>
|
||||
<td className="px-6 py-4 text-sm text-gray-600">3.0+</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="px-6 py-4 text-sm font-medium text-gray-900">Flutter Version</td>
|
||||
<td className="px-6 py-4 text-sm text-gray-600">3.16+</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="px-6 py-4 text-sm font-medium text-gray-900">Plattformen</td>
|
||||
<td className="px-6 py-4 text-sm text-gray-600">iOS, Android, Web</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Installation */}
|
||||
<section className="mb-12">
|
||||
<h2 className="text-xl font-semibold text-gray-900 mb-4">Installation</h2>
|
||||
<CodeBlock
|
||||
filename="pubspec.yaml"
|
||||
code={`dependencies:
|
||||
consent_sdk: ^1.0.0`}
|
||||
/>
|
||||
<div className="mt-4">
|
||||
<CodeBlock code="flutter pub get" />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Basic Setup */}
|
||||
<section className="mb-12">
|
||||
<h2 className="text-xl font-semibold text-gray-900 mb-4">Grundlegende Einrichtung</h2>
|
||||
<CodeBlock
|
||||
filename="main.dart"
|
||||
code={`import 'package:flutter/material.dart';
|
||||
import 'package:consent_sdk/consent_sdk.dart';
|
||||
|
||||
void main() async {
|
||||
WidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
// Consent SDK initialisieren
|
||||
await ConsentManager.instance.initialize(
|
||||
config: ConsentConfig(
|
||||
apiEndpoint: 'https://api.example.com/consent',
|
||||
siteId: 'my-flutter-app',
|
||||
),
|
||||
);
|
||||
|
||||
runApp(const MyApp());
|
||||
}
|
||||
|
||||
class MyApp extends StatelessWidget {
|
||||
const MyApp({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return MaterialApp(
|
||||
home: const ConsentWrapper(
|
||||
child: HomeScreen(),
|
||||
),
|
||||
);
|
||||
}
|
||||
}`}
|
||||
/>
|
||||
</section>
|
||||
|
||||
{/* Widget Usage */}
|
||||
<section className="mb-12">
|
||||
<h2 className="text-xl font-semibold text-gray-900 mb-4">Widget Integration</h2>
|
||||
<CodeBlock
|
||||
filename="home_screen.dart"
|
||||
code={`import 'package:flutter/material.dart';
|
||||
import 'package:consent_sdk/consent_sdk.dart';
|
||||
|
||||
class HomeScreen extends StatelessWidget {
|
||||
const HomeScreen({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
body: Column(
|
||||
children: [
|
||||
// StreamBuilder fuer reaktive Updates
|
||||
StreamBuilder<ConsentState?>(
|
||||
stream: ConsentManager.instance.consentStream,
|
||||
builder: (context, snapshot) {
|
||||
final consent = snapshot.data;
|
||||
|
||||
if (consent?.hasConsent(ConsentCategory.analytics) ?? false) {
|
||||
return const AnalyticsWidget();
|
||||
}
|
||||
|
||||
return const SizedBox.shrink();
|
||||
},
|
||||
),
|
||||
|
||||
// ConsentGate Widget
|
||||
ConsentGate(
|
||||
category: ConsentCategory.marketing,
|
||||
fallback: const Center(
|
||||
child: Text('Marketing-Zustimmung erforderlich'),
|
||||
),
|
||||
child: const MarketingWidget(),
|
||||
),
|
||||
|
||||
// Buttons
|
||||
ElevatedButton(
|
||||
onPressed: () => ConsentManager.instance.acceptAll(),
|
||||
child: const Text('Alle akzeptieren'),
|
||||
),
|
||||
|
||||
ElevatedButton(
|
||||
onPressed: () => ConsentManager.instance.rejectAll(),
|
||||
child: const Text('Alle ablehnen'),
|
||||
),
|
||||
|
||||
TextButton(
|
||||
onPressed: () => ConsentManager.instance.showSettings(context),
|
||||
child: const Text('Einstellungen'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}`}
|
||||
/>
|
||||
</section>
|
||||
|
||||
{/* Custom Banner */}
|
||||
<section className="mb-12">
|
||||
<h2 className="text-xl font-semibold text-gray-900 mb-4">Custom Cookie Banner</h2>
|
||||
<CodeBlock
|
||||
filename="cookie_banner.dart"
|
||||
code={`import 'package:flutter/material.dart';
|
||||
import 'package:consent_sdk/consent_sdk.dart';
|
||||
|
||||
class CustomCookieBanner extends StatelessWidget {
|
||||
const CustomCookieBanner({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return StreamBuilder<bool>(
|
||||
stream: ConsentManager.instance.isBannerVisibleStream,
|
||||
builder: (context, snapshot) {
|
||||
if (!(snapshot.data ?? false)) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withOpacity(0.1),
|
||||
blurRadius: 10,
|
||||
),
|
||||
],
|
||||
),
|
||||
child: SafeArea(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const Text(
|
||||
'Wir verwenden Cookies um Ihr Erlebnis zu verbessern.',
|
||||
style: TextStyle(fontSize: 14),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.end,
|
||||
children: [
|
||||
TextButton(
|
||||
onPressed: () => ConsentManager.instance.rejectAll(),
|
||||
child: const Text('Ablehnen'),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () => ConsentManager.instance.showSettings(context),
|
||||
child: const Text('Einstellungen'),
|
||||
),
|
||||
ElevatedButton(
|
||||
onPressed: () => ConsentManager.instance.acceptAll(),
|
||||
child: const Text('Alle akzeptieren'),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}`}
|
||||
/>
|
||||
</section>
|
||||
|
||||
{/* API Reference */}
|
||||
<section>
|
||||
<h2 className="text-xl font-semibold text-gray-900 mb-4">API Referenz</h2>
|
||||
<div className="bg-white rounded-xl border border-gray-200 overflow-hidden">
|
||||
<table className="min-w-full divide-y divide-gray-200">
|
||||
<thead className="bg-gray-50">
|
||||
<tr>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Methode/Property</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Beschreibung</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-200">
|
||||
<tr>
|
||||
<td className="px-6 py-4"><code className="text-violet-600">initialize()</code></td>
|
||||
<td className="px-6 py-4 text-sm text-gray-600">SDK initialisieren (Future)</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="px-6 py-4"><code className="text-violet-600">hasConsent()</code></td>
|
||||
<td className="px-6 py-4 text-sm text-gray-600">Consent pruefen</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="px-6 py-4"><code className="text-violet-600">consentStream</code></td>
|
||||
<td className="px-6 py-4 text-sm text-gray-600">Stream fuer Consent-Updates</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="px-6 py-4"><code className="text-violet-600">isBannerVisibleStream</code></td>
|
||||
<td className="px-6 py-4 text-sm text-gray-600">Stream fuer Banner-Sichtbarkeit</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="px-6 py-4"><code className="text-violet-600">acceptAll()</code></td>
|
||||
<td className="px-6 py-4 text-sm text-gray-600">Alle akzeptieren (Future)</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="px-6 py-4"><code className="text-violet-600">rejectAll()</code></td>
|
||||
<td className="px-6 py-4 text-sm text-gray-600">Alle ablehnen (Future)</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="px-6 py-4"><code className="text-violet-600">setConsent()</code></td>
|
||||
<td className="px-6 py-4 text-sm text-gray-600">Kategorien setzen (Future)</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="px-6 py-4"><code className="text-violet-600">showSettings()</code></td>
|
||||
<td className="px-6 py-4 text-sm text-gray-600">Einstellungs-Dialog oeffnen</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,283 @@
|
||||
'use client'
|
||||
|
||||
import React, { useState } from 'react'
|
||||
import { SDKDocsSidebar } from '@/components/developers/SDKDocsSidebar'
|
||||
import { Copy, Check, Apple } from 'lucide-react'
|
||||
|
||||
function CopyButton({ text }: { text: string }) {
|
||||
const [copied, setCopied] = useState(false)
|
||||
const handleCopy = async () => {
|
||||
await navigator.clipboard.writeText(text)
|
||||
setCopied(true)
|
||||
setTimeout(() => setCopied(false), 2000)
|
||||
}
|
||||
return (
|
||||
<button onClick={handleCopy} className="p-2 hover:bg-gray-700 rounded transition-colors">
|
||||
{copied ? <Check className="w-4 h-4 text-green-400" /> : <Copy className="w-4 h-4 text-gray-400" />}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
function CodeBlock({ code, filename }: { code: string; filename?: string }) {
|
||||
return (
|
||||
<div className="bg-gray-900 rounded-lg overflow-hidden">
|
||||
{filename && (
|
||||
<div className="px-4 py-2 bg-gray-800 text-gray-400 text-xs border-b border-gray-700">
|
||||
{filename}
|
||||
</div>
|
||||
)}
|
||||
<div className="relative">
|
||||
<div className="absolute top-2 right-2">
|
||||
<CopyButton text={code} />
|
||||
</div>
|
||||
<pre className="p-4 text-sm text-gray-300 font-mono overflow-x-auto">
|
||||
<code>{code}</code>
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function iOSSDKPage() {
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 flex">
|
||||
<SDKDocsSidebar />
|
||||
|
||||
<main className="flex-1 ml-64">
|
||||
<div className="max-w-4xl mx-auto px-8 py-12">
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<div className="w-10 h-10 rounded-lg bg-gray-900 flex items-center justify-center">
|
||||
<Apple className="w-6 h-6 text-white" />
|
||||
</div>
|
||||
<h1 className="text-3xl font-bold text-gray-900">iOS SDK (Swift)</h1>
|
||||
</div>
|
||||
<p className="text-lg text-gray-600 mb-8">
|
||||
Native Swift SDK fuer iOS 15+ und iPadOS mit SwiftUI-Unterstuetzung.
|
||||
</p>
|
||||
|
||||
{/* Requirements */}
|
||||
<section className="mb-12">
|
||||
<h2 className="text-xl font-semibold text-gray-900 mb-4">Systemvoraussetzungen</h2>
|
||||
<div className="bg-white rounded-xl border border-gray-200 overflow-hidden">
|
||||
<table className="min-w-full divide-y divide-gray-200">
|
||||
<tbody className="divide-y divide-gray-200">
|
||||
<tr>
|
||||
<td className="px-6 py-4 text-sm font-medium text-gray-900">Swift Version</td>
|
||||
<td className="px-6 py-4 text-sm text-gray-600">5.9+</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="px-6 py-4 text-sm font-medium text-gray-900">iOS Deployment Target</td>
|
||||
<td className="px-6 py-4 text-sm text-gray-600">iOS 15.0+</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="px-6 py-4 text-sm font-medium text-gray-900">Xcode Version</td>
|
||||
<td className="px-6 py-4 text-sm text-gray-600">15.0+</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Installation */}
|
||||
<section className="mb-12">
|
||||
<h2 className="text-xl font-semibold text-gray-900 mb-4">Installation</h2>
|
||||
<h3 className="font-medium text-gray-900 mb-2">Swift Package Manager</h3>
|
||||
<CodeBlock
|
||||
filename="Package.swift"
|
||||
code={`dependencies: [
|
||||
.package(url: "https://github.com/breakpilot/consent-sdk-ios.git", from: "1.0.0")
|
||||
]`}
|
||||
/>
|
||||
<p className="text-sm text-gray-600 mt-4">
|
||||
Oder in Xcode: File → Add Package Dependencies → URL eingeben
|
||||
</p>
|
||||
</section>
|
||||
|
||||
{/* Basic Usage */}
|
||||
<section className="mb-12">
|
||||
<h2 className="text-xl font-semibold text-gray-900 mb-4">Grundlegende Verwendung</h2>
|
||||
<CodeBlock
|
||||
filename="AppDelegate.swift"
|
||||
code={`import ConsentSDK
|
||||
|
||||
@main
|
||||
class AppDelegate: UIResponder, UIApplicationDelegate {
|
||||
func application(
|
||||
_ application: UIApplication,
|
||||
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
|
||||
) -> Bool {
|
||||
|
||||
// Consent Manager konfigurieren
|
||||
ConsentManager.shared.configure(
|
||||
apiEndpoint: "https://api.example.com/consent",
|
||||
siteId: "my-ios-app"
|
||||
)
|
||||
|
||||
// Initialisieren
|
||||
Task {
|
||||
await ConsentManager.shared.initialize()
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
}`}
|
||||
/>
|
||||
</section>
|
||||
|
||||
{/* SwiftUI Integration */}
|
||||
<section className="mb-12">
|
||||
<h2 className="text-xl font-semibold text-gray-900 mb-4">SwiftUI Integration</h2>
|
||||
<CodeBlock
|
||||
filename="ContentView.swift"
|
||||
code={`import SwiftUI
|
||||
import ConsentSDK
|
||||
|
||||
struct ContentView: View {
|
||||
@StateObject private var consent = ConsentManager.shared
|
||||
|
||||
var body: some View {
|
||||
VStack {
|
||||
if consent.hasConsent(.analytics) {
|
||||
AnalyticsView()
|
||||
}
|
||||
|
||||
Button("Alle akzeptieren") {
|
||||
Task {
|
||||
await consent.acceptAll()
|
||||
}
|
||||
}
|
||||
}
|
||||
.consentBanner() // Zeigt Banner automatisch
|
||||
}
|
||||
}
|
||||
|
||||
// Consent Gate Modifier
|
||||
struct ProtectedView: View {
|
||||
var body: some View {
|
||||
Text("Geschuetzter Inhalt")
|
||||
.requiresConsent(.marketing) {
|
||||
// Fallback wenn kein Consent
|
||||
Text("Marketing-Zustimmung erforderlich")
|
||||
}
|
||||
}
|
||||
}`}
|
||||
/>
|
||||
</section>
|
||||
|
||||
{/* UIKit Integration */}
|
||||
<section className="mb-12">
|
||||
<h2 className="text-xl font-semibold text-gray-900 mb-4">UIKit Integration</h2>
|
||||
<CodeBlock
|
||||
filename="ViewController.swift"
|
||||
code={`import UIKit
|
||||
import ConsentSDK
|
||||
import Combine
|
||||
|
||||
class ViewController: UIViewController {
|
||||
private var cancellables = Set<AnyCancellable>()
|
||||
|
||||
override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
|
||||
// Reaktiv auf Consent-Aenderungen reagieren
|
||||
ConsentManager.shared.$consent
|
||||
.receive(on: DispatchQueue.main)
|
||||
.sink { [weak self] state in
|
||||
self?.updateUI(consent: state)
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
}
|
||||
|
||||
private func updateUI(consent: ConsentState?) {
|
||||
if consent?.hasConsent(.analytics) == true {
|
||||
loadAnalytics()
|
||||
}
|
||||
}
|
||||
|
||||
@IBAction func acceptAllTapped(_ sender: UIButton) {
|
||||
Task {
|
||||
await ConsentManager.shared.acceptAll()
|
||||
}
|
||||
}
|
||||
}`}
|
||||
/>
|
||||
</section>
|
||||
|
||||
{/* Consent Categories */}
|
||||
<section className="mb-12">
|
||||
<h2 className="text-xl font-semibold text-gray-900 mb-4">Consent-Kategorien</h2>
|
||||
<CodeBlock
|
||||
code={`// Verfuegbare Kategorien
|
||||
enum ConsentCategory {
|
||||
case essential // Immer aktiv
|
||||
case functional // Funktionale Features
|
||||
case analytics // Statistik & Analyse
|
||||
case marketing // Werbung & Tracking
|
||||
case social // Social Media Integration
|
||||
}
|
||||
|
||||
// Consent pruefen
|
||||
if ConsentManager.shared.hasConsent(.analytics) {
|
||||
// Analytics laden
|
||||
}
|
||||
|
||||
// Mehrere Kategorien pruefen
|
||||
if ConsentManager.shared.hasConsent([.analytics, .marketing]) {
|
||||
// Beide Kategorien haben Consent
|
||||
}`}
|
||||
/>
|
||||
</section>
|
||||
|
||||
{/* API Reference */}
|
||||
<section>
|
||||
<h2 className="text-xl font-semibold text-gray-900 mb-4">API Referenz</h2>
|
||||
<div className="bg-white rounded-xl border border-gray-200 overflow-hidden">
|
||||
<table className="min-w-full divide-y divide-gray-200">
|
||||
<thead className="bg-gray-50">
|
||||
<tr>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Methode</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Beschreibung</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-200">
|
||||
<tr>
|
||||
<td className="px-6 py-4"><code className="text-violet-600">configure()</code></td>
|
||||
<td className="px-6 py-4 text-sm text-gray-600">SDK konfigurieren</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="px-6 py-4"><code className="text-violet-600">initialize()</code></td>
|
||||
<td className="px-6 py-4 text-sm text-gray-600">SDK initialisieren (async)</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="px-6 py-4"><code className="text-violet-600">hasConsent(_:)</code></td>
|
||||
<td className="px-6 py-4 text-sm text-gray-600">Consent fuer Kategorie pruefen</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="px-6 py-4"><code className="text-violet-600">acceptAll()</code></td>
|
||||
<td className="px-6 py-4 text-sm text-gray-600">Alle Kategorien akzeptieren (async)</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="px-6 py-4"><code className="text-violet-600">rejectAll()</code></td>
|
||||
<td className="px-6 py-4 text-sm text-gray-600">Alle ablehnen (async)</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="px-6 py-4"><code className="text-violet-600">setConsent(_:)</code></td>
|
||||
<td className="px-6 py-4 text-sm text-gray-600">Spezifische Kategorien setzen (async)</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="px-6 py-4"><code className="text-violet-600">showBanner()</code></td>
|
||||
<td className="px-6 py-4 text-sm text-gray-600">Banner anzeigen</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="px-6 py-4"><code className="text-violet-600">exportConsent()</code></td>
|
||||
<td className="px-6 py-4 text-sm text-gray-600">Consent-Daten exportieren (DSGVO)</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
'use client'
|
||||
|
||||
import React from 'react'
|
||||
import Link from 'next/link'
|
||||
import { SDKDocsSidebar } from '@/components/developers/SDKDocsSidebar'
|
||||
import { ChevronRight, Apple, Smartphone } from 'lucide-react'
|
||||
|
||||
const platforms = [
|
||||
{
|
||||
name: 'iOS (Swift)',
|
||||
href: '/developers/sdk/consent/mobile/ios',
|
||||
description: 'Native Swift SDK fuer iOS 15+ und iPadOS',
|
||||
features: ['Swift 5.9+', 'iOS 15.0+', 'SwiftUI Support', 'Combine Integration'],
|
||||
color: 'bg-gray-900',
|
||||
icon: Apple,
|
||||
},
|
||||
{
|
||||
name: 'Android (Kotlin)',
|
||||
href: '/developers/sdk/consent/mobile/android',
|
||||
description: 'Native Kotlin SDK fuer Android API 26+',
|
||||
features: ['Kotlin 1.9+', 'API 26+', 'Jetpack Compose', 'Coroutines'],
|
||||
color: 'bg-green-600',
|
||||
icon: Smartphone,
|
||||
},
|
||||
{
|
||||
name: 'Flutter',
|
||||
href: '/developers/sdk/consent/mobile/flutter',
|
||||
description: 'Cross-Platform SDK fuer Flutter 3.16+',
|
||||
features: ['Dart 3.0+', 'Flutter 3.16+', 'iOS & Android', 'Web Support'],
|
||||
color: 'bg-blue-500',
|
||||
icon: Smartphone,
|
||||
},
|
||||
]
|
||||
|
||||
export default function MobileSDKsPage() {
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 flex">
|
||||
<SDKDocsSidebar />
|
||||
|
||||
<main className="flex-1 ml-64">
|
||||
<div className="max-w-4xl mx-auto px-8 py-12">
|
||||
<h1 className="text-3xl font-bold text-gray-900 mb-2">Mobile SDKs</h1>
|
||||
<p className="text-lg text-gray-600 mb-8">
|
||||
Native SDKs fuer iOS, Android und Flutter mit vollstaendiger DSGVO-Konformitaet.
|
||||
</p>
|
||||
|
||||
<div className="space-y-4">
|
||||
{platforms.map((platform) => (
|
||||
<Link
|
||||
key={platform.name}
|
||||
href={platform.href}
|
||||
className="block bg-white rounded-xl border border-gray-200 p-6 hover:border-violet-300 hover:shadow-md transition-all group"
|
||||
>
|
||||
<div className="flex items-start gap-4">
|
||||
<div className={`w-12 h-12 rounded-xl ${platform.color} flex items-center justify-center shrink-0`}>
|
||||
<platform.icon className="w-6 h-6 text-white" />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<h2 className="text-xl font-semibold text-gray-900 group-hover:text-violet-600 transition-colors">
|
||||
{platform.name}
|
||||
</h2>
|
||||
<ChevronRight className="w-5 h-5 text-gray-400 group-hover:text-violet-600 transition-colors" />
|
||||
</div>
|
||||
<p className="text-gray-600 mt-1">{platform.description}</p>
|
||||
<div className="flex flex-wrap gap-2 mt-3">
|
||||
{platform.features.map((feature) => (
|
||||
<span
|
||||
key={feature}
|
||||
className="px-2 py-1 bg-gray-100 text-gray-600 text-xs rounded-md"
|
||||
>
|
||||
{feature}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Cross-Platform Note */}
|
||||
<div className="mt-8 p-4 bg-blue-50 border border-blue-200 rounded-xl">
|
||||
<h3 className="font-medium text-blue-900">Cross-Platform Konsistenz</h3>
|
||||
<p className="text-sm text-blue-700 mt-1">
|
||||
Alle Mobile SDKs bieten dieselbe API-Oberflaeche wie das Web SDK.
|
||||
Consent-Daten werden ueber die API synchronisiert, sodass Benutzer auf allen Geraeten
|
||||
denselben Consent-Status haben.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,262 @@
|
||||
'use client'
|
||||
|
||||
import React, { useState } from 'react'
|
||||
import Link from 'next/link'
|
||||
import {
|
||||
Shield, Code, Download, Smartphone, FileCode, Lock,
|
||||
ChevronRight, Copy, Check, Zap, Globe, Layers,
|
||||
BookOpen, Terminal
|
||||
} from 'lucide-react'
|
||||
import { SDKDocsSidebar } from '@/components/developers/SDKDocsSidebar'
|
||||
|
||||
type Framework = 'npm' | 'yarn' | 'pnpm'
|
||||
|
||||
const installCommands: Record<Framework, string> = {
|
||||
npm: 'npm install @breakpilot/consent-sdk',
|
||||
yarn: 'yarn add @breakpilot/consent-sdk',
|
||||
pnpm: 'pnpm add @breakpilot/consent-sdk',
|
||||
}
|
||||
|
||||
function CopyButton({ text }: { text: string }) {
|
||||
const [copied, setCopied] = useState(false)
|
||||
|
||||
const handleCopy = async () => {
|
||||
await navigator.clipboard.writeText(text)
|
||||
setCopied(true)
|
||||
setTimeout(() => setCopied(false), 2000)
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={handleCopy}
|
||||
className="p-2 hover:bg-gray-700 rounded transition-colors"
|
||||
title="Kopieren"
|
||||
>
|
||||
{copied ? (
|
||||
<Check className="w-4 h-4 text-green-400" />
|
||||
) : (
|
||||
<Copy className="w-4 h-4 text-gray-400" />
|
||||
)}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
export default function ConsentSDKHubPage() {
|
||||
const [selectedPM, setSelectedPM] = useState<Framework>('npm')
|
||||
|
||||
const quickLinks = [
|
||||
{
|
||||
title: 'Installation',
|
||||
description: 'SDK in wenigen Minuten einrichten',
|
||||
href: '/developers/sdk/consent/installation',
|
||||
icon: Download,
|
||||
color: 'bg-blue-500',
|
||||
},
|
||||
{
|
||||
title: 'API Referenz',
|
||||
description: 'Vollstaendige API-Dokumentation',
|
||||
href: '/developers/sdk/consent/api-reference',
|
||||
icon: FileCode,
|
||||
color: 'bg-purple-500',
|
||||
},
|
||||
{
|
||||
title: 'Frameworks',
|
||||
description: 'React, Vue, Angular Integration',
|
||||
href: '/developers/sdk/consent/frameworks',
|
||||
icon: Layers,
|
||||
color: 'bg-green-500',
|
||||
},
|
||||
{
|
||||
title: 'Mobile SDKs',
|
||||
description: 'iOS, Android, Flutter',
|
||||
href: '/developers/sdk/consent/mobile',
|
||||
icon: Smartphone,
|
||||
color: 'bg-orange-500',
|
||||
},
|
||||
{
|
||||
title: 'Sicherheit',
|
||||
description: 'Best Practices & Compliance',
|
||||
href: '/developers/sdk/consent/security',
|
||||
icon: Lock,
|
||||
color: 'bg-red-500',
|
||||
},
|
||||
]
|
||||
|
||||
const features = [
|
||||
{
|
||||
title: 'DSGVO & TTDSG Konform',
|
||||
description: 'Vollstaendige Unterstuetzung fuer EU-Datenschutzverordnungen mit revisionssicherer Consent-Speicherung.',
|
||||
icon: Shield,
|
||||
},
|
||||
{
|
||||
title: 'Google Consent Mode v2',
|
||||
description: 'Native Integration mit automatischer Synchronisation zu Google Analytics und Ads.',
|
||||
icon: Globe,
|
||||
},
|
||||
{
|
||||
title: 'Script Blocking',
|
||||
description: 'Automatisches Blockieren von Third-Party Scripts bis zur Einwilligung.',
|
||||
icon: Code,
|
||||
},
|
||||
{
|
||||
title: 'Multi-Platform',
|
||||
description: 'Unterstuetzung fuer Web, PWA, iOS, Android und Flutter aus einer Codebasis.',
|
||||
icon: Smartphone,
|
||||
},
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 flex">
|
||||
<SDKDocsSidebar />
|
||||
|
||||
<main className="flex-1 ml-64">
|
||||
<div className="max-w-5xl mx-auto px-8 py-12">
|
||||
{/* Header */}
|
||||
<div className="mb-12">
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<div className="w-12 h-12 rounded-xl bg-gradient-to-br from-violet-600 to-purple-600 flex items-center justify-center">
|
||||
<Shield className="w-7 h-7 text-white" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-gray-900">Consent SDK</h1>
|
||||
<div className="flex items-center gap-2 mt-1">
|
||||
<span className="px-2 py-0.5 bg-green-100 text-green-800 text-xs font-medium rounded-full">
|
||||
v1.0.0
|
||||
</span>
|
||||
<span className="text-sm text-gray-500">DSGVO/TTDSG Compliant</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-lg text-gray-600 max-w-3xl">
|
||||
Das Consent SDK ermoeglicht DSGVO-konforme Einwilligungsverwaltung fuer Web, PWA und Mobile Apps.
|
||||
Mit nativer Unterstuetzung fuer React, Vue, Angular und Mobile Platforms.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Quick Install */}
|
||||
<div className="mb-12 bg-white rounded-xl border border-gray-200 overflow-hidden">
|
||||
<div className="px-6 py-4 border-b border-gray-200 flex items-center justify-between">
|
||||
<h2 className="font-semibold text-gray-900">Schnellinstallation</h2>
|
||||
<div className="flex gap-1 bg-gray-100 rounded-lg p-1">
|
||||
{(['npm', 'yarn', 'pnpm'] as const).map((pm) => (
|
||||
<button
|
||||
key={pm}
|
||||
onClick={() => setSelectedPM(pm)}
|
||||
className={`px-3 py-1 text-sm rounded-md transition-colors ${
|
||||
selectedPM === pm
|
||||
? 'bg-white text-gray-900 shadow-sm'
|
||||
: 'text-gray-600 hover:text-gray-900'
|
||||
}`}
|
||||
>
|
||||
{pm}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-gray-900 px-6 py-4 flex items-center justify-between">
|
||||
<code className="text-green-400 font-mono text-sm">
|
||||
$ {installCommands[selectedPM]}
|
||||
</code>
|
||||
<CopyButton text={installCommands[selectedPM]} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Quick Links */}
|
||||
<div className="mb-12">
|
||||
<h2 className="text-xl font-semibold text-gray-900 mb-4">Dokumentation</h2>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{quickLinks.map((link) => (
|
||||
<Link
|
||||
key={link.href}
|
||||
href={link.href}
|
||||
className="group p-4 bg-white rounded-xl border border-gray-200 hover:border-violet-300 hover:shadow-md transition-all"
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
<div className={`w-10 h-10 rounded-lg ${link.color} flex items-center justify-center shrink-0`}>
|
||||
<link.icon className="w-5 h-5 text-white" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3 className="font-medium text-gray-900 group-hover:text-violet-600 transition-colors flex items-center gap-1">
|
||||
{link.title}
|
||||
<ChevronRight className="w-4 h-4 opacity-0 group-hover:opacity-100 transition-opacity" />
|
||||
</h3>
|
||||
<p className="text-sm text-gray-500 mt-1">{link.description}</p>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Quick Start Code */}
|
||||
<div className="mb-12 bg-white rounded-xl border border-gray-200 overflow-hidden">
|
||||
<div className="px-6 py-4 border-b border-gray-200">
|
||||
<h2 className="font-semibold text-gray-900">Schnellstart</h2>
|
||||
</div>
|
||||
<div className="bg-gray-900 p-6">
|
||||
<pre className="text-sm text-gray-300 font-mono overflow-x-auto">
|
||||
{`import { ConsentManager } from '@breakpilot/consent-sdk';
|
||||
|
||||
// Manager initialisieren
|
||||
const consent = new ConsentManager({
|
||||
apiEndpoint: 'https://api.example.com/consent',
|
||||
siteId: 'your-site-id',
|
||||
});
|
||||
|
||||
// SDK starten
|
||||
await consent.init();
|
||||
|
||||
// Consent pruefen
|
||||
if (consent.hasConsent('analytics')) {
|
||||
// Analytics laden
|
||||
}
|
||||
|
||||
// Events abonnieren
|
||||
consent.on('change', (state) => {
|
||||
console.log('Consent geaendert:', state);
|
||||
});`}
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Features */}
|
||||
<div className="mb-12">
|
||||
<h2 className="text-xl font-semibold text-gray-900 mb-4">Features</h2>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{features.map((feature) => (
|
||||
<div
|
||||
key={feature.title}
|
||||
className="p-4 bg-white rounded-xl border border-gray-200"
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="w-10 h-10 rounded-lg bg-violet-100 flex items-center justify-center shrink-0">
|
||||
<feature.icon className="w-5 h-5 text-violet-600" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-medium text-gray-900">{feature.title}</h3>
|
||||
<p className="text-sm text-gray-500 mt-1">{feature.description}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Compliance Notice */}
|
||||
<div className="p-4 bg-blue-50 border border-blue-200 rounded-xl">
|
||||
<div className="flex items-start gap-3">
|
||||
<Shield className="w-5 h-5 text-blue-600 mt-0.5" />
|
||||
<div>
|
||||
<h3 className="font-medium text-blue-900">DSGVO & TTDSG Compliance</h3>
|
||||
<p className="text-sm text-blue-700 mt-1">
|
||||
Das Consent SDK erfuellt alle Anforderungen der DSGVO (Art. 6, 7, 13, 14, 17, 20) und des TTDSG (§ 25).
|
||||
Alle Einwilligungen werden revisionssicher gespeichert und koennen jederzeit exportiert werden.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,290 @@
|
||||
'use client'
|
||||
|
||||
import React from 'react'
|
||||
import { SDKDocsSidebar } from '@/components/developers/SDKDocsSidebar'
|
||||
import { Shield, Lock, Eye, Database, Key, AlertTriangle, CheckCircle } from 'lucide-react'
|
||||
|
||||
function SecurityCard({
|
||||
title,
|
||||
description,
|
||||
icon: Icon,
|
||||
items,
|
||||
}: {
|
||||
title: string
|
||||
description: string
|
||||
icon: React.ComponentType<{ className?: string }>
|
||||
items: string[]
|
||||
}) {
|
||||
return (
|
||||
<div className="bg-white rounded-xl border border-gray-200 p-6">
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="w-10 h-10 rounded-lg bg-violet-100 flex items-center justify-center shrink-0">
|
||||
<Icon className="w-5 h-5 text-violet-600" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-semibold text-gray-900">{title}</h3>
|
||||
<p className="text-sm text-gray-600 mt-1">{description}</p>
|
||||
<ul className="mt-3 space-y-1">
|
||||
{items.map((item, i) => (
|
||||
<li key={i} className="flex items-center gap-2 text-sm text-gray-600">
|
||||
<CheckCircle className="w-4 h-4 text-green-500 shrink-0" />
|
||||
{item}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function SecurityPage() {
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 flex">
|
||||
<SDKDocsSidebar />
|
||||
|
||||
<main className="flex-1 ml-64">
|
||||
<div className="max-w-4xl mx-auto px-8 py-12">
|
||||
<h1 className="text-3xl font-bold text-gray-900 mb-2">Sicherheit & Compliance</h1>
|
||||
<p className="text-lg text-gray-600 mb-8">
|
||||
Best Practices fuer sichere Implementierung und DSGVO-konforme Nutzung des Consent SDK.
|
||||
</p>
|
||||
|
||||
{/* Security Features */}
|
||||
<section className="mb-12">
|
||||
<h2 className="text-xl font-semibold text-gray-900 mb-6">Sicherheits-Features</h2>
|
||||
<div className="grid gap-4">
|
||||
<SecurityCard
|
||||
title="Datenverschluesselung"
|
||||
description="Alle Daten werden verschluesselt uebertragen und gespeichert."
|
||||
icon={Lock}
|
||||
items={[
|
||||
'TLS 1.3 fuer alle API-Kommunikation',
|
||||
'HMAC-Signatur fuer lokale Storage-Integritaet',
|
||||
'Keine Klartextspeicherung sensibler Daten',
|
||||
]}
|
||||
/>
|
||||
|
||||
<SecurityCard
|
||||
title="Datenschutzkonformes Fingerprinting"
|
||||
description="Anonymisiertes Fingerprinting ohne invasive Techniken."
|
||||
icon={Eye}
|
||||
items={[
|
||||
'Kein Canvas/WebGL/Audio Fingerprinting',
|
||||
'Nur anonymisierte Browser-Eigenschaften',
|
||||
'SHA-256 Hash der Komponenten',
|
||||
'Nicht eindeutig identifizierend',
|
||||
]}
|
||||
/>
|
||||
|
||||
<SecurityCard
|
||||
title="Sichere Speicherung"
|
||||
description="Lokale Speicherung mit Manipulationsschutz."
|
||||
icon={Database}
|
||||
items={[
|
||||
'Signierte localStorage-Eintraege',
|
||||
'Automatische Signaturverifikation',
|
||||
'HttpOnly Cookies fuer SSR',
|
||||
'SameSite=Lax gegen CSRF',
|
||||
]}
|
||||
/>
|
||||
|
||||
<SecurityCard
|
||||
title="API-Sicherheit"
|
||||
description="Sichere Backend-Kommunikation."
|
||||
icon={Key}
|
||||
items={[
|
||||
'Request-Signierung mit Timestamp',
|
||||
'Credentials-Include fuer Session-Cookies',
|
||||
'CORS-Konfiguration erforderlich',
|
||||
'Rate-Limiting auf Server-Seite',
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* DSGVO Compliance */}
|
||||
<section className="mb-12">
|
||||
<h2 className="text-xl font-semibold text-gray-900 mb-6">DSGVO Compliance</h2>
|
||||
|
||||
<div className="bg-white rounded-xl border border-gray-200 overflow-hidden">
|
||||
<table className="min-w-full divide-y divide-gray-200">
|
||||
<thead className="bg-gray-50">
|
||||
<tr>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">
|
||||
DSGVO Artikel
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">
|
||||
Anforderung
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">
|
||||
SDK-Unterstuetzung
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-200">
|
||||
<tr>
|
||||
<td className="px-6 py-4 text-sm font-medium text-gray-900">Art. 6</td>
|
||||
<td className="px-6 py-4 text-sm text-gray-600">Rechtmaessigkeit der Verarbeitung</td>
|
||||
<td className="px-6 py-4">
|
||||
<span className="px-2 py-1 bg-green-100 text-green-800 text-xs rounded-full">
|
||||
Vollstaendig
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="px-6 py-4 text-sm font-medium text-gray-900">Art. 7</td>
|
||||
<td className="px-6 py-4 text-sm text-gray-600">Bedingungen fuer Einwilligung</td>
|
||||
<td className="px-6 py-4">
|
||||
<span className="px-2 py-1 bg-green-100 text-green-800 text-xs rounded-full">
|
||||
Vollstaendig
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="px-6 py-4 text-sm font-medium text-gray-900">Art. 13/14</td>
|
||||
<td className="px-6 py-4 text-sm text-gray-600">Informationspflichten</td>
|
||||
<td className="px-6 py-4">
|
||||
<span className="px-2 py-1 bg-green-100 text-green-800 text-xs rounded-full">
|
||||
Vollstaendig
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="px-6 py-4 text-sm font-medium text-gray-900">Art. 17</td>
|
||||
<td className="px-6 py-4 text-sm text-gray-600">Recht auf Loeschung</td>
|
||||
<td className="px-6 py-4">
|
||||
<span className="px-2 py-1 bg-green-100 text-green-800 text-xs rounded-full">
|
||||
Vollstaendig
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="px-6 py-4 text-sm font-medium text-gray-900">Art. 20</td>
|
||||
<td className="px-6 py-4 text-sm text-gray-600">Datenportabilitaet</td>
|
||||
<td className="px-6 py-4">
|
||||
<span className="px-2 py-1 bg-green-100 text-green-800 text-xs rounded-full">
|
||||
Vollstaendig
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* TTDSG Compliance */}
|
||||
<section className="mb-12">
|
||||
<h2 className="text-xl font-semibold text-gray-900 mb-6">TTDSG Compliance</h2>
|
||||
<div className="bg-white rounded-xl border border-gray-200 p-6">
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="w-10 h-10 rounded-lg bg-blue-100 flex items-center justify-center shrink-0">
|
||||
<Shield className="w-5 h-5 text-blue-600" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-semibold text-gray-900">§ 25 TTDSG - Schutz der Privatsphaere</h3>
|
||||
<p className="text-sm text-gray-600 mt-1">
|
||||
Das SDK erfuellt alle Anforderungen des § 25 TTDSG (Telemediengesetz):
|
||||
</p>
|
||||
<ul className="mt-3 space-y-2">
|
||||
<li className="flex items-start gap-2 text-sm text-gray-600">
|
||||
<CheckCircle className="w-4 h-4 text-green-500 shrink-0 mt-0.5" />
|
||||
<span>
|
||||
<strong>Einwilligung vor Speicherung:</strong> Cookies und localStorage werden erst nach
|
||||
Einwilligung gesetzt (ausser technisch notwendige).
|
||||
</span>
|
||||
</li>
|
||||
<li className="flex items-start gap-2 text-sm text-gray-600">
|
||||
<CheckCircle className="w-4 h-4 text-green-500 shrink-0 mt-0.5" />
|
||||
<span>
|
||||
<strong>Informierte Einwilligung:</strong> Klare Kategorisierung und Beschreibung
|
||||
aller Cookies und Tracker.
|
||||
</span>
|
||||
</li>
|
||||
<li className="flex items-start gap-2 text-sm text-gray-600">
|
||||
<CheckCircle className="w-4 h-4 text-green-500 shrink-0 mt-0.5" />
|
||||
<span>
|
||||
<strong>Widerrufsrecht:</strong> Jederzeit widerrufbare Einwilligung mit einem Klick.
|
||||
</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Best Practices */}
|
||||
<section className="mb-12">
|
||||
<h2 className="text-xl font-semibold text-gray-900 mb-6">Best Practices</h2>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="bg-green-50 border border-green-200 rounded-xl p-4">
|
||||
<h3 className="font-medium text-green-900 flex items-center gap-2">
|
||||
<CheckCircle className="w-5 h-5" />
|
||||
Empfohlen
|
||||
</h3>
|
||||
<ul className="mt-2 space-y-1 text-sm text-green-800">
|
||||
<li>• HTTPS fuer alle API-Aufrufe verwenden</li>
|
||||
<li>• Consent-Banner vor dem Laden von Third-Party Scripts anzeigen</li>
|
||||
<li>• Alle Kategorien klar und verstaendlich beschreiben</li>
|
||||
<li>• Ablehnen-Button gleichwertig zum Akzeptieren-Button darstellen</li>
|
||||
<li>• Consent-Aenderungen serverseitig protokollieren</li>
|
||||
<li>• Regelmaessige Ueberpruefung der Consent-Gultigkeit (recheckAfterDays)</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div className="bg-red-50 border border-red-200 rounded-xl p-4">
|
||||
<h3 className="font-medium text-red-900 flex items-center gap-2">
|
||||
<AlertTriangle className="w-5 h-5" />
|
||||
Vermeiden
|
||||
</h3>
|
||||
<ul className="mt-2 space-y-1 text-sm text-red-800">
|
||||
<li>• Dark Patterns (versteckte Ablehnen-Buttons)</li>
|
||||
<li>• Pre-checked Consent-Optionen</li>
|
||||
<li>• Tracking vor Einwilligung</li>
|
||||
<li>• Cookie-Walls ohne echte Alternative</li>
|
||||
<li>• Unklare oder irrefuehrende Kategoriebezeichnungen</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Audit Trail */}
|
||||
<section>
|
||||
<h2 className="text-xl font-semibold text-gray-900 mb-6">Audit Trail</h2>
|
||||
<div className="bg-white rounded-xl border border-gray-200 p-6">
|
||||
<p className="text-gray-600 mb-4">
|
||||
Das SDK speichert fuer jeden Consent-Vorgang revisionssichere Daten:
|
||||
</p>
|
||||
<div className="bg-gray-50 rounded-lg p-4 font-mono text-sm">
|
||||
<pre className="text-gray-700">
|
||||
{`{
|
||||
"consentId": "consent_abc123...",
|
||||
"timestamp": "2024-01-15T10:30:00.000Z",
|
||||
"categories": {
|
||||
"essential": true,
|
||||
"analytics": true,
|
||||
"marketing": false
|
||||
},
|
||||
"metadata": {
|
||||
"userAgent": "Mozilla/5.0...",
|
||||
"language": "de-DE",
|
||||
"platform": "web",
|
||||
"screenResolution": "1920x1080"
|
||||
},
|
||||
"signature": "sha256=...",
|
||||
"version": "1.0.0"
|
||||
}`}
|
||||
</pre>
|
||||
</div>
|
||||
<p className="text-sm text-gray-500 mt-4">
|
||||
Diese Daten werden sowohl lokal als auch auf dem Server gespeichert und koennen
|
||||
jederzeit fuer Audits exportiert werden.
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
import { DevPortalLayout, CodeBlock, InfoBox, ParameterTable } from '@/components/developers/DevPortalLayout'
|
||||
|
||||
export default function SDKInstallationPage() {
|
||||
return (
|
||||
<DevPortalLayout
|
||||
title="SDK Installation"
|
||||
description="Installationsanleitung fuer das AI Compliance SDK"
|
||||
>
|
||||
<h2>Voraussetzungen</h2>
|
||||
<ul>
|
||||
<li>Node.js 18 oder hoeher</li>
|
||||
<li>React 18+ / Next.js 14+</li>
|
||||
<li>TypeScript 5.0+ (empfohlen)</li>
|
||||
</ul>
|
||||
|
||||
<h2>Installation</h2>
|
||||
<p>
|
||||
Installieren Sie das SDK ueber Ihren bevorzugten Paketmanager:
|
||||
</p>
|
||||
<CodeBlock language="bash" filename="npm">
|
||||
{`npm install @breakpilot/compliance-sdk`}
|
||||
</CodeBlock>
|
||||
<CodeBlock language="bash" filename="yarn">
|
||||
{`yarn add @breakpilot/compliance-sdk`}
|
||||
</CodeBlock>
|
||||
<CodeBlock language="bash" filename="pnpm">
|
||||
{`pnpm add @breakpilot/compliance-sdk`}
|
||||
</CodeBlock>
|
||||
|
||||
<h2>Peer Dependencies</h2>
|
||||
<p>
|
||||
Das SDK hat folgende Peer Dependencies, die automatisch installiert werden sollten:
|
||||
</p>
|
||||
<CodeBlock language="json" filename="package.json">
|
||||
{`{
|
||||
"peerDependencies": {
|
||||
"react": ">=18.0.0",
|
||||
"react-dom": ">=18.0.0"
|
||||
}
|
||||
}`}
|
||||
</CodeBlock>
|
||||
|
||||
<h2>Zusaetzliche Pakete (optional)</h2>
|
||||
<p>
|
||||
Fuer erweiterte Funktionen koennen Sie folgende Pakete installieren:
|
||||
</p>
|
||||
<ParameterTable
|
||||
parameters={[
|
||||
{
|
||||
name: 'jspdf',
|
||||
type: 'npm package',
|
||||
required: false,
|
||||
description: 'Fuer PDF-Export (wird automatisch geladen wenn verfuegbar)',
|
||||
},
|
||||
{
|
||||
name: 'jszip',
|
||||
type: 'npm package',
|
||||
required: false,
|
||||
description: 'Fuer ZIP-Export aller Dokumente',
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
<h2>TypeScript Konfiguration</h2>
|
||||
<p>
|
||||
Das SDK ist vollstaendig in TypeScript geschrieben. Stellen Sie sicher,
|
||||
dass Ihre tsconfig.json folgende Optionen enthaelt:
|
||||
</p>
|
||||
<CodeBlock language="json" filename="tsconfig.json">
|
||||
{`{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"lib": ["dom", "dom.iterable", "esnext"],
|
||||
"module": "esnext",
|
||||
"moduleResolution": "bundler",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true
|
||||
}
|
||||
}`}
|
||||
</CodeBlock>
|
||||
|
||||
<h2>Next.js Integration</h2>
|
||||
<p>
|
||||
Fuer Next.js 14+ mit App Router, fuegen Sie den Provider in Ihr Root-Layout ein:
|
||||
</p>
|
||||
<CodeBlock language="typescript" filename="app/layout.tsx">
|
||||
{`import { SDKProvider } from '@breakpilot/compliance-sdk'
|
||||
|
||||
export default function RootLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
return (
|
||||
<html lang="de">
|
||||
<body>
|
||||
<SDKProvider
|
||||
tenantId={process.env.NEXT_PUBLIC_TENANT_ID!}
|
||||
apiKey={process.env.BREAKPILOT_API_KEY}
|
||||
enableBackendSync={true}
|
||||
>
|
||||
{children}
|
||||
</SDKProvider>
|
||||
</body>
|
||||
</html>
|
||||
)
|
||||
}`}
|
||||
</CodeBlock>
|
||||
|
||||
<InfoBox type="warning" title="Wichtig fuer Server Components">
|
||||
Der SDKProvider ist ein Client-Component. Wenn Sie Server Components
|
||||
verwenden, wrappen Sie nur die Teile der App, die das SDK benoetigen.
|
||||
</InfoBox>
|
||||
|
||||
<h2>Umgebungsvariablen</h2>
|
||||
<p>
|
||||
Erstellen Sie eine .env.local Datei mit folgenden Variablen:
|
||||
</p>
|
||||
<CodeBlock language="bash" filename=".env.local">
|
||||
{`# Pflicht
|
||||
NEXT_PUBLIC_TENANT_ID=your-tenant-id
|
||||
|
||||
# Optional (fuer Backend-Sync)
|
||||
BREAKPILOT_API_KEY=sk_live_...
|
||||
|
||||
# Optional (fuer Self-Hosted)
|
||||
NEXT_PUBLIC_SDK_API_URL=https://your-server.com/sdk/v1`}
|
||||
</CodeBlock>
|
||||
|
||||
<InfoBox type="info" title="API Key Sicherheit">
|
||||
Der API Key sollte niemals im Frontend-Code oder in NEXT_PUBLIC_ Variablen
|
||||
erscheinen. Verwenden Sie Server-Side API Routes fuer authentifizierte Anfragen.
|
||||
</InfoBox>
|
||||
|
||||
<h2>Verifizierung</h2>
|
||||
<p>
|
||||
Testen Sie die Installation mit einer einfachen Komponente:
|
||||
</p>
|
||||
<CodeBlock language="typescript" filename="app/test/page.tsx">
|
||||
{`'use client'
|
||||
|
||||
import { useSDK } from '@breakpilot/compliance-sdk'
|
||||
|
||||
export default function TestPage() {
|
||||
const { state, completionPercentage } = useSDK()
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h1>SDK Test</h1>
|
||||
<p>Fortschritt: {completionPercentage}%</p>
|
||||
<p>Aktuelle Phase: {state.currentPhase}</p>
|
||||
<p>Use Cases: {state.useCases.length}</p>
|
||||
</div>
|
||||
)
|
||||
}`}
|
||||
</CodeBlock>
|
||||
|
||||
<h2>Fehlerbehebung</h2>
|
||||
|
||||
<h3>Error: useSDK must be used within SDKProvider</h3>
|
||||
<p>
|
||||
Stellen Sie sicher, dass der SDKProvider das gesamte Layout umschliesst
|
||||
und dass Sie {'\'use client\''} in Client-Komponenten verwenden.
|
||||
</p>
|
||||
|
||||
<h3>Error: Module not found</h3>
|
||||
<p>
|
||||
Loeschen Sie node_modules und package-lock.json, dann reinstallieren:
|
||||
</p>
|
||||
<CodeBlock language="bash" filename="Terminal">
|
||||
{`rm -rf node_modules package-lock.json
|
||||
npm install`}
|
||||
</CodeBlock>
|
||||
|
||||
<h3>TypeScript Errors</h3>
|
||||
<p>
|
||||
Stellen Sie sicher, dass TypeScript 5.0+ installiert ist:
|
||||
</p>
|
||||
<CodeBlock language="bash" filename="Terminal">
|
||||
{`npm install typescript@latest`}
|
||||
</CodeBlock>
|
||||
</DevPortalLayout>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,281 @@
|
||||
import Link from 'next/link'
|
||||
import { DevPortalLayout, CodeBlock, InfoBox } from '@/components/developers/DevPortalLayout'
|
||||
|
||||
export default function SDKOverviewPage() {
|
||||
return (
|
||||
<DevPortalLayout
|
||||
title="SDK Documentation"
|
||||
description="TypeScript SDK für React und Next.js Integration"
|
||||
>
|
||||
<h2>Übersicht</h2>
|
||||
<p>
|
||||
Das AI Compliance SDK ist ein TypeScript-Paket für die Integration des
|
||||
Compliance-Workflows in React und Next.js Anwendungen. Es bietet:
|
||||
</p>
|
||||
<ul>
|
||||
<li>React Context Provider für State Management</li>
|
||||
<li>Hooks für einfachen Zugriff auf Compliance-Daten</li>
|
||||
<li>Automatische Synchronisation mit dem Backend</li>
|
||||
<li>Offline-Support mit localStorage Fallback</li>
|
||||
<li>Export-Funktionen (PDF, JSON, ZIP)</li>
|
||||
</ul>
|
||||
|
||||
<h2>Kernkomponenten</h2>
|
||||
|
||||
<h3>SDKProvider</h3>
|
||||
<p>
|
||||
Der Provider wrappet Ihre App und stellt den SDK-Kontext bereit:
|
||||
</p>
|
||||
<CodeBlock language="typescript" filename="app/layout.tsx">
|
||||
{`import { SDKProvider } from '@breakpilot/compliance-sdk'
|
||||
|
||||
export default function Layout({ children }) {
|
||||
return (
|
||||
<SDKProvider
|
||||
tenantId="your-tenant"
|
||||
enableBackendSync={true}
|
||||
>
|
||||
{children}
|
||||
</SDKProvider>
|
||||
)
|
||||
}`}
|
||||
</CodeBlock>
|
||||
|
||||
<h3>useSDK Hook</h3>
|
||||
<p>
|
||||
Der Haupt-Hook für den Zugriff auf alle SDK-Funktionen:
|
||||
</p>
|
||||
<CodeBlock language="typescript" filename="component.tsx">
|
||||
{`import { useSDK } from '@breakpilot/compliance-sdk'
|
||||
|
||||
function MyComponent() {
|
||||
const {
|
||||
// State
|
||||
state,
|
||||
dispatch,
|
||||
|
||||
// Navigation
|
||||
currentStep,
|
||||
goToStep,
|
||||
goToNextStep,
|
||||
goToPreviousStep,
|
||||
canGoNext,
|
||||
canGoPrevious,
|
||||
|
||||
// Progress
|
||||
completionPercentage,
|
||||
phase1Completion,
|
||||
phase2Completion,
|
||||
|
||||
// Checkpoints
|
||||
validateCheckpoint,
|
||||
overrideCheckpoint,
|
||||
getCheckpointStatus,
|
||||
|
||||
// Data Updates
|
||||
updateUseCase,
|
||||
addRisk,
|
||||
updateControl,
|
||||
|
||||
// Persistence
|
||||
saveState,
|
||||
loadState,
|
||||
|
||||
// Demo Data
|
||||
seedDemoData,
|
||||
clearDemoData,
|
||||
isDemoDataLoaded,
|
||||
|
||||
// Sync
|
||||
syncState,
|
||||
forceSyncToServer,
|
||||
isOnline,
|
||||
|
||||
// Export
|
||||
exportState,
|
||||
|
||||
// Command Bar
|
||||
isCommandBarOpen,
|
||||
setCommandBarOpen,
|
||||
} = useSDK()
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h1>Progress: {completionPercentage}%</h1>
|
||||
<button onClick={() => goToStep('risks')}>
|
||||
Zur Risikoanalyse
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}`}
|
||||
</CodeBlock>
|
||||
|
||||
<h2>Types</h2>
|
||||
<p>
|
||||
Das SDK exportiert alle TypeScript-Types für volle Typsicherheit:
|
||||
</p>
|
||||
<CodeBlock language="typescript" filename="types.ts">
|
||||
{`import type {
|
||||
// Core Types
|
||||
SDKState,
|
||||
SDKAction,
|
||||
SDKStep,
|
||||
SDKPhase,
|
||||
|
||||
// Use Cases
|
||||
UseCaseAssessment,
|
||||
AssessmentResult,
|
||||
|
||||
// Risk Management
|
||||
Risk,
|
||||
RiskSeverity,
|
||||
RiskMitigation,
|
||||
|
||||
// Controls & Evidence
|
||||
Control,
|
||||
Evidence,
|
||||
Requirement,
|
||||
|
||||
// Checkpoints
|
||||
Checkpoint,
|
||||
CheckpointStatus,
|
||||
ValidationError,
|
||||
|
||||
// DSFA
|
||||
DSFA,
|
||||
DSFASection,
|
||||
DSFAApproval,
|
||||
|
||||
// TOMs & VVT
|
||||
TOM,
|
||||
ProcessingActivity,
|
||||
RetentionPolicy,
|
||||
|
||||
// AI Act
|
||||
AIActResult,
|
||||
AIActRiskCategory,
|
||||
} from '@breakpilot/compliance-sdk'`}
|
||||
</CodeBlock>
|
||||
|
||||
<h2>Utility Functions</h2>
|
||||
<p>
|
||||
Hilfreiche Funktionen für die Arbeit mit dem SDK:
|
||||
</p>
|
||||
<CodeBlock language="typescript" filename="utils.ts">
|
||||
{`import {
|
||||
// Step Navigation
|
||||
getStepById,
|
||||
getStepByUrl,
|
||||
getNextStep,
|
||||
getPreviousStep,
|
||||
getStepsForPhase,
|
||||
|
||||
// Risk Calculation
|
||||
calculateRiskScore,
|
||||
getRiskSeverityFromScore,
|
||||
calculateResidualRisk,
|
||||
|
||||
// Progress
|
||||
getCompletionPercentage,
|
||||
getPhaseCompletionPercentage,
|
||||
} from '@breakpilot/compliance-sdk'
|
||||
|
||||
// Beispiel: Risk Score berechnen
|
||||
const inherentRisk = calculateRiskScore(4, 5) // likelihood * impact = 20
|
||||
const severity = getRiskSeverityFromScore(20) // 'CRITICAL'`}
|
||||
</CodeBlock>
|
||||
|
||||
<h2>API Client</h2>
|
||||
<p>
|
||||
Für direkten API-Zugriff ohne React Context:
|
||||
</p>
|
||||
<CodeBlock language="typescript" filename="api.ts">
|
||||
{`import {
|
||||
getSDKApiClient,
|
||||
SDKApiClient,
|
||||
} from '@breakpilot/compliance-sdk'
|
||||
|
||||
const client = getSDKApiClient('your-tenant-id')
|
||||
|
||||
// State laden
|
||||
const state = await client.getState()
|
||||
|
||||
// State speichern
|
||||
await client.saveState(updatedState)
|
||||
|
||||
// Checkpoint validieren
|
||||
const result = await client.validateCheckpoint('CP-UC', state)
|
||||
|
||||
// Export
|
||||
const blob = await client.exportState('pdf')`}
|
||||
</CodeBlock>
|
||||
|
||||
<h2>RAG & LLM Client</h2>
|
||||
<p>
|
||||
Zugriff auf die RAG-Suche und Dokumentengenerierung:
|
||||
</p>
|
||||
<CodeBlock language="typescript" filename="rag.ts">
|
||||
{`import {
|
||||
getSDKBackendClient,
|
||||
isLegalQuery,
|
||||
} from '@breakpilot/compliance-sdk'
|
||||
|
||||
const client = getSDKBackendClient()
|
||||
|
||||
// RAG-Suche
|
||||
const results = await client.search('DSGVO Art. 5', 5)
|
||||
console.log(results) // SearchResult[]
|
||||
|
||||
// Dokumentengenerierung
|
||||
const dsfa = await client.generateDSFA(context)
|
||||
const toms = await client.generateTOM(context)
|
||||
const vvt = await client.generateVVT(context)
|
||||
|
||||
// Prüfen ob eine Query rechtliche Inhalte betrifft
|
||||
if (isLegalQuery('Einwilligung DSGVO')) {
|
||||
// RAG-Suche durchführen
|
||||
}`}
|
||||
</CodeBlock>
|
||||
|
||||
<h2>Export</h2>
|
||||
<p>
|
||||
Exportieren Sie Compliance-Daten in verschiedenen Formaten:
|
||||
</p>
|
||||
<CodeBlock language="typescript" filename="export.ts">
|
||||
{`import { exportToPDF, exportToZIP, downloadExport } from '@breakpilot/compliance-sdk'
|
||||
|
||||
// PDF Export
|
||||
const pdfBlob = await exportToPDF(state)
|
||||
downloadExport(pdfBlob, 'compliance-report.pdf')
|
||||
|
||||
// ZIP Export (alle Dokumente)
|
||||
const zipBlob = await exportToZIP(state)
|
||||
downloadExport(zipBlob, 'compliance-export.zip')
|
||||
|
||||
// Über den Hook
|
||||
const { exportState } = useSDK()
|
||||
const blob = await exportState('pdf') // 'json' | 'pdf' | 'zip'`}
|
||||
</CodeBlock>
|
||||
|
||||
<InfoBox type="success" title="Weitere Dokumentation">
|
||||
<ul className="list-disc list-inside space-y-1">
|
||||
<li>
|
||||
<Link href="/developers/sdk/installation" className="text-blue-600 hover:underline">
|
||||
Installation Guide
|
||||
</Link>
|
||||
</li>
|
||||
<li>
|
||||
<Link href="/developers/sdk/configuration" className="text-blue-600 hover:underline">
|
||||
Konfigurationsoptionen
|
||||
</Link>
|
||||
</li>
|
||||
<li>
|
||||
<Link href="/developers/guides/phase1" className="text-blue-600 hover:underline">
|
||||
Phase 1 Workflow Guide
|
||||
</Link>
|
||||
</li>
|
||||
</ul>
|
||||
</InfoBox>
|
||||
</DevPortalLayout>
|
||||
)
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,39 @@
|
||||
'use client'
|
||||
|
||||
import { PagePurpose } from '@/components/common/PagePurpose'
|
||||
import { getModuleByHref } from '@/lib/navigation'
|
||||
import { GraduationCap, Construction } from 'lucide-react'
|
||||
|
||||
export default function CompanionPage() {
|
||||
const moduleInfo = getModuleByHref('/development/companion')
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{moduleInfo && (
|
||||
<PagePurpose
|
||||
title={moduleInfo.module.name}
|
||||
purpose={moduleInfo.module.purpose}
|
||||
audience={moduleInfo.module.audience}
|
||||
collapsible={true}
|
||||
defaultCollapsed={true}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="bg-white border border-slate-200 rounded-xl p-8 text-center">
|
||||
<div className="flex justify-center mb-4">
|
||||
<div className="p-4 bg-slate-100 rounded-full">
|
||||
<GraduationCap className="w-12 h-12 text-slate-400" />
|
||||
</div>
|
||||
</div>
|
||||
<h2 className="text-xl font-semibold text-slate-800 mb-2">Companion Dev</h2>
|
||||
<p className="text-slate-600 mb-4">
|
||||
Lesson-Modus Entwicklung fuer strukturiertes Lernen.
|
||||
</p>
|
||||
<div className="inline-flex items-center gap-2 px-4 py-2 bg-amber-50 border border-amber-200 rounded-lg text-amber-700">
|
||||
<Construction className="w-4 h-4" />
|
||||
<span className="text-sm font-medium">In Entwicklung</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,216 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect } from 'react'
|
||||
import { PagePurpose } from '@/components/common/PagePurpose'
|
||||
import { getModuleByHref } from '@/lib/navigation'
|
||||
import { ExternalLink, Maximize2, Minimize2, RefreshCw, Search, BookOpen, ArrowRight } from 'lucide-react'
|
||||
|
||||
// Quick links to important documentation sections
|
||||
const quickLinks = [
|
||||
{ name: 'Architektur', path: '#architektur', icon: '🏗️' },
|
||||
{ name: 'Klausur-Service', path: 'services/klausur-service/', icon: '📝' },
|
||||
{ name: 'AI-Compliance-SDK', path: 'services/ai-compliance-sdk/', icon: '🔒' },
|
||||
{ name: 'Voice-Service', path: 'services/voice-service/', icon: '🎤' },
|
||||
{ name: 'Agent-Core', path: 'services/agent-core/', icon: '🤖' },
|
||||
{ name: 'CI/CD Pipeline', path: 'development/ci-cd-pipeline/', icon: '🚀' },
|
||||
]
|
||||
|
||||
export default function DocsPage() {
|
||||
const [isFullscreen, setIsFullscreen] = useState(false)
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
const [currentPath, setCurrentPath] = useState('')
|
||||
const moduleInfo = getModuleByHref('/development/docs')
|
||||
|
||||
// Determine docs URL based on environment
|
||||
// Use same-origin proxy at /docs/ to avoid mixed content issues (HTTPS -> HTTP)
|
||||
const getDocsUrl = () => {
|
||||
if (typeof window !== 'undefined') {
|
||||
// Use same-origin proxy path to avoid mixed content issues
|
||||
const protocol = window.location.protocol
|
||||
const hostname = window.location.hostname
|
||||
const port = window.location.port
|
||||
return `${protocol}//${hostname}${port ? ':' + port : ''}/docs`
|
||||
}
|
||||
return '/docs'
|
||||
}
|
||||
|
||||
const docsUrl = getDocsUrl()
|
||||
|
||||
const handleIframeLoad = () => {
|
||||
setIsLoading(false)
|
||||
}
|
||||
|
||||
const navigateTo = (path: string) => {
|
||||
setCurrentPath(path)
|
||||
setIsLoading(true)
|
||||
}
|
||||
|
||||
const toggleFullscreen = () => {
|
||||
setIsFullscreen(!isFullscreen)
|
||||
}
|
||||
|
||||
const openInNewTab = () => {
|
||||
window.open(`${docsUrl}/${currentPath}`, '_blank')
|
||||
}
|
||||
|
||||
const refreshDocs = () => {
|
||||
setIsLoading(true)
|
||||
// Force iframe reload by toggling key
|
||||
setCurrentPath(currentPath + '?refresh=' + Date.now())
|
||||
setTimeout(() => setCurrentPath(currentPath), 100)
|
||||
}
|
||||
|
||||
if (isFullscreen) {
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 bg-white">
|
||||
{/* Fullscreen Toolbar */}
|
||||
<div className="absolute top-0 left-0 right-0 h-12 bg-slate-900 flex items-center justify-between px-4 z-10">
|
||||
<div className="flex items-center gap-2 text-white">
|
||||
<BookOpen className="w-5 h-5" />
|
||||
<span className="font-semibold">Breakpilot Dokumentation</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={openInNewTab}
|
||||
className="p-2 text-slate-300 hover:text-white hover:bg-slate-700 rounded transition-colors"
|
||||
title="In neuem Tab oeffnen"
|
||||
>
|
||||
<ExternalLink className="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={toggleFullscreen}
|
||||
className="p-2 text-slate-300 hover:text-white hover:bg-slate-700 rounded transition-colors"
|
||||
title="Vollbild beenden"
|
||||
>
|
||||
<Minimize2 className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<iframe
|
||||
src={`${docsUrl}/${currentPath}`}
|
||||
className="w-full h-full pt-12"
|
||||
title="Breakpilot Documentation"
|
||||
onLoad={handleIframeLoad}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Page Purpose */}
|
||||
{moduleInfo && (
|
||||
<PagePurpose
|
||||
title={moduleInfo.module.name}
|
||||
purpose={moduleInfo.module.purpose}
|
||||
audience={moduleInfo.module.audience}
|
||||
architecture={{
|
||||
services: ['MkDocs (Static Site)', 'Nginx (Port 8009)'],
|
||||
databases: [],
|
||||
}}
|
||||
relatedPages={[
|
||||
{ name: 'CI/CD', href: '/infrastructure/ci-cd', description: 'Deployment Pipeline' },
|
||||
{ name: 'Architektur', href: '/architecture', description: 'System-Uebersicht' },
|
||||
{ name: 'SBOM', href: '/infrastructure/sbom', description: 'Abhaengigkeiten' },
|
||||
]}
|
||||
collapsible={true}
|
||||
defaultCollapsed={true}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Quick Links */}
|
||||
<div className="bg-white border border-slate-200 rounded-xl p-4">
|
||||
<h3 className="text-sm font-semibold text-slate-700 mb-3 flex items-center gap-2">
|
||||
<Search className="w-4 h-4" />
|
||||
Schnellzugriff
|
||||
</h3>
|
||||
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-6 gap-2">
|
||||
{quickLinks.map((link) => (
|
||||
<button
|
||||
key={link.path}
|
||||
onClick={() => navigateTo(link.path)}
|
||||
className="flex items-center gap-2 px-3 py-2 text-sm bg-slate-50 hover:bg-slate-100 border border-slate-200 rounded-lg transition-colors text-left"
|
||||
>
|
||||
<span>{link.icon}</span>
|
||||
<span className="truncate">{link.name}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Toolbar */}
|
||||
<div className="flex items-center justify-between bg-white border border-slate-200 rounded-xl p-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<BookOpen className="w-5 h-5 text-slate-500" />
|
||||
<span className="text-sm font-medium text-slate-700">
|
||||
Breakpilot Dokumentation
|
||||
</span>
|
||||
<span className="text-xs text-slate-400">
|
||||
(MkDocs Material)
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={refreshDocs}
|
||||
className="p-2 text-slate-500 hover:text-slate-700 hover:bg-slate-100 rounded-lg transition-colors"
|
||||
title="Aktualisieren"
|
||||
>
|
||||
<RefreshCw className={`w-4 h-4 ${isLoading ? 'animate-spin' : ''}`} />
|
||||
</button>
|
||||
<button
|
||||
onClick={openInNewTab}
|
||||
className="p-2 text-slate-500 hover:text-slate-700 hover:bg-slate-100 rounded-lg transition-colors"
|
||||
title="In neuem Tab oeffnen"
|
||||
>
|
||||
<ExternalLink className="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={toggleFullscreen}
|
||||
className="p-2 text-slate-500 hover:text-slate-700 hover:bg-slate-100 rounded-lg transition-colors"
|
||||
title="Vollbild"
|
||||
>
|
||||
<Maximize2 className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Documentation Iframe */}
|
||||
<div className="relative bg-white border border-slate-200 rounded-xl overflow-hidden" style={{ height: 'calc(100vh - 400px)', minHeight: '500px' }}>
|
||||
{isLoading && (
|
||||
<div className="absolute inset-0 bg-white flex items-center justify-center z-10">
|
||||
<div className="flex flex-col items-center gap-3">
|
||||
<div className="w-8 h-8 border-2 border-slate-300 border-t-slate-600 rounded-full animate-spin" />
|
||||
<span className="text-sm text-slate-500">Dokumentation wird geladen...</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<iframe
|
||||
key={currentPath}
|
||||
src={`${docsUrl}/${currentPath}`}
|
||||
className="w-full h-full"
|
||||
title="Breakpilot Documentation"
|
||||
onLoad={handleIframeLoad}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Info Box */}
|
||||
<div className="bg-slate-50 border border-slate-200 rounded-xl p-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="p-2 bg-slate-200 rounded-lg">
|
||||
<ArrowRight className="w-4 h-4 text-slate-600" />
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="font-medium text-slate-800">Dokumentation bearbeiten</h4>
|
||||
<p className="text-sm text-slate-600 mt-1">
|
||||
Die Dokumentation befindet sich im Repository unter <code className="text-xs bg-slate-200 px-1.5 py-0.5 rounded">docs-src/</code>.
|
||||
Nach Aenderungen muss der Docs-Container neu gebaut werden.
|
||||
</p>
|
||||
<div className="mt-2 text-xs text-slate-500 font-mono bg-slate-100 p-2 rounded">
|
||||
rsync docs-src/ macmini:... && ssh macmini "docker compose build docs && docker compose up -d docs"
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
'use client'
|
||||
|
||||
import { PagePurpose } from '@/components/common/PagePurpose'
|
||||
import { getModuleByHref } from '@/lib/navigation'
|
||||
import { Gamepad2, Construction } from 'lucide-react'
|
||||
|
||||
export default function GamePage() {
|
||||
const moduleInfo = getModuleByHref('/development/game')
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{moduleInfo && (
|
||||
<PagePurpose
|
||||
title={moduleInfo.module.name}
|
||||
purpose={moduleInfo.module.purpose}
|
||||
audience={moduleInfo.module.audience}
|
||||
collapsible={true}
|
||||
defaultCollapsed={true}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="bg-white border border-slate-200 rounded-xl p-8 text-center">
|
||||
<div className="flex justify-center mb-4">
|
||||
<div className="p-4 bg-slate-100 rounded-full">
|
||||
<Gamepad2 className="w-12 h-12 text-slate-400" />
|
||||
</div>
|
||||
</div>
|
||||
<h2 className="text-xl font-semibold text-slate-800 mb-2">Breakpilot Drive</h2>
|
||||
<p className="text-slate-600 mb-4">
|
||||
Lernspiel-Management fuer Level, Inhalte und Lernziele.
|
||||
</p>
|
||||
<div className="inline-flex items-center gap-2 px-4 py-2 bg-amber-50 border border-amber-200 rounded-lg text-amber-700">
|
||||
<Construction className="w-4 h-4" />
|
||||
<span className="text-sm font-medium">In Entwicklung</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
'use client'
|
||||
|
||||
import { getCategoryById } from '@/lib/navigation'
|
||||
import { ModuleCard } from '@/components/common/ModuleCard'
|
||||
import { PagePurpose } from '@/components/common/PagePurpose'
|
||||
|
||||
export default function DevelopmentPage() {
|
||||
const category = getCategoryById('development')
|
||||
|
||||
if (!category) {
|
||||
return <div>Kategorie nicht gefunden</div>
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* Page Purpose */}
|
||||
<PagePurpose
|
||||
title={category.name}
|
||||
purpose="Diese Kategorie umfasst alle Entwicklungs- und Produkt-Module. Hier konfigurieren Sie den Voice-Service, verwalten Spielinhalte, erstellen Dokumentation und pflegen das Brandbook."
|
||||
audience={['Entwickler', 'Designer', 'Content Manager']}
|
||||
architecture={{
|
||||
services: ['voice-service (Python)', 'breakpilot-drive (Unity)', 'backend (Python)'],
|
||||
databases: ['PostgreSQL', 'MinIO'],
|
||||
}}
|
||||
relatedPages={[
|
||||
{ name: 'GPU Infrastruktur', href: '/infrastructure/gpu', description: 'GPU fuer Voice/Game' },
|
||||
{ name: 'LLM Vergleich', href: '/ai/llm-compare', description: 'LLM fuer Voice/Game' },
|
||||
]}
|
||||
collapsible={true}
|
||||
defaultCollapsed={false}
|
||||
/>
|
||||
|
||||
{/* Modules Grid */}
|
||||
<h2 className="text-lg font-semibold text-slate-900 mb-4">Module</h2>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{category.modules.map((module) => (
|
||||
<ModuleCard key={module.id} module={module} category={category} />
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Info Section */}
|
||||
<div className="mt-8 bg-slate-100 border border-slate-300 rounded-xl p-6">
|
||||
<h3 className="font-semibold text-slate-800 flex items-center gap-2">
|
||||
<span>💻</span>
|
||||
Entwickler-Ressourcen
|
||||
</h3>
|
||||
<p className="text-sm text-slate-700 mt-2">
|
||||
Die Developer Docs enthalten alle API-Dokumentationen und Architektur-Diagramme.
|
||||
Das Brandbook definiert Corporate-Design-Richtlinien fuer konsistente UI/UX.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,797 @@
|
||||
'use client'
|
||||
|
||||
/**
|
||||
* Screen Flow Visualization
|
||||
*
|
||||
* Visualisiert alle Screens aus:
|
||||
* - Studio (Port 8000): Lehrer-Oberflaeche
|
||||
* - 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-llm-compare', name: 'LLM Vergleich', description: 'KI-Provider Vergleich', category: 'ai', icon: '🤖', url: '/ai/llm-compare' },
|
||||
{ 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-onboarding', target: 'admin-llm-compare' },
|
||||
{ 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-llm-compare', target: 'admin-rag', label: 'Daten' },
|
||||
{ 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
|
||||
// ============================================
|
||||
|
||||
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)
|
||||
|
||||
// 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)
|
||||
setSelectedNode(null)
|
||||
setSelectedCategory(null)
|
||||
setPreviewScreen(null)
|
||||
}, [])
|
||||
|
||||
// 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)
|
||||
setSelectedCategory(null)
|
||||
|
||||
if (screen) {
|
||||
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))
|
||||
: []
|
||||
|
||||
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>
|
||||
|
||||
<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>
|
||||
|
||||
{/* 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>
|
||||
|
||||
{/* 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>
|
||||
|
||||
{/* 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
|
||||
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>
|
||||
|
||||
{/* 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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
'use client'
|
||||
|
||||
import { PagePurpose } from '@/components/common/PagePurpose'
|
||||
import { getModuleByHref } from '@/lib/navigation'
|
||||
import { Box, Construction } from 'lucide-react'
|
||||
|
||||
export default function UnityBridgePage() {
|
||||
const moduleInfo = getModuleByHref('/development/unity-bridge')
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{moduleInfo && (
|
||||
<PagePurpose
|
||||
title={moduleInfo.module.name}
|
||||
purpose={moduleInfo.module.purpose}
|
||||
audience={moduleInfo.module.audience}
|
||||
collapsible={true}
|
||||
defaultCollapsed={true}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="bg-white border border-slate-200 rounded-xl p-8 text-center">
|
||||
<div className="flex justify-center mb-4">
|
||||
<div className="p-4 bg-slate-100 rounded-full">
|
||||
<Box className="w-12 h-12 text-slate-400" />
|
||||
</div>
|
||||
</div>
|
||||
<h2 className="text-xl font-semibold text-slate-800 mb-2">Unity Bridge</h2>
|
||||
<p className="text-slate-600 mb-4">
|
||||
Remote-Steuerung des Unity Editors fuer Game-Development.
|
||||
</p>
|
||||
<div className="inline-flex items-center gap-2 px-4 py-2 bg-amber-50 border border-amber-200 rounded-lg text-amber-700">
|
||||
<Construction className="w-4 h-4" />
|
||||
<span className="text-sm font-medium">In Entwicklung</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,665 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect } from 'react'
|
||||
import {
|
||||
GitBranch,
|
||||
Terminal,
|
||||
Server,
|
||||
Database,
|
||||
CheckCircle2,
|
||||
ArrowRight,
|
||||
Laptop,
|
||||
HardDrive,
|
||||
RefreshCw,
|
||||
Clock,
|
||||
Shield,
|
||||
Users,
|
||||
FileCode,
|
||||
Play,
|
||||
Eye,
|
||||
Download,
|
||||
AlertTriangle,
|
||||
Info,
|
||||
Container
|
||||
} from 'lucide-react'
|
||||
|
||||
interface WorkflowStep {
|
||||
id: number
|
||||
title: string
|
||||
description: string
|
||||
command?: string
|
||||
icon: React.ReactNode
|
||||
location: 'macbook' | 'macmini'
|
||||
}
|
||||
|
||||
interface BackupInfo {
|
||||
lastRun: string | null
|
||||
nextRun: string
|
||||
status: 'ok' | 'warning' | 'error'
|
||||
}
|
||||
|
||||
export default function WorkflowPage() {
|
||||
const [activeStep, setActiveStep] = useState<number>(1)
|
||||
const [backupInfo, setBackupInfo] = useState<BackupInfo>({
|
||||
lastRun: null,
|
||||
nextRun: '02:00 Uhr',
|
||||
status: 'ok'
|
||||
})
|
||||
|
||||
const workflowSteps: WorkflowStep[] = [
|
||||
{
|
||||
id: 1,
|
||||
title: 'Code bearbeiten',
|
||||
description: 'Arbeite mit Claude Code im Terminal. Beschreibe was du brauchst und Claude schreibt den Code.',
|
||||
command: 'claude',
|
||||
icon: <Terminal className="h-6 w-6" />,
|
||||
location: 'macbook'
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
title: 'Änderungen stagen',
|
||||
description: 'Füge die geänderten Dateien zum nächsten Commit hinzu.',
|
||||
command: 'git add <dateien>',
|
||||
icon: <FileCode className="h-6 w-6" />,
|
||||
location: 'macbook'
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
title: 'Commit erstellen',
|
||||
description: 'Erstelle einen Commit mit einer aussagekräftigen Nachricht.',
|
||||
command: 'git commit -m "feat: neue Funktion"',
|
||||
icon: <GitBranch className="h-6 w-6" />,
|
||||
location: 'macbook'
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
title: 'Push zum Server',
|
||||
description: 'Sende die Änderungen an den Mac Mini. Dies startet automatisch die CI/CD Pipeline.',
|
||||
command: 'git push origin main',
|
||||
icon: <ArrowRight className="h-6 w-6" />,
|
||||
location: 'macbook'
|
||||
},
|
||||
{
|
||||
id: 5,
|
||||
title: 'CI/CD Pipeline',
|
||||
description: 'Woodpecker führt automatisch Tests aus und baut die Container.',
|
||||
command: '(automatisch)',
|
||||
icon: <RefreshCw className="h-6 w-6" />,
|
||||
location: 'macmini'
|
||||
},
|
||||
{
|
||||
id: 6,
|
||||
title: 'Integration Tests',
|
||||
description: 'Docker Compose Test-Umgebung mit Backend, DB und Consent-Service fuer vollstaendige E2E-Tests.',
|
||||
command: 'docker compose -f docker-compose.test.yml up -d',
|
||||
icon: <Container className="h-6 w-6" />,
|
||||
location: 'macmini'
|
||||
},
|
||||
{
|
||||
id: 7,
|
||||
title: 'Frontend testen',
|
||||
description: 'Teste die Änderungen im Browser auf dem Mac Mini.',
|
||||
command: 'http://macmini:3000',
|
||||
icon: <Eye className="h-6 w-6" />,
|
||||
location: 'macbook'
|
||||
}
|
||||
]
|
||||
|
||||
const services = [
|
||||
{ name: 'Website', url: 'http://macmini:3000', port: 3000, status: 'running' },
|
||||
{ name: 'Admin v2', url: 'http://macmini:3002', port: 3002, status: 'running' },
|
||||
{ name: 'Studio v2', url: 'http://macmini:3001', port: 3001, status: 'running' },
|
||||
{ name: 'Backend', url: 'http://macmini:8000', port: 8000, status: 'running' },
|
||||
{ name: 'Gitea', url: 'http://macmini:3003', port: 3003, status: 'running' },
|
||||
{ name: 'Klausur-Service', url: 'http://macmini:8086', port: 8086, status: 'running' },
|
||||
]
|
||||
|
||||
const commitTypes = [
|
||||
{ type: 'feat:', description: 'Neue Funktion', example: 'feat: add user login' },
|
||||
{ type: 'fix:', description: 'Bugfix', example: 'fix: resolve login timeout' },
|
||||
{ type: 'docs:', description: 'Dokumentation', example: 'docs: update API docs' },
|
||||
{ type: 'style:', description: 'Formatierung', example: 'style: fix indentation' },
|
||||
{ type: 'refactor:', description: 'Code-Umbau', example: 'refactor: extract helper' },
|
||||
{ type: 'test:', description: 'Tests', example: 'test: add unit tests' },
|
||||
{ type: 'chore:', description: 'Wartung', example: 'chore: update deps' },
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
{/* Header */}
|
||||
<div className="bg-gradient-to-r from-indigo-600 to-purple-600 rounded-2xl p-8 text-white">
|
||||
<h1 className="text-3xl font-bold mb-2">Entwicklungs-Workflow</h1>
|
||||
<p className="text-indigo-100">
|
||||
Wie wir bei BreakPilot entwickeln - von der Idee bis zum Deployment
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Architecture Overview */}
|
||||
<div className="bg-white rounded-xl border border-slate-200 p-6">
|
||||
<h2 className="text-xl font-semibold text-slate-900 mb-4 flex items-center gap-2">
|
||||
<Server className="h-5 w-5 text-indigo-600" />
|
||||
Systemarchitektur
|
||||
</h2>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
{/* MacBook */}
|
||||
<div className="bg-slate-50 rounded-xl p-5 border-2 border-slate-200">
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<div className="p-2 bg-blue-100 rounded-lg">
|
||||
<Laptop className="h-6 w-6 text-blue-600" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-semibold text-slate-900">MacBook (Entwicklung)</h3>
|
||||
<p className="text-sm text-slate-500">Dein Arbeitsplatz</p>
|
||||
</div>
|
||||
</div>
|
||||
<ul className="space-y-2 text-sm">
|
||||
<li className="flex items-center gap-2">
|
||||
<CheckCircle2 className="h-4 w-4 text-green-500" />
|
||||
<span>Terminal + Claude Code</span>
|
||||
</li>
|
||||
<li className="flex items-center gap-2">
|
||||
<CheckCircle2 className="h-4 w-4 text-green-500" />
|
||||
<span>Lokales Git Repository</span>
|
||||
</li>
|
||||
<li className="flex items-center gap-2">
|
||||
<CheckCircle2 className="h-4 w-4 text-green-500" />
|
||||
<span>Browser für Frontend-Tests</span>
|
||||
</li>
|
||||
<li className="flex items-center gap-2">
|
||||
<AlertTriangle className="h-4 w-4 text-amber-500" />
|
||||
<span>Backup manuell (MacBook nachts aus)</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
{/* Mac Mini */}
|
||||
<div className="bg-slate-50 rounded-xl p-5 border-2 border-indigo-200">
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<div className="p-2 bg-indigo-100 rounded-lg">
|
||||
<HardDrive className="h-6 w-6 text-indigo-600" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-semibold text-slate-900">Mac Mini (Server)</h3>
|
||||
<p className="text-sm text-slate-500">192.168.178.100</p>
|
||||
</div>
|
||||
</div>
|
||||
<ul className="space-y-2 text-sm">
|
||||
<li className="flex items-center gap-2">
|
||||
<CheckCircle2 className="h-4 w-4 text-green-500" />
|
||||
<span>Gitea (Git Server)</span>
|
||||
</li>
|
||||
<li className="flex items-center gap-2">
|
||||
<CheckCircle2 className="h-4 w-4 text-green-500" />
|
||||
<span>Woodpecker (CI/CD)</span>
|
||||
</li>
|
||||
<li className="flex items-center gap-2">
|
||||
<CheckCircle2 className="h-4 w-4 text-green-500" />
|
||||
<span>Docker Container (alle Services)</span>
|
||||
</li>
|
||||
<li className="flex items-center gap-2">
|
||||
<CheckCircle2 className="h-4 w-4 text-green-500" />
|
||||
<span>PostgreSQL Datenbank</span>
|
||||
</li>
|
||||
<li className="flex items-center gap-2">
|
||||
<CheckCircle2 className="h-4 w-4 text-green-500" />
|
||||
<span>Automatisches Backup (02:00 Uhr lokal)</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Workflow Steps */}
|
||||
<div className="bg-white rounded-xl border border-slate-200 p-6">
|
||||
<h2 className="text-xl font-semibold text-slate-900 mb-6 flex items-center gap-2">
|
||||
<Play className="h-5 w-5 text-indigo-600" />
|
||||
Entwicklungs-Schritte
|
||||
</h2>
|
||||
|
||||
<div className="space-y-4">
|
||||
{workflowSteps.map((step, index) => (
|
||||
<div
|
||||
key={step.id}
|
||||
className={`relative flex items-start gap-4 p-4 rounded-xl transition-all cursor-pointer ${
|
||||
activeStep === step.id
|
||||
? 'bg-indigo-50 border-2 border-indigo-300'
|
||||
: 'bg-slate-50 border-2 border-transparent hover:border-slate-200'
|
||||
}`}
|
||||
onClick={() => setActiveStep(step.id)}
|
||||
>
|
||||
{/* Step Number */}
|
||||
<div className={`flex-shrink-0 w-10 h-10 rounded-full flex items-center justify-center font-bold ${
|
||||
activeStep === step.id
|
||||
? 'bg-indigo-600 text-white'
|
||||
: 'bg-slate-200 text-slate-600'
|
||||
}`}>
|
||||
{step.id}
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-grow">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<h3 className="font-semibold text-slate-900">{step.title}</h3>
|
||||
<span className={`text-xs px-2 py-0.5 rounded-full ${
|
||||
step.location === 'macbook'
|
||||
? 'bg-blue-100 text-blue-700'
|
||||
: 'bg-purple-100 text-purple-700'
|
||||
}`}>
|
||||
{step.location === 'macbook' ? 'MacBook' : 'Mac Mini'}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-sm text-slate-600 mb-2">{step.description}</p>
|
||||
{step.command && (
|
||||
<code className="text-xs bg-slate-800 text-green-400 px-3 py-1.5 rounded-lg font-mono">
|
||||
{step.command}
|
||||
</code>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Icon */}
|
||||
<div className={`flex-shrink-0 p-2 rounded-lg ${
|
||||
activeStep === step.id ? 'bg-indigo-100 text-indigo-600' : 'bg-slate-100 text-slate-400'
|
||||
}`}>
|
||||
{step.icon}
|
||||
</div>
|
||||
|
||||
{/* Connector Line */}
|
||||
{index < workflowSteps.length - 1 && (
|
||||
<div className="absolute left-9 top-14 w-0.5 h-8 bg-slate-200" />
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Services & URLs */}
|
||||
<div className="bg-white rounded-xl border border-slate-200 p-6">
|
||||
<h2 className="text-xl font-semibold text-slate-900 mb-4 flex items-center gap-2">
|
||||
<Eye className="h-5 w-5 text-indigo-600" />
|
||||
Services & URLs zum Testen
|
||||
</h2>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3">
|
||||
{services.map((service) => (
|
||||
<a
|
||||
key={service.name}
|
||||
href={service.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center justify-between p-4 bg-slate-50 rounded-lg hover:bg-slate-100 transition-colors border border-slate-200"
|
||||
>
|
||||
<div>
|
||||
<h3 className="font-medium text-slate-900">{service.name}</h3>
|
||||
<p className="text-sm text-slate-500">Port {service.port}</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="w-2 h-2 bg-green-500 rounded-full animate-pulse" />
|
||||
<ArrowRight className="h-4 w-4 text-slate-400" />
|
||||
</div>
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Commit Convention */}
|
||||
<div className="bg-white rounded-xl border border-slate-200 p-6">
|
||||
<h2 className="text-xl font-semibold text-slate-900 mb-4 flex items-center gap-2">
|
||||
<GitBranch className="h-5 w-5 text-indigo-600" />
|
||||
Commit-Konventionen
|
||||
</h2>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-3">
|
||||
{commitTypes.map((item) => (
|
||||
<div key={item.type} className="bg-slate-50 rounded-lg p-3 border border-slate-200">
|
||||
<code className="text-sm font-bold text-indigo-600">{item.type}</code>
|
||||
<p className="text-sm text-slate-600 mt-1">{item.description}</p>
|
||||
<p className="text-xs text-slate-400 mt-1 font-mono">{item.example}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Backup Info */}
|
||||
<div className="bg-white rounded-xl border border-slate-200 p-6">
|
||||
<h2 className="text-xl font-semibold text-slate-900 mb-4 flex items-center gap-2">
|
||||
<Shield className="h-5 w-5 text-indigo-600" />
|
||||
Backup & Sicherheit
|
||||
</h2>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||
{/* Mac Mini - Automatisches lokales Backup */}
|
||||
<div className="bg-green-50 rounded-xl p-5 border border-green-200">
|
||||
<div className="flex items-center gap-3 mb-3">
|
||||
<Clock className="h-5 w-5 text-green-600" />
|
||||
<h3 className="font-semibold text-green-900">Mac Mini (Auto)</h3>
|
||||
</div>
|
||||
<ul className="space-y-2 text-sm text-green-800">
|
||||
<li>• Automatisch um 02:00 Uhr</li>
|
||||
<li>• PostgreSQL-Dump lokal</li>
|
||||
<li>• Git Repository gesichert</li>
|
||||
<li>• 7 Tage Aufbewahrung</li>
|
||||
</ul>
|
||||
<div className="mt-4 p-3 bg-green-100 rounded-lg">
|
||||
<code className="text-xs text-green-700 font-mono">
|
||||
~/Projekte/backup-logs/
|
||||
</code>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* MacBook - Manuelles Backup */}
|
||||
<div className="bg-amber-50 rounded-xl p-5 border border-amber-200">
|
||||
<div className="flex items-center gap-3 mb-3">
|
||||
<AlertTriangle className="h-5 w-5 text-amber-600" />
|
||||
<h3 className="font-semibold text-amber-900">MacBook (Manuell)</h3>
|
||||
</div>
|
||||
<ul className="space-y-2 text-sm text-amber-800">
|
||||
<li>• MacBook nachts aus (02:00)</li>
|
||||
<li>• Keine Auto-Synchronisation</li>
|
||||
<li>• Backup manuell anstoßen</li>
|
||||
</ul>
|
||||
<div className="mt-4 p-3 bg-amber-100 rounded-lg">
|
||||
<code className="text-xs text-amber-700 font-mono">
|
||||
rsync -avz macmini:~/Projekte/ ~/Projekte/
|
||||
</code>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Manuelles Backup starten */}
|
||||
<div className="bg-blue-50 rounded-xl p-5 border border-blue-200">
|
||||
<div className="flex items-center gap-3 mb-3">
|
||||
<Download className="h-5 w-5 text-blue-600" />
|
||||
<h3 className="font-semibold text-blue-900">Backup Script</h3>
|
||||
</div>
|
||||
<p className="text-sm text-blue-800 mb-3">
|
||||
Backup jederzeit manuell starten:
|
||||
</p>
|
||||
<code className="block text-xs bg-slate-800 text-green-400 p-3 rounded-lg font-mono">
|
||||
~/Projekte/breakpilot-pwa/scripts/daily-backup.sh
|
||||
</code>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Quick Commands */}
|
||||
<div className="bg-slate-800 rounded-xl p-6 text-white">
|
||||
<h2 className="text-xl font-semibold mb-4 flex items-center gap-2">
|
||||
<Terminal className="h-5 w-5 text-green-400" />
|
||||
Wichtige Befehle
|
||||
</h2>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 font-mono text-sm">
|
||||
<div className="bg-slate-900 rounded-lg p-4">
|
||||
<p className="text-slate-400 mb-2"># CI/CD Logs ansehen</p>
|
||||
<code className="text-green-400">ssh macmini "docker logs breakpilot-pwa-backend --tail 50"</code>
|
||||
</div>
|
||||
<div className="bg-slate-900 rounded-lg p-4">
|
||||
<p className="text-slate-400 mb-2"># Container neu starten</p>
|
||||
<code className="text-green-400">ssh macmini "docker compose restart backend"</code>
|
||||
</div>
|
||||
<div className="bg-slate-900 rounded-lg p-4">
|
||||
<p className="text-slate-400 mb-2"># Alle Container Status</p>
|
||||
<code className="text-green-400">ssh macmini "docker ps"</code>
|
||||
</div>
|
||||
<div className="bg-slate-900 rounded-lg p-4">
|
||||
<p className="text-slate-400 mb-2"># Pipeline Status (Gitea)</p>
|
||||
<code className="text-green-400">open http://macmini:3003</code>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Team Workflow with Feature Branches */}
|
||||
<div className="bg-indigo-50 rounded-xl border border-indigo-200 p-6">
|
||||
<h2 className="text-xl font-semibold text-indigo-900 mb-4 flex items-center gap-2">
|
||||
<GitBranch className="h-5 w-5 text-indigo-600" />
|
||||
Team-Workflow (3+ Entwickler)
|
||||
</h2>
|
||||
|
||||
<div className="bg-white rounded-xl p-5 mb-4">
|
||||
<h3 className="font-semibold text-slate-900 mb-3">Feature Branch Workflow</h3>
|
||||
<div className="flex flex-wrap items-center gap-2 text-sm">
|
||||
<code className="bg-slate-100 px-2 py-1 rounded">main</code>
|
||||
<ArrowRight className="h-4 w-4 text-slate-400" />
|
||||
<code className="bg-blue-100 text-blue-700 px-2 py-1 rounded">feature/neue-funktion</code>
|
||||
<ArrowRight className="h-4 w-4 text-slate-400" />
|
||||
<span className="text-slate-600">Entwicklung</span>
|
||||
<ArrowRight className="h-4 w-4 text-slate-400" />
|
||||
<span className="bg-purple-100 text-purple-700 px-2 py-1 rounded">Pull Request</span>
|
||||
<ArrowRight className="h-4 w-4 text-slate-400" />
|
||||
<span className="bg-green-100 text-green-700 px-2 py-1 rounded">Code Review</span>
|
||||
<ArrowRight className="h-4 w-4 text-slate-400" />
|
||||
<code className="bg-slate-100 px-2 py-1 rounded">main</code>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div className="bg-white rounded-lg p-4 border border-indigo-100">
|
||||
<h4 className="font-medium text-slate-900 mb-2">1. Feature Branch erstellen</h4>
|
||||
<code className="block text-xs bg-slate-800 text-green-400 p-2 rounded font-mono">
|
||||
git checkout -b feature/mein-feature
|
||||
</code>
|
||||
</div>
|
||||
<div className="bg-white rounded-lg p-4 border border-indigo-100">
|
||||
<h4 className="font-medium text-slate-900 mb-2">2. Änderungen committen</h4>
|
||||
<code className="block text-xs bg-slate-800 text-green-400 p-2 rounded font-mono">
|
||||
git commit -m "feat: beschreibung"
|
||||
</code>
|
||||
</div>
|
||||
<div className="bg-white rounded-lg p-4 border border-indigo-100">
|
||||
<h4 className="font-medium text-slate-900 mb-2">3. Branch pushen</h4>
|
||||
<code className="block text-xs bg-slate-800 text-green-400 p-2 rounded font-mono">
|
||||
git push -u origin feature/mein-feature
|
||||
</code>
|
||||
</div>
|
||||
<div className="bg-white rounded-lg p-4 border border-indigo-100">
|
||||
<h4 className="font-medium text-slate-900 mb-2">4. Pull Request in Gitea</h4>
|
||||
<code className="block text-xs bg-slate-800 text-green-400 p-2 rounded font-mono">
|
||||
http://macmini:3003 → Pull Request
|
||||
</code>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 p-4 bg-indigo-100 rounded-lg">
|
||||
<h4 className="font-medium text-indigo-900 mb-2">Branch-Namenskonvention</h4>
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-2 text-sm">
|
||||
<div><code className="text-indigo-700">feature/</code> Neue Funktion</div>
|
||||
<div><code className="text-indigo-700">fix/</code> Bugfix</div>
|
||||
<div><code className="text-indigo-700">hotfix/</code> Dringender Fix</div>
|
||||
<div><code className="text-indigo-700">refactor/</code> Code-Umbau</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Team Rules */}
|
||||
<div className="bg-amber-50 rounded-xl border border-amber-200 p-6">
|
||||
<h2 className="text-xl font-semibold text-amber-900 mb-4 flex items-center gap-2">
|
||||
<Users className="h-5 w-5 text-amber-600" />
|
||||
Team-Regeln
|
||||
</h2>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<CheckCircle2 className="h-5 w-5 text-green-600 flex-shrink-0 mt-0.5" />
|
||||
<div>
|
||||
<h3 className="font-medium text-slate-900">Feature Branches nutzen</h3>
|
||||
<p className="text-sm text-slate-600">Nie direkt auf main pushen - immer über Pull Request</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-start gap-3">
|
||||
<CheckCircle2 className="h-5 w-5 text-green-600 flex-shrink-0 mt-0.5" />
|
||||
<div>
|
||||
<h3 className="font-medium text-slate-900">Code Review erforderlich</h3>
|
||||
<p className="text-sm text-slate-600">Mindestens 1 Approval vor dem Merge</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-start gap-3">
|
||||
<CheckCircle2 className="h-5 w-5 text-green-600 flex-shrink-0 mt-0.5" />
|
||||
<div>
|
||||
<h3 className="font-medium text-slate-900">Tests müssen grün sein</h3>
|
||||
<p className="text-sm text-slate-600">CI/CD Pipeline muss erfolgreich durchlaufen</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-start gap-3">
|
||||
<CheckCircle2 className="h-5 w-5 text-green-600 flex-shrink-0 mt-0.5" />
|
||||
<div>
|
||||
<h3 className="font-medium text-slate-900">Aussagekräftige Commits</h3>
|
||||
<p className="text-sm text-slate-600">Nutze Conventional Commits (feat:, fix:, etc.)</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-start gap-3">
|
||||
<CheckCircle2 className="h-5 w-5 text-green-600 flex-shrink-0 mt-0.5" />
|
||||
<div>
|
||||
<h3 className="font-medium text-slate-900">Branch aktuell halten</h3>
|
||||
<p className="text-sm text-slate-600">Regelmäßig main in deinen Branch mergen</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-start gap-3">
|
||||
<AlertTriangle className="h-5 w-5 text-amber-600 flex-shrink-0 mt-0.5" />
|
||||
<div>
|
||||
<h3 className="font-medium text-slate-900">Nie Force-Push auf main</h3>
|
||||
<p className="text-sm text-slate-600">Geschichte von main nie überschreiben</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* CI/CD Infrastruktur - Automatisierte OAuth Integration */}
|
||||
<div className="bg-white rounded-xl border border-slate-200 p-6">
|
||||
<h2 className="text-xl font-semibold text-slate-900 mb-4 flex items-center gap-2">
|
||||
<Shield className="h-5 w-5 text-indigo-600" />
|
||||
CI/CD Infrastruktur (Automatisiert)
|
||||
</h2>
|
||||
|
||||
<div className="bg-blue-50 rounded-xl p-4 mb-6 border border-blue-200">
|
||||
<div className="flex items-start gap-3">
|
||||
<Info className="h-5 w-5 text-blue-600 flex-shrink-0 mt-0.5" />
|
||||
<div>
|
||||
<h4 className="font-medium text-blue-900">Warum automatisiert?</h4>
|
||||
<p className="text-sm text-blue-800 mt-1">
|
||||
Die OAuth-Integration zwischen Woodpecker und Gitea ist vollautomatisiert.
|
||||
Dies ist eine DevSecOps Best Practice: Credentials werden in HashiCorp Vault gespeichert
|
||||
und können bei Bedarf automatisch regeneriert werden.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
{/* Architektur */}
|
||||
<div className="bg-slate-50 rounded-xl p-5 border border-slate-200">
|
||||
<h3 className="font-semibold text-slate-900 mb-3">Architektur</h3>
|
||||
<div className="space-y-3 text-sm">
|
||||
<div className="flex items-center gap-3 p-2 bg-white rounded-lg border">
|
||||
<div className="w-3 h-3 bg-green-500 rounded-full" />
|
||||
<span className="font-medium">Gitea</span>
|
||||
<span className="text-slate-500">Port 3003</span>
|
||||
<span className="text-xs text-slate-400 ml-auto">Git Server</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-center">
|
||||
<ArrowRight className="h-4 w-4 text-slate-400 rotate-90" />
|
||||
<span className="text-xs text-slate-500 ml-2">OAuth 2.0</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 p-2 bg-white rounded-lg border">
|
||||
<div className="w-3 h-3 bg-blue-500 rounded-full" />
|
||||
<span className="font-medium">Woodpecker</span>
|
||||
<span className="text-slate-500">Port 8090</span>
|
||||
<span className="text-xs text-slate-400 ml-auto">CI/CD Server</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-center">
|
||||
<ArrowRight className="h-4 w-4 text-slate-400 rotate-90" />
|
||||
<span className="text-xs text-slate-500 ml-2">Credentials</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 p-2 bg-white rounded-lg border">
|
||||
<div className="w-3 h-3 bg-purple-500 rounded-full" />
|
||||
<span className="font-medium">Vault</span>
|
||||
<span className="text-slate-500">Port 8200</span>
|
||||
<span className="text-xs text-slate-400 ml-auto">Secrets Manager</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Credentials Speicherort */}
|
||||
<div className="bg-slate-50 rounded-xl p-5 border border-slate-200">
|
||||
<h3 className="font-semibold text-slate-900 mb-3">Credentials Speicherorte</h3>
|
||||
<div className="space-y-3 text-sm">
|
||||
<div className="p-3 bg-white rounded-lg border">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<Database className="h-4 w-4 text-purple-500" />
|
||||
<span className="font-medium">HashiCorp Vault</span>
|
||||
</div>
|
||||
<code className="text-xs bg-slate-100 px-2 py-1 rounded">
|
||||
secret/cicd/woodpecker
|
||||
</code>
|
||||
<p className="text-xs text-slate-500 mt-1">Client ID + Secret (Quelle der Wahrheit)</p>
|
||||
</div>
|
||||
<div className="p-3 bg-white rounded-lg border">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<FileCode className="h-4 w-4 text-blue-500" />
|
||||
<span className="font-medium">.env Datei</span>
|
||||
</div>
|
||||
<code className="text-xs bg-slate-100 px-2 py-1 rounded">
|
||||
WOODPECKER_GITEA_CLIENT/SECRET
|
||||
</code>
|
||||
<p className="text-xs text-slate-500 mt-1">Für Docker Compose (aus Vault geladen)</p>
|
||||
</div>
|
||||
<div className="p-3 bg-white rounded-lg border">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<Database className="h-4 w-4 text-green-500" />
|
||||
<span className="font-medium">Gitea PostgreSQL</span>
|
||||
</div>
|
||||
<code className="text-xs bg-slate-100 px-2 py-1 rounded">
|
||||
oauth2_application
|
||||
</code>
|
||||
<p className="text-xs text-slate-500 mt-1">OAuth App Registration (gehashtes Secret)</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Troubleshooting */}
|
||||
<div className="mt-6 bg-amber-50 rounded-xl p-5 border border-amber-200">
|
||||
<h3 className="font-semibold text-amber-900 mb-3 flex items-center gap-2">
|
||||
<AlertTriangle className="h-5 w-5 text-amber-600" />
|
||||
Troubleshooting: OAuth Fehler beheben
|
||||
</h3>
|
||||
<p className="text-sm text-amber-800 mb-3">
|
||||
Falls der Fehler "Client ID not registered" oder "user does not exist" auftritt:
|
||||
</p>
|
||||
<div className="bg-slate-800 rounded-lg p-4 font-mono text-sm">
|
||||
<p className="text-slate-400"># Credentials automatisch regenerieren</p>
|
||||
<p className="text-green-400">./scripts/sync-woodpecker-credentials.sh --regenerate</p>
|
||||
<p className="text-slate-400 mt-2"># Oder manuell: Vault → Gitea → .env → Restart</p>
|
||||
<p className="text-green-400">rsync .env macmini:~/Projekte/breakpilot-pwa/</p>
|
||||
<p className="text-green-400">ssh macmini "cd ~/Projekte/breakpilot-pwa && docker compose up -d --force-recreate woodpecker-server"</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Team Members Info */}
|
||||
<div className="bg-white rounded-xl border border-slate-200 p-6">
|
||||
<h2 className="text-xl font-semibold text-slate-900 mb-4 flex items-center gap-2">
|
||||
<Users className="h-5 w-5 text-indigo-600" />
|
||||
Team-Kommunikation
|
||||
</h2>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<div className="bg-slate-50 rounded-lg p-4 text-center">
|
||||
<div className="text-3xl mb-2">💬</div>
|
||||
<h3 className="font-medium text-slate-900">Pull Request Kommentare</h3>
|
||||
<p className="text-sm text-slate-600 mt-1">Code-Diskussionen im PR</p>
|
||||
</div>
|
||||
<div className="bg-slate-50 rounded-lg p-4 text-center">
|
||||
<div className="text-3xl mb-2">📋</div>
|
||||
<h3 className="font-medium text-slate-900">Issues in Gitea</h3>
|
||||
<p className="text-sm text-slate-600 mt-1">Bugs & Features tracken</p>
|
||||
</div>
|
||||
<div className="bg-slate-50 rounded-lg p-4 text-center">
|
||||
<div className="text-3xl mb-2">🔔</div>
|
||||
<h3 className="font-medium text-slate-900">CI/CD Notifications</h3>
|
||||
<p className="text-sm text-slate-600 mt-1">Pipeline-Status per Mail</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,223 @@
|
||||
'use client'
|
||||
|
||||
/**
|
||||
* AehnlicheDokumente - RAG-based similar documents panel
|
||||
* Shows documents with similar content based on vector similarity
|
||||
*/
|
||||
|
||||
import { useState, useEffect } from 'react'
|
||||
import { Loader2, FileText, AlertCircle, RefreshCw, ExternalLink } from 'lucide-react'
|
||||
import type { AbiturDokument } from '@/lib/education/abitur-docs-types'
|
||||
import type { SimilarDocument } from '@/lib/education/abitur-archiv-types'
|
||||
import { FAECHER } from '@/lib/education/abitur-docs-types'
|
||||
|
||||
interface AehnlicheDokumenteProps {
|
||||
documentId: string
|
||||
onSelectDocument: (doc: AbiturDokument) => void
|
||||
limit?: number
|
||||
}
|
||||
|
||||
export function AehnlicheDokumente({
|
||||
documentId,
|
||||
onSelectDocument,
|
||||
limit = 5
|
||||
}: AehnlicheDokumenteProps) {
|
||||
const [similarDocs, setSimilarDocs] = useState<SimilarDocument[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
const fetchSimilarDocuments = async () => {
|
||||
if (!documentId) return
|
||||
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
|
||||
try {
|
||||
const res = await fetch(`/api/education/abitur-archiv/similar?id=${documentId}&limit=${limit}`)
|
||||
|
||||
if (!res.ok) {
|
||||
// Use mock data if endpoint not available
|
||||
setSimilarDocs(getMockSimilarDocuments(documentId))
|
||||
return
|
||||
}
|
||||
|
||||
const data = await res.json()
|
||||
setSimilarDocs(data.similar || [])
|
||||
} catch (err) {
|
||||
console.log('Similar docs fetch failed, using mock data')
|
||||
setSimilarDocs(getMockSimilarDocuments(documentId))
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
fetchSimilarDocuments()
|
||||
}, [documentId, limit])
|
||||
|
||||
const handleRefresh = () => {
|
||||
setLoading(true)
|
||||
// Re-trigger the effect
|
||||
setSimilarDocs([])
|
||||
setTimeout(() => {
|
||||
setSimilarDocs(getMockSimilarDocuments(documentId))
|
||||
setLoading(false)
|
||||
}, 500)
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center py-8">
|
||||
<Loader2 className="w-8 h-8 text-blue-600 animate-spin mb-3" />
|
||||
<p className="text-sm text-slate-500">Suche aehnliche Dokumente...</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="text-center py-8">
|
||||
<AlertCircle className="w-10 h-10 text-red-400 mx-auto mb-3" />
|
||||
<p className="text-sm text-red-600 mb-3">{error}</p>
|
||||
<button
|
||||
onClick={handleRefresh}
|
||||
className="px-4 py-2 text-sm text-blue-600 hover:bg-blue-50 rounded-lg flex items-center gap-2 mx-auto"
|
||||
>
|
||||
<RefreshCw className="w-4 h-4" />
|
||||
Erneut versuchen
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (similarDocs.length === 0) {
|
||||
return (
|
||||
<div className="text-center py-8">
|
||||
<FileText className="w-10 h-10 text-slate-300 mx-auto mb-3" />
|
||||
<p className="text-sm text-slate-500">Keine aehnlichen Dokumente gefunden</p>
|
||||
<p className="text-xs text-slate-400 mt-1">
|
||||
Versuchen Sie eine andere Suche oder laden Sie mehr Dokumente hoch.
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<h4 className="text-sm font-medium text-slate-700">Aehnliche Dokumente</h4>
|
||||
<button
|
||||
onClick={handleRefresh}
|
||||
className="p-1.5 text-slate-400 hover:text-slate-600 hover:bg-slate-100 rounded"
|
||||
title="Aktualisieren"
|
||||
>
|
||||
<RefreshCw className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
{similarDocs.map((doc) => (
|
||||
<SimilarDocumentCard
|
||||
key={doc.id}
|
||||
document={doc}
|
||||
onSelect={() => {
|
||||
// Convert SimilarDocument to AbiturDokument for selection
|
||||
// In production, this would fetch the full document
|
||||
onSelectDocument(doc as unknown as AbiturDokument)
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<p className="text-xs text-slate-400 text-center pt-2">
|
||||
Basierend auf semantischer Aehnlichkeit (RAG)
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function SimilarDocumentCard({
|
||||
document,
|
||||
onSelect
|
||||
}: {
|
||||
document: SimilarDocument
|
||||
onSelect: () => void
|
||||
}) {
|
||||
const fachLabel = FAECHER.find(f => f.id === document.fach)?.label || document.fach
|
||||
const similarityPercent = Math.round(document.similarity_score * 100)
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={onSelect}
|
||||
className="w-full flex items-start gap-3 p-3 bg-white border border-slate-200 rounded-lg
|
||||
hover:bg-blue-50 hover:border-blue-200 transition-colors text-left group"
|
||||
>
|
||||
{/* Icon */}
|
||||
<div className="w-10 h-10 bg-slate-100 rounded-lg flex items-center justify-center flex-shrink-0
|
||||
group-hover:bg-blue-100 transition-colors">
|
||||
<FileText className="w-5 h-5 text-slate-400 group-hover:text-blue-500" />
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="font-medium text-slate-800 truncate group-hover:text-blue-700">
|
||||
{fachLabel} {document.jahr}
|
||||
</div>
|
||||
<div className="text-sm text-slate-500 flex items-center gap-2">
|
||||
<span>{document.niveau}</span>
|
||||
<span>|</span>
|
||||
<span>Aufgabe {document.aufgaben_nummer}</span>
|
||||
{document.typ === 'erwartungshorizont' && (
|
||||
<>
|
||||
<span>|</span>
|
||||
<span className="text-orange-600">EWH</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Similarity Score */}
|
||||
<div className="flex-shrink-0">
|
||||
<div className={`px-2 py-1 rounded-full text-xs font-medium ${
|
||||
similarityPercent >= 80
|
||||
? 'bg-green-100 text-green-700'
|
||||
: similarityPercent >= 60
|
||||
? 'bg-yellow-100 text-yellow-700'
|
||||
: 'bg-slate-100 text-slate-600'
|
||||
}`}>
|
||||
{similarityPercent}%
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
// Mock data generator for development
|
||||
function getMockSimilarDocuments(documentId: string): SimilarDocument[] {
|
||||
// Generate consistent mock data based on document ID
|
||||
const idHash = documentId.split('').reduce((acc, char) => acc + char.charCodeAt(0), 0)
|
||||
|
||||
const faecher = ['deutsch', 'englisch']
|
||||
const jahre = [2021, 2022, 2023, 2024, 2025]
|
||||
const niveaus: Array<'eA' | 'gA'> = ['eA', 'gA']
|
||||
const nummern = ['I', 'II', 'III']
|
||||
const typen: Array<'aufgabe' | 'erwartungshorizont'> = ['aufgabe', 'erwartungshorizont']
|
||||
|
||||
const docs: SimilarDocument[] = []
|
||||
|
||||
for (let i = 0; i < 5; i++) {
|
||||
const idx = (idHash + i) % (faecher.length * jahre.length * niveaus.length)
|
||||
docs.push({
|
||||
id: `similar-${documentId}-${i}`,
|
||||
dateiname: `${jahre[idx % jahre.length]}_${faecher[idx % faecher.length]}_${niveaus[idx % niveaus.length]}_${nummern[idx % nummern.length]}.pdf`,
|
||||
similarity_score: 0.95 - (i * 0.1) + (Math.random() * 0.05),
|
||||
fach: faecher[idx % faecher.length],
|
||||
jahr: jahre[(idx + i) % jahre.length],
|
||||
niveau: niveaus[idx % niveaus.length],
|
||||
typ: typen[(idx + i) % typen.length],
|
||||
aufgaben_nummer: nummern[(idx + i) % nummern.length]
|
||||
})
|
||||
}
|
||||
|
||||
return docs.sort((a, b) => b.similarity_score - a.similarity_score)
|
||||
}
|
||||
@@ -0,0 +1,203 @@
|
||||
'use client'
|
||||
|
||||
/**
|
||||
* DokumentCard - Card component for Abitur document grid view
|
||||
* Features: Preview, Download, Add to Klausur actions
|
||||
*/
|
||||
|
||||
import { useState } from 'react'
|
||||
import { FileText, Eye, Download, Plus, Calendar, Layers, BookOpen, ExternalLink } from 'lucide-react'
|
||||
import type { AbiturDokument } from '@/lib/education/abitur-docs-types'
|
||||
import { formatFileSize, FAECHER, NIVEAUS } from '@/lib/education/abitur-docs-types'
|
||||
|
||||
interface DokumentCardProps {
|
||||
document: AbiturDokument
|
||||
onPreview: (doc: AbiturDokument) => void
|
||||
onDownload: (doc: AbiturDokument) => void
|
||||
onAddToKlausur?: (doc: AbiturDokument) => void
|
||||
}
|
||||
|
||||
export function DokumentCard({
|
||||
document,
|
||||
onPreview,
|
||||
onDownload,
|
||||
onAddToKlausur
|
||||
}: DokumentCardProps) {
|
||||
const [isHovered, setIsHovered] = useState(false)
|
||||
|
||||
const fachLabel = FAECHER.find(f => f.id === document.fach)?.label || document.fach
|
||||
const niveauLabel = document.niveau === 'eA' ? 'Erhoehtes Niveau' : 'Grundlegendes Niveau'
|
||||
|
||||
const handleDownload = (e: React.MouseEvent) => {
|
||||
e.stopPropagation()
|
||||
onDownload(document)
|
||||
}
|
||||
|
||||
const handleAddToKlausur = (e: React.MouseEvent) => {
|
||||
e.stopPropagation()
|
||||
onAddToKlausur?.(document)
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="bg-white rounded-xl border border-slate-200 overflow-hidden hover:shadow-lg
|
||||
transition-all duration-200 cursor-pointer group"
|
||||
onMouseEnter={() => setIsHovered(true)}
|
||||
onMouseLeave={() => setIsHovered(false)}
|
||||
onClick={() => onPreview(document)}
|
||||
>
|
||||
{/* Header with Type Badge */}
|
||||
<div className="relative h-32 bg-gradient-to-br from-slate-100 to-slate-50 flex items-center justify-center">
|
||||
<FileText className="w-16 h-16 text-slate-300 group-hover:text-blue-400 transition-colors" />
|
||||
|
||||
{/* Type Badge */}
|
||||
<div className="absolute top-3 left-3">
|
||||
<span className={`px-2.5 py-1 rounded-full text-xs font-medium ${
|
||||
document.typ === 'erwartungshorizont'
|
||||
? 'bg-orange-100 text-orange-700'
|
||||
: 'bg-purple-100 text-purple-700'
|
||||
}`}>
|
||||
{document.typ === 'erwartungshorizont' ? 'Erwartungshorizont' : 'Aufgabe'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Year Badge */}
|
||||
<div className="absolute top-3 right-3">
|
||||
<span className="px-2 py-1 bg-white/80 backdrop-blur-sm rounded-lg text-xs font-semibold text-slate-700">
|
||||
{document.jahr}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Status Badge */}
|
||||
<div className="absolute bottom-3 right-3">
|
||||
<span className={`px-2 py-0.5 rounded-full text-xs ${
|
||||
document.status === 'indexed'
|
||||
? 'bg-green-100 text-green-700'
|
||||
: document.status === 'error'
|
||||
? 'bg-red-100 text-red-700'
|
||||
: 'bg-yellow-100 text-yellow-700'
|
||||
}`}>
|
||||
{document.status === 'indexed' ? 'Indexiert' : document.status === 'error' ? 'Fehler' : 'Ausstehend'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Hover Overlay with Preview */}
|
||||
{isHovered && (
|
||||
<div className="absolute inset-0 bg-black/40 flex items-center justify-center">
|
||||
<button
|
||||
className="px-4 py-2 bg-white text-slate-800 rounded-lg font-medium
|
||||
flex items-center gap-2 shadow-lg hover:bg-blue-50 transition-colors"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
onPreview(document)
|
||||
}}
|
||||
>
|
||||
<Eye className="w-4 h-4" />
|
||||
Vorschau
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="p-4">
|
||||
{/* Title */}
|
||||
<h3 className="font-semibold text-slate-800 mb-2 line-clamp-2 min-h-[2.5rem]">
|
||||
{fachLabel} {document.niveau} - Aufgabe {document.aufgaben_nummer}
|
||||
</h3>
|
||||
|
||||
{/* Metadata */}
|
||||
<div className="space-y-1.5 mb-4">
|
||||
<div className="flex items-center gap-2 text-sm text-slate-500">
|
||||
<BookOpen className="w-4 h-4" />
|
||||
<span>{fachLabel}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-sm text-slate-500">
|
||||
<Layers className="w-4 h-4" />
|
||||
<span>{niveauLabel}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-sm text-slate-500">
|
||||
<ExternalLink className="w-4 h-4" />
|
||||
<span className="capitalize">{document.bundesland}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-xs text-slate-400">
|
||||
<span>{formatFileSize(document.file_size)}</span>
|
||||
<span>|</span>
|
||||
<span>{document.dateiname}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Action Buttons */}
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => onPreview(document)}
|
||||
className="flex-1 px-3 py-2 bg-blue-50 text-blue-700 rounded-lg hover:bg-blue-100
|
||||
transition-colors text-sm font-medium flex items-center justify-center gap-1.5"
|
||||
>
|
||||
<Eye className="w-4 h-4" />
|
||||
Vorschau
|
||||
</button>
|
||||
<button
|
||||
onClick={handleDownload}
|
||||
className="px-3 py-2 text-slate-600 hover:bg-slate-100 rounded-lg transition-colors"
|
||||
title="Herunterladen"
|
||||
>
|
||||
<Download className="w-4 h-4" />
|
||||
</button>
|
||||
{onAddToKlausur && (
|
||||
<button
|
||||
onClick={handleAddToKlausur}
|
||||
className="px-3 py-2 text-green-600 hover:bg-green-50 rounded-lg transition-colors"
|
||||
title="Zur Klausur hinzufuegen"
|
||||
>
|
||||
<Plus className="w-4 h-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Compact card variant for list view or similar documents
|
||||
*/
|
||||
export function DokumentCardCompact({
|
||||
document,
|
||||
onPreview,
|
||||
similarity_score
|
||||
}: {
|
||||
document: AbiturDokument
|
||||
onPreview: (doc: AbiturDokument) => void
|
||||
similarity_score?: number
|
||||
}) {
|
||||
const fachLabel = FAECHER.find(f => f.id === document.fach)?.label || document.fach
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={() => onPreview(document)}
|
||||
className="w-full flex items-center gap-3 p-3 bg-white border border-slate-200 rounded-lg
|
||||
hover:bg-slate-50 hover:border-slate-300 transition-colors text-left"
|
||||
>
|
||||
<div className="w-10 h-10 bg-slate-100 rounded-lg flex items-center justify-center flex-shrink-0">
|
||||
<FileText className="w-5 h-5 text-slate-400" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="font-medium text-slate-800 truncate">
|
||||
{fachLabel} {document.jahr} - {document.niveau}
|
||||
</div>
|
||||
<div className="text-sm text-slate-500 truncate">
|
||||
Aufgabe {document.aufgaben_nummer}
|
||||
{document.typ === 'erwartungshorizont' && ' (EWH)'}
|
||||
</div>
|
||||
</div>
|
||||
{similarity_score !== undefined && (
|
||||
<div className="flex-shrink-0">
|
||||
<span className="px-2 py-1 bg-green-100 text-green-700 text-xs rounded-full">
|
||||
{Math.round(similarity_score * 100)}%
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,456 @@
|
||||
'use client'
|
||||
|
||||
/**
|
||||
* FullscreenViewer - Enhanced PDF viewer with fullscreen, zoom, and page navigation
|
||||
* Features: Keyboard shortcuts, zoom controls, similar documents panel
|
||||
*/
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import {
|
||||
X, Download, ZoomIn, ZoomOut, Maximize2, Minimize2,
|
||||
ChevronLeft, ChevronRight, RotateCw, FileText, Search,
|
||||
BookOpen, Calendar, Layers, ExternalLink, Plus
|
||||
} from 'lucide-react'
|
||||
import type { AbiturDokument } from '@/lib/education/abitur-docs-types'
|
||||
import { formatFileSize, formatDocumentTitle, FAECHER, NIVEAUS } from '@/lib/education/abitur-docs-types'
|
||||
import { ZOOM_LEVELS, MIN_ZOOM, MAX_ZOOM, ZOOM_STEP } from '@/lib/education/abitur-archiv-types'
|
||||
import { AehnlicheDokumente } from './AehnlicheDokumente'
|
||||
|
||||
interface FullscreenViewerProps {
|
||||
document: AbiturDokument | null
|
||||
onClose: () => void
|
||||
onAddToKlausur?: (doc: AbiturDokument) => void
|
||||
backendUrl?: string
|
||||
}
|
||||
|
||||
export function FullscreenViewer({
|
||||
document,
|
||||
onClose,
|
||||
onAddToKlausur,
|
||||
backendUrl = ''
|
||||
}: FullscreenViewerProps) {
|
||||
const [isFullscreen, setIsFullscreen] = useState(false)
|
||||
const [zoom, setZoom] = useState(100)
|
||||
const [currentPage, setCurrentPage] = useState(1)
|
||||
const [totalPages, setTotalPages] = useState(1)
|
||||
const [showSidebar, setShowSidebar] = useState(true)
|
||||
const [activeTab, setActiveTab] = useState<'details' | 'similar'>('details')
|
||||
|
||||
// Reset state when document changes
|
||||
useEffect(() => {
|
||||
setZoom(100)
|
||||
setCurrentPage(1)
|
||||
setIsFullscreen(false)
|
||||
}, [document?.id])
|
||||
|
||||
// Keyboard shortcuts
|
||||
useEffect(() => {
|
||||
if (!document) return
|
||||
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
// Ignore if typing in an input
|
||||
if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) {
|
||||
return
|
||||
}
|
||||
|
||||
switch (e.key) {
|
||||
case 'Escape':
|
||||
if (isFullscreen) {
|
||||
setIsFullscreen(false)
|
||||
} else {
|
||||
onClose()
|
||||
}
|
||||
break
|
||||
case 'f':
|
||||
case 'F11':
|
||||
e.preventDefault()
|
||||
setIsFullscreen(prev => !prev)
|
||||
break
|
||||
case '+':
|
||||
case '=':
|
||||
e.preventDefault()
|
||||
setZoom(z => Math.min(MAX_ZOOM, z + ZOOM_STEP))
|
||||
break
|
||||
case '-':
|
||||
e.preventDefault()
|
||||
setZoom(z => Math.max(MIN_ZOOM, z - ZOOM_STEP))
|
||||
break
|
||||
case '0':
|
||||
e.preventDefault()
|
||||
setZoom(100)
|
||||
break
|
||||
case 'ArrowLeft':
|
||||
e.preventDefault()
|
||||
setCurrentPage(p => Math.max(1, p - 1))
|
||||
break
|
||||
case 'ArrowRight':
|
||||
e.preventDefault()
|
||||
setCurrentPage(p => Math.min(totalPages, p + 1))
|
||||
break
|
||||
case 's':
|
||||
if (e.ctrlKey || e.metaKey) {
|
||||
e.preventDefault()
|
||||
handleDownload()
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener('keydown', handleKeyDown)
|
||||
return () => window.removeEventListener('keydown', handleKeyDown)
|
||||
}, [document, isFullscreen, totalPages, onClose])
|
||||
|
||||
// Handle native fullscreen changes
|
||||
useEffect(() => {
|
||||
const handleFullscreenChange = () => {
|
||||
setIsFullscreen(!!window.document.fullscreenElement)
|
||||
}
|
||||
|
||||
window.document.addEventListener('fullscreenchange', handleFullscreenChange)
|
||||
return () => window.document.removeEventListener('fullscreenchange', handleFullscreenChange)
|
||||
}, [])
|
||||
|
||||
const handleDownload = useCallback(() => {
|
||||
if (!document) return
|
||||
const link = window.document.createElement('a')
|
||||
link.href = pdfUrl
|
||||
link.download = document.dateiname
|
||||
link.click()
|
||||
}, [document])
|
||||
|
||||
const handleSearchInRAG = () => {
|
||||
if (!document) return
|
||||
window.location.href = `/education/edu-search?doc=${document.id}&search=1`
|
||||
}
|
||||
|
||||
const handleAddToKlausur = () => {
|
||||
if (!document || !onAddToKlausur) return
|
||||
onAddToKlausur(document)
|
||||
}
|
||||
|
||||
if (!document) return null
|
||||
|
||||
const fachLabel = FAECHER.find(f => f.id === document.fach)?.label || document.fach
|
||||
const niveauLabel = NIVEAUS.find(n => n.id === document.niveau)?.label || document.niveau
|
||||
|
||||
// Build PDF URL
|
||||
const pdfUrl = backendUrl
|
||||
? `${backendUrl}/api/abitur-docs/${document.id}/file`
|
||||
: document.file_path
|
||||
|
||||
return (
|
||||
<div className={`fixed inset-0 z-50 flex ${isFullscreen ? 'bg-black' : 'bg-black/60 backdrop-blur-sm'}`}>
|
||||
{/* Modal Container */}
|
||||
<div className={`relative bg-white flex flex-col ${
|
||||
isFullscreen ? 'w-full h-full' : 'w-[95vw] h-[95vh] max-w-7xl m-auto rounded-2xl overflow-hidden shadow-2xl'
|
||||
}`}>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-4 py-3 bg-white border-b border-slate-200">
|
||||
<div className="flex items-center gap-3">
|
||||
<FileText className="w-5 h-5 text-blue-600" />
|
||||
<div>
|
||||
<h2 className="font-semibold text-slate-900">
|
||||
{formatDocumentTitle(document)}
|
||||
</h2>
|
||||
<p className="text-sm text-slate-500">
|
||||
{document.dateiname}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Toolbar */}
|
||||
<div className="flex items-center gap-2">
|
||||
{/* Zoom Controls */}
|
||||
<div className="flex items-center gap-1 px-2 py-1 bg-slate-100 rounded-lg">
|
||||
<button
|
||||
onClick={() => setZoom(z => Math.max(MIN_ZOOM, z - ZOOM_STEP))}
|
||||
className="p-1.5 hover:bg-slate-200 rounded"
|
||||
title="Verkleinern (-)"
|
||||
>
|
||||
<ZoomOut className="w-4 h-4 text-slate-600" />
|
||||
</button>
|
||||
<span className="text-sm font-medium text-slate-700 w-12 text-center">
|
||||
{zoom}%
|
||||
</span>
|
||||
<button
|
||||
onClick={() => setZoom(z => Math.min(MAX_ZOOM, z + ZOOM_STEP))}
|
||||
className="p-1.5 hover:bg-slate-200 rounded"
|
||||
title="Vergroessern (+)"
|
||||
>
|
||||
<ZoomIn className="w-4 h-4 text-slate-600" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setZoom(100)}
|
||||
className="p-1.5 hover:bg-slate-200 rounded ml-1"
|
||||
title="Zuruecksetzen (0)"
|
||||
>
|
||||
<RotateCw className="w-4 h-4 text-slate-600" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Page Navigation */}
|
||||
{totalPages > 1 && (
|
||||
<div className="flex items-center gap-1 px-2 py-1 bg-slate-100 rounded-lg">
|
||||
<button
|
||||
onClick={() => setCurrentPage(p => Math.max(1, p - 1))}
|
||||
disabled={currentPage === 1}
|
||||
className="p-1.5 hover:bg-slate-200 rounded disabled:opacity-50"
|
||||
>
|
||||
<ChevronLeft className="w-4 h-4 text-slate-600" />
|
||||
</button>
|
||||
<span className="text-sm font-medium text-slate-700 min-w-[60px] text-center">
|
||||
{currentPage} / {totalPages}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => setCurrentPage(p => Math.min(totalPages, p + 1))}
|
||||
disabled={currentPage === totalPages}
|
||||
className="p-1.5 hover:bg-slate-200 rounded disabled:opacity-50"
|
||||
>
|
||||
<ChevronRight className="w-4 h-4 text-slate-600" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="w-px h-6 bg-slate-200" />
|
||||
|
||||
{/* Action Buttons */}
|
||||
<button
|
||||
onClick={handleSearchInRAG}
|
||||
className="px-3 py-1.5 text-sm bg-purple-100 text-purple-700 rounded-lg hover:bg-purple-200 flex items-center gap-1.5"
|
||||
title="In RAG suchen"
|
||||
>
|
||||
<Search className="w-4 h-4" />
|
||||
<span className="hidden sm:inline">RAG-Suche</span>
|
||||
</button>
|
||||
|
||||
{onAddToKlausur && (
|
||||
<button
|
||||
onClick={handleAddToKlausur}
|
||||
className="px-3 py-1.5 text-sm bg-green-100 text-green-700 rounded-lg hover:bg-green-200 flex items-center gap-1.5"
|
||||
title="Als Vorlage verwenden"
|
||||
>
|
||||
<Plus className="w-4 h-4" />
|
||||
<span className="hidden sm:inline">Zur Klausur</span>
|
||||
</button>
|
||||
)}
|
||||
|
||||
<button
|
||||
onClick={handleDownload}
|
||||
className="px-3 py-1.5 text-sm bg-blue-100 text-blue-700 rounded-lg hover:bg-blue-200 flex items-center gap-1.5"
|
||||
title="Herunterladen (Ctrl+S)"
|
||||
>
|
||||
<Download className="w-4 h-4" />
|
||||
<span className="hidden sm:inline">Download</span>
|
||||
</button>
|
||||
|
||||
<div className="w-px h-6 bg-slate-200" />
|
||||
|
||||
<button
|
||||
onClick={() => setShowSidebar(!showSidebar)}
|
||||
className={`p-2 rounded-lg transition-colors ${
|
||||
showSidebar ? 'bg-slate-200 text-slate-700' : 'text-slate-500 hover:bg-slate-100'
|
||||
}`}
|
||||
title="Seitenleiste"
|
||||
>
|
||||
<Layers className="w-4 h-4" />
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => setIsFullscreen(!isFullscreen)}
|
||||
className="p-2 hover:bg-slate-100 rounded-lg"
|
||||
title={isFullscreen ? 'Vollbild beenden (F)' : 'Vollbild (F)'}
|
||||
>
|
||||
{isFullscreen ? (
|
||||
<Minimize2 className="w-5 h-5 text-slate-600" />
|
||||
) : (
|
||||
<Maximize2 className="w-5 h-5 text-slate-600" />
|
||||
)}
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="p-2 hover:bg-slate-100 rounded-lg"
|
||||
title="Schliessen (Esc)"
|
||||
>
|
||||
<X className="w-5 h-5 text-slate-500" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex flex-1 overflow-hidden">
|
||||
{/* PDF Viewer */}
|
||||
<div className="flex-1 bg-slate-100 p-4 overflow-auto">
|
||||
<div
|
||||
className="bg-white rounded-lg border border-slate-200 mx-auto shadow-sm transition-transform duration-200"
|
||||
style={{
|
||||
transform: `scale(${zoom / 100})`,
|
||||
transformOrigin: 'top center',
|
||||
width: '100%',
|
||||
maxWidth: zoom > 100 ? 'none' : '100%'
|
||||
}}
|
||||
>
|
||||
<iframe
|
||||
src={pdfUrl}
|
||||
className="w-full h-[calc(90vh-120px)] rounded-lg"
|
||||
title={document.dateiname}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Sidebar */}
|
||||
{showSidebar && (
|
||||
<div className="w-80 border-l border-slate-200 bg-slate-50 flex flex-col">
|
||||
{/* Sidebar Tabs */}
|
||||
<div className="flex border-b border-slate-200">
|
||||
<button
|
||||
onClick={() => setActiveTab('details')}
|
||||
className={`flex-1 px-4 py-3 text-sm font-medium transition-colors ${
|
||||
activeTab === 'details'
|
||||
? 'text-blue-600 border-b-2 border-blue-600 bg-white'
|
||||
: 'text-slate-600 hover:text-slate-800'
|
||||
}`}
|
||||
>
|
||||
Details
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab('similar')}
|
||||
className={`flex-1 px-4 py-3 text-sm font-medium transition-colors ${
|
||||
activeTab === 'similar'
|
||||
? 'text-blue-600 border-b-2 border-blue-600 bg-white'
|
||||
: 'text-slate-600 hover:text-slate-800'
|
||||
}`}
|
||||
>
|
||||
Aehnliche
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Sidebar Content */}
|
||||
<div className="flex-1 overflow-y-auto p-4">
|
||||
{activeTab === 'details' ? (
|
||||
<div className="space-y-4">
|
||||
{/* Fach */}
|
||||
<div className="flex items-start gap-3">
|
||||
<BookOpen className="w-5 h-5 text-slate-400 mt-0.5" />
|
||||
<div>
|
||||
<div className="text-xs text-slate-500 uppercase tracking-wide">Fach</div>
|
||||
<div className="font-medium text-slate-900">{fachLabel}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Jahr */}
|
||||
<div className="flex items-start gap-3">
|
||||
<Calendar className="w-5 h-5 text-slate-400 mt-0.5" />
|
||||
<div>
|
||||
<div className="text-xs text-slate-500 uppercase tracking-wide">Jahr</div>
|
||||
<div className="font-medium text-slate-900">{document.jahr}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Niveau */}
|
||||
<div className="flex items-start gap-3">
|
||||
<Layers className="w-5 h-5 text-slate-400 mt-0.5" />
|
||||
<div>
|
||||
<div className="text-xs text-slate-500 uppercase tracking-wide">Niveau</div>
|
||||
<div className="font-medium text-slate-900">{niveauLabel}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Aufgabe */}
|
||||
<div className="flex items-start gap-3">
|
||||
<FileText className="w-5 h-5 text-slate-400 mt-0.5" />
|
||||
<div>
|
||||
<div className="text-xs text-slate-500 uppercase tracking-wide">Aufgabe</div>
|
||||
<div className="font-medium text-slate-900">
|
||||
{document.aufgaben_nummer}
|
||||
<span className="ml-2 px-2 py-0.5 bg-slate-200 text-slate-700 text-xs rounded-full">
|
||||
{document.typ === 'erwartungshorizont' ? 'Erwartungshorizont' : 'Aufgabe'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Bundesland */}
|
||||
<div className="flex items-start gap-3">
|
||||
<ExternalLink className="w-5 h-5 text-slate-400 mt-0.5" />
|
||||
<div>
|
||||
<div className="text-xs text-slate-500 uppercase tracking-wide">Bundesland</div>
|
||||
<div className="font-medium text-slate-900 capitalize">{document.bundesland}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<hr className="border-slate-200" />
|
||||
|
||||
{/* File Info */}
|
||||
<div>
|
||||
<div className="text-xs text-slate-500 uppercase tracking-wide mb-2">Datei-Info</div>
|
||||
<div className="bg-white rounded-lg border border-slate-200 p-3 text-sm space-y-2">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-slate-500">Dateiname</span>
|
||||
<span className="text-slate-900 font-mono text-xs truncate max-w-[150px]" title={document.dateiname}>
|
||||
{document.dateiname}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-slate-500">Groesse</span>
|
||||
<span className="text-slate-900">{formatFileSize(document.file_size)}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-slate-500">Status</span>
|
||||
<span className={`px-2 py-0.5 rounded-full text-xs ${
|
||||
document.status === 'indexed'
|
||||
? 'bg-green-100 text-green-700'
|
||||
: document.status === 'error'
|
||||
? 'bg-red-100 text-red-700'
|
||||
: 'bg-yellow-100 text-yellow-700'
|
||||
}`}>
|
||||
{document.status === 'indexed' ? 'Indexiert' : document.status === 'error' ? 'Fehler' : 'Ausstehend'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* RAG Info */}
|
||||
{document.indexed && document.vector_ids.length > 0 && (
|
||||
<div>
|
||||
<div className="text-xs text-slate-500 uppercase tracking-wide mb-2">RAG-Index</div>
|
||||
<div className="bg-purple-50 border border-purple-200 rounded-lg p-3 text-sm">
|
||||
<div className="flex items-center gap-2 text-purple-700">
|
||||
<Search className="w-4 h-4" />
|
||||
<span>{document.vector_ids.length} Vektoren indexiert</span>
|
||||
</div>
|
||||
<div className="mt-2 text-xs text-purple-600">
|
||||
Confidence: {(document.confidence * 100).toFixed(0)}%
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Timestamps */}
|
||||
<div className="text-xs text-slate-400 pt-2">
|
||||
<div>Erstellt: {new Date(document.created_at).toLocaleString('de-DE')}</div>
|
||||
<div>Aktualisiert: {new Date(document.updated_at).toLocaleString('de-DE')}</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<AehnlicheDokumente
|
||||
documentId={document.id}
|
||||
onSelectDocument={(doc) => {
|
||||
// This would be handled by parent - for now just show preview
|
||||
console.log('Selected similar document:', doc.id)
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Keyboard Shortcut Hint */}
|
||||
<div className="absolute bottom-4 left-4 text-xs text-slate-400 bg-white/80 backdrop-blur-sm px-3 py-1.5 rounded-lg shadow-sm">
|
||||
Tastenkuerzel: F (Vollbild) | +/- (Zoom) | 0 (Reset) | Esc (Schliessen)
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,243 @@
|
||||
'use client'
|
||||
|
||||
/**
|
||||
* ThemenSuche - Autocomplete search for Abitur themes
|
||||
* Features debounced API calls, suggestion display, and keyboard navigation
|
||||
*/
|
||||
|
||||
import { useState, useEffect, useRef, useCallback } from 'react'
|
||||
import { Search, X, Loader2 } from 'lucide-react'
|
||||
import type { ThemaSuggestion } from '@/lib/education/abitur-archiv-types'
|
||||
import { POPULAR_THEMES } from '@/lib/education/abitur-archiv-types'
|
||||
|
||||
interface ThemenSucheProps {
|
||||
onSearch: (query: string) => void
|
||||
onClear: () => void
|
||||
placeholder?: string
|
||||
}
|
||||
|
||||
export function ThemenSuche({
|
||||
onSearch,
|
||||
onClear,
|
||||
placeholder = 'Thema suchen (z.B. Gedichtanalyse, Eroerterung, Drama...)'
|
||||
}: ThemenSucheProps) {
|
||||
const [query, setQuery] = useState('')
|
||||
const [suggestions, setSuggestions] = useState<ThemaSuggestion[]>([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [showDropdown, setShowDropdown] = useState(false)
|
||||
const [selectedIndex, setSelectedIndex] = useState(-1)
|
||||
|
||||
const inputRef = useRef<HTMLInputElement>(null)
|
||||
const dropdownRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
// Debounced API call for suggestions
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(async () => {
|
||||
if (query.length >= 2) {
|
||||
setLoading(true)
|
||||
try {
|
||||
const res = await fetch(`/api/education/abitur-archiv/suggest?q=${encodeURIComponent(query)}`)
|
||||
const data = await res.json()
|
||||
setSuggestions(data.suggestions || [])
|
||||
setShowDropdown(true)
|
||||
} catch (error) {
|
||||
console.error('Suggest error:', error)
|
||||
// Fallback to popular themes
|
||||
setSuggestions(POPULAR_THEMES.filter(t =>
|
||||
t.label.toLowerCase().includes(query.toLowerCase())
|
||||
))
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
} else if (query.length === 0) {
|
||||
setSuggestions(POPULAR_THEMES)
|
||||
} else {
|
||||
setSuggestions([])
|
||||
}
|
||||
}, 300)
|
||||
|
||||
return () => clearTimeout(timer)
|
||||
}, [query])
|
||||
|
||||
// Close dropdown when clicking outside
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (e: MouseEvent) => {
|
||||
if (
|
||||
dropdownRef.current &&
|
||||
!dropdownRef.current.contains(e.target as Node) &&
|
||||
inputRef.current &&
|
||||
!inputRef.current.contains(e.target as Node)
|
||||
) {
|
||||
setShowDropdown(false)
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('mousedown', handleClickOutside)
|
||||
return () => document.removeEventListener('mousedown', handleClickOutside)
|
||||
}, [])
|
||||
|
||||
const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
|
||||
if (!showDropdown || suggestions.length === 0) return
|
||||
|
||||
switch (e.key) {
|
||||
case 'ArrowDown':
|
||||
e.preventDefault()
|
||||
setSelectedIndex(prev => Math.min(prev + 1, suggestions.length - 1))
|
||||
break
|
||||
case 'ArrowUp':
|
||||
e.preventDefault()
|
||||
setSelectedIndex(prev => Math.max(prev - 1, -1))
|
||||
break
|
||||
case 'Enter':
|
||||
e.preventDefault()
|
||||
if (selectedIndex >= 0) {
|
||||
handleSelectSuggestion(suggestions[selectedIndex])
|
||||
} else if (query.trim()) {
|
||||
handleSearch()
|
||||
}
|
||||
break
|
||||
case 'Escape':
|
||||
setShowDropdown(false)
|
||||
setSelectedIndex(-1)
|
||||
break
|
||||
}
|
||||
}, [showDropdown, suggestions, selectedIndex, query])
|
||||
|
||||
const handleSelectSuggestion = (suggestion: ThemaSuggestion) => {
|
||||
setQuery(suggestion.label)
|
||||
setShowDropdown(false)
|
||||
setSelectedIndex(-1)
|
||||
onSearch(suggestion.label)
|
||||
}
|
||||
|
||||
const handleSearch = () => {
|
||||
if (query.trim()) {
|
||||
onSearch(query.trim())
|
||||
setShowDropdown(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleClear = () => {
|
||||
setQuery('')
|
||||
setSuggestions(POPULAR_THEMES)
|
||||
setShowDropdown(false)
|
||||
setSelectedIndex(-1)
|
||||
onClear()
|
||||
inputRef.current?.focus()
|
||||
}
|
||||
|
||||
const handleFocus = () => {
|
||||
if (query.length === 0) {
|
||||
setSuggestions(POPULAR_THEMES)
|
||||
}
|
||||
setShowDropdown(true)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
{/* Search Input */}
|
||||
<div className="relative flex items-center">
|
||||
<div className="absolute left-4 text-slate-400">
|
||||
{loading ? (
|
||||
<Loader2 className="w-5 h-5 animate-spin" />
|
||||
) : (
|
||||
<Search className="w-5 h-5" />
|
||||
)}
|
||||
</div>
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
value={query}
|
||||
onChange={(e) => {
|
||||
setQuery(e.target.value)
|
||||
setSelectedIndex(-1)
|
||||
}}
|
||||
onKeyDown={handleKeyDown}
|
||||
onFocus={handleFocus}
|
||||
placeholder={placeholder}
|
||||
className="w-full pl-12 pr-24 py-3 text-lg border border-slate-300 rounded-xl
|
||||
focus:ring-2 focus:ring-blue-500 focus:border-transparent
|
||||
bg-white shadow-sm"
|
||||
/>
|
||||
<div className="absolute right-2 flex items-center gap-2">
|
||||
{query && (
|
||||
<button
|
||||
onClick={handleClear}
|
||||
className="p-1.5 text-slate-400 hover:text-slate-600 hover:bg-slate-100 rounded-lg"
|
||||
title="Suche loeschen"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={handleSearch}
|
||||
disabled={!query.trim()}
|
||||
className="px-4 py-1.5 bg-blue-600 text-white rounded-lg hover:bg-blue-700
|
||||
disabled:opacity-50 disabled:cursor-not-allowed text-sm font-medium"
|
||||
>
|
||||
Suchen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Suggestions Dropdown */}
|
||||
{showDropdown && suggestions.length > 0 && (
|
||||
<div
|
||||
ref={dropdownRef}
|
||||
className="absolute top-full left-0 right-0 mt-2 bg-white rounded-xl border border-slate-200
|
||||
shadow-lg z-50 max-h-80 overflow-y-auto"
|
||||
>
|
||||
<div className="p-2">
|
||||
{query.length === 0 && (
|
||||
<div className="px-3 py-2 text-xs font-medium text-slate-500 uppercase tracking-wide">
|
||||
Beliebte Themen
|
||||
</div>
|
||||
)}
|
||||
{suggestions.map((suggestion, index) => (
|
||||
<button
|
||||
key={`${suggestion.aufgabentyp}-${suggestion.label}`}
|
||||
onClick={() => handleSelectSuggestion(suggestion)}
|
||||
className={`w-full px-3 py-2.5 text-left rounded-lg flex items-center justify-between
|
||||
transition-colors ${
|
||||
index === selectedIndex
|
||||
? 'bg-blue-50 text-blue-700'
|
||||
: 'hover:bg-slate-50'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<Search className="w-4 h-4 text-slate-400" />
|
||||
<div>
|
||||
<div className="font-medium text-slate-800">{suggestion.label}</div>
|
||||
{suggestion.kategorie && (
|
||||
<div className="text-xs text-slate-500">{suggestion.kategorie}</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<span className="text-sm text-slate-400">
|
||||
{suggestion.count} Dokumente
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Quick Theme Tags */}
|
||||
{!showDropdown && query.length === 0 && (
|
||||
<div className="mt-3 flex flex-wrap gap-2">
|
||||
<span className="text-sm text-slate-500">Vorschlaege:</span>
|
||||
{POPULAR_THEMES.slice(0, 5).map((theme) => (
|
||||
<button
|
||||
key={theme.aufgabentyp}
|
||||
onClick={() => handleSelectSuggestion(theme)}
|
||||
className="px-3 py-1 text-sm bg-slate-100 text-slate-700 rounded-full
|
||||
hover:bg-slate-200 transition-colors"
|
||||
>
|
||||
{theme.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,516 @@
|
||||
'use client'
|
||||
|
||||
/**
|
||||
* Abitur-Archiv - Hauptseite
|
||||
* 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 { ThemenSuche } from './components/ThemenSuche'
|
||||
import { DokumentCard } from './components/DokumentCard'
|
||||
import { FullscreenViewer } from './components/FullscreenViewer'
|
||||
|
||||
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
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-slate-50">
|
||||
{/* Header */}
|
||||
<div className="bg-white border-b border-slate-200">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-12 h-12 bg-gradient-to-br from-blue-500 to-purple-600 rounded-xl flex items-center justify-center">
|
||||
<Archive className="w-6 h-6 text-white" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-slate-900">Abitur-Archiv</h1>
|
||||
<p className="text-sm text-slate-500">Zentralabitur-Materialien 2021-2025</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Stats */}
|
||||
<div className="hidden md:flex items-center gap-6">
|
||||
<div className="text-center">
|
||||
<div className="text-2xl font-bold text-slate-800">{stats.total}</div>
|
||||
<div className="text-xs text-slate-500">Dokumente</div>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<div className="text-2xl font-bold text-green-600">{stats.indexed}</div>
|
||||
<div className="text-xs text-slate-500">Indexiert</div>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<div className="text-2xl font-bold text-blue-600">{stats.faecher}</div>
|
||||
<div className="text-xs text-slate-500">Faecher</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Main Content */}
|
||||
<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}
|
||||
/>
|
||||
</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 && (
|
||||
<div className="flex items-center gap-2 px-4 py-2 bg-blue-50 border border-blue-200 rounded-lg">
|
||||
<Search className="w-4 h-4 text-blue-600" />
|
||||
<span className="text-sm text-blue-700">
|
||||
Suche: <strong>{searchQuery}</strong>
|
||||
</span>
|
||||
<button
|
||||
onClick={handleClearSearch}
|
||||
className="ml-auto text-blue-600 hover:text-blue-800"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
</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}
|
||||
onDownload={handleDownload}
|
||||
onAddToKlausur={handleAddToKlausur}
|
||||
/>
|
||||
))}
|
||||
</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 */}
|
||||
<FullscreenViewer
|
||||
document={selectedDocument}
|
||||
onClose={() => setSelectedDocument(null)}
|
||||
onAddToKlausur={handleAddToKlausur}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,319 @@
|
||||
'use client'
|
||||
|
||||
/**
|
||||
* Education Search Page
|
||||
* Bildungsquellen und Crawler-Verwaltung
|
||||
*/
|
||||
|
||||
import { useState, useCallback } from 'react'
|
||||
import { PagePurpose } from '@/components/common/PagePurpose'
|
||||
import { Search, Database, RefreshCw, ExternalLink, FileText, BookOpen, FolderOpen } from 'lucide-react'
|
||||
import { DokumenteTab } from '@/components/education/DokumenteTab'
|
||||
|
||||
interface DataSource {
|
||||
id: string
|
||||
name: string
|
||||
type: 'api' | 'crawler' | 'manual'
|
||||
status: 'active' | 'inactive' | 'error'
|
||||
lastUpdate?: string
|
||||
documentCount: number
|
||||
url?: string
|
||||
}
|
||||
|
||||
const DATA_SOURCES: DataSource[] = [
|
||||
{
|
||||
id: 'nibis',
|
||||
name: 'NiBiS (Niedersachsen)',
|
||||
type: 'crawler',
|
||||
status: 'active',
|
||||
lastUpdate: '2026-01-20',
|
||||
documentCount: 1250,
|
||||
url: 'https://nibis.de',
|
||||
},
|
||||
{
|
||||
id: 'kmk',
|
||||
name: 'KMK Beschluesse',
|
||||
type: 'crawler',
|
||||
status: 'active',
|
||||
lastUpdate: '2026-01-10',
|
||||
documentCount: 450,
|
||||
url: 'https://kmk.org',
|
||||
},
|
||||
]
|
||||
|
||||
export default function EduSearchPage() {
|
||||
const [searchQuery, setSearchQuery] = useState('')
|
||||
const [activeTab, setActiveTab] = useState<'search' | 'documents' | 'sources' | 'crawler'>('search')
|
||||
const [documentCount, setDocumentCount] = useState<number>(0)
|
||||
|
||||
const handleDocumentCountChange = useCallback((count: number) => {
|
||||
setDocumentCount(count)
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<PagePurpose
|
||||
title="Education Search"
|
||||
purpose="Durchsuchen Sie Bildungsquellen und verwalten Sie Crawler fuer Lehrplaene, Erlasse und Schulinformationen. Zentraler Zugang zu bildungsrelevanten Dokumenten."
|
||||
audience={['Content Manager', 'Entwickler', 'Bildungs-Admins']}
|
||||
architecture={{
|
||||
services: ['edu-search-service (Go)', 'OpenSearch'],
|
||||
databases: ['OpenSearch (bp_documents_v1)', 'PostgreSQL'],
|
||||
}}
|
||||
collapsible={true}
|
||||
defaultCollapsed={true}
|
||||
/>
|
||||
|
||||
{/* Stats */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
|
||||
<div className="bg-white rounded-xl border border-slate-200 p-4">
|
||||
<div className="text-3xl font-bold text-blue-600">
|
||||
{DATA_SOURCES.reduce((sum, s) => sum + s.documentCount, 0).toLocaleString()}
|
||||
</div>
|
||||
<div className="text-sm text-slate-500">Dokumente gesamt</div>
|
||||
</div>
|
||||
<div className="bg-white rounded-xl border border-slate-200 p-4">
|
||||
<div className="text-3xl font-bold text-green-600">{DATA_SOURCES.length}</div>
|
||||
<div className="text-sm text-slate-500">Datenquellen</div>
|
||||
</div>
|
||||
<div className="bg-white rounded-xl border border-slate-200 p-4">
|
||||
<div className="text-3xl font-bold text-purple-600">
|
||||
{DATA_SOURCES.filter(s => s.type === 'crawler').length}
|
||||
</div>
|
||||
<div className="text-sm text-slate-500">Aktive Crawler</div>
|
||||
</div>
|
||||
<div className="bg-white rounded-xl border border-slate-200 p-4">
|
||||
<div className="text-3xl font-bold text-orange-600">16</div>
|
||||
<div className="text-sm text-slate-500">Bundeslaender</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tabs */}
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => setActiveTab('search')}
|
||||
className={`px-4 py-2 rounded-lg font-medium transition-colors ${
|
||||
activeTab === 'search'
|
||||
? 'bg-blue-600 text-white'
|
||||
: 'bg-slate-100 text-slate-600 hover:bg-slate-200'
|
||||
}`}
|
||||
>
|
||||
<Search className="w-4 h-4 inline mr-2" />
|
||||
Suche
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab('documents')}
|
||||
className={`px-4 py-2 rounded-lg font-medium transition-colors ${
|
||||
activeTab === 'documents'
|
||||
? 'bg-blue-600 text-white'
|
||||
: 'bg-slate-100 text-slate-600 hover:bg-slate-200'
|
||||
}`}
|
||||
>
|
||||
<FolderOpen className="w-4 h-4 inline mr-2" />
|
||||
Dokumente
|
||||
{documentCount > 0 && (
|
||||
<span className="ml-2 px-1.5 py-0.5 bg-white/20 rounded text-xs">
|
||||
{documentCount}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab('sources')}
|
||||
className={`px-4 py-2 rounded-lg font-medium transition-colors ${
|
||||
activeTab === 'sources'
|
||||
? 'bg-blue-600 text-white'
|
||||
: 'bg-slate-100 text-slate-600 hover:bg-slate-200'
|
||||
}`}
|
||||
>
|
||||
<Database className="w-4 h-4 inline mr-2" />
|
||||
Datenquellen
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab('crawler')}
|
||||
className={`px-4 py-2 rounded-lg font-medium transition-colors ${
|
||||
activeTab === 'crawler'
|
||||
? 'bg-blue-600 text-white'
|
||||
: 'bg-slate-100 text-slate-600 hover:bg-slate-200'
|
||||
}`}
|
||||
>
|
||||
<RefreshCw className="w-4 h-4 inline mr-2" />
|
||||
Crawler
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Search Tab */}
|
||||
{activeTab === 'search' && (
|
||||
<div className="bg-white rounded-xl border border-slate-200 p-6">
|
||||
<div className="flex gap-4 mb-6">
|
||||
<input
|
||||
type="text"
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
placeholder="Suche nach Lehrplaenen, Erlassen, Curricula..."
|
||||
className="flex-1 px-4 py-3 border border-slate-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 text-lg"
|
||||
/>
|
||||
<button className="px-8 py-3 bg-blue-600 text-white rounded-lg hover:bg-blue-700 font-medium">
|
||||
Suchen
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-2 mb-6">
|
||||
<span className="text-sm text-slate-500 mr-2">Schnellfilter:</span>
|
||||
<button className="px-3 py-1 bg-slate-100 text-slate-700 rounded-full text-sm hover:bg-slate-200">
|
||||
Lehrplaene
|
||||
</button>
|
||||
<button className="px-3 py-1 bg-slate-100 text-slate-700 rounded-full text-sm hover:bg-slate-200">
|
||||
Erlasse
|
||||
</button>
|
||||
<button className="px-3 py-1 bg-slate-100 text-slate-700 rounded-full text-sm hover:bg-slate-200">
|
||||
Kerncurricula
|
||||
</button>
|
||||
<button className="px-3 py-1 bg-slate-100 text-slate-700 rounded-full text-sm hover:bg-slate-200">
|
||||
Abitur
|
||||
</button>
|
||||
<button className="px-3 py-1 bg-slate-100 text-slate-700 rounded-full text-sm hover:bg-slate-200">
|
||||
Niedersachsen
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="text-center py-12 text-slate-500">
|
||||
<BookOpen className="w-16 h-16 mx-auto mb-4 opacity-50" />
|
||||
<p>Geben Sie einen Suchbegriff ein, um Bildungsdokumente zu durchsuchen</p>
|
||||
<p className="text-sm mt-2">Die Suche durchsucht alle angebundenen Datenquellen</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Documents Tab */}
|
||||
{activeTab === 'documents' && (
|
||||
<DokumenteTab onDocumentCountChange={handleDocumentCountChange} />
|
||||
)}
|
||||
|
||||
{/* Sources Tab */}
|
||||
{activeTab === 'sources' && (
|
||||
<div className="bg-white rounded-xl border border-slate-200 overflow-hidden">
|
||||
<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">Datenquelle</th>
|
||||
<th className="text-left px-4 py-3 font-medium text-slate-600">Typ</th>
|
||||
<th className="text-center px-4 py-3 font-medium text-slate-600">Status</th>
|
||||
<th className="text-right px-4 py-3 font-medium text-slate-600">Dokumente</th>
|
||||
<th className="text-left px-4 py-3 font-medium text-slate-600">Letztes Update</th>
|
||||
<th className="text-center px-4 py-3 font-medium text-slate-600">Aktion</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{DATA_SOURCES.map((source) => (
|
||||
<tr key={source.id} className="border-b border-slate-100 hover:bg-slate-50">
|
||||
<td className="px-4 py-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Database className="w-4 h-4 text-slate-400" />
|
||||
<div className="font-medium text-slate-900">{source.name}</div>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<span className={`px-2 py-0.5 rounded-full text-xs ${
|
||||
source.type === 'api' ? 'bg-blue-100 text-blue-700' :
|
||||
source.type === 'crawler' ? 'bg-purple-100 text-purple-700' :
|
||||
'bg-slate-100 text-slate-700'
|
||||
}`}>
|
||||
{source.type.toUpperCase()}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-center">
|
||||
<span className={`px-2 py-0.5 rounded-full text-xs ${
|
||||
source.status === 'active' ? 'bg-green-100 text-green-700' :
|
||||
source.status === 'error' ? 'bg-red-100 text-red-700' :
|
||||
'bg-slate-100 text-slate-700'
|
||||
}`}>
|
||||
{source.status === 'active' ? 'Aktiv' : source.status === 'error' ? 'Fehler' : 'Inaktiv'}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-right font-medium">
|
||||
{source.documentCount.toLocaleString()}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-slate-500">
|
||||
{source.lastUpdate || '-'}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-center">
|
||||
{source.url && (
|
||||
<a
|
||||
href={source.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="p-1 text-blue-600 hover:bg-blue-50 rounded inline-block"
|
||||
title="Quelle oeffnen"
|
||||
>
|
||||
<ExternalLink className="w-4 h-4" />
|
||||
</a>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Crawler Tab */}
|
||||
{activeTab === 'crawler' && (
|
||||
<div className="space-y-6">
|
||||
<div className="bg-white rounded-xl border border-slate-200 p-6">
|
||||
<h3 className="text-lg font-semibold text-slate-900 mb-4">Crawler-Verwaltung</h3>
|
||||
<p className="text-sm text-slate-600 mb-4">
|
||||
Hier koennen Sie die Crawler fuer verschiedene Bildungsquellen steuern.
|
||||
Das System crawlt ausschliesslich oeffentliche Bildungsdokumente (Lehrplaene, Erlasse, Curricula). Keine Personendaten.
|
||||
</p>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div className="border border-slate-200 rounded-lg p-4">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<FileText className="w-5 h-5 text-purple-600" />
|
||||
<span className="font-medium">NiBiS Crawler</span>
|
||||
</div>
|
||||
<p className="text-sm text-slate-600 mb-3">
|
||||
Crawlt Lehrplaene und Erlasse aus Niedersachsen
|
||||
</p>
|
||||
<button className="px-4 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 text-sm">
|
||||
Crawl starten
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="border border-slate-200 rounded-lg p-4">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<FileText className="w-5 h-5 text-blue-600" />
|
||||
<span className="font-medium">KMK Crawler</span>
|
||||
</div>
|
||||
<p className="text-sm text-slate-600 mb-3">
|
||||
Crawlt Beschluesse der Kultusministerkonferenz
|
||||
</p>
|
||||
<button className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 text-sm">
|
||||
Crawl starten
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Info Box */}
|
||||
<div className="bg-blue-50 border border-blue-200 rounded-xl p-6">
|
||||
<h3 className="font-semibold text-blue-800 flex items-center gap-2">
|
||||
<span>ℹ️</span>
|
||||
Verwandte Module
|
||||
</h3>
|
||||
<div className="mt-3 grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<a href="/education/zeugnisse-crawler" className="block p-3 bg-white rounded-lg hover:shadow-md transition-shadow">
|
||||
<div className="font-medium text-slate-900">Zeugnisse-Crawler</div>
|
||||
<div className="text-sm text-slate-500">Zeugnis-Strukturen verwalten</div>
|
||||
</a>
|
||||
<a href="/ai/rag-pipeline" className="block p-3 bg-white rounded-lg hover:shadow-md transition-shadow">
|
||||
<div className="font-medium text-slate-900">RAG Pipeline</div>
|
||||
<div className="text-sm text-slate-500">Bildungsdokumente indexieren</div>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
+1320
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,484 @@
|
||||
'use client'
|
||||
|
||||
/**
|
||||
* Fairness-Dashboard
|
||||
*
|
||||
* Visualizes grading consistency and identifies outliers for review.
|
||||
*/
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { useParams, useRouter } from 'next/navigation'
|
||||
import Link from 'next/link'
|
||||
|
||||
// Same-origin proxy to avoid CORS issues
|
||||
const API_BASE = '/klausur-api'
|
||||
|
||||
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'
|
||||
}
|
||||
|
||||
const CRITERION_COLORS: Record<string, string> = {
|
||||
rechtschreibung: '#dc2626',
|
||||
grammatik: '#2563eb',
|
||||
inhalt: '#16a34a',
|
||||
struktur: '#9333ea',
|
||||
stil: '#ea580c',
|
||||
}
|
||||
|
||||
interface FairnessData {
|
||||
klausur_id: string
|
||||
students_count: number
|
||||
graded_count: number
|
||||
statistics: {
|
||||
average_grade: number
|
||||
average_raw_points: number
|
||||
min_grade: number
|
||||
max_grade: number
|
||||
spread: number
|
||||
standard_deviation: number
|
||||
}
|
||||
criteria_breakdown: Record<string, {
|
||||
average: number
|
||||
min: number
|
||||
max: number
|
||||
count: number
|
||||
}>
|
||||
outliers: Array<{
|
||||
student_id: string
|
||||
student_name: string
|
||||
grade_points: number
|
||||
deviation: number
|
||||
direction: 'above' | 'below'
|
||||
}>
|
||||
fairness_score: number
|
||||
warnings: string[]
|
||||
recommendation: string
|
||||
}
|
||||
|
||||
interface Klausur {
|
||||
id: string
|
||||
title: string
|
||||
subject: string
|
||||
students: Array<{
|
||||
id: string
|
||||
student_name: string
|
||||
anonym_id: string
|
||||
grade_points: number
|
||||
criteria_scores: Record<string, { score: number }>
|
||||
}>
|
||||
}
|
||||
|
||||
export default function FairnessDashboardPage() {
|
||||
const params = useParams()
|
||||
const router = useRouter()
|
||||
const klausurId = params.klausurId as string
|
||||
|
||||
const [klausur, setKlausur] = useState<Klausur | null>(null)
|
||||
const [fairnessData, setFairnessData] = useState<FairnessData | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
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 fairnessRes = await fetch(`${API_BASE}/api/v1/klausuren/${klausurId}/fairness`)
|
||||
if (fairnessRes.ok) {
|
||||
setFairnessData(await fairnessRes.json())
|
||||
} else {
|
||||
const errData = await fairnessRes.json()
|
||||
setError(errData.detail || 'Fehler beim Laden der Fairness-Analyse')
|
||||
}
|
||||
|
||||
setError(null)
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch data:', err)
|
||||
setError('Fehler beim Laden der Daten')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [klausurId])
|
||||
|
||||
useEffect(() => {
|
||||
fetchData()
|
||||
}, [fetchData])
|
||||
|
||||
const getGradeDistribution = () => {
|
||||
if (!klausur?.students) return []
|
||||
|
||||
const distribution: Record<number, number> = {}
|
||||
for (let i = 0; i <= 15; i++) {
|
||||
distribution[i] = 0
|
||||
}
|
||||
|
||||
klausur.students.forEach(s => {
|
||||
if (s.grade_points >= 0 && s.grade_points <= 15) {
|
||||
distribution[s.grade_points]++
|
||||
}
|
||||
})
|
||||
|
||||
return Object.entries(distribution).map(([grade, count]) => ({
|
||||
grade: parseInt(grade),
|
||||
count,
|
||||
label: GRADE_LABELS[parseInt(grade)] || grade
|
||||
}))
|
||||
}
|
||||
|
||||
const gradeDistribution = getGradeDistribution()
|
||||
const maxCount = Math.max(...gradeDistribution.map(d => d.count), 1)
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="min-h-screen bg-slate-50 flex items-center justify-center">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-purple-600"></div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-slate-50">
|
||||
<div className="max-w-7xl mx-auto px-4 py-6">
|
||||
{/* Header */}
|
||||
<div className="bg-white border-b border-slate-200 -mx-4 -mt-6 px-4 py-4 mb-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<Link
|
||||
href={`/education/klausur-korrektur/${klausurId}`}
|
||||
className="text-purple-600 hover:text-purple-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 zur Klausur
|
||||
</Link>
|
||||
|
||||
<div className="text-sm text-slate-500">
|
||||
{fairnessData?.graded_count || 0} von {fairnessData?.students_count || 0} Arbeiten bewertet
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Page header */}
|
||||
<div className="mb-6">
|
||||
<h1 className="text-2xl font-bold text-slate-800">Fairness-Analyse</h1>
|
||||
<p className="text-sm text-slate-500">{klausur?.title || ''}</p>
|
||||
</div>
|
||||
|
||||
{/* Error display */}
|
||||
{error && (
|
||||
<div className="mb-6 p-4 bg-red-50 border border-red-200 rounded-lg text-red-800">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{fairnessData && (
|
||||
<div className="space-y-6">
|
||||
{/* Top Row: Fairness Score + Statistics */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||
{/* Fairness Score Gauge */}
|
||||
<div className="bg-white rounded-lg border border-slate-200 p-6">
|
||||
<h3 className="text-sm font-medium text-slate-500 mb-4">Fairness-Score</h3>
|
||||
<div className="flex items-center justify-center">
|
||||
<div className="relative w-32 h-32">
|
||||
<svg className="w-32 h-32 transform -rotate-90">
|
||||
<circle
|
||||
cx="64"
|
||||
cy="64"
|
||||
r="56"
|
||||
fill="none"
|
||||
stroke="#e2e8f0"
|
||||
strokeWidth="12"
|
||||
/>
|
||||
<circle
|
||||
cx="64"
|
||||
cy="64"
|
||||
r="56"
|
||||
fill="none"
|
||||
stroke={
|
||||
fairnessData.fairness_score >= 70 ? '#16a34a' :
|
||||
fairnessData.fairness_score >= 40 ? '#eab308' : '#dc2626'
|
||||
}
|
||||
strokeWidth="12"
|
||||
strokeLinecap="round"
|
||||
strokeDasharray={`${(fairnessData.fairness_score / 100) * 352} 352`}
|
||||
/>
|
||||
</svg>
|
||||
<div className="absolute inset-0 flex flex-col items-center justify-center">
|
||||
<span className="text-3xl font-bold">{fairnessData.fairness_score}</span>
|
||||
<span className="text-xs text-slate-500">von 100</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className={`mt-4 text-center text-sm font-medium ${
|
||||
fairnessData.fairness_score >= 70 ? 'text-green-600' :
|
||||
fairnessData.fairness_score >= 40 ? 'text-yellow-600' : 'text-red-600'
|
||||
}`}>
|
||||
{fairnessData.recommendation}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Statistics */}
|
||||
<div className="bg-white rounded-lg border border-slate-200 p-6">
|
||||
<h3 className="text-sm font-medium text-slate-500 mb-4">Statistik</h3>
|
||||
<div className="space-y-3">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-slate-600">Durchschnitt</span>
|
||||
<span className="font-semibold">
|
||||
{fairnessData.statistics.average_grade} P ({GRADE_LABELS[Math.round(fairnessData.statistics.average_grade)]})
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-slate-600">Minimum</span>
|
||||
<span className="font-semibold">
|
||||
{fairnessData.statistics.min_grade} P ({GRADE_LABELS[fairnessData.statistics.min_grade]})
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-slate-600">Maximum</span>
|
||||
<span className="font-semibold">
|
||||
{fairnessData.statistics.max_grade} P ({GRADE_LABELS[fairnessData.statistics.max_grade]})
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-slate-600">Spreizung</span>
|
||||
<span className="font-semibold">{fairnessData.statistics.spread} P</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-slate-600">Standardabweichung</span>
|
||||
<span className="font-semibold">{fairnessData.statistics.standard_deviation}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Warnings */}
|
||||
<div className="bg-white rounded-lg border border-slate-200 p-6">
|
||||
<h3 className="text-sm font-medium text-slate-500 mb-4">Hinweise</h3>
|
||||
{fairnessData.warnings.length > 0 ? (
|
||||
<div className="space-y-2">
|
||||
{fairnessData.warnings.map((warning, i) => (
|
||||
<div key={i} className="flex items-start gap-2 text-sm">
|
||||
<svg className="w-5 h-5 text-amber-500 flex-shrink-0 mt-0.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
|
||||
</svg>
|
||||
<span className="text-slate-700">{warning}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center gap-2 text-green-600">
|
||||
<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>
|
||||
<span className="text-sm">Keine Auffaelligkeiten erkannt</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Grade Distribution Histogram */}
|
||||
<div className="bg-white rounded-lg border border-slate-200 p-6">
|
||||
<h3 className="text-sm font-medium text-slate-500 mb-4">Notenverteilung</h3>
|
||||
<div className="flex items-end gap-1 h-48">
|
||||
{gradeDistribution.map(({ grade, count, label }) => (
|
||||
<div key={grade} className="flex-1 flex flex-col items-center">
|
||||
<div
|
||||
className={`w-full rounded-t transition-all ${
|
||||
count > 0 ? 'bg-purple-500' : 'bg-slate-200'
|
||||
}`}
|
||||
style={{ height: `${(count / maxCount) * 160}px`, minHeight: count > 0 ? '8px' : '2px' }}
|
||||
title={`${count} Arbeiten`}
|
||||
/>
|
||||
<div className="text-xs text-slate-500 mt-1 transform -rotate-45 origin-top-left w-6 text-center">
|
||||
{label}
|
||||
</div>
|
||||
{count > 0 && (
|
||||
<div className="text-xs font-medium text-slate-700 mt-1">{count}</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex justify-between text-xs text-slate-400 mt-6">
|
||||
<span>6 (0 Punkte)</span>
|
||||
<span>1+ (15 Punkte)</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Criteria Breakdown Heatmap */}
|
||||
<div className="bg-white rounded-lg border border-slate-200 p-6">
|
||||
<h3 className="text-sm font-medium text-slate-500 mb-4">Kriterien-Vergleich</h3>
|
||||
<div className="space-y-3">
|
||||
{Object.entries(fairnessData.criteria_breakdown).map(([criterion, data]) => {
|
||||
const color = CRITERION_COLORS[criterion] || '#6b7280'
|
||||
const range = data.max - data.min
|
||||
|
||||
return (
|
||||
<div key={criterion} className="flex items-center gap-4">
|
||||
<div className="w-32 flex items-center gap-2">
|
||||
<div className="w-3 h-3 rounded-full" style={{ backgroundColor: color }} />
|
||||
<span className="text-sm font-medium capitalize">{criterion}</span>
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<div className="relative h-6 bg-slate-100 rounded-full overflow-hidden">
|
||||
<div
|
||||
className="absolute h-full opacity-30"
|
||||
style={{
|
||||
backgroundColor: color,
|
||||
left: `${data.min}%`,
|
||||
width: `${range}%`
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
className="absolute top-0 bottom-0 w-1 rounded"
|
||||
style={{
|
||||
backgroundColor: color,
|
||||
left: `${data.average}%`
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="w-24 text-right">
|
||||
<span className="text-sm font-semibold">{data.average}%</span>
|
||||
<span className="text-xs text-slate-400 ml-1">avg</span>
|
||||
</div>
|
||||
<div className="w-20 text-right text-xs text-slate-500">
|
||||
{data.min}% - {data.max}%
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Outliers List */}
|
||||
{fairnessData.outliers.length > 0 && (
|
||||
<div className="bg-white rounded-lg border border-slate-200 p-6">
|
||||
<h3 className="text-sm font-medium text-slate-500 mb-4">
|
||||
Ausreisser ({fairnessData.outliers.length})
|
||||
</h3>
|
||||
<div className="space-y-2">
|
||||
{fairnessData.outliers.map((outlier) => (
|
||||
<div
|
||||
key={outlier.student_id}
|
||||
className={`flex items-center justify-between p-3 rounded-lg border ${
|
||||
outlier.direction === 'above'
|
||||
? 'bg-green-50 border-green-200'
|
||||
: 'bg-red-50 border-red-200'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className={`w-8 h-8 rounded-full flex items-center justify-center text-white font-bold ${
|
||||
outlier.direction === 'above' ? 'bg-green-500' : 'bg-red-500'
|
||||
}`}>
|
||||
{outlier.direction === 'above' ? '↑' : '↓'}
|
||||
</div>
|
||||
<div>
|
||||
<div className="font-medium">{outlier.student_name}</div>
|
||||
<div className="text-sm text-slate-500">
|
||||
{outlier.grade_points} Punkte ({GRADE_LABELS[outlier.grade_points]}) -
|
||||
Abweichung: {outlier.deviation} Punkte {outlier.direction === 'above' ? 'ueber' : 'unter'} Durchschnitt
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Link
|
||||
href={`/education/klausur-korrektur/${klausurId}/${outlier.student_id}`}
|
||||
className="px-4 py-2 bg-white border border-slate-300 rounded-lg text-sm hover:bg-slate-50 flex items-center gap-2"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z" />
|
||||
</svg>
|
||||
Pruefen
|
||||
</Link>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* All Students Table */}
|
||||
<div className="bg-white rounded-lg border border-slate-200 p-6">
|
||||
<h3 className="text-sm font-medium text-slate-500 mb-4">
|
||||
Alle Arbeiten ({klausur?.students.length || 0})
|
||||
</h3>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-slate-200">
|
||||
<th className="text-left py-2 px-3 font-medium text-slate-600">Student</th>
|
||||
<th className="text-center py-2 px-3 font-medium text-slate-600">Note</th>
|
||||
<th className="text-center py-2 px-3 font-medium text-slate-600">RS</th>
|
||||
<th className="text-center py-2 px-3 font-medium text-slate-600">Gram</th>
|
||||
<th className="text-center py-2 px-3 font-medium text-slate-600">Inhalt</th>
|
||||
<th className="text-center py-2 px-3 font-medium text-slate-600">Struktur</th>
|
||||
<th className="text-center py-2 px-3 font-medium text-slate-600">Stil</th>
|
||||
<th className="text-right py-2 px-3 font-medium text-slate-600">Aktion</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{klausur?.students
|
||||
.sort((a, b) => b.grade_points - a.grade_points)
|
||||
.map((student) => {
|
||||
const isOutlier = fairnessData.outliers.some(o => o.student_id === student.id)
|
||||
const outlierInfo = fairnessData.outliers.find(o => o.student_id === student.id)
|
||||
|
||||
return (
|
||||
<tr
|
||||
key={student.id}
|
||||
className={`border-b border-slate-100 ${
|
||||
isOutlier
|
||||
? outlierInfo?.direction === 'above'
|
||||
? 'bg-green-50'
|
||||
: 'bg-red-50'
|
||||
: ''
|
||||
}`}
|
||||
>
|
||||
<td className="py-2 px-3">
|
||||
<div className="font-medium">{student.anonym_id}</div>
|
||||
</td>
|
||||
<td className="py-2 px-3 text-center">
|
||||
<span className="font-bold">
|
||||
{student.grade_points} ({GRADE_LABELS[student.grade_points] || '-'})
|
||||
</span>
|
||||
</td>
|
||||
<td className="py-2 px-3 text-center">
|
||||
{student.criteria_scores?.rechtschreibung?.score ?? '-'}%
|
||||
</td>
|
||||
<td className="py-2 px-3 text-center">
|
||||
{student.criteria_scores?.grammatik?.score ?? '-'}%
|
||||
</td>
|
||||
<td className="py-2 px-3 text-center">
|
||||
{student.criteria_scores?.inhalt?.score ?? '-'}%
|
||||
</td>
|
||||
<td className="py-2 px-3 text-center">
|
||||
{student.criteria_scores?.struktur?.score ?? '-'}%
|
||||
</td>
|
||||
<td className="py-2 px-3 text-center">
|
||||
{student.criteria_scores?.stil?.score ?? '-'}%
|
||||
</td>
|
||||
<td className="py-2 px-3 text-right">
|
||||
<Link
|
||||
href={`/education/klausur-korrektur/${klausurId}/${student.id}`}
|
||||
className="text-purple-600 hover:text-purple-800 text-sm"
|
||||
>
|
||||
Bearbeiten
|
||||
</Link>
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,489 @@
|
||||
'use client'
|
||||
|
||||
/**
|
||||
* Klausur Detail Page - Student List
|
||||
*
|
||||
* Shows all student works for a specific Klausur with upload capability.
|
||||
* Allows navigation to individual correction workspaces.
|
||||
*/
|
||||
|
||||
import { useState, useEffect, useCallback, useRef } from 'react'
|
||||
import { useParams, useRouter } from 'next/navigation'
|
||||
import Link from 'next/link'
|
||||
import type { Klausur, StudentWork } from '../types'
|
||||
|
||||
// Same-origin proxy to avoid CORS issues
|
||||
const API_BASE = '/klausur-api'
|
||||
|
||||
const statusConfig: Record<string, { color: string; label: string; bg: string }> = {
|
||||
UPLOADED: { color: 'text-gray-600', label: 'Hochgeladen', bg: 'bg-gray-100' },
|
||||
OCR_PROCESSING: { color: 'text-yellow-600', label: 'OCR laeuft', bg: 'bg-yellow-100' },
|
||||
OCR_COMPLETE: { color: 'text-blue-600', label: 'OCR fertig', bg: 'bg-blue-100' },
|
||||
ANALYZING: { color: 'text-purple-600', label: 'Analyse', bg: 'bg-purple-100' },
|
||||
FIRST_EXAMINER: { color: 'text-orange-600', label: 'Erstkorrektur', bg: 'bg-orange-100' },
|
||||
SECOND_EXAMINER: { color: 'text-cyan-600', label: 'Zweitkorrektur', bg: 'bg-cyan-100' },
|
||||
COMPLETED: { color: 'text-green-600', label: 'Fertig', bg: 'bg-green-100' },
|
||||
ERROR: { color: 'text-red-600', label: 'Fehler', bg: 'bg-red-100' },
|
||||
}
|
||||
|
||||
export default function KlausurDetailPage() {
|
||||
const params = useParams()
|
||||
const router = useRouter()
|
||||
const klausurId = params.klausurId as string
|
||||
|
||||
const [klausur, setKlausur] = useState<Klausur | null>(null)
|
||||
const [students, setStudents] = useState<StudentWork[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [uploading, setUploading] = useState(false)
|
||||
const [uploadProgress, setUploadProgress] = useState(0)
|
||||
const [exporting, setExporting] = useState(false)
|
||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
const fetchKlausur = useCallback(async () => {
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/api/v1/klausuren/${klausurId}`)
|
||||
if (res.ok) {
|
||||
const data = await res.json()
|
||||
setKlausur(data)
|
||||
} else if (res.status === 404) {
|
||||
setError('Klausur nicht gefunden')
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch klausur:', err)
|
||||
setError('Verbindung fehlgeschlagen')
|
||||
}
|
||||
}, [klausurId])
|
||||
|
||||
const fetchStudents = useCallback(async () => {
|
||||
try {
|
||||
setLoading(true)
|
||||
const res = await fetch(`${API_BASE}/api/v1/klausuren/${klausurId}/students`)
|
||||
if (res.ok) {
|
||||
const data = await res.json()
|
||||
setStudents(Array.isArray(data) ? data : data.students || [])
|
||||
setError(null)
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch students:', err)
|
||||
setError('Fehler beim Laden der Arbeiten')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [klausurId])
|
||||
|
||||
useEffect(() => {
|
||||
fetchKlausur()
|
||||
fetchStudents()
|
||||
}, [fetchKlausur, fetchStudents])
|
||||
|
||||
const exportOverviewPDF = async () => {
|
||||
try {
|
||||
setExporting(true)
|
||||
const res = await fetch(`${API_BASE}/api/v1/klausuren/${klausurId}/export/overview`)
|
||||
if (res.ok) {
|
||||
const blob = await res.blob()
|
||||
const url = window.URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = `Notenuebersicht_${klausur?.title?.replace(/\s+/g, '_') || 'Klausur'}_${new Date().toISOString().split('T')[0]}.pdf`
|
||||
document.body.appendChild(a)
|
||||
a.click()
|
||||
document.body.removeChild(a)
|
||||
window.URL.revokeObjectURL(url)
|
||||
} else {
|
||||
setError('Fehler beim PDF-Export')
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to export overview PDF:', err)
|
||||
setError('Fehler beim PDF-Export')
|
||||
} finally {
|
||||
setExporting(false)
|
||||
}
|
||||
}
|
||||
|
||||
const exportAllGutachtenPDF = async () => {
|
||||
try {
|
||||
setExporting(true)
|
||||
const res = await fetch(`${API_BASE}/api/v1/klausuren/${klausurId}/export/all-gutachten`)
|
||||
if (res.ok) {
|
||||
const blob = await res.blob()
|
||||
const url = window.URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = `Alle_Gutachten_${klausur?.title?.replace(/\s+/g, '_') || 'Klausur'}_${new Date().toISOString().split('T')[0]}.pdf`
|
||||
document.body.appendChild(a)
|
||||
a.click()
|
||||
document.body.removeChild(a)
|
||||
window.URL.revokeObjectURL(url)
|
||||
} else {
|
||||
setError('Fehler beim PDF-Export')
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to export all gutachten PDF:', err)
|
||||
setError('Fehler beim PDF-Export')
|
||||
} finally {
|
||||
setExporting(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleFileUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const files = e.target.files
|
||||
if (!files || files.length === 0) return
|
||||
|
||||
setUploading(true)
|
||||
setUploadProgress(0)
|
||||
setError(null)
|
||||
|
||||
const totalFiles = files.length
|
||||
let uploadedCount = 0
|
||||
|
||||
for (const file of Array.from(files)) {
|
||||
try {
|
||||
const formData = new FormData()
|
||||
formData.append('file', file)
|
||||
|
||||
const res = await fetch(`${API_BASE}/api/v1/klausuren/${klausurId}/students`, {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
})
|
||||
|
||||
if (!res.ok) {
|
||||
const errorData = await res.json()
|
||||
console.error(`Failed to upload ${file.name}:`, errorData)
|
||||
}
|
||||
|
||||
uploadedCount++
|
||||
setUploadProgress(Math.round((uploadedCount / totalFiles) * 100))
|
||||
} catch (err) {
|
||||
console.error(`Failed to upload ${file.name}:`, err)
|
||||
}
|
||||
}
|
||||
|
||||
setUploading(false)
|
||||
setUploadProgress(0)
|
||||
fetchStudents()
|
||||
|
||||
if (fileInputRef.current) {
|
||||
fileInputRef.current.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
const handleDeleteStudent = async (studentId: string) => {
|
||||
if (!confirm('Studentenarbeit wirklich loeschen?')) return
|
||||
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/api/v1/students/${studentId}`, {
|
||||
method: 'DELETE',
|
||||
})
|
||||
|
||||
if (res.ok) {
|
||||
setStudents(prev => prev.filter(s => s.id !== studentId))
|
||||
} else {
|
||||
setError('Fehler beim Loeschen')
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to delete student:', err)
|
||||
setError('Fehler beim Loeschen')
|
||||
}
|
||||
}
|
||||
|
||||
const getGradeDisplay = (student: StudentWork) => {
|
||||
if (student.grade_points === undefined || student.grade_points === null) {
|
||||
return { points: '-', label: '-' }
|
||||
}
|
||||
const 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'
|
||||
}
|
||||
return {
|
||||
points: student.grade_points.toString(),
|
||||
label: labels[student.grade_points] || '-'
|
||||
}
|
||||
}
|
||||
|
||||
const stats = {
|
||||
total: students.length,
|
||||
completed: students.filter(s => s.status === 'COMPLETED').length,
|
||||
inProgress: students.filter(s => ['FIRST_EXAMINER', 'SECOND_EXAMINER', 'ANALYZING'].includes(s.status)).length,
|
||||
pending: students.filter(s => ['UPLOADED', 'OCR_PROCESSING', 'OCR_COMPLETE'].includes(s.status)).length,
|
||||
avgGrade: students.filter(s => s.grade_points !== undefined && s.grade_points !== null)
|
||||
.reduce((sum, s, _, arr) => sum + (s.grade_points || 0) / arr.length, 0).toFixed(1),
|
||||
}
|
||||
|
||||
if (loading && !klausur) {
|
||||
return (
|
||||
<div className="min-h-screen bg-slate-50 flex items-center justify-center">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-purple-600"></div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-slate-50">
|
||||
<div className="max-w-7xl mx-auto px-4 py-6">
|
||||
{/* Breadcrumb */}
|
||||
<div className="mb-4">
|
||||
<Link
|
||||
href="/education/klausur-korrektur"
|
||||
className="text-purple-600 hover:text-purple-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 zur Uebersicht
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* Page header */}
|
||||
<div className="mb-6">
|
||||
<h1 className="text-2xl font-bold text-slate-800">{klausur?.title || 'Klausur'}</h1>
|
||||
<p className="text-sm text-slate-500">{klausur?.subject} - {klausur?.year} | {students.length} Arbeiten</p>
|
||||
</div>
|
||||
|
||||
{/* Error display */}
|
||||
{error && (
|
||||
<div className="mb-4 p-4 bg-red-50 border border-red-200 rounded-lg flex items-center gap-3">
|
||||
<svg className="w-5 h-5 text-red-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
<span className="text-red-800">{error}</span>
|
||||
<button onClick={() => setError(null)} className="ml-auto text-red-600 hover:text-red-800">
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Statistics Cards */}
|
||||
<div className="grid grid-cols-2 md:grid-cols-5 gap-4 mb-6">
|
||||
<div className="bg-white rounded-lg border border-slate-200 p-4">
|
||||
<div className="text-2xl font-bold text-slate-800">{stats.total}</div>
|
||||
<div className="text-sm text-slate-500">Gesamt</div>
|
||||
</div>
|
||||
<div className="bg-white rounded-lg border border-slate-200 p-4">
|
||||
<div className="text-2xl font-bold text-green-600">{stats.completed}</div>
|
||||
<div className="text-sm text-slate-500">Fertig</div>
|
||||
</div>
|
||||
<div className="bg-white rounded-lg border border-slate-200 p-4">
|
||||
<div className="text-2xl font-bold text-orange-600">{stats.inProgress}</div>
|
||||
<div className="text-sm text-slate-500">In Arbeit</div>
|
||||
</div>
|
||||
<div className="bg-white rounded-lg border border-slate-200 p-4">
|
||||
<div className="text-2xl font-bold text-gray-600">{stats.pending}</div>
|
||||
<div className="text-sm text-slate-500">Ausstehend</div>
|
||||
</div>
|
||||
<div className="bg-white rounded-lg border border-slate-200 p-4">
|
||||
<div className="text-2xl font-bold text-purple-600">{stats.avgGrade}</div>
|
||||
<div className="text-sm text-slate-500">Durchschnitt Note</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Fairness Analysis Button */}
|
||||
{stats.completed >= 2 && (
|
||||
<div className="mb-6 flex flex-wrap gap-3">
|
||||
<Link
|
||||
href={`/education/klausur-korrektur/${klausurId}/fairness`}
|
||||
className="inline-flex items-center gap-2 px-4 py-3 bg-gradient-to-r from-purple-600 to-indigo-600 text-white rounded-lg hover:from-purple-700 hover:to-indigo-700 transition-all shadow-sm"
|
||||
>
|
||||
<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>
|
||||
Fairness-Analyse oeffnen
|
||||
<span className="text-xs bg-white/20 px-2 py-0.5 rounded-full">
|
||||
{stats.completed} bewertet
|
||||
</span>
|
||||
</Link>
|
||||
|
||||
<button
|
||||
onClick={exportOverviewPDF}
|
||||
disabled={exporting}
|
||||
className="inline-flex items-center gap-2 px-4 py-3 bg-white border border-slate-300 text-slate-700 rounded-lg hover:bg-slate-50 transition-all shadow-sm disabled:opacity-50"
|
||||
>
|
||||
<svg className="w-5 h-5" 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...' : 'Notenuebersicht PDF'}
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={exportAllGutachtenPDF}
|
||||
disabled={exporting}
|
||||
className="inline-flex items-center gap-2 px-4 py-3 bg-white border border-slate-300 text-slate-700 rounded-lg hover:bg-slate-50 transition-all shadow-sm disabled:opacity-50"
|
||||
>
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
|
||||
</svg>
|
||||
{exporting ? 'Exportiere...' : 'Alle Gutachten PDF'}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Upload Section */}
|
||||
<div className="bg-white rounded-lg border border-slate-200 p-6 mb-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-slate-800">Studentenarbeiten hochladen</h2>
|
||||
<p className="text-sm text-slate-500">PDF oder Bilder (JPG, PNG) der gescannten Arbeiten</p>
|
||||
</div>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
multiple
|
||||
accept=".pdf,.jpg,.jpeg,.png"
|
||||
onChange={handleFileUpload}
|
||||
className="hidden"
|
||||
id="file-upload"
|
||||
/>
|
||||
<label
|
||||
htmlFor="file-upload"
|
||||
className={`px-4 py-2 rounded-lg flex items-center gap-2 cursor-pointer ${
|
||||
uploading
|
||||
? 'bg-gray-100 text-gray-400 cursor-not-allowed'
|
||||
: 'bg-purple-600 text-white hover:bg-purple-700'
|
||||
}`}
|
||||
>
|
||||
{uploading ? (
|
||||
<>
|
||||
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white"></div>
|
||||
{uploadProgress}%
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<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>
|
||||
Dateien hochladen
|
||||
</>
|
||||
)}
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{uploading && (
|
||||
<div className="h-2 bg-slate-100 rounded-full overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-purple-600 rounded-full transition-all"
|
||||
style={{ width: `${uploadProgress}%` }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Students List */}
|
||||
<div className="bg-white rounded-lg border border-slate-200 overflow-hidden">
|
||||
<div className="p-4 border-b border-slate-200">
|
||||
<h2 className="text-lg font-semibold text-slate-800">Studentenarbeiten ({students.length})</h2>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center h-32">
|
||||
<div className="animate-spin rounded-full h-6 w-6 border-b-2 border-purple-600"></div>
|
||||
</div>
|
||||
) : students.length === 0 ? (
|
||||
<div className="p-8 text-center text-slate-500">
|
||||
<svg className="mx-auto h-12 w-12 text-slate-300 mb-3" 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>
|
||||
<p>Noch keine Arbeiten hochgeladen</p>
|
||||
<p className="text-sm">Laden Sie gescannte PDFs oder Bilder hoch</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="divide-y divide-slate-200">
|
||||
{students.map((student, index) => {
|
||||
const grade = getGradeDisplay(student)
|
||||
const status = statusConfig[student.status] || statusConfig.UPLOADED
|
||||
|
||||
return (
|
||||
<div
|
||||
key={student.id}
|
||||
className="p-4 hover:bg-slate-50 flex items-center gap-4"
|
||||
>
|
||||
<div className="w-8 h-8 rounded-full bg-slate-100 flex items-center justify-center text-sm font-medium text-slate-600">
|
||||
{index + 1}
|
||||
</div>
|
||||
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="font-medium text-slate-800 truncate">
|
||||
{student.anonym_id || `Arbeit ${index + 1}`}
|
||||
</div>
|
||||
<div className="text-sm text-slate-500 flex items-center gap-2">
|
||||
<span className={`px-2 py-0.5 rounded-full text-xs ${status.bg} ${status.color}`}>
|
||||
{status.label}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="text-center w-20">
|
||||
<div className="text-lg font-bold text-slate-800">{grade.points}</div>
|
||||
<div className="text-xs text-slate-500">{grade.label}</div>
|
||||
</div>
|
||||
|
||||
<div className="w-24">
|
||||
{student.criteria_scores && Object.keys(student.criteria_scores).length > 0 ? (
|
||||
<div className="flex gap-1">
|
||||
{['rechtschreibung', 'grammatik', 'inhalt', 'struktur', 'stil'].map(criterion => (
|
||||
<div
|
||||
key={criterion}
|
||||
className={`h-2 flex-1 rounded-full ${
|
||||
student.criteria_scores[criterion] !== undefined
|
||||
? 'bg-green-500'
|
||||
: 'bg-slate-200'
|
||||
}`}
|
||||
title={criterion}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-xs text-slate-400">Keine Bewertung</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Link
|
||||
href={`/education/klausur-korrektur/${klausurId}/${student.id}`}
|
||||
className="px-3 py-1.5 bg-purple-600 text-white text-sm rounded-lg hover:bg-purple-700"
|
||||
>
|
||||
Korrigieren
|
||||
</Link>
|
||||
<button
|
||||
onClick={() => handleDeleteStudent(student.id)}
|
||||
className="p-1.5 text-red-600 hover:bg-red-50 rounded-lg"
|
||||
title="Loeschen"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Fairness Check Button */}
|
||||
{students.filter(s => s.status === 'COMPLETED').length >= 3 && (
|
||||
<div className="mt-6 p-4 bg-blue-50 border border-blue-200 rounded-lg">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h3 className="font-semibold text-blue-800">Fairness-Check verfuegbar</h3>
|
||||
<p className="text-sm text-blue-600">
|
||||
Pruefen Sie die Bewertungen auf Konsistenz und Fairness
|
||||
</p>
|
||||
</div>
|
||||
<Link
|
||||
href={`/education/klausur-korrektur/${klausurId}/fairness`}
|
||||
className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700"
|
||||
>
|
||||
Fairness-Check starten
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,281 @@
|
||||
'use client'
|
||||
|
||||
/**
|
||||
* AnnotationLayer
|
||||
*
|
||||
* SVG overlay component for displaying and creating annotations on documents.
|
||||
* Renders positioned rectangles with color-coding by annotation type.
|
||||
*/
|
||||
|
||||
import { useState, useRef, useCallback } from 'react'
|
||||
import type { Annotation, AnnotationType, AnnotationPosition } from '../types'
|
||||
import { ANNOTATION_COLORS } from '../types'
|
||||
|
||||
interface AnnotationLayerProps {
|
||||
annotations: Annotation[]
|
||||
selectedTool: AnnotationType | null
|
||||
onCreateAnnotation: (position: AnnotationPosition, type: AnnotationType) => void
|
||||
onSelectAnnotation: (annotation: Annotation) => void
|
||||
selectedAnnotationId?: string
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
export default function AnnotationLayer({
|
||||
annotations,
|
||||
selectedTool,
|
||||
onCreateAnnotation,
|
||||
onSelectAnnotation,
|
||||
selectedAnnotationId,
|
||||
disabled = false,
|
||||
}: AnnotationLayerProps) {
|
||||
const svgRef = useRef<SVGSVGElement>(null)
|
||||
const [isDrawing, setIsDrawing] = useState(false)
|
||||
const [startPos, setStartPos] = useState<{ x: number; y: number } | null>(null)
|
||||
const [currentRect, setCurrentRect] = useState<AnnotationPosition | null>(null)
|
||||
|
||||
// Convert mouse position to percentage
|
||||
const getPercentPosition = useCallback((e: React.MouseEvent<SVGSVGElement>) => {
|
||||
if (!svgRef.current) return null
|
||||
|
||||
const rect = svgRef.current.getBoundingClientRect()
|
||||
const x = ((e.clientX - rect.left) / rect.width) * 100
|
||||
const y = ((e.clientY - rect.top) / rect.height) * 100
|
||||
|
||||
return { x: Math.max(0, Math.min(100, x)), y: Math.max(0, Math.min(100, y)) }
|
||||
}, [])
|
||||
|
||||
// Handle mouse down - start drawing
|
||||
const handleMouseDown = useCallback(
|
||||
(e: React.MouseEvent<SVGSVGElement>) => {
|
||||
if (disabled || !selectedTool) return
|
||||
|
||||
const pos = getPercentPosition(e)
|
||||
if (!pos) return
|
||||
|
||||
setIsDrawing(true)
|
||||
setStartPos(pos)
|
||||
setCurrentRect({ x: pos.x, y: pos.y, width: 0, height: 0 })
|
||||
},
|
||||
[disabled, selectedTool, getPercentPosition]
|
||||
)
|
||||
|
||||
// Handle mouse move - update rectangle
|
||||
const handleMouseMove = useCallback(
|
||||
(e: React.MouseEvent<SVGSVGElement>) => {
|
||||
if (!isDrawing || !startPos) return
|
||||
|
||||
const pos = getPercentPosition(e)
|
||||
if (!pos) return
|
||||
|
||||
const x = Math.min(startPos.x, pos.x)
|
||||
const y = Math.min(startPos.y, pos.y)
|
||||
const width = Math.abs(pos.x - startPos.x)
|
||||
const height = Math.abs(pos.y - startPos.y)
|
||||
|
||||
setCurrentRect({ x, y, width, height })
|
||||
},
|
||||
[isDrawing, startPos, getPercentPosition]
|
||||
)
|
||||
|
||||
// Handle mouse up - finish drawing
|
||||
const handleMouseUp = useCallback(() => {
|
||||
if (!isDrawing || !currentRect || !selectedTool) {
|
||||
setIsDrawing(false)
|
||||
setStartPos(null)
|
||||
setCurrentRect(null)
|
||||
return
|
||||
}
|
||||
|
||||
// Only create annotation if rectangle is large enough (min 1% x 0.5%)
|
||||
if (currentRect.width > 1 && currentRect.height > 0.5) {
|
||||
onCreateAnnotation(currentRect, selectedTool)
|
||||
}
|
||||
|
||||
setIsDrawing(false)
|
||||
setStartPos(null)
|
||||
setCurrentRect(null)
|
||||
}, [isDrawing, currentRect, selectedTool, onCreateAnnotation])
|
||||
|
||||
// Handle clicking on existing annotation
|
||||
const handleAnnotationClick = useCallback(
|
||||
(e: React.MouseEvent, annotation: Annotation) => {
|
||||
e.stopPropagation()
|
||||
onSelectAnnotation(annotation)
|
||||
},
|
||||
[onSelectAnnotation]
|
||||
)
|
||||
|
||||
return (
|
||||
<svg
|
||||
ref={svgRef}
|
||||
className={`absolute inset-0 w-full h-full ${
|
||||
selectedTool && !disabled ? 'cursor-crosshair' : 'cursor-default'
|
||||
}`}
|
||||
style={{ pointerEvents: disabled ? 'none' : 'auto' }}
|
||||
onMouseDown={handleMouseDown}
|
||||
onMouseMove={handleMouseMove}
|
||||
onMouseUp={handleMouseUp}
|
||||
onMouseLeave={handleMouseUp}
|
||||
>
|
||||
{/* SVG Defs for patterns */}
|
||||
<defs>
|
||||
{/* Wavy pattern for Rechtschreibung errors */}
|
||||
<pattern id="wavyPattern" patternUnits="userSpaceOnUse" width="10" height="4">
|
||||
<path
|
||||
d="M0 2 Q 2.5 0, 5 2 T 10 2"
|
||||
stroke="#dc2626"
|
||||
strokeWidth="1.5"
|
||||
fill="none"
|
||||
/>
|
||||
</pattern>
|
||||
{/* Straight underline pattern for Grammatik errors */}
|
||||
<pattern id="straightPattern" patternUnits="userSpaceOnUse" width="6" height="3">
|
||||
<line x1="0" y1="1.5" x2="6" y2="1.5" stroke="#2563eb" strokeWidth="1.5" />
|
||||
</pattern>
|
||||
</defs>
|
||||
|
||||
{/* Existing annotations */}
|
||||
{annotations.map((annotation) => {
|
||||
const isSelected = annotation.id === selectedAnnotationId
|
||||
const color = ANNOTATION_COLORS[annotation.type] || '#6b7280'
|
||||
const isRS = annotation.type === 'rechtschreibung'
|
||||
const isGram = annotation.type === 'grammatik'
|
||||
|
||||
return (
|
||||
<g key={annotation.id} onClick={(e) => handleAnnotationClick(e, annotation)}>
|
||||
{/* Background rectangle - different styles for RS/Gram */}
|
||||
{isRS || isGram ? (
|
||||
<>
|
||||
{/* Light highlight background */}
|
||||
<rect
|
||||
x={`${annotation.position.x}%`}
|
||||
y={`${annotation.position.y}%`}
|
||||
width={`${annotation.position.width}%`}
|
||||
height={`${annotation.position.height}%`}
|
||||
fill={color}
|
||||
fillOpacity={isSelected ? 0.25 : 0.15}
|
||||
className="cursor-pointer hover:fill-opacity-25 transition-all"
|
||||
/>
|
||||
{/* Underline - wavy for RS, straight for Gram */}
|
||||
<rect
|
||||
x={`${annotation.position.x}%`}
|
||||
y={`${annotation.position.y + annotation.position.height - 0.5}%`}
|
||||
width={`${annotation.position.width}%`}
|
||||
height="0.5%"
|
||||
fill={isRS ? 'url(#wavyPattern)' : color}
|
||||
stroke="none"
|
||||
/>
|
||||
{/* Border when selected */}
|
||||
{isSelected && (
|
||||
<rect
|
||||
x={`${annotation.position.x}%`}
|
||||
y={`${annotation.position.y}%`}
|
||||
width={`${annotation.position.width}%`}
|
||||
height={`${annotation.position.height}%`}
|
||||
fill="none"
|
||||
stroke={color}
|
||||
strokeWidth={2}
|
||||
strokeDasharray="4,2"
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
/* Standard rectangle for other annotation types */
|
||||
<rect
|
||||
x={`${annotation.position.x}%`}
|
||||
y={`${annotation.position.y}%`}
|
||||
width={`${annotation.position.width}%`}
|
||||
height={`${annotation.position.height}%`}
|
||||
fill={color}
|
||||
fillOpacity={0.2}
|
||||
stroke={color}
|
||||
strokeWidth={isSelected ? 3 : 2}
|
||||
strokeDasharray={annotation.severity === 'minor' ? '4,2' : undefined}
|
||||
className="cursor-pointer hover:fill-opacity-30 transition-all"
|
||||
rx="2"
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Type indicator icon (small circle in corner) */}
|
||||
<circle
|
||||
cx={`${annotation.position.x}%`}
|
||||
cy={`${annotation.position.y}%`}
|
||||
r="6"
|
||||
fill={color}
|
||||
stroke="white"
|
||||
strokeWidth="1"
|
||||
/>
|
||||
|
||||
{/* Type letter */}
|
||||
<text
|
||||
x={`${annotation.position.x}%`}
|
||||
y={`${annotation.position.y}%`}
|
||||
textAnchor="middle"
|
||||
dominantBaseline="middle"
|
||||
fill="white"
|
||||
fontSize="8"
|
||||
fontWeight="bold"
|
||||
style={{ pointerEvents: 'none' }}
|
||||
>
|
||||
{annotation.type.charAt(0).toUpperCase()}
|
||||
</text>
|
||||
|
||||
{/* Severity indicator (small dot) */}
|
||||
{annotation.severity === 'critical' && (
|
||||
<circle
|
||||
cx={`${annotation.position.x + annotation.position.width}%`}
|
||||
cy={`${annotation.position.y}%`}
|
||||
r="4"
|
||||
fill="#dc2626"
|
||||
stroke="white"
|
||||
strokeWidth="1"
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Selection indicator */}
|
||||
{isSelected && (
|
||||
<>
|
||||
{/* Corner handles */}
|
||||
{[
|
||||
{ cx: annotation.position.x, cy: annotation.position.y },
|
||||
{ cx: annotation.position.x + annotation.position.width, cy: annotation.position.y },
|
||||
{ cx: annotation.position.x, cy: annotation.position.y + annotation.position.height },
|
||||
{
|
||||
cx: annotation.position.x + annotation.position.width,
|
||||
cy: annotation.position.y + annotation.position.height,
|
||||
},
|
||||
].map((corner, i) => (
|
||||
<circle
|
||||
key={i}
|
||||
cx={`${corner.cx}%`}
|
||||
cy={`${corner.cy}%`}
|
||||
r="4"
|
||||
fill="white"
|
||||
stroke={color}
|
||||
strokeWidth="2"
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</g>
|
||||
)
|
||||
})}
|
||||
|
||||
{/* Currently drawing rectangle */}
|
||||
{currentRect && selectedTool && (
|
||||
<rect
|
||||
x={`${currentRect.x}%`}
|
||||
y={`${currentRect.y}%`}
|
||||
width={`${currentRect.width}%`}
|
||||
height={`${currentRect.height}%`}
|
||||
fill={ANNOTATION_COLORS[selectedTool]}
|
||||
fillOpacity={0.3}
|
||||
stroke={ANNOTATION_COLORS[selectedTool]}
|
||||
strokeWidth={2}
|
||||
strokeDasharray="5,5"
|
||||
rx="2"
|
||||
/>
|
||||
)}
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,267 @@
|
||||
'use client'
|
||||
|
||||
/**
|
||||
* AnnotationPanel
|
||||
*
|
||||
* Panel for viewing, editing, and managing annotations.
|
||||
* Shows a list of all annotations with options to edit text, change severity, or delete.
|
||||
*/
|
||||
|
||||
import { useState } from 'react'
|
||||
import type { Annotation, AnnotationType } from '../types'
|
||||
import { ANNOTATION_COLORS } from '../types'
|
||||
|
||||
interface AnnotationPanelProps {
|
||||
annotations: Annotation[]
|
||||
selectedAnnotation: Annotation | null
|
||||
onSelectAnnotation: (annotation: Annotation | null) => void
|
||||
onUpdateAnnotation: (id: string, updates: Partial<Annotation>) => void
|
||||
onDeleteAnnotation: (id: string) => void
|
||||
}
|
||||
|
||||
const SEVERITY_OPTIONS = [
|
||||
{ value: 'minor', label: 'Leicht', color: '#fbbf24' },
|
||||
{ value: 'major', label: 'Mittel', color: '#f97316' },
|
||||
{ value: 'critical', label: 'Schwer', color: '#dc2626' },
|
||||
] as const
|
||||
|
||||
const TYPE_LABELS: Record<AnnotationType, string> = {
|
||||
rechtschreibung: 'Rechtschreibung',
|
||||
grammatik: 'Grammatik',
|
||||
inhalt: 'Inhalt',
|
||||
struktur: 'Struktur',
|
||||
stil: 'Stil',
|
||||
comment: 'Kommentar',
|
||||
highlight: 'Markierung',
|
||||
}
|
||||
|
||||
export default function AnnotationPanel({
|
||||
annotations,
|
||||
selectedAnnotation,
|
||||
onSelectAnnotation,
|
||||
onUpdateAnnotation,
|
||||
onDeleteAnnotation,
|
||||
}: AnnotationPanelProps) {
|
||||
const [editingId, setEditingId] = useState<string | null>(null)
|
||||
const [editText, setEditText] = useState('')
|
||||
const [editSuggestion, setEditSuggestion] = useState('')
|
||||
|
||||
// Group annotations by type
|
||||
const groupedAnnotations = annotations.reduce(
|
||||
(acc, ann) => {
|
||||
if (!acc[ann.type]) {
|
||||
acc[ann.type] = []
|
||||
}
|
||||
acc[ann.type].push(ann)
|
||||
return acc
|
||||
},
|
||||
{} as Record<AnnotationType, Annotation[]>
|
||||
)
|
||||
|
||||
const handleEdit = (annotation: Annotation) => {
|
||||
setEditingId(annotation.id)
|
||||
setEditText(annotation.text)
|
||||
setEditSuggestion(annotation.suggestion || '')
|
||||
}
|
||||
|
||||
const handleSaveEdit = (id: string) => {
|
||||
onUpdateAnnotation(id, { text: editText, suggestion: editSuggestion || undefined })
|
||||
setEditingId(null)
|
||||
setEditText('')
|
||||
setEditSuggestion('')
|
||||
}
|
||||
|
||||
const handleCancelEdit = () => {
|
||||
setEditingId(null)
|
||||
setEditText('')
|
||||
setEditSuggestion('')
|
||||
}
|
||||
|
||||
if (annotations.length === 0) {
|
||||
return (
|
||||
<div className="p-4 text-center text-slate-500">
|
||||
<svg className="w-12 h-12 mx-auto mb-3 text-slate-300" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={1.5}
|
||||
d="M7 8h10M7 12h4m1 8l-4-4H5a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v8a2 2 0 01-2 2h-3l-4 4z"
|
||||
/>
|
||||
</svg>
|
||||
<p className="text-sm">Keine Annotationen vorhanden</p>
|
||||
<p className="text-xs mt-1">Waehlen Sie ein Werkzeug und markieren Sie Stellen im Dokument</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="h-full overflow-auto">
|
||||
{/* Summary */}
|
||||
<div className="p-3 border-b border-slate-200 bg-slate-50">
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="font-medium text-slate-700">{annotations.length} Annotationen</span>
|
||||
<div className="flex gap-2">
|
||||
{Object.entries(groupedAnnotations).map(([type, anns]) => (
|
||||
<span
|
||||
key={type}
|
||||
className="px-2 py-0.5 text-xs rounded-full text-white"
|
||||
style={{ backgroundColor: ANNOTATION_COLORS[type as AnnotationType] }}
|
||||
>
|
||||
{anns.length}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Annotations list by type */}
|
||||
<div className="divide-y divide-slate-100">
|
||||
{(Object.entries(groupedAnnotations) as [AnnotationType, Annotation[]][]).map(([type, anns]) => (
|
||||
<div key={type}>
|
||||
{/* Type header */}
|
||||
<div
|
||||
className="px-3 py-2 text-xs font-semibold text-white"
|
||||
style={{ backgroundColor: ANNOTATION_COLORS[type] }}
|
||||
>
|
||||
{TYPE_LABELS[type]} ({anns.length})
|
||||
</div>
|
||||
|
||||
{/* Annotations in this type */}
|
||||
{anns.map((annotation) => {
|
||||
const isSelected = selectedAnnotation?.id === annotation.id
|
||||
const isEditing = editingId === annotation.id
|
||||
const severityInfo = SEVERITY_OPTIONS.find((s) => s.value === annotation.severity)
|
||||
|
||||
return (
|
||||
<div
|
||||
key={annotation.id}
|
||||
className={`p-3 cursor-pointer transition-colors ${
|
||||
isSelected ? 'bg-blue-50 border-l-4 border-blue-500' : 'hover:bg-slate-50'
|
||||
}`}
|
||||
onClick={() => onSelectAnnotation(isSelected ? null : annotation)}
|
||||
>
|
||||
{isEditing ? (
|
||||
/* Edit mode */
|
||||
<div className="space-y-2" onClick={(e) => e.stopPropagation()}>
|
||||
<textarea
|
||||
value={editText}
|
||||
onChange={(e) => setEditText(e.target.value)}
|
||||
placeholder="Kommentar..."
|
||||
className="w-full p-2 text-sm border border-slate-300 rounded resize-none focus:ring-2 focus:ring-purple-500"
|
||||
rows={2}
|
||||
autoFocus
|
||||
/>
|
||||
|
||||
{(type === 'rechtschreibung' || type === 'grammatik') && (
|
||||
<input
|
||||
type="text"
|
||||
value={editSuggestion}
|
||||
onChange={(e) => setEditSuggestion(e.target.value)}
|
||||
placeholder="Korrekturvorschlag..."
|
||||
className="w-full p-2 text-sm border border-slate-300 rounded focus:ring-2 focus:ring-purple-500"
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => handleSaveEdit(annotation.id)}
|
||||
className="flex-1 py-1 text-xs bg-purple-600 text-white rounded hover:bg-purple-700"
|
||||
>
|
||||
Speichern
|
||||
</button>
|
||||
<button
|
||||
onClick={handleCancelEdit}
|
||||
className="flex-1 py-1 text-xs bg-slate-200 text-slate-700 rounded hover:bg-slate-300"
|
||||
>
|
||||
Abbrechen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
/* View mode */
|
||||
<>
|
||||
{/* Severity badge */}
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<span
|
||||
className="px-1.5 py-0.5 text-[10px] rounded text-white"
|
||||
style={{ backgroundColor: severityInfo?.color || '#6b7280' }}
|
||||
>
|
||||
{severityInfo?.label || 'Unbekannt'}
|
||||
</span>
|
||||
<span className="text-[10px] text-slate-400">Seite {annotation.page}</span>
|
||||
</div>
|
||||
|
||||
{/* Text */}
|
||||
{annotation.text && <p className="text-sm text-slate-700 mb-1">{annotation.text}</p>}
|
||||
|
||||
{/* Suggestion */}
|
||||
{annotation.suggestion && (
|
||||
<p className="text-xs text-green-700 bg-green-50 px-2 py-1 rounded mb-1">
|
||||
<span className="font-medium">Korrektur:</span> {annotation.suggestion}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Actions (only when selected) */}
|
||||
{isSelected && (
|
||||
<div className="flex gap-2 mt-2 pt-2 border-t border-slate-200">
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
handleEdit(annotation)
|
||||
}}
|
||||
className="flex-1 py-1 text-xs bg-slate-100 text-slate-700 rounded hover:bg-slate-200"
|
||||
>
|
||||
Bearbeiten
|
||||
</button>
|
||||
|
||||
{/* Severity buttons */}
|
||||
<div className="flex gap-1">
|
||||
{SEVERITY_OPTIONS.map((sev) => (
|
||||
<button
|
||||
key={sev.value}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
onUpdateAnnotation(annotation.id, { severity: sev.value })
|
||||
}}
|
||||
className={`w-6 h-6 rounded text-xs text-white font-bold ${
|
||||
annotation.severity === sev.value ? 'ring-2 ring-offset-1 ring-slate-400' : ''
|
||||
}`}
|
||||
style={{ backgroundColor: sev.color }}
|
||||
title={sev.label}
|
||||
>
|
||||
{sev.label[0]}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
if (confirm('Annotation loeschen?')) {
|
||||
onDeleteAnnotation(annotation.id)
|
||||
}
|
||||
}}
|
||||
className="px-2 py-1 text-xs bg-red-100 text-red-700 rounded hover:bg-red-200"
|
||||
>
|
||||
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
+139
@@ -0,0 +1,139 @@
|
||||
'use client'
|
||||
|
||||
/**
|
||||
* AnnotationToolbar
|
||||
*
|
||||
* Toolbar for selecting annotation tools and controlling the document viewer.
|
||||
*/
|
||||
|
||||
import type { AnnotationType } from '../types'
|
||||
import { ANNOTATION_COLORS } from '../types'
|
||||
|
||||
interface AnnotationToolbarProps {
|
||||
selectedTool: AnnotationType | null
|
||||
onSelectTool: (tool: AnnotationType | null) => void
|
||||
zoom: number
|
||||
onZoomChange: (zoom: number) => void
|
||||
annotationCounts: Record<AnnotationType, number>
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
const ANNOTATION_TOOLS: { type: AnnotationType; label: string; shortcut: string }[] = [
|
||||
{ type: 'rechtschreibung', label: 'Rechtschreibung', shortcut: 'R' },
|
||||
{ type: 'grammatik', label: 'Grammatik', shortcut: 'G' },
|
||||
{ type: 'inhalt', label: 'Inhalt', shortcut: 'I' },
|
||||
{ type: 'struktur', label: 'Struktur', shortcut: 'S' },
|
||||
{ type: 'stil', label: 'Stil', shortcut: 'T' },
|
||||
{ type: 'comment', label: 'Kommentar', shortcut: 'K' },
|
||||
]
|
||||
|
||||
export default function AnnotationToolbar({
|
||||
selectedTool,
|
||||
onSelectTool,
|
||||
zoom,
|
||||
onZoomChange,
|
||||
annotationCounts,
|
||||
disabled = false,
|
||||
}: AnnotationToolbarProps) {
|
||||
const handleToolClick = (type: AnnotationType) => {
|
||||
if (disabled) return
|
||||
onSelectTool(selectedTool === type ? null : type)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-3 border-b border-slate-200 flex items-center justify-between bg-slate-50">
|
||||
{/* Annotation tools */}
|
||||
<div className="flex items-center gap-1">
|
||||
<span className="text-xs text-slate-500 mr-2">Markieren:</span>
|
||||
{ANNOTATION_TOOLS.map(({ type, label, shortcut }) => {
|
||||
const isSelected = selectedTool === type
|
||||
const count = annotationCounts[type] || 0
|
||||
const color = ANNOTATION_COLORS[type]
|
||||
|
||||
return (
|
||||
<button
|
||||
key={type}
|
||||
onClick={() => handleToolClick(type)}
|
||||
disabled={disabled}
|
||||
className={`
|
||||
relative px-2 py-1.5 text-xs rounded border-2 transition-all
|
||||
${disabled ? 'opacity-50 cursor-not-allowed' : 'hover:opacity-80'}
|
||||
${isSelected ? 'ring-2 ring-offset-1 ring-slate-400' : ''}
|
||||
`}
|
||||
style={{
|
||||
borderColor: color,
|
||||
color: isSelected ? 'white' : color,
|
||||
backgroundColor: isSelected ? color : 'transparent',
|
||||
}}
|
||||
title={`${label} (${shortcut})`}
|
||||
>
|
||||
<span className="font-medium">{shortcut}</span>
|
||||
{count > 0 && (
|
||||
<span
|
||||
className="absolute -top-2 -right-2 w-4 h-4 text-[10px] rounded-full flex items-center justify-center text-white"
|
||||
style={{ backgroundColor: color }}
|
||||
>
|
||||
{count > 99 ? '99+' : count}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
|
||||
{/* Clear selection button */}
|
||||
{selectedTool && (
|
||||
<button
|
||||
onClick={() => onSelectTool(null)}
|
||||
className="ml-2 px-2 py-1 text-xs text-slate-500 hover:text-slate-700 hover:bg-slate-200 rounded"
|
||||
>
|
||||
<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>
|
||||
|
||||
{/* Mode indicator */}
|
||||
{selectedTool && (
|
||||
<div
|
||||
className="px-3 py-1 text-xs rounded-full text-white"
|
||||
style={{ backgroundColor: ANNOTATION_COLORS[selectedTool] }}
|
||||
>
|
||||
{ANNOTATION_TOOLS.find((t) => t.type === selectedTool)?.label || selectedTool}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Zoom controls */}
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => onZoomChange(Math.max(50, zoom - 10))}
|
||||
disabled={zoom <= 50}
|
||||
className="p-1 rounded hover:bg-slate-200 disabled:opacity-50"
|
||||
title="Verkleinern"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M20 12H4" />
|
||||
</svg>
|
||||
</button>
|
||||
<span className="text-sm w-12 text-center">{zoom}%</span>
|
||||
<button
|
||||
onClick={() => onZoomChange(Math.min(200, zoom + 10))}
|
||||
disabled={zoom >= 200}
|
||||
className="p-1 rounded hover:bg-slate-200 disabled:opacity-50"
|
||||
title="Vergroessern"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => onZoomChange(100)}
|
||||
className="px-2 py-1 text-xs rounded hover:bg-slate-200"
|
||||
title="Zuruecksetzen"
|
||||
>
|
||||
Fit
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
+279
@@ -0,0 +1,279 @@
|
||||
'use client'
|
||||
|
||||
/**
|
||||
* EHSuggestionPanel
|
||||
*
|
||||
* Panel for displaying Erwartungshorizont-based suggestions.
|
||||
* Uses RAG to find relevant passages from the linked EH.
|
||||
*/
|
||||
|
||||
import { useState, useCallback } from 'react'
|
||||
import type { AnnotationType } from '../types'
|
||||
import { ANNOTATION_COLORS } from '../types'
|
||||
|
||||
interface EHSuggestion {
|
||||
id: string
|
||||
eh_id: string
|
||||
eh_title: string
|
||||
text: string
|
||||
score: number
|
||||
criterion: string
|
||||
source_chunk_index: number
|
||||
decrypted: boolean
|
||||
}
|
||||
|
||||
interface EHSuggestionPanelProps {
|
||||
studentId: string
|
||||
klausurId: string
|
||||
hasEH: boolean
|
||||
apiBase: string
|
||||
onInsertSuggestion?: (text: string, criterion: string) => void
|
||||
}
|
||||
|
||||
const CRITERIA = [
|
||||
{ id: 'allgemein', label: 'Alle Kriterien' },
|
||||
{ id: 'inhalt', label: 'Inhalt', color: '#16a34a' },
|
||||
{ id: 'struktur', label: 'Struktur', color: '#9333ea' },
|
||||
{ id: 'stil', label: 'Stil', color: '#ea580c' },
|
||||
]
|
||||
|
||||
export default function EHSuggestionPanel({
|
||||
studentId,
|
||||
klausurId,
|
||||
hasEH,
|
||||
apiBase,
|
||||
onInsertSuggestion,
|
||||
}: EHSuggestionPanelProps) {
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [suggestions, setSuggestions] = useState<EHSuggestion[]>([])
|
||||
const [selectedCriterion, setSelectedCriterion] = useState<string>('allgemein')
|
||||
const [passphrase, setPassphrase] = useState('')
|
||||
const [needsPassphrase, setNeedsPassphrase] = useState(false)
|
||||
const [queryPreview, setQueryPreview] = useState<string | null>(null)
|
||||
|
||||
const fetchSuggestions = useCallback(async () => {
|
||||
try {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
|
||||
const res = await fetch(`${apiBase}/api/v1/students/${studentId}/eh-suggestions`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
criterion: selectedCriterion === 'allgemein' ? null : selectedCriterion,
|
||||
passphrase: passphrase || null,
|
||||
limit: 5,
|
||||
}),
|
||||
})
|
||||
|
||||
if (!res.ok) {
|
||||
const data = await res.json()
|
||||
throw new Error(data.detail || 'Fehler beim Laden der Vorschlaege')
|
||||
}
|
||||
|
||||
const data = await res.json()
|
||||
|
||||
if (data.needs_passphrase) {
|
||||
setNeedsPassphrase(true)
|
||||
setSuggestions([])
|
||||
setError(data.message)
|
||||
} else {
|
||||
setNeedsPassphrase(false)
|
||||
setSuggestions(data.suggestions || [])
|
||||
setQueryPreview(data.query_preview || null)
|
||||
|
||||
if (data.suggestions?.length === 0) {
|
||||
setError(data.message || 'Keine passenden Vorschlaege gefunden')
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch EH suggestions:', err)
|
||||
setError(err instanceof Error ? err.message : 'Unbekannter Fehler')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [apiBase, studentId, selectedCriterion, passphrase])
|
||||
|
||||
const handleInsert = (suggestion: EHSuggestion) => {
|
||||
if (onInsertSuggestion) {
|
||||
onInsertSuggestion(suggestion.text, suggestion.criterion)
|
||||
}
|
||||
}
|
||||
|
||||
if (!hasEH) {
|
||||
return (
|
||||
<div className="p-4 text-center">
|
||||
<div className="text-slate-400 mb-4">
|
||||
<svg className="w-12 h-12 mx-auto mb-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={1.5}
|
||||
d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"
|
||||
/>
|
||||
</svg>
|
||||
<p className="text-sm">Kein Erwartungshorizont verknuepft</p>
|
||||
<p className="text-xs mt-1">Laden Sie einen EH in der RAG-Verwaltung hoch</p>
|
||||
</div>
|
||||
<a
|
||||
href="/ai/rag"
|
||||
className="inline-block px-4 py-2 bg-purple-600 text-white text-sm rounded-lg hover:bg-purple-700"
|
||||
>
|
||||
Zur RAG-Verwaltung
|
||||
</a>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="h-full flex flex-col">
|
||||
{/* Criterion selector */}
|
||||
<div className="p-3 border-b border-slate-200 bg-slate-50">
|
||||
<div className="flex gap-1 flex-wrap">
|
||||
{CRITERIA.map((c) => (
|
||||
<button
|
||||
key={c.id}
|
||||
onClick={() => setSelectedCriterion(c.id)}
|
||||
className={`px-2 py-1 text-xs rounded transition-colors ${
|
||||
selectedCriterion === c.id
|
||||
? 'text-white'
|
||||
: 'bg-slate-200 text-slate-600 hover:bg-slate-300'
|
||||
}`}
|
||||
style={
|
||||
selectedCriterion === c.id
|
||||
? { backgroundColor: c.color || '#6366f1' }
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{c.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Passphrase input (if needed) */}
|
||||
{needsPassphrase && (
|
||||
<div className="p-3 bg-yellow-50 border-b border-yellow-200">
|
||||
<label className="block text-xs font-medium text-yellow-800 mb-1">
|
||||
EH-Passphrase (verschluesselt)
|
||||
</label>
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
type="password"
|
||||
value={passphrase}
|
||||
onChange={(e) => setPassphrase(e.target.value)}
|
||||
placeholder="Passphrase eingeben..."
|
||||
className="flex-1 px-2 py-1 text-sm border border-yellow-300 rounded focus:ring-2 focus:ring-yellow-500"
|
||||
/>
|
||||
<button
|
||||
onClick={fetchSuggestions}
|
||||
disabled={!passphrase}
|
||||
className="px-3 py-1 text-xs bg-yellow-600 text-white rounded hover:bg-yellow-700 disabled:opacity-50"
|
||||
>
|
||||
Laden
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Fetch button */}
|
||||
<div className="p-3 border-b border-slate-200">
|
||||
<button
|
||||
onClick={fetchSuggestions}
|
||||
disabled={loading}
|
||||
className="w-full py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 disabled:opacity-50 flex items-center justify-center gap-2"
|
||||
>
|
||||
{loading ? (
|
||||
<>
|
||||
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white"></div>
|
||||
Lade Vorschlaege...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"
|
||||
/>
|
||||
</svg>
|
||||
EH-Vorschlaege laden
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Query preview */}
|
||||
{queryPreview && (
|
||||
<div className="px-3 py-2 bg-slate-50 border-b border-slate-200">
|
||||
<div className="text-xs text-slate-500 mb-1">Basierend auf:</div>
|
||||
<div className="text-xs text-slate-700 italic truncate">"{queryPreview}"</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Error message */}
|
||||
{error && !needsPassphrase && (
|
||||
<div className="p-3 bg-red-50 border-b border-red-200">
|
||||
<p className="text-sm text-red-700">{error}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Suggestions list */}
|
||||
<div className="flex-1 overflow-auto">
|
||||
{suggestions.length === 0 && !loading && !error && (
|
||||
<div className="p-4 text-center text-slate-400 text-sm">
|
||||
Klicken Sie auf "EH-Vorschlaege laden" um passende Stellen aus dem Erwartungshorizont zu
|
||||
finden.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{suggestions.map((suggestion, idx) => (
|
||||
<div
|
||||
key={suggestion.id}
|
||||
className="p-3 border-b border-slate-100 hover:bg-slate-50 transition-colors"
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs font-medium text-slate-500">#{idx + 1}</span>
|
||||
<span
|
||||
className="px-1.5 py-0.5 text-[10px] rounded text-white"
|
||||
style={{
|
||||
backgroundColor:
|
||||
ANNOTATION_COLORS[suggestion.criterion as AnnotationType] || '#6366f1',
|
||||
}}
|
||||
>
|
||||
{suggestion.criterion}
|
||||
</span>
|
||||
<span className="text-[10px] text-slate-400">
|
||||
Relevanz: {Math.round(suggestion.score * 100)}%
|
||||
</span>
|
||||
</div>
|
||||
{!suggestion.decrypted && (
|
||||
<span className="text-[10px] text-yellow-600">Verschluesselt</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<p className="text-sm text-slate-700 mb-2 line-clamp-4">{suggestion.text}</p>
|
||||
|
||||
{/* Source */}
|
||||
<div className="flex items-center justify-between text-[10px] text-slate-400">
|
||||
<span>Quelle: {suggestion.eh_title}</span>
|
||||
{onInsertSuggestion && suggestion.decrypted && (
|
||||
<button
|
||||
onClick={() => handleInsert(suggestion)}
|
||||
className="px-2 py-1 bg-purple-100 text-purple-700 rounded hover:bg-purple-200"
|
||||
>
|
||||
Im Gutachten verwenden
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
export { default as AnnotationLayer } from './AnnotationLayer'
|
||||
export { default as AnnotationPanel } from './AnnotationPanel'
|
||||
export { default as AnnotationToolbar } from './AnnotationToolbar'
|
||||
export { default as EHSuggestionPanel } from './EHSuggestionPanel'
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,195 @@
|
||||
// TypeScript Interfaces für Klausur-Korrektur
|
||||
|
||||
export interface Klausur {
|
||||
id: string
|
||||
title: string
|
||||
subject: string
|
||||
year: number
|
||||
semester: string
|
||||
modus: 'abitur' | 'vorabitur'
|
||||
eh_id?: string
|
||||
created_at: string
|
||||
student_count?: number
|
||||
completed_count?: number
|
||||
status?: 'draft' | 'in_progress' | 'completed'
|
||||
}
|
||||
|
||||
export interface StudentWork {
|
||||
id: string
|
||||
klausur_id: string
|
||||
anonym_id: string
|
||||
file_path: string
|
||||
file_type: 'pdf' | 'image'
|
||||
ocr_text: string
|
||||
criteria_scores: CriteriaScores
|
||||
gutachten: string
|
||||
status: StudentStatus
|
||||
raw_points: number
|
||||
grade_points: number
|
||||
grade_label?: string
|
||||
created_at: string
|
||||
examiner_id?: string
|
||||
second_examiner_id?: string
|
||||
second_examiner_grade?: number
|
||||
}
|
||||
|
||||
export type StudentStatus =
|
||||
| 'UPLOADED'
|
||||
| 'OCR_PROCESSING'
|
||||
| 'OCR_COMPLETE'
|
||||
| 'ANALYZING'
|
||||
| 'FIRST_EXAMINER'
|
||||
| 'SECOND_EXAMINER'
|
||||
| 'COMPLETED'
|
||||
| 'ERROR'
|
||||
|
||||
export interface CriteriaScores {
|
||||
rechtschreibung?: number
|
||||
grammatik?: number
|
||||
inhalt?: number
|
||||
struktur?: number
|
||||
stil?: number
|
||||
[key: string]: number | undefined
|
||||
}
|
||||
|
||||
export interface Criterion {
|
||||
id: string
|
||||
name: string
|
||||
weight: number
|
||||
description?: string
|
||||
}
|
||||
|
||||
export interface GradeInfo {
|
||||
thresholds: Record<number, number>
|
||||
labels: Record<number, string>
|
||||
criteria: Record<string, Criterion>
|
||||
}
|
||||
|
||||
export interface Annotation {
|
||||
id: string
|
||||
student_work_id: string
|
||||
page: number
|
||||
position: AnnotationPosition
|
||||
type: AnnotationType
|
||||
text: string
|
||||
severity: 'minor' | 'major' | 'critical'
|
||||
suggestion?: string
|
||||
created_by: string
|
||||
created_at: string
|
||||
role: 'first_examiner' | 'second_examiner'
|
||||
linked_criterion?: string
|
||||
}
|
||||
|
||||
export interface AnnotationPosition {
|
||||
x: number // Prozent (0-100)
|
||||
y: number // Prozent (0-100)
|
||||
width: number // Prozent (0-100)
|
||||
height: number // Prozent (0-100)
|
||||
}
|
||||
|
||||
export type AnnotationType =
|
||||
| 'rechtschreibung'
|
||||
| 'grammatik'
|
||||
| 'inhalt'
|
||||
| 'struktur'
|
||||
| 'stil'
|
||||
| 'comment'
|
||||
| 'highlight'
|
||||
|
||||
export interface FairnessAnalysis {
|
||||
klausur_id: string
|
||||
student_count: number
|
||||
average_grade: number
|
||||
std_deviation: number
|
||||
spread: number
|
||||
outliers: OutlierInfo[]
|
||||
criteria_analysis: Record<string, CriteriaStats>
|
||||
fairness_score: number
|
||||
warnings: string[]
|
||||
}
|
||||
|
||||
export interface OutlierInfo {
|
||||
student_id: string
|
||||
anonym_id: string
|
||||
grade_points: number
|
||||
deviation: number
|
||||
reason: string
|
||||
}
|
||||
|
||||
export interface CriteriaStats {
|
||||
min: number
|
||||
max: number
|
||||
average: number
|
||||
std_deviation: number
|
||||
}
|
||||
|
||||
export interface EHSuggestion {
|
||||
criterion: string
|
||||
excerpt: string
|
||||
relevance_score: number
|
||||
source_chunk_id: string
|
||||
}
|
||||
|
||||
export interface GutachtenSection {
|
||||
title: string
|
||||
content: string
|
||||
evidence_links?: string[]
|
||||
}
|
||||
|
||||
export interface Gutachten {
|
||||
einleitung: string
|
||||
hauptteil: string
|
||||
fazit: string
|
||||
staerken: string[]
|
||||
schwaechen: string[]
|
||||
generated_at?: string
|
||||
}
|
||||
|
||||
// API Response Types
|
||||
export interface KlausurenResponse {
|
||||
klausuren: Klausur[]
|
||||
total: number
|
||||
}
|
||||
|
||||
export interface StudentsResponse {
|
||||
students: StudentWork[]
|
||||
total: number
|
||||
}
|
||||
|
||||
export interface AnnotationsResponse {
|
||||
annotations: Annotation[]
|
||||
}
|
||||
|
||||
// Color mapping for annotation types
|
||||
export const ANNOTATION_COLORS: Record<AnnotationType, string> = {
|
||||
rechtschreibung: '#dc2626', // Red
|
||||
grammatik: '#2563eb', // Blue
|
||||
inhalt: '#16a34a', // Green
|
||||
struktur: '#9333ea', // Purple
|
||||
stil: '#ea580c', // Orange
|
||||
comment: '#6b7280', // Gray
|
||||
highlight: '#eab308', // Yellow
|
||||
}
|
||||
|
||||
// Status colors
|
||||
export const STATUS_COLORS: Record<StudentStatus, string> = {
|
||||
UPLOADED: '#6b7280',
|
||||
OCR_PROCESSING: '#eab308',
|
||||
OCR_COMPLETE: '#3b82f6',
|
||||
ANALYZING: '#8b5cf6',
|
||||
FIRST_EXAMINER: '#f97316',
|
||||
SECOND_EXAMINER: '#06b6d4',
|
||||
COMPLETED: '#22c55e',
|
||||
ERROR: '#ef4444',
|
||||
}
|
||||
|
||||
export const STATUS_LABELS: Record<StudentStatus, string> = {
|
||||
UPLOADED: 'Hochgeladen',
|
||||
OCR_PROCESSING: 'OCR laeuft',
|
||||
OCR_COMPLETE: 'OCR fertig',
|
||||
ANALYZING: 'Analyse laeuft',
|
||||
FIRST_EXAMINER: 'Erstkorrektur',
|
||||
SECOND_EXAMINER: 'Zweitkorrektur',
|
||||
COMPLETED: 'Abgeschlossen',
|
||||
ERROR: 'Fehler',
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
'use client'
|
||||
|
||||
import { getCategoryById } from '@/lib/navigation'
|
||||
import { ModuleCard } from '@/components/common/ModuleCard'
|
||||
import { PagePurpose } from '@/components/common/PagePurpose'
|
||||
|
||||
export default function EducationPage() {
|
||||
const category = getCategoryById('education')
|
||||
|
||||
if (!category) {
|
||||
return <div>Kategorie nicht gefunden</div>
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* Page Purpose */}
|
||||
<PagePurpose
|
||||
title={category.name}
|
||||
purpose="Diese Kategorie umfasst Module fuer Bildungsdokumente. Hier verwalten Sie Crawler fuer Lehrplaene, Erlasse und amtliche Bildungsquellen."
|
||||
audience={['Content Manager', 'Entwickler']}
|
||||
architecture={{
|
||||
services: ['edu-search-service (Go)'],
|
||||
databases: ['PostgreSQL', 'OpenSearch'],
|
||||
}}
|
||||
collapsible={true}
|
||||
defaultCollapsed={false}
|
||||
/>
|
||||
|
||||
{/* Stats */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 mb-8">
|
||||
<div className="bg-white rounded-xl border border-slate-200 p-4">
|
||||
<div className="text-3xl font-bold text-blue-600">2</div>
|
||||
<div className="text-sm text-slate-500">Aktive Crawler</div>
|
||||
<div className="text-xs text-slate-400">NiBiS, KMK</div>
|
||||
</div>
|
||||
<div className="bg-white rounded-xl border border-slate-200 p-4">
|
||||
<div className="text-3xl font-bold text-blue-600">16</div>
|
||||
<div className="text-sm text-slate-500">Bundeslaender</div>
|
||||
<div className="text-xs text-slate-400">Geplant</div>
|
||||
</div>
|
||||
<div className="bg-white rounded-xl border border-slate-200 p-4">
|
||||
<div className="text-3xl font-bold text-green-600">0</div>
|
||||
<div className="text-sm text-slate-500">Personendaten</div>
|
||||
<div className="text-xs text-green-500">Datenschutz-konform</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Modules Grid */}
|
||||
<h2 className="text-lg font-semibold text-slate-900 mb-4">Module</h2>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{category.modules.map((module) => (
|
||||
<ModuleCard key={module.id} module={module} category={category} />
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Info Section */}
|
||||
<div className="mt-8 bg-blue-50 border border-blue-200 rounded-xl p-6">
|
||||
<h3 className="font-semibold text-blue-800 flex items-center gap-2">
|
||||
<span>📚</span>
|
||||
Bildungsdokumente
|
||||
</h3>
|
||||
<p className="text-sm text-blue-700 mt-2">
|
||||
Das System crawlt ausschliesslich oeffentliche Bildungsdokumente (Lehrplaene, Erlasse, Beschluesse).
|
||||
<strong> Keine personenbezogenen Daten</strong> werden erfasst oder gespeichert.
|
||||
Alle Crawler respektieren robots.txt und verwenden Rate-Limiting.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Compliance Note */}
|
||||
<div className="mt-4 bg-green-50 border border-green-200 rounded-xl p-6">
|
||||
<h3 className="font-semibold text-green-800 flex items-center gap-2">
|
||||
<span>✅</span>
|
||||
Datenschutz-Hinweis
|
||||
</h3>
|
||||
<p className="text-sm text-green-700 mt-2">
|
||||
Dieses Modul verarbeitet <strong>keine personenbezogenen Daten</strong>.
|
||||
Es werden ausschliesslich amtliche Dokumente und Metadaten aus oeffentlichen Quellen indexiert.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
'use client'
|
||||
|
||||
/**
|
||||
* Zeugnisse-Crawler Page
|
||||
* Verwaltet Zeugnis-Strukturen und -Vorlagen
|
||||
*/
|
||||
|
||||
import { PagePurpose } from '@/components/common/PagePurpose'
|
||||
import { getModuleByHref } from '@/lib/navigation'
|
||||
import { FileText, Upload, Settings, Database, RefreshCw } from 'lucide-react'
|
||||
|
||||
export default function ZeugnisseCrawlerPage() {
|
||||
const moduleInfo = getModuleByHref('/education/zeugnisse-crawler')
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{moduleInfo && (
|
||||
<PagePurpose
|
||||
title={moduleInfo.module.name}
|
||||
purpose={moduleInfo.module.purpose}
|
||||
audience={moduleInfo.module.audience}
|
||||
collapsible={true}
|
||||
defaultCollapsed={true}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Stats */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
|
||||
<div className="bg-white rounded-xl border border-slate-200 p-4">
|
||||
<div className="text-3xl font-bold text-blue-600">16</div>
|
||||
<div className="text-sm text-slate-500">Bundeslaender</div>
|
||||
</div>
|
||||
<div className="bg-white rounded-xl border border-slate-200 p-4">
|
||||
<div className="text-3xl font-bold text-green-600">48</div>
|
||||
<div className="text-sm text-slate-500">Zeugnis-Vorlagen</div>
|
||||
</div>
|
||||
<div className="bg-white rounded-xl border border-slate-200 p-4">
|
||||
<div className="text-3xl font-bold text-purple-600">12</div>
|
||||
<div className="text-sm text-slate-500">Schulformen</div>
|
||||
</div>
|
||||
<div className="bg-white rounded-xl border border-slate-200 p-4">
|
||||
<div className="text-3xl font-bold text-orange-600">156</div>
|
||||
<div className="text-sm text-slate-500">Felder erkannt</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Main Content */}
|
||||
<div className="bg-white rounded-xl border border-slate-200 p-6">
|
||||
<h2 className="text-lg font-semibold text-slate-900 mb-4">Zeugnis-Strukturen</h2>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{/* Upload Card */}
|
||||
<div className="border border-dashed border-slate-300 rounded-xl p-6 text-center hover:border-blue-500 hover:bg-blue-50/50 transition-colors cursor-pointer">
|
||||
<Upload className="w-10 h-10 mx-auto mb-3 text-slate-400" />
|
||||
<div className="font-medium text-slate-700">Zeugnis hochladen</div>
|
||||
<div className="text-sm text-slate-500 mt-1">PDF oder Bild</div>
|
||||
</div>
|
||||
|
||||
{/* Niedersachsen */}
|
||||
<div className="border border-slate-200 rounded-xl p-4 hover:shadow-md transition-shadow">
|
||||
<div className="flex items-center gap-3 mb-3">
|
||||
<FileText className="w-8 h-8 text-blue-600" />
|
||||
<div>
|
||||
<div className="font-medium text-slate-900">Niedersachsen</div>
|
||||
<div className="text-xs text-slate-500">12 Vorlagen</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
<span className="px-2 py-0.5 bg-slate-100 text-slate-600 text-xs rounded">Grundschule</span>
|
||||
<span className="px-2 py-0.5 bg-slate-100 text-slate-600 text-xs rounded">Gymnasium</span>
|
||||
<span className="px-2 py-0.5 bg-slate-100 text-slate-600 text-xs rounded">IGS</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Bayern */}
|
||||
<div className="border border-slate-200 rounded-xl p-4 hover:shadow-md transition-shadow">
|
||||
<div className="flex items-center gap-3 mb-3">
|
||||
<FileText className="w-8 h-8 text-blue-600" />
|
||||
<div>
|
||||
<div className="font-medium text-slate-900">Bayern</div>
|
||||
<div className="text-xs text-slate-500">10 Vorlagen</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
<span className="px-2 py-0.5 bg-slate-100 text-slate-600 text-xs rounded">Grundschule</span>
|
||||
<span className="px-2 py-0.5 bg-slate-100 text-slate-600 text-xs rounded">Gymnasium</span>
|
||||
<span className="px-2 py-0.5 bg-slate-100 text-slate-600 text-xs rounded">Realschule</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* NRW */}
|
||||
<div className="border border-slate-200 rounded-xl p-4 hover:shadow-md transition-shadow">
|
||||
<div className="flex items-center gap-3 mb-3">
|
||||
<FileText className="w-8 h-8 text-blue-600" />
|
||||
<div>
|
||||
<div className="font-medium text-slate-900">Nordrhein-Westfalen</div>
|
||||
<div className="text-xs text-slate-500">14 Vorlagen</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
<span className="px-2 py-0.5 bg-slate-100 text-slate-600 text-xs rounded">Grundschule</span>
|
||||
<span className="px-2 py-0.5 bg-slate-100 text-slate-600 text-xs rounded">Gesamtschule</span>
|
||||
<span className="px-2 py-0.5 bg-slate-100 text-slate-600 text-xs rounded">Gymnasium</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Baden-Württemberg */}
|
||||
<div className="border border-slate-200 rounded-xl p-4 hover:shadow-md transition-shadow">
|
||||
<div className="flex items-center gap-3 mb-3">
|
||||
<FileText className="w-8 h-8 text-blue-600" />
|
||||
<div>
|
||||
<div className="font-medium text-slate-900">Baden-Wuerttemberg</div>
|
||||
<div className="text-xs text-slate-500">8 Vorlagen</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
<span className="px-2 py-0.5 bg-slate-100 text-slate-600 text-xs rounded">Grundschule</span>
|
||||
<span className="px-2 py-0.5 bg-slate-100 text-slate-600 text-xs rounded">Gymnasium</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Weitere */}
|
||||
<div className="border border-slate-200 rounded-xl p-4 hover:shadow-md transition-shadow bg-slate-50">
|
||||
<div className="flex items-center gap-3 mb-3">
|
||||
<Database className="w-8 h-8 text-slate-400" />
|
||||
<div>
|
||||
<div className="font-medium text-slate-700">Weitere Bundeslaender</div>
|
||||
<div className="text-xs text-slate-500">4 Vorlagen</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-sm text-slate-500">
|
||||
Hessen, Sachsen, Berlin, Hamburg...
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Crawler Section */}
|
||||
<div className="bg-white rounded-xl border border-slate-200 p-6">
|
||||
<h3 className="text-lg font-semibold text-slate-900 mb-4 flex items-center gap-2">
|
||||
<RefreshCw className="w-5 h-5" />
|
||||
Crawler-Status
|
||||
</h3>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div className="border border-slate-200 rounded-lg p-4">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="font-medium">Schulportal NI</span>
|
||||
<span className="px-2 py-0.5 bg-green-100 text-green-700 text-xs rounded-full">Aktiv</span>
|
||||
</div>
|
||||
<div className="text-sm text-slate-500">Letzter Crawl: vor 2 Stunden</div>
|
||||
</div>
|
||||
<div className="border border-slate-200 rounded-lg p-4">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="font-medium">KMK Vorlagen</span>
|
||||
<span className="px-2 py-0.5 bg-green-100 text-green-700 text-xs rounded-full">Aktiv</span>
|
||||
</div>
|
||||
<div className="text-sm text-slate-500">Letzter Crawl: vor 1 Tag</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Info Box */}
|
||||
<div className="bg-blue-50 border border-blue-200 rounded-xl p-6">
|
||||
<h3 className="font-semibold text-blue-800 flex items-center gap-2">
|
||||
<Settings className="w-5 h-5" />
|
||||
Verwandte Module
|
||||
</h3>
|
||||
<div className="mt-3 grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<a href="/education/edu-search" className="block p-3 bg-white rounded-lg hover:shadow-md transition-shadow">
|
||||
<div className="font-medium text-slate-900">Education Search</div>
|
||||
<div className="text-sm text-slate-500">Bildungsdokumente durchsuchen</div>
|
||||
</a>
|
||||
<a href="/ai/rag-pipeline" className="block p-3 bg-white rounded-lg hover:shadow-md transition-shadow">
|
||||
<div className="font-medium text-slate-900">RAG Pipeline</div>
|
||||
<div className="text-sm text-slate-500">Dokumente indexieren</div>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,391 @@
|
||||
'use client'
|
||||
|
||||
/**
|
||||
* GPU Infrastructure Admin Page
|
||||
*
|
||||
* vast.ai GPU Management for LLM Processing
|
||||
*/
|
||||
|
||||
import { useEffect, useState, useCallback } from 'react'
|
||||
import { PagePurpose } from '@/components/common/PagePurpose'
|
||||
|
||||
interface VastStatus {
|
||||
instance_id: number | null
|
||||
status: string
|
||||
gpu_name: string | null
|
||||
dph_total: number | null
|
||||
endpoint_base_url: string | null
|
||||
last_activity: string | null
|
||||
auto_shutdown_in_minutes: number | null
|
||||
total_runtime_hours: number | null
|
||||
total_cost_usd: number | null
|
||||
account_credit: number | null
|
||||
account_total_spend: number | null
|
||||
session_runtime_minutes: number | null
|
||||
session_cost_usd: number | null
|
||||
message: string | null
|
||||
error?: string
|
||||
}
|
||||
|
||||
export default function GPUInfrastructurePage() {
|
||||
const [status, setStatus] = useState<VastStatus | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [actionLoading, setActionLoading] = useState<string | null>(null)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [message, setMessage] = useState<string | null>(null)
|
||||
|
||||
const API_PROXY = '/api/admin/gpu'
|
||||
|
||||
const fetchStatus = useCallback(async () => {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
|
||||
try {
|
||||
const response = await fetch(API_PROXY)
|
||||
const data = await response.json()
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(data.error || `HTTP ${response.status}`)
|
||||
}
|
||||
|
||||
setStatus(data)
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Verbindungsfehler')
|
||||
setStatus({
|
||||
instance_id: null,
|
||||
status: 'error',
|
||||
gpu_name: null,
|
||||
dph_total: null,
|
||||
endpoint_base_url: null,
|
||||
last_activity: null,
|
||||
auto_shutdown_in_minutes: null,
|
||||
total_runtime_hours: null,
|
||||
total_cost_usd: null,
|
||||
account_credit: null,
|
||||
account_total_spend: null,
|
||||
session_runtime_minutes: null,
|
||||
session_cost_usd: null,
|
||||
message: 'Verbindung fehlgeschlagen'
|
||||
})
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
fetchStatus()
|
||||
}, [fetchStatus])
|
||||
|
||||
useEffect(() => {
|
||||
const interval = setInterval(fetchStatus, 30000)
|
||||
return () => clearInterval(interval)
|
||||
}, [fetchStatus])
|
||||
|
||||
const powerOn = async () => {
|
||||
setActionLoading('on')
|
||||
setError(null)
|
||||
setMessage(null)
|
||||
|
||||
try {
|
||||
const response = await fetch(API_PROXY, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ action: 'on' }),
|
||||
})
|
||||
|
||||
const data = await response.json()
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(data.error || data.detail || 'Aktion fehlgeschlagen')
|
||||
}
|
||||
|
||||
setMessage('Start angefordert')
|
||||
setTimeout(fetchStatus, 3000)
|
||||
setTimeout(fetchStatus, 10000)
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Fehler beim Starten')
|
||||
fetchStatus()
|
||||
} finally {
|
||||
setActionLoading(null)
|
||||
}
|
||||
}
|
||||
|
||||
const powerOff = async () => {
|
||||
setActionLoading('off')
|
||||
setError(null)
|
||||
setMessage(null)
|
||||
|
||||
try {
|
||||
const response = await fetch(API_PROXY, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ action: 'off' }),
|
||||
})
|
||||
|
||||
const data = await response.json()
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(data.error || data.detail || 'Aktion fehlgeschlagen')
|
||||
}
|
||||
|
||||
setMessage('Stop angefordert')
|
||||
setTimeout(fetchStatus, 3000)
|
||||
setTimeout(fetchStatus, 10000)
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Fehler beim Stoppen')
|
||||
fetchStatus()
|
||||
} finally {
|
||||
setActionLoading(null)
|
||||
}
|
||||
}
|
||||
|
||||
const getStatusBadge = (s: string) => {
|
||||
const baseClasses = 'px-3 py-1 rounded-full text-sm font-semibold uppercase'
|
||||
switch (s) {
|
||||
case 'running':
|
||||
return `${baseClasses} bg-green-100 text-green-800`
|
||||
case 'stopped':
|
||||
case 'exited':
|
||||
return `${baseClasses} bg-red-100 text-red-800`
|
||||
case 'loading':
|
||||
case 'scheduling':
|
||||
case 'creating':
|
||||
case 'starting...':
|
||||
case 'stopping...':
|
||||
return `${baseClasses} bg-yellow-100 text-yellow-800`
|
||||
default:
|
||||
return `${baseClasses} bg-slate-100 text-slate-600`
|
||||
}
|
||||
}
|
||||
|
||||
const getCreditColor = (credit: number | null) => {
|
||||
if (credit === null) return 'text-slate-500'
|
||||
if (credit < 5) return 'text-red-600'
|
||||
if (credit < 15) return 'text-yellow-600'
|
||||
return 'text-green-600'
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* Page Purpose */}
|
||||
<PagePurpose
|
||||
title="GPU Infrastruktur"
|
||||
purpose="Verwalten Sie die vast.ai GPU-Instanzen fuer LLM-Verarbeitung und OCR. Starten/Stoppen Sie GPUs bei Bedarf und ueberwachen Sie Kosten in Echtzeit."
|
||||
audience={['DevOps', 'Entwickler', 'System-Admins']}
|
||||
architecture={{
|
||||
services: ['vast.ai API', 'Ollama', 'VLLM'],
|
||||
databases: ['PostgreSQL (Logs)'],
|
||||
}}
|
||||
relatedPages={[
|
||||
{ name: 'LLM Vergleich', href: '/ai/llm-compare', description: 'KI-Provider testen' },
|
||||
{ name: 'Security', href: '/infrastructure/security', description: 'DevSecOps Dashboard' },
|
||||
{ name: 'Builds', href: '/infrastructure/builds', description: 'CI/CD Pipeline' },
|
||||
]}
|
||||
collapsible={true}
|
||||
defaultCollapsed={true}
|
||||
/>
|
||||
|
||||
{/* Status Cards */}
|
||||
<div className="bg-white rounded-xl border border-slate-200 p-6 mb-6">
|
||||
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-6 gap-6">
|
||||
<div>
|
||||
<div className="text-sm text-slate-500 mb-2">Status</div>
|
||||
{loading ? (
|
||||
<span className="px-3 py-1 rounded-full text-sm font-semibold bg-slate-100 text-slate-600">
|
||||
Laden...
|
||||
</span>
|
||||
) : (
|
||||
<span className={getStatusBadge(
|
||||
actionLoading === 'on' ? 'starting...' :
|
||||
actionLoading === 'off' ? 'stopping...' :
|
||||
status?.status || 'unknown'
|
||||
)}>
|
||||
{actionLoading === 'on' ? 'starting...' :
|
||||
actionLoading === 'off' ? 'stopping...' :
|
||||
status?.status || 'unbekannt'}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="text-sm text-slate-500 mb-2">GPU</div>
|
||||
<div className="font-semibold text-slate-900">
|
||||
{status?.gpu_name || '-'}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="text-sm text-slate-500 mb-2">Kosten/h</div>
|
||||
<div className="font-semibold text-slate-900">
|
||||
{status?.dph_total ? `$${status.dph_total.toFixed(3)}` : '-'}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="text-sm text-slate-500 mb-2">Auto-Stop</div>
|
||||
<div className="font-semibold text-slate-900">
|
||||
{status && status.auto_shutdown_in_minutes !== null
|
||||
? `${status.auto_shutdown_in_minutes} min`
|
||||
: '-'}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="text-sm text-slate-500 mb-2">Budget</div>
|
||||
<div className={`font-bold text-lg ${getCreditColor(status?.account_credit ?? null)}`}>
|
||||
{status && status.account_credit !== null
|
||||
? `$${status.account_credit.toFixed(2)}`
|
||||
: '-'}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="text-sm text-slate-500 mb-2">Session</div>
|
||||
<div className="font-semibold text-slate-900">
|
||||
{status && status.session_runtime_minutes !== null && status.session_cost_usd !== null
|
||||
? `${Math.round(status.session_runtime_minutes)} min / $${status.session_cost_usd.toFixed(3)}`
|
||||
: '-'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Buttons */}
|
||||
<div className="flex items-center gap-4 mt-6 pt-6 border-t border-slate-200">
|
||||
<button
|
||||
onClick={powerOn}
|
||||
disabled={actionLoading !== null || status?.status === 'running'}
|
||||
className="px-6 py-2 bg-orange-600 text-white rounded-lg font-medium hover:bg-orange-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
Starten
|
||||
</button>
|
||||
<button
|
||||
onClick={powerOff}
|
||||
disabled={actionLoading !== null || status?.status !== 'running'}
|
||||
className="px-6 py-2 bg-red-600 text-white rounded-lg font-medium hover:bg-red-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
Stoppen
|
||||
</button>
|
||||
<button
|
||||
onClick={fetchStatus}
|
||||
disabled={loading}
|
||||
className="px-4 py-2 border border-slate-300 text-slate-700 rounded-lg font-medium hover:bg-slate-50 disabled:opacity-50 transition-colors"
|
||||
>
|
||||
{loading ? 'Aktualisiere...' : 'Aktualisieren'}
|
||||
</button>
|
||||
|
||||
{message && (
|
||||
<span className="ml-4 text-sm text-green-600 font-medium">{message}</span>
|
||||
)}
|
||||
{error && (
|
||||
<span className="ml-4 text-sm text-red-600 font-medium">{error}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Extended Stats */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-6">
|
||||
<div className="bg-white rounded-xl border border-slate-200 p-6">
|
||||
<h3 className="font-semibold text-slate-900 mb-4">Kosten-Uebersicht</h3>
|
||||
<div className="space-y-4">
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-slate-600">Session Laufzeit</span>
|
||||
<span className="font-semibold">
|
||||
{status && status.session_runtime_minutes !== null
|
||||
? `${Math.round(status.session_runtime_minutes)} Minuten`
|
||||
: '-'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-slate-600">Session Kosten</span>
|
||||
<span className="font-semibold">
|
||||
{status && status.session_cost_usd !== null
|
||||
? `$${status.session_cost_usd.toFixed(4)}`
|
||||
: '-'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center pt-4 border-t border-slate-100">
|
||||
<span className="text-slate-600">Gesamtlaufzeit</span>
|
||||
<span className="font-semibold">
|
||||
{status && status.total_runtime_hours !== null
|
||||
? `${status.total_runtime_hours.toFixed(1)} Stunden`
|
||||
: '-'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-slate-600">Gesamtkosten</span>
|
||||
<span className="font-semibold">
|
||||
{status && status.total_cost_usd !== null
|
||||
? `$${status.total_cost_usd.toFixed(2)}`
|
||||
: '-'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-slate-600">vast.ai Ausgaben</span>
|
||||
<span className="font-semibold">
|
||||
{status && status.account_total_spend !== null
|
||||
? `$${status.account_total_spend.toFixed(2)}`
|
||||
: '-'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-xl border border-slate-200 p-6">
|
||||
<h3 className="font-semibold text-slate-900 mb-4">Instanz-Details</h3>
|
||||
<div className="space-y-4">
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-slate-600">Instanz ID</span>
|
||||
<span className="font-mono text-sm">
|
||||
{status?.instance_id || '-'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-slate-600">GPU</span>
|
||||
<span className="font-semibold">
|
||||
{status?.gpu_name || '-'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-slate-600">Stundensatz</span>
|
||||
<span className="font-semibold">
|
||||
{status?.dph_total ? `$${status.dph_total.toFixed(4)}/h` : '-'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-slate-600">Letzte Aktivitaet</span>
|
||||
<span className="text-sm">
|
||||
{status?.last_activity
|
||||
? new Date(status.last_activity).toLocaleString('de-DE')
|
||||
: '-'}
|
||||
</span>
|
||||
</div>
|
||||
{status?.endpoint_base_url && status.status === 'running' && (
|
||||
<div className="pt-4 border-t border-slate-100">
|
||||
<div className="text-slate-600 text-sm mb-1">Endpoint</div>
|
||||
<code className="text-xs bg-slate-100 px-2 py-1 rounded block overflow-x-auto">
|
||||
{status.endpoint_base_url}
|
||||
</code>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Info */}
|
||||
<div className="bg-orange-50 border border-orange-200 rounded-xl p-4">
|
||||
<div className="flex gap-3">
|
||||
<svg className="w-5 h-5 text-orange-600 flex-shrink-0 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>
|
||||
<h4 className="font-semibold text-orange-900">Auto-Shutdown</h4>
|
||||
<p className="text-sm text-orange-800 mt-1">
|
||||
Die GPU-Instanz wird automatisch gestoppt, wenn sie laengere Zeit inaktiv ist.
|
||||
Der Status wird alle 30 Sekunden automatisch aktualisiert.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,650 @@
|
||||
'use client'
|
||||
|
||||
/**
|
||||
* Middleware Admin - Rate Limiting, IP Whitelist/Blacklist, Events
|
||||
*
|
||||
* Manage middleware configurations and monitor events
|
||||
* Migrated from old admin (/admin/middleware)
|
||||
*/
|
||||
|
||||
import { useEffect, useState, useCallback } from 'react'
|
||||
import { PagePurpose } from '@/components/common/PagePurpose'
|
||||
|
||||
interface MiddlewareConfig {
|
||||
id: string
|
||||
middleware_name: string
|
||||
enabled: boolean
|
||||
config: Record<string, unknown>
|
||||
updated_at: string | null
|
||||
}
|
||||
|
||||
interface RateLimitIP {
|
||||
id: string
|
||||
ip_address: string
|
||||
list_type: 'whitelist' | 'blacklist'
|
||||
reason: string | null
|
||||
expires_at: string | null
|
||||
created_at: string
|
||||
}
|
||||
|
||||
interface MiddlewareEvent {
|
||||
id: string
|
||||
middleware_name: string
|
||||
event_type: string
|
||||
ip_address: string | null
|
||||
user_id: string | null
|
||||
request_path: string | null
|
||||
request_method: string | null
|
||||
details: Record<string, unknown> | null
|
||||
created_at: string
|
||||
}
|
||||
|
||||
interface MiddlewareStats {
|
||||
middleware_name: string
|
||||
total_events: number
|
||||
events_last_hour: number
|
||||
events_last_24h: number
|
||||
top_event_types: Array<{ event_type: string; count: number }>
|
||||
top_ips: Array<{ ip_address: string; count: number }>
|
||||
}
|
||||
|
||||
export default function MiddlewareAdminPage() {
|
||||
const [configs, setConfigs] = useState<MiddlewareConfig[]>([])
|
||||
const [ipList, setIpList] = useState<RateLimitIP[]>([])
|
||||
const [events, setEvents] = useState<MiddlewareEvent[]>([])
|
||||
const [stats, setStats] = useState<MiddlewareStats[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [activeTab, setActiveTab] = useState<'overview' | 'config' | 'ip-list' | 'events' | 'stats'>('overview')
|
||||
const [actionLoading, setActionLoading] = useState<string | null>(null)
|
||||
|
||||
// IP Form
|
||||
const [newIP, setNewIP] = useState('')
|
||||
const [newIPType, setNewIPType] = useState<'whitelist' | 'blacklist'>('whitelist')
|
||||
const [newIPReason, setNewIPReason] = useState('')
|
||||
|
||||
const fetchData = useCallback(async () => {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
|
||||
try {
|
||||
const [configsRes, ipListRes, eventsRes, statsRes] = await Promise.all([
|
||||
fetch('/api/admin/middleware'),
|
||||
fetch('/api/admin/middleware/rate-limit/ip-list'),
|
||||
fetch('/api/admin/middleware/events?limit=50'),
|
||||
fetch('/api/admin/middleware/stats'),
|
||||
])
|
||||
|
||||
if (configsRes.ok) {
|
||||
setConfigs(await configsRes.json())
|
||||
}
|
||||
if (ipListRes.ok) {
|
||||
setIpList(await ipListRes.json())
|
||||
}
|
||||
if (eventsRes.ok) {
|
||||
setEvents(await eventsRes.json())
|
||||
}
|
||||
if (statsRes.ok) {
|
||||
setStats(await statsRes.json())
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Verbindung zum Backend fehlgeschlagen')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
fetchData()
|
||||
}, [fetchData])
|
||||
|
||||
useEffect(() => {
|
||||
const interval = setInterval(fetchData, 30000)
|
||||
return () => clearInterval(interval)
|
||||
}, [fetchData])
|
||||
|
||||
const toggleMiddleware = async (name: string, enabled: boolean) => {
|
||||
setActionLoading(name)
|
||||
setError(null)
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/admin/middleware/${name}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ enabled }),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Fehler beim Aktualisieren: ${response.statusText}`)
|
||||
}
|
||||
|
||||
// Update local state
|
||||
setConfigs(prev =>
|
||||
prev.map(c => (c.middleware_name === name ? { ...c, enabled } : c))
|
||||
)
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Aktualisierung fehlgeschlagen')
|
||||
} finally {
|
||||
setActionLoading(null)
|
||||
}
|
||||
}
|
||||
|
||||
const addIP = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
if (!newIP.trim()) return
|
||||
|
||||
setActionLoading('add-ip')
|
||||
setError(null)
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/admin/middleware/rate-limit/ip-list', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
ip_address: newIP.trim(),
|
||||
list_type: newIPType,
|
||||
reason: newIPReason.trim() || null,
|
||||
}),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const data = await response.json()
|
||||
throw new Error(data.detail || `Fehler: ${response.statusText}`)
|
||||
}
|
||||
|
||||
const newEntry = await response.json()
|
||||
setIpList(prev => [newEntry, ...prev])
|
||||
setNewIP('')
|
||||
setNewIPReason('')
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'IP konnte nicht hinzugefuegt werden')
|
||||
} finally {
|
||||
setActionLoading(null)
|
||||
}
|
||||
}
|
||||
|
||||
const removeIP = async (id: string) => {
|
||||
setActionLoading(`remove-${id}`)
|
||||
setError(null)
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/admin/middleware/rate-limit/ip-list/${id}`, {
|
||||
method: 'DELETE',
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Fehler beim Loeschen: ${response.statusText}`)
|
||||
}
|
||||
|
||||
setIpList(prev => prev.filter(ip => ip.id !== id))
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'IP konnte nicht entfernt werden')
|
||||
} finally {
|
||||
setActionLoading(null)
|
||||
}
|
||||
}
|
||||
|
||||
const getMiddlewareDescription = (name: string): { icon: string; desc: string } => {
|
||||
const descriptions: Record<string, { icon: string; desc: string }> = {
|
||||
request_id: { icon: '🆔', desc: 'Generiert eindeutige Request-IDs fuer Tracing' },
|
||||
security_headers: { icon: '🛡️', desc: 'Fuegt Security-Header hinzu (CSP, HSTS, etc.)' },
|
||||
cors: { icon: '🌐', desc: 'Cross-Origin Resource Sharing Konfiguration' },
|
||||
rate_limiter: { icon: '⏱️', desc: 'Rate Limiting zum Schutz vor Missbrauch' },
|
||||
pii_redactor: { icon: '🔒', desc: 'Redaktiert personenbezogene Daten in Logs' },
|
||||
input_gate: { icon: '🚪', desc: 'Validiert und sanitisiert Eingaben' },
|
||||
}
|
||||
return descriptions[name] || { icon: '⚙️', desc: 'Middleware-Komponente' }
|
||||
}
|
||||
|
||||
const getEventTypeColor = (eventType: string) => {
|
||||
if (eventType.includes('error') || eventType.includes('blocked') || eventType.includes('blacklist')) {
|
||||
return 'bg-red-100 text-red-800'
|
||||
}
|
||||
if (eventType.includes('warning') || eventType.includes('rate_limit')) {
|
||||
return 'bg-yellow-100 text-yellow-800'
|
||||
}
|
||||
if (eventType.includes('success') || eventType.includes('whitelist')) {
|
||||
return 'bg-green-100 text-green-800'
|
||||
}
|
||||
return 'bg-slate-100 text-slate-800'
|
||||
}
|
||||
|
||||
const whitelistCount = ipList.filter(ip => ip.list_type === 'whitelist').length
|
||||
const blacklistCount = ipList.filter(ip => ip.list_type === 'blacklist').length
|
||||
|
||||
return (
|
||||
<div>
|
||||
<PagePurpose
|
||||
title="Middleware Admin"
|
||||
purpose="Verwalten Sie die Middleware-Konfiguration, Rate Limiting und IP-Listen. Ueberwachen Sie Middleware-Events und Statistiken in Echtzeit."
|
||||
audience={['DevOps', 'Security', 'System-Admins']}
|
||||
gdprArticles={['Art. 32 (Sicherheit der Verarbeitung)']}
|
||||
architecture={{
|
||||
services: ['FastAPI Middleware Stack', 'PostgreSQL'],
|
||||
databases: ['middleware_config', 'rate_limit_ip_list', 'middleware_events'],
|
||||
}}
|
||||
relatedPages={[
|
||||
{ name: 'Security', href: '/infrastructure/security', description: 'DevSecOps Dashboard' },
|
||||
{ name: 'Mac Mini', href: '/infrastructure/mac-mini', description: 'Server-Monitoring' },
|
||||
{ name: 'Controls', href: '/sdk/controls', description: 'Security Controls' },
|
||||
]}
|
||||
collapsible={true}
|
||||
defaultCollapsed={true}
|
||||
/>
|
||||
|
||||
{/* Stats Overview */}
|
||||
<div className="bg-white rounded-xl border border-slate-200 p-6 mb-6">
|
||||
<div className="flex justify-between items-center mb-6">
|
||||
<h2 className="text-xl font-semibold text-slate-900">Middleware Status</h2>
|
||||
<button
|
||||
onClick={fetchData}
|
||||
disabled={loading}
|
||||
className="px-4 py-2 text-sm bg-orange-600 text-white rounded-lg hover:bg-orange-700 disabled:opacity-50 transition-colors"
|
||||
>
|
||||
{loading ? 'Laden...' : 'Aktualisieren'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
<div className="bg-slate-50 rounded-lg p-4 border border-slate-200">
|
||||
<div className="text-2xl font-bold text-slate-900">{configs.length}</div>
|
||||
<div className="text-sm text-slate-600">Middleware</div>
|
||||
</div>
|
||||
<div className="bg-green-50 rounded-lg p-4 border border-green-200">
|
||||
<div className="text-2xl font-bold text-green-600">{whitelistCount}</div>
|
||||
<div className="text-sm text-slate-600">Whitelist IPs</div>
|
||||
</div>
|
||||
<div className="bg-red-50 rounded-lg p-4 border border-red-200">
|
||||
<div className="text-2xl font-bold text-red-600">{blacklistCount}</div>
|
||||
<div className="text-sm text-slate-600">Blacklist IPs</div>
|
||||
</div>
|
||||
<div className="bg-blue-50 rounded-lg p-4 border border-blue-200">
|
||||
<div className="text-2xl font-bold text-blue-600">{events.length}</div>
|
||||
<div className="text-sm text-slate-600">Recent Events</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tabs */}
|
||||
<div className="bg-white rounded-xl border border-slate-200 overflow-hidden mb-6">
|
||||
<div className="flex border-b border-slate-200 overflow-x-auto">
|
||||
{(['overview', 'config', 'ip-list', 'events', 'stats'] as const).map(tab => (
|
||||
<button
|
||||
key={tab}
|
||||
onClick={() => setActiveTab(tab)}
|
||||
className={`px-6 py-3 text-sm font-medium whitespace-nowrap transition-colors ${
|
||||
activeTab === tab
|
||||
? 'bg-orange-50 text-orange-700 border-b-2 border-orange-600'
|
||||
: 'text-slate-600 hover:bg-slate-50'
|
||||
}`}
|
||||
>
|
||||
{tab === 'overview' && 'Uebersicht'}
|
||||
{tab === 'config' && 'Konfiguration'}
|
||||
{tab === 'ip-list' && `IP-Listen (${ipList.length})`}
|
||||
{tab === 'events' && 'Events'}
|
||||
{tab === 'stats' && 'Statistiken'}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="p-6">
|
||||
{error && (
|
||||
<div className="mb-4 p-4 bg-red-50 border border-red-200 rounded-lg text-red-700">{error}</div>
|
||||
)}
|
||||
|
||||
{loading ? (
|
||||
<div className="flex justify-center py-12">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-orange-600" />
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{/* Overview Tab */}
|
||||
{activeTab === 'overview' && (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{configs.map(config => {
|
||||
const info = getMiddlewareDescription(config.middleware_name)
|
||||
return (
|
||||
<div
|
||||
key={config.id}
|
||||
className={`rounded-lg p-4 border ${
|
||||
config.enabled
|
||||
? 'bg-green-50 border-green-200'
|
||||
: 'bg-slate-50 border-slate-200'
|
||||
}`}
|
||||
>
|
||||
<div className="flex justify-between items-start mb-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xl">{info.icon}</span>
|
||||
<span className="font-semibold text-slate-900 capitalize">
|
||||
{config.middleware_name.replace('_', ' ')}
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => toggleMiddleware(config.middleware_name, !config.enabled)}
|
||||
disabled={actionLoading === config.middleware_name}
|
||||
className={`px-3 py-1 rounded-full text-xs font-semibold transition-colors ${
|
||||
config.enabled
|
||||
? 'bg-green-200 text-green-800 hover:bg-green-300'
|
||||
: 'bg-slate-200 text-slate-600 hover:bg-slate-300'
|
||||
}`}
|
||||
>
|
||||
{actionLoading === config.middleware_name
|
||||
? '...'
|
||||
: config.enabled
|
||||
? 'Aktiv'
|
||||
: 'Inaktiv'}
|
||||
</button>
|
||||
</div>
|
||||
<p className="text-sm text-slate-600">{info.desc}</p>
|
||||
{config.updated_at && (
|
||||
<div className="mt-2 text-xs text-slate-400">
|
||||
Aktualisiert: {new Date(config.updated_at).toLocaleString('de-DE')}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Config Tab */}
|
||||
{activeTab === 'config' && (
|
||||
<div className="space-y-4">
|
||||
{configs.map(config => {
|
||||
const info = getMiddlewareDescription(config.middleware_name)
|
||||
return (
|
||||
<div key={config.id} className="bg-slate-50 rounded-lg p-4 border border-slate-200">
|
||||
<div className="flex justify-between items-start mb-4">
|
||||
<div>
|
||||
<h3 className="font-semibold text-slate-900 flex items-center gap-2">
|
||||
<span>{info.icon}</span>
|
||||
<span className="capitalize">{config.middleware_name.replace('_', ' ')}</span>
|
||||
</h3>
|
||||
<p className="text-sm text-slate-600">{info.desc}</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<span
|
||||
className={`px-3 py-1 rounded-full text-xs font-semibold ${
|
||||
config.enabled ? 'bg-green-100 text-green-800' : 'bg-red-100 text-red-800'
|
||||
}`}
|
||||
>
|
||||
{config.enabled ? 'Aktiviert' : 'Deaktiviert'}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => toggleMiddleware(config.middleware_name, !config.enabled)}
|
||||
disabled={actionLoading === config.middleware_name}
|
||||
className="px-4 py-1.5 text-sm border border-slate-300 rounded-lg hover:bg-slate-100 disabled:opacity-50 transition-colors"
|
||||
>
|
||||
{actionLoading === config.middleware_name
|
||||
? '...'
|
||||
: config.enabled
|
||||
? 'Deaktivieren'
|
||||
: 'Aktivieren'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{Object.keys(config.config).length > 0 && (
|
||||
<div className="mt-3 pt-3 border-t border-slate-200">
|
||||
<div className="text-xs font-semibold text-slate-500 uppercase mb-2">
|
||||
Konfiguration
|
||||
</div>
|
||||
<pre className="text-xs bg-white p-3 rounded border border-slate-200 overflow-x-auto">
|
||||
{JSON.stringify(config.config, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* IP List Tab */}
|
||||
{activeTab === 'ip-list' && (
|
||||
<div>
|
||||
{/* Add IP Form */}
|
||||
<form onSubmit={addIP} className="mb-6 p-4 bg-slate-50 rounded-lg border border-slate-200">
|
||||
<h3 className="font-semibold text-slate-900 mb-4">IP hinzufuegen</h3>
|
||||
<div className="flex flex-wrap gap-3">
|
||||
<input
|
||||
type="text"
|
||||
value={newIP}
|
||||
onChange={e => setNewIP(e.target.value)}
|
||||
placeholder="IP-Adresse (z.B. 192.168.1.1)"
|
||||
className="flex-1 min-w-[200px] px-4 py-2 border border-slate-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-orange-500"
|
||||
/>
|
||||
<select
|
||||
value={newIPType}
|
||||
onChange={e => setNewIPType(e.target.value as 'whitelist' | 'blacklist')}
|
||||
className="px-4 py-2 border border-slate-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-orange-500"
|
||||
>
|
||||
<option value="whitelist">Whitelist</option>
|
||||
<option value="blacklist">Blacklist</option>
|
||||
</select>
|
||||
<input
|
||||
type="text"
|
||||
value={newIPReason}
|
||||
onChange={e => setNewIPReason(e.target.value)}
|
||||
placeholder="Grund (optional)"
|
||||
className="flex-1 min-w-[150px] px-4 py-2 border border-slate-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-orange-500"
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!newIP.trim() || actionLoading === 'add-ip'}
|
||||
className="px-6 py-2 bg-orange-600 text-white rounded-lg font-medium hover:bg-orange-700 disabled:opacity-50 transition-colors"
|
||||
>
|
||||
{actionLoading === 'add-ip' ? 'Hinzufuegen...' : 'Hinzufuegen'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{/* IP List Table */}
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full">
|
||||
<thead>
|
||||
<tr className="border-b border-slate-200">
|
||||
<th className="text-left py-3 px-4 text-xs font-semibold text-slate-500 uppercase">
|
||||
IP-Adresse
|
||||
</th>
|
||||
<th className="text-left py-3 px-4 text-xs font-semibold text-slate-500 uppercase">
|
||||
Typ
|
||||
</th>
|
||||
<th className="text-left py-3 px-4 text-xs font-semibold text-slate-500 uppercase">
|
||||
Grund
|
||||
</th>
|
||||
<th className="text-left py-3 px-4 text-xs font-semibold text-slate-500 uppercase">
|
||||
Hinzugefuegt
|
||||
</th>
|
||||
<th className="text-right py-3 px-4 text-xs font-semibold text-slate-500 uppercase">
|
||||
Aktion
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{ipList.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={5} className="text-center py-8 text-slate-500">
|
||||
Keine IP-Eintraege vorhanden.
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
ipList.map(ip => (
|
||||
<tr key={ip.id} className="border-b border-slate-100 hover:bg-slate-50">
|
||||
<td className="py-3 px-4 font-mono text-sm">{ip.ip_address}</td>
|
||||
<td className="py-3 px-4">
|
||||
<span
|
||||
className={`px-2 py-1 rounded text-xs font-semibold ${
|
||||
ip.list_type === 'whitelist'
|
||||
? 'bg-green-100 text-green-800'
|
||||
: 'bg-red-100 text-red-800'
|
||||
}`}
|
||||
>
|
||||
{ip.list_type === 'whitelist' ? 'Whitelist' : 'Blacklist'}
|
||||
</span>
|
||||
</td>
|
||||
<td className="py-3 px-4 text-sm text-slate-600">{ip.reason || '-'}</td>
|
||||
<td className="py-3 px-4 text-sm text-slate-500">
|
||||
{new Date(ip.created_at).toLocaleString('de-DE')}
|
||||
</td>
|
||||
<td className="py-3 px-4 text-right">
|
||||
<button
|
||||
onClick={() => removeIP(ip.id)}
|
||||
disabled={actionLoading === `remove-${ip.id}`}
|
||||
className="px-3 py-1 text-sm text-red-600 hover:bg-red-50 rounded transition-colors disabled:opacity-50"
|
||||
>
|
||||
{actionLoading === `remove-${ip.id}` ? '...' : 'Entfernen'}
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Events Tab */}
|
||||
{activeTab === 'events' && (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full">
|
||||
<thead>
|
||||
<tr className="border-b border-slate-200">
|
||||
<th className="text-left py-3 px-4 text-xs font-semibold text-slate-500 uppercase">
|
||||
Zeit
|
||||
</th>
|
||||
<th className="text-left py-3 px-4 text-xs font-semibold text-slate-500 uppercase">
|
||||
Middleware
|
||||
</th>
|
||||
<th className="text-left py-3 px-4 text-xs font-semibold text-slate-500 uppercase">
|
||||
Event
|
||||
</th>
|
||||
<th className="text-left py-3 px-4 text-xs font-semibold text-slate-500 uppercase">
|
||||
IP
|
||||
</th>
|
||||
<th className="text-left py-3 px-4 text-xs font-semibold text-slate-500 uppercase">
|
||||
Pfad
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{events.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={5} className="text-center py-8 text-slate-500">
|
||||
Keine Events vorhanden.
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
events.map(event => (
|
||||
<tr key={event.id} className="border-b border-slate-100 hover:bg-slate-50">
|
||||
<td className="py-3 px-4 text-sm text-slate-500">
|
||||
{new Date(event.created_at).toLocaleString('de-DE')}
|
||||
</td>
|
||||
<td className="py-3 px-4 text-sm capitalize">
|
||||
{event.middleware_name.replace('_', ' ')}
|
||||
</td>
|
||||
<td className="py-3 px-4">
|
||||
<span
|
||||
className={`px-2 py-1 rounded text-xs font-semibold ${getEventTypeColor(event.event_type)}`}
|
||||
>
|
||||
{event.event_type}
|
||||
</span>
|
||||
</td>
|
||||
<td className="py-3 px-4 text-sm font-mono text-slate-600">
|
||||
{event.ip_address || '-'}
|
||||
</td>
|
||||
<td className="py-3 px-4 text-sm text-slate-600 max-w-xs truncate">
|
||||
{event.request_method && event.request_path
|
||||
? `${event.request_method} ${event.request_path}`
|
||||
: '-'}
|
||||
</td>
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Stats Tab */}
|
||||
{activeTab === 'stats' && (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
{stats.map(stat => {
|
||||
const info = getMiddlewareDescription(stat.middleware_name)
|
||||
return (
|
||||
<div key={stat.middleware_name} className="bg-slate-50 rounded-lg p-4 border border-slate-200">
|
||||
<h3 className="font-semibold text-slate-900 flex items-center gap-2 mb-4">
|
||||
<span>{info.icon}</span>
|
||||
<span className="capitalize">{stat.middleware_name.replace('_', ' ')}</span>
|
||||
</h3>
|
||||
<div className="grid grid-cols-3 gap-4 mb-4">
|
||||
<div>
|
||||
<div className="text-2xl font-bold text-slate-900">{stat.total_events}</div>
|
||||
<div className="text-xs text-slate-500">Gesamt</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-2xl font-bold text-blue-600">{stat.events_last_hour}</div>
|
||||
<div className="text-xs text-slate-500">Letzte Stunde</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-2xl font-bold text-orange-600">{stat.events_last_24h}</div>
|
||||
<div className="text-xs text-slate-500">24 Stunden</div>
|
||||
</div>
|
||||
</div>
|
||||
{stat.top_event_types.length > 0 && (
|
||||
<div className="mb-3">
|
||||
<div className="text-xs font-semibold text-slate-500 uppercase mb-2">
|
||||
Top Event-Typen
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{stat.top_event_types.slice(0, 3).map(et => (
|
||||
<span
|
||||
key={et.event_type}
|
||||
className={`px-2 py-1 rounded text-xs ${getEventTypeColor(et.event_type)}`}
|
||||
>
|
||||
{et.event_type} ({et.count})
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{stat.top_ips.length > 0 && (
|
||||
<div>
|
||||
<div className="text-xs font-semibold text-slate-500 uppercase mb-2">Top IPs</div>
|
||||
<div className="text-xs text-slate-600">
|
||||
{stat.top_ips
|
||||
.slice(0, 3)
|
||||
.map(ip => `${ip.ip_address} (${ip.count})`)
|
||||
.join(', ')}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Info Box */}
|
||||
<div className="bg-orange-50 border border-orange-200 rounded-xl p-4">
|
||||
<div className="flex gap-3">
|
||||
<svg className="w-5 h-5 text-orange-600 flex-shrink-0 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>
|
||||
<h4 className="font-semibold text-orange-900">Middleware Stack</h4>
|
||||
<p className="text-sm text-orange-800 mt-1">
|
||||
Der Middleware Stack verarbeitet alle API-Anfragen in der konfigurierten Reihenfolge.
|
||||
Aenderungen an der Konfiguration werden sofort wirksam.
|
||||
Verwenden Sie die Whitelist fuer vertrauenswuerdige IPs und die Blacklist fuer bekannte Angreifer.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,561 @@
|
||||
'use client'
|
||||
|
||||
/**
|
||||
* Night Mode - Dashboard-gesteuerte Nachtabschaltung
|
||||
*
|
||||
* Ermoeglicht das automatische Stoppen und Starten von Docker-Services
|
||||
* nach Zeitplan. Manuelles Starten/Stoppen ebenfalls moeglich.
|
||||
*/
|
||||
|
||||
import { useEffect, useState, useCallback } from 'react'
|
||||
|
||||
interface NightModeConfig {
|
||||
enabled: boolean
|
||||
shutdown_time: string
|
||||
startup_time: string
|
||||
last_action: string | null
|
||||
last_action_time: string | null
|
||||
excluded_services: string[]
|
||||
}
|
||||
|
||||
interface NightModeStatus {
|
||||
config: NightModeConfig
|
||||
current_time: string
|
||||
next_action: string | null
|
||||
next_action_time: string | null
|
||||
time_until_next_action: string | null
|
||||
services_status: Record<string, string>
|
||||
}
|
||||
|
||||
interface ServicesInfo {
|
||||
all_services: string[]
|
||||
excluded_services: string[]
|
||||
status: Record<string, string>
|
||||
}
|
||||
|
||||
export default function NightModePage() {
|
||||
const [status, setStatus] = useState<NightModeStatus | null>(null)
|
||||
const [services, setServices] = useState<ServicesInfo | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [actionLoading, setActionLoading] = useState<string | null>(null)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [successMessage, setSuccessMessage] = useState<string | null>(null)
|
||||
|
||||
// Lokale Konfiguration fuer Bearbeitung
|
||||
const [editMode, setEditMode] = useState(false)
|
||||
const [editConfig, setEditConfig] = useState<NightModeConfig | null>(null)
|
||||
|
||||
const fetchData = useCallback(async () => {
|
||||
setError(null)
|
||||
try {
|
||||
const [statusRes, servicesRes] = await Promise.all([
|
||||
fetch('/api/admin/night-mode'),
|
||||
fetch('/api/admin/night-mode/services'),
|
||||
])
|
||||
|
||||
if (statusRes.ok) {
|
||||
const data = await statusRes.json()
|
||||
setStatus(data)
|
||||
if (!editMode) {
|
||||
setEditConfig(data.config)
|
||||
}
|
||||
} else {
|
||||
const errData = await statusRes.json()
|
||||
setError(errData.error || 'Fehler beim Laden des Status')
|
||||
}
|
||||
|
||||
if (servicesRes.ok) {
|
||||
setServices(await servicesRes.json())
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Verbindung zum Night-Scheduler fehlgeschlagen')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [editMode])
|
||||
|
||||
useEffect(() => {
|
||||
fetchData()
|
||||
}, [fetchData])
|
||||
|
||||
// Auto-Refresh alle 30 Sekunden
|
||||
useEffect(() => {
|
||||
const interval = setInterval(fetchData, 30000)
|
||||
return () => clearInterval(interval)
|
||||
}, [fetchData])
|
||||
|
||||
const saveConfig = async () => {
|
||||
if (!editConfig) return
|
||||
|
||||
setActionLoading('save')
|
||||
setError(null)
|
||||
setSuccessMessage(null)
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/admin/night-mode', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(editConfig),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const errData = await response.json()
|
||||
throw new Error(errData.error || 'Fehler beim Speichern')
|
||||
}
|
||||
|
||||
setEditMode(false)
|
||||
setSuccessMessage('Konfiguration gespeichert')
|
||||
setTimeout(() => setSuccessMessage(null), 3000)
|
||||
fetchData()
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Speichern fehlgeschlagen')
|
||||
} finally {
|
||||
setActionLoading(null)
|
||||
}
|
||||
}
|
||||
|
||||
const executeAction = async (action: 'start' | 'stop') => {
|
||||
setActionLoading(action)
|
||||
setError(null)
|
||||
setSuccessMessage(null)
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/admin/night-mode/execute', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ action }),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const errData = await response.json()
|
||||
throw new Error(errData.error || `Fehler bei ${action}`)
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
setSuccessMessage(data.message || `${action === 'start' ? 'Gestartet' : 'Gestoppt'}`)
|
||||
setTimeout(() => setSuccessMessage(null), 5000)
|
||||
fetchData()
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : `${action} fehlgeschlagen`)
|
||||
} finally {
|
||||
setActionLoading(null)
|
||||
}
|
||||
}
|
||||
|
||||
const toggleEnabled = async () => {
|
||||
if (!editConfig) return
|
||||
|
||||
const newConfig = { ...editConfig, enabled: !editConfig.enabled }
|
||||
setEditConfig(newConfig)
|
||||
|
||||
setActionLoading('toggle')
|
||||
setError(null)
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/admin/night-mode', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(newConfig),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Fehler beim Umschalten')
|
||||
}
|
||||
|
||||
setSuccessMessage(newConfig.enabled ? 'Nachtmodus aktiviert' : 'Nachtmodus deaktiviert')
|
||||
setTimeout(() => setSuccessMessage(null), 3000)
|
||||
fetchData()
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Umschalten fehlgeschlagen')
|
||||
// Zuruecksetzen bei Fehler
|
||||
setEditConfig({ ...editConfig })
|
||||
} finally {
|
||||
setActionLoading(null)
|
||||
}
|
||||
}
|
||||
|
||||
const getServiceStatusColor = (state: string) => {
|
||||
const lower = state.toLowerCase()
|
||||
if (lower === 'running' || lower.includes('up')) {
|
||||
return 'bg-green-100 text-green-800'
|
||||
}
|
||||
if (lower === 'exited' || lower.includes('exit')) {
|
||||
return 'bg-slate-100 text-slate-600'
|
||||
}
|
||||
if (lower === 'paused' || lower.includes('pause')) {
|
||||
return 'bg-yellow-100 text-yellow-800'
|
||||
}
|
||||
return 'bg-slate-100 text-slate-600'
|
||||
}
|
||||
|
||||
const runningCount = Object.values(status?.services_status || {}).filter(
|
||||
s => s.toLowerCase() === 'running' || s.toLowerCase().includes('up')
|
||||
).length
|
||||
const stoppedCount = Object.values(status?.services_status || {}).filter(
|
||||
s => s.toLowerCase() === 'exited' || s.toLowerCase().includes('exit')
|
||||
).length
|
||||
|
||||
return (
|
||||
<div className="p-6">
|
||||
<div className="mb-6">
|
||||
<h1 className="text-2xl font-bold text-slate-900">Nachtabschaltung</h1>
|
||||
<p className="text-slate-500 mt-1">Automatisches Stoppen und Starten von Docker-Services nach Zeitplan.</p>
|
||||
</div>
|
||||
|
||||
{/* Status Messages */}
|
||||
{error && (
|
||||
<div className="mb-6 p-4 bg-red-50 border border-red-200 rounded-lg text-red-700 flex items-center gap-3">
|
||||
<svg className="w-5 h-5 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
{successMessage && (
|
||||
<div className="mb-6 p-4 bg-green-50 border border-green-200 rounded-lg text-green-700 flex items-center gap-3">
|
||||
<svg className="w-5 h-5 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>
|
||||
{successMessage}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{loading ? (
|
||||
<div className="flex justify-center py-12">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-orange-600" />
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{/* Haupt-Steuerung */}
|
||||
<div className="bg-white rounded-xl border border-slate-200 p-6 mb-6">
|
||||
<div className="flex flex-col lg:flex-row lg:items-center justify-between gap-6">
|
||||
{/* Toggle */}
|
||||
<div className="flex items-center gap-6">
|
||||
<div
|
||||
onClick={toggleEnabled}
|
||||
className={`relative inline-flex h-10 w-20 cursor-pointer items-center rounded-full transition-colors ${
|
||||
editConfig?.enabled ? 'bg-orange-600' : 'bg-slate-300'
|
||||
} ${actionLoading === 'toggle' ? 'opacity-50 cursor-wait' : ''}`}
|
||||
>
|
||||
<span
|
||||
className={`inline-block h-8 w-8 transform rounded-full bg-white shadow-lg transition-transform ${
|
||||
editConfig?.enabled ? 'translate-x-11' : 'translate-x-1'
|
||||
}`}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold text-slate-900">
|
||||
Nachtmodus: {editConfig?.enabled ? 'Aktiv' : 'Inaktiv'}
|
||||
</h2>
|
||||
<p className="text-sm text-slate-500">
|
||||
{editConfig?.enabled
|
||||
? `Abschaltung um ${editConfig.shutdown_time}, Start um ${editConfig.startup_time}`
|
||||
: 'Zeitgesteuerte Abschaltung ist deaktiviert'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Manuelle Aktionen */}
|
||||
<div className="flex gap-3">
|
||||
<button
|
||||
onClick={() => executeAction('stop')}
|
||||
disabled={actionLoading !== null}
|
||||
className="flex items-center gap-2 px-5 py-2.5 bg-red-600 text-white rounded-lg font-medium hover:bg-red-700 disabled:opacity-50 transition-colors"
|
||||
>
|
||||
{actionLoading === 'stop' ? (
|
||||
<span className="animate-spin">◠</span>
|
||||
) : (
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 10a1 1 0 011-1h4a1 1 0 011 1v4a1 1 0 01-1 1h-4a1 1 0 01-1-1v-4z" />
|
||||
</svg>
|
||||
)}
|
||||
Jetzt abschalten
|
||||
</button>
|
||||
<button
|
||||
onClick={() => executeAction('start')}
|
||||
disabled={actionLoading !== null}
|
||||
className="flex items-center gap-2 px-5 py-2.5 bg-green-600 text-white rounded-lg font-medium hover:bg-green-700 disabled:opacity-50 transition-colors"
|
||||
>
|
||||
{actionLoading === 'start' ? (
|
||||
<span className="animate-spin">◠</span>
|
||||
) : (
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M14.752 11.168l-3.197-2.132A1 1 0 0010 9.87v4.263a1 1 0 001.555.832l3.197-2.132a1 1 0 000-1.664z" />
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
)}
|
||||
Jetzt starten
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Status-Karten */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4 mb-6">
|
||||
<div className="bg-white rounded-xl border border-slate-200 p-5">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-12 h-12 rounded-full bg-blue-100 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="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-2xl font-bold text-slate-900">{status?.current_time || '--:--'}</div>
|
||||
<div className="text-sm text-slate-500">Aktuelle Zeit</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-xl border border-slate-200 p-5">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className={`w-12 h-12 rounded-full flex items-center justify-center ${
|
||||
status?.next_action === 'shutdown' ? 'bg-red-100' : 'bg-green-100'
|
||||
}`}>
|
||||
{status?.next_action === 'shutdown' ? (
|
||||
<svg className="w-6 h-6 text-red-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M20.354 15.354A9 9 0 018.646 3.646 9.003 9.003 0 0012 21a9.003 9.003 0 008.354-5.646z" />
|
||||
</svg>
|
||||
) : (
|
||||
<svg className="w-6 h-6 text-green-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 3v1m0 16v1m9-9h-1M4 12H3m15.364 6.364l-.707-.707M6.343 6.343l-.707-.707m12.728 0l-.707.707M6.343 17.657l-.707.707M16 12a4 4 0 11-8 0 4 4 0 018 0z" />
|
||||
</svg>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-2xl font-bold text-slate-900">
|
||||
{status?.next_action_time || '--:--'}
|
||||
</div>
|
||||
<div className="text-sm text-slate-500">
|
||||
{status?.next_action === 'shutdown' ? 'Naechste Abschaltung' : 'Naechster Start'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-xl border border-slate-200 p-5">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-12 h-12 rounded-full bg-orange-100 flex items-center justify-center">
|
||||
<svg className="w-6 h-6 text-orange-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-2xl font-bold text-slate-900">
|
||||
{status?.time_until_next_action || '-'}
|
||||
</div>
|
||||
<div className="text-sm text-slate-500">Countdown</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-xl border border-slate-200 p-5">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-12 h-12 rounded-full bg-slate-100 flex items-center justify-center">
|
||||
<svg className="w-6 h-6 text-slate-600" 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>
|
||||
</div>
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-lg font-bold text-green-600">{runningCount}</span>
|
||||
<span className="text-slate-400">/</span>
|
||||
<span className="text-lg font-bold text-slate-600">{stoppedCount}</span>
|
||||
</div>
|
||||
<div className="text-sm text-slate-500">Aktiv / Gestoppt</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Zeitkonfiguration */}
|
||||
<div className="bg-white rounded-xl border border-slate-200 p-6 mb-6">
|
||||
<div className="flex justify-between items-center mb-6">
|
||||
<h3 className="text-lg font-semibold text-slate-900">Zeitkonfiguration</h3>
|
||||
{editMode ? (
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => {
|
||||
setEditMode(false)
|
||||
setEditConfig(status?.config || null)
|
||||
}}
|
||||
className="px-4 py-2 text-sm border border-slate-300 rounded-lg hover:bg-slate-50 transition-colors"
|
||||
>
|
||||
Abbrechen
|
||||
</button>
|
||||
<button
|
||||
onClick={saveConfig}
|
||||
disabled={actionLoading === 'save'}
|
||||
className="px-4 py-2 text-sm bg-orange-600 text-white rounded-lg hover:bg-orange-700 disabled:opacity-50 transition-colors"
|
||||
>
|
||||
{actionLoading === 'save' ? 'Speichern...' : 'Speichern'}
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => setEditMode(true)}
|
||||
className="px-4 py-2 text-sm border border-slate-300 rounded-lg hover:bg-slate-50 transition-colors"
|
||||
>
|
||||
Bearbeiten
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-2">
|
||||
<span className="flex items-center gap-2">
|
||||
<svg className="w-4 h-4 text-red-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M20.354 15.354A9 9 0 018.646 3.646 9.003 9.003 0 0012 21a9.003 9.003 0 008.354-5.646z" />
|
||||
</svg>
|
||||
Abschaltung um
|
||||
</span>
|
||||
</label>
|
||||
{editMode ? (
|
||||
<input
|
||||
type="time"
|
||||
value={editConfig?.shutdown_time || '22:00'}
|
||||
onChange={e => setEditConfig(prev => prev ? { ...prev, shutdown_time: e.target.value } : null)}
|
||||
className="w-full px-4 py-3 text-xl font-mono border border-slate-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-orange-500"
|
||||
/>
|
||||
) : (
|
||||
<div className="text-3xl font-mono font-bold text-slate-900 bg-slate-50 px-4 py-3 rounded-lg">
|
||||
{editConfig?.shutdown_time || '22:00'}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-2">
|
||||
<span className="flex items-center gap-2">
|
||||
<svg className="w-4 h-4 text-yellow-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 3v1m0 16v1m9-9h-1M4 12H3m15.364 6.364l-.707-.707M6.343 6.343l-.707-.707m12.728 0l-.707.707M6.343 17.657l-.707.707M16 12a4 4 0 11-8 0 4 4 0 018 0z" />
|
||||
</svg>
|
||||
Start um
|
||||
</span>
|
||||
</label>
|
||||
{editMode ? (
|
||||
<input
|
||||
type="time"
|
||||
value={editConfig?.startup_time || '06:00'}
|
||||
onChange={e => setEditConfig(prev => prev ? { ...prev, startup_time: e.target.value } : null)}
|
||||
className="w-full px-4 py-3 text-xl font-mono border border-slate-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-orange-500"
|
||||
/>
|
||||
) : (
|
||||
<div className="text-3xl font-mono font-bold text-slate-900 bg-slate-50 px-4 py-3 rounded-lg">
|
||||
{editConfig?.startup_time || '06:00'}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Letzte Aktion */}
|
||||
{status?.config.last_action && (
|
||||
<div className="mt-6 pt-6 border-t border-slate-200">
|
||||
<div className="text-sm text-slate-500">
|
||||
Letzte Aktion:{' '}
|
||||
<span className={`font-semibold ${status.config.last_action === 'startup' ? 'text-green-600' : 'text-red-600'}`}>
|
||||
{status.config.last_action === 'startup' ? 'Gestartet' : 'Abgeschaltet'}
|
||||
</span>
|
||||
{status.config.last_action_time && (
|
||||
<span className="ml-2">
|
||||
am {new Date(status.config.last_action_time).toLocaleString('de-DE')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Service-Liste */}
|
||||
<div className="bg-white rounded-xl border border-slate-200 overflow-hidden">
|
||||
<div className="p-6 border-b border-slate-200">
|
||||
<h3 className="text-lg font-semibold text-slate-900">Services</h3>
|
||||
<p className="text-sm text-slate-500 mt-1">
|
||||
Gruen = wird verwaltet, Grau = ausgeschlossen (laeuft immer)
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="p-6">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-3">
|
||||
{services?.all_services.map(service => {
|
||||
const serviceStatus = status?.services_status[service] || 'unknown'
|
||||
const isExcluded = services.excluded_services.includes(service)
|
||||
|
||||
return (
|
||||
<div
|
||||
key={service}
|
||||
className={`flex items-center justify-between px-4 py-3 rounded-lg border ${
|
||||
isExcluded
|
||||
? 'bg-slate-50 border-slate-200'
|
||||
: 'bg-orange-50 border-orange-200'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
{isExcluded ? (
|
||||
<svg className="w-4 h-4 text-slate-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="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>
|
||||
) : (
|
||||
<svg className="w-4 h-4 text-orange-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
)}
|
||||
<span className={`text-sm font-medium ${isExcluded ? 'text-slate-500' : 'text-slate-900'}`}>
|
||||
{service}
|
||||
</span>
|
||||
</div>
|
||||
<span className={`px-2 py-0.5 rounded text-xs font-semibold ${getServiceStatusColor(serviceStatus)}`}>
|
||||
{serviceStatus}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
|
||||
{/* Auch excluded Services anzeigen, die nicht in all_services sind */}
|
||||
{services?.excluded_services
|
||||
.filter(s => !services.all_services.includes(s))
|
||||
.map(service => (
|
||||
<div
|
||||
key={service}
|
||||
className="flex items-center justify-between px-4 py-3 rounded-lg border bg-slate-50 border-slate-200"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<svg className="w-4 h-4 text-slate-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="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>
|
||||
<span className="text-sm font-medium text-slate-500">
|
||||
{service}
|
||||
</span>
|
||||
</div>
|
||||
<span className="px-2 py-0.5 rounded text-xs font-semibold bg-slate-100 text-slate-600">
|
||||
excluded
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Info Box */}
|
||||
<div className="mt-6 bg-orange-50 border border-orange-200 rounded-xl p-4">
|
||||
<div className="flex gap-3">
|
||||
<svg className="w-5 h-5 text-orange-600 flex-shrink-0 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>
|
||||
<h4 className="font-semibold text-orange-900">Hinweise zur Nachtabschaltung</h4>
|
||||
<ul className="text-sm text-orange-800 mt-1 space-y-1 list-disc list-inside">
|
||||
<li>Der <strong>night-scheduler</strong> und <strong>nginx</strong> bleiben immer aktiv</li>
|
||||
<li>Services werden mit <code>docker compose stop</code> angehalten (Daten bleiben erhalten)</li>
|
||||
<li>Bei manuellem Start/Stop wird die letzte Aktion gespeichert</li>
|
||||
<li>Der Scheduler prueft jede Minute, ob eine Aktion faellig ist</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
'use client'
|
||||
|
||||
import { getCategoryById } from '@/lib/navigation'
|
||||
import { ModuleCard } from '@/components/common/ModuleCard'
|
||||
import { PagePurpose } from '@/components/common/PagePurpose'
|
||||
|
||||
export default function InfrastructurePage() {
|
||||
const category = getCategoryById('infrastructure')
|
||||
|
||||
if (!category) {
|
||||
return <div>Kategorie nicht gefunden</div>
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* Page Purpose */}
|
||||
<PagePurpose
|
||||
title={category.name}
|
||||
purpose="Diese Kategorie umfasst alle DevOps- und Infrastruktur-Tools. Hier verwalten Sie GPU-Ressourcen, ueberwachen Security-Scans, pruefen SBOM-Compliance und monitoren den Mac Mini Server."
|
||||
audience={['DevOps', 'System-Administratoren', 'Security']}
|
||||
architecture={{
|
||||
services: ['nginx (Reverse Proxy)', 'docker-compose', 'vault (Secrets)'],
|
||||
databases: ['PostgreSQL', 'Valkey (Cache)'],
|
||||
}}
|
||||
collapsible={true}
|
||||
defaultCollapsed={false}
|
||||
/>
|
||||
|
||||
{/* Modules Grid */}
|
||||
<h2 className="text-lg font-semibold text-slate-900 mb-4">Module</h2>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{category.modules.map((module) => (
|
||||
<ModuleCard key={module.id} module={module} category={category} />
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Info Section */}
|
||||
<div className="mt-8 bg-orange-50 border border-orange-200 rounded-xl p-6">
|
||||
<h3 className="font-semibold text-orange-800 flex items-center gap-2">
|
||||
<span>🖥️</span>
|
||||
Mac Mini Server
|
||||
</h3>
|
||||
<p className="text-sm text-orange-700 mt-2">
|
||||
Der Mac Mini mit Apple Silicon dient als lokaler Server fuer alle Breakpilot-Services.
|
||||
GPU-intensive Workloads koennen bei Bedarf auf vast.ai ausgelagert werden.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,912 @@
|
||||
'use client'
|
||||
|
||||
/**
|
||||
* SBOM (Software Bill of Materials) Admin Page
|
||||
*
|
||||
* Migriert von /admin/sbom (website) nach /infrastructure/sbom (admin-v2)
|
||||
*
|
||||
* Displays:
|
||||
* - All infrastructure components (Docker services)
|
||||
* - Python/Go dependencies
|
||||
* - Node.js packages
|
||||
* - License information
|
||||
* - Version tracking
|
||||
*/
|
||||
|
||||
import { useState, useEffect } from 'react'
|
||||
import Link from 'next/link'
|
||||
import { PagePurpose } from '@/components/common/PagePurpose'
|
||||
import { DevOpsPipelineSidebarResponsive } from '@/components/infrastructure/DevOpsPipelineSidebar'
|
||||
|
||||
interface Component {
|
||||
type: string
|
||||
name: string
|
||||
version: string
|
||||
purl?: string
|
||||
licenses?: { license: { id: string } }[]
|
||||
category?: string
|
||||
port?: string
|
||||
description?: string
|
||||
license?: string
|
||||
sourceUrl?: string
|
||||
}
|
||||
|
||||
interface SBOMData {
|
||||
bomFormat?: string
|
||||
specVersion?: string
|
||||
version?: number
|
||||
metadata?: {
|
||||
timestamp?: string
|
||||
tools?: { vendor: string; name: string; version: string }[]
|
||||
component?: { type: string; name: string; version: string }
|
||||
}
|
||||
components?: Component[]
|
||||
}
|
||||
|
||||
type CategoryType = 'all' | 'infrastructure' | 'security-tools' | 'python' | 'go' | 'nodejs' | 'unity' | 'csharp'
|
||||
type InfoTabType = 'audit' | 'documentation'
|
||||
|
||||
// Infrastructure components from docker-compose.yml and project analysis
|
||||
const INFRASTRUCTURE_COMPONENTS: Component[] = [
|
||||
// ===== DATABASES =====
|
||||
{ type: 'service', name: 'PostgreSQL', version: '16-alpine', category: 'database', port: '5432', description: 'Hauptdatenbank', license: 'PostgreSQL', sourceUrl: 'https://github.com/postgres/postgres' },
|
||||
{ type: 'service', name: 'Synapse PostgreSQL', version: '16-alpine', category: 'database', port: '-', description: 'Matrix Datenbank', license: 'PostgreSQL', sourceUrl: 'https://github.com/postgres/postgres' },
|
||||
{ type: 'service', name: 'ERPNext MariaDB', version: '10.6', category: 'database', port: '-', description: 'ERPNext Datenbank', license: 'GPL-2.0', sourceUrl: 'https://github.com/MariaDB/server' },
|
||||
{ type: 'service', name: 'MongoDB', version: '7.0', category: 'database', port: '27017', description: 'LibreChat Datenbank', license: 'SSPL-1.0', sourceUrl: 'https://github.com/mongodb/mongo' },
|
||||
|
||||
// ===== CACHE & QUEUE =====
|
||||
{ type: 'service', name: 'Valkey', version: '8-alpine', category: 'cache', port: '6379', description: 'In-Memory Cache & Sessions (Redis OSS Fork)', license: 'BSD-3-Clause', sourceUrl: 'https://github.com/valkey-io/valkey' },
|
||||
{ type: 'service', name: 'ERPNext Valkey Queue', version: 'alpine', category: 'cache', port: '-', description: 'Job Queue', license: 'BSD-3-Clause', sourceUrl: 'https://github.com/valkey-io/valkey' },
|
||||
{ type: 'service', name: 'ERPNext Valkey Cache', version: 'alpine', category: 'cache', port: '-', description: 'Cache Layer', license: 'BSD-3-Clause', sourceUrl: 'https://github.com/valkey-io/valkey' },
|
||||
|
||||
// ===== SEARCH ENGINES =====
|
||||
{ type: 'service', name: 'Qdrant', version: '1.7.4', category: 'search', port: '6333', description: 'Vector Database (RAG/Embeddings)', license: 'Apache-2.0', sourceUrl: 'https://github.com/qdrant/qdrant' },
|
||||
{ type: 'service', name: 'OpenSearch', version: '2.x', category: 'search', port: '9200', description: 'Volltext-Suche (Elasticsearch Fork)', license: 'Apache-2.0', sourceUrl: 'https://github.com/opensearch-project/OpenSearch' },
|
||||
{ type: 'service', name: 'Meilisearch', version: 'latest', category: 'search', port: '7700', description: 'Instant Search Engine', license: 'MIT', sourceUrl: 'https://github.com/meilisearch/meilisearch' },
|
||||
|
||||
// ===== OBJECT STORAGE =====
|
||||
{ type: 'service', name: 'MinIO', version: 'latest', category: 'storage', port: '9000/9001', description: 'S3-kompatibel Object Storage', license: 'AGPL-3.0', sourceUrl: 'https://github.com/minio/minio' },
|
||||
{ type: 'service', name: 'IPFS (Kubo)', version: '0.24', category: 'storage', port: '5001', description: 'Dezentrales Speichersystem', license: 'MIT/Apache-2.0', sourceUrl: 'https://github.com/ipfs/kubo' },
|
||||
{ type: 'service', name: 'DSMS Gateway', version: '1.0', category: 'storage', port: '8082', description: 'IPFS REST API', license: 'Proprietary', sourceUrl: '-' },
|
||||
|
||||
// ===== SECURITY =====
|
||||
{ type: 'service', name: 'HashiCorp Vault', version: '1.15', category: 'security', port: '8200', description: 'Secrets Management', license: 'BUSL-1.1', sourceUrl: 'https://github.com/hashicorp/vault' },
|
||||
{ type: 'service', name: 'Keycloak', version: '23.0', category: 'security', port: '8180', description: 'Identity Provider (SSO/OIDC)', license: 'Apache-2.0', sourceUrl: 'https://github.com/keycloak/keycloak' },
|
||||
{ type: 'service', name: 'NetBird', version: '0.64.5', category: 'security', port: '-', description: 'Zero-Trust Mesh VPN (WireGuard-basiert)', license: 'BSD-3-Clause', sourceUrl: 'https://github.com/netbirdio/netbird' },
|
||||
|
||||
// ===== COMMUNICATION =====
|
||||
{ type: 'service', name: 'Matrix Synapse', version: 'latest', category: 'communication', port: '8008', description: 'E2EE Messenger Server', license: 'AGPL-3.0', sourceUrl: 'https://github.com/element-hq/synapse' },
|
||||
{ type: 'service', name: 'Jitsi Web', version: 'stable-9823', category: 'communication', port: '8443', description: 'Videokonferenz UI', license: 'Apache-2.0', sourceUrl: 'https://github.com/jitsi/jitsi-meet' },
|
||||
{ type: 'service', name: 'Jitsi Prosody (XMPP)', version: 'stable-9823', category: 'communication', port: '-', description: 'XMPP Server', license: 'MIT', sourceUrl: 'https://github.com/bjc/prosody' },
|
||||
{ type: 'service', name: 'Jitsi Jicofo', version: 'stable-9823', category: 'communication', port: '-', description: 'Conference Focus Component', license: 'Apache-2.0', sourceUrl: 'https://github.com/jitsi/jicofo' },
|
||||
{ type: 'service', name: 'Jitsi JVB', version: 'stable-9823', category: 'communication', port: '10000/udp', description: 'Videobridge (WebRTC SFU)', license: 'Apache-2.0', sourceUrl: 'https://github.com/jitsi/jitsi-videobridge' },
|
||||
{ type: 'service', name: 'Jibri', version: 'stable-9823', category: 'communication', port: '-', description: 'Recording & Streaming Service', license: 'Apache-2.0', sourceUrl: 'https://github.com/jitsi/jibri' },
|
||||
|
||||
// ===== APPLICATION SERVICES (Python) =====
|
||||
{ type: 'service', name: 'Python Backend (FastAPI)', version: '3.12', category: 'application', port: '8000', description: 'Haupt-Backend API, Studio & Alerts Agent', license: 'Proprietary', sourceUrl: '-' },
|
||||
{ type: 'service', name: 'Klausur Service', version: '1.0', category: 'application', port: '8086', description: 'Abitur-Klausurkorrektur (BYOEH)', license: 'Proprietary', sourceUrl: '-' },
|
||||
{ type: 'service', name: 'Compliance Module', version: '2.0', category: 'application', port: '8000', description: 'GRC Framework (19 Regulations, 558 Requirements, AI)', license: 'Proprietary', sourceUrl: '-' },
|
||||
{ type: 'service', name: 'Transcription Worker', version: '1.0', category: 'application', port: '-', description: 'Whisper + pyannote Transkription', license: 'Proprietary', sourceUrl: '-' },
|
||||
|
||||
// ===== APPLICATION SERVICES (Go) =====
|
||||
{ type: 'service', name: 'Go Consent Service', version: '1.21', category: 'application', port: '8081', description: 'DSGVO Consent Management', license: 'Proprietary', sourceUrl: '-' },
|
||||
{ type: 'service', name: 'Go School Service', version: '1.21', category: 'application', port: '8084', description: 'Klausuren, Noten, Zeugnisse', license: 'Proprietary', sourceUrl: '-' },
|
||||
{ type: 'service', name: 'Go Billing Service', version: '1.21', category: 'application', port: '8083', description: 'Stripe Billing Integration', license: 'Proprietary', sourceUrl: '-' },
|
||||
|
||||
// ===== APPLICATION SERVICES (Node.js) =====
|
||||
{ type: 'service', name: 'Next.js Admin Frontend', version: '15.1', category: 'application', port: '3000', description: 'Admin Dashboard (React)', license: 'Proprietary', sourceUrl: '-' },
|
||||
{ type: 'service', name: 'H5P Content Service', version: 'latest', category: 'application', port: '8085', description: 'Interaktive Inhalte', license: 'MIT', sourceUrl: 'https://github.com/h5p/h5p-server' },
|
||||
{ type: 'service', name: 'Policy Vault (NestJS)', version: '1.0', category: 'application', port: '3001', description: 'Richtlinien-Verwaltung API', license: 'Proprietary', sourceUrl: '-' },
|
||||
{ type: 'service', name: 'Policy Vault (Angular)', version: '17', category: 'application', port: '4200', description: 'Richtlinien-Verwaltung UI', license: 'Proprietary', sourceUrl: '-' },
|
||||
|
||||
// ===== APPLICATION SERVICES (Vue) =====
|
||||
{ type: 'service', name: 'Creator Studio (Vue 3)', version: '3.4', category: 'application', port: '-', description: 'Content Creation UI', license: 'Proprietary', sourceUrl: '-' },
|
||||
|
||||
// ===== AI/LLM SERVICES =====
|
||||
{ type: 'service', name: 'LibreChat', version: 'latest', category: 'ai', port: '3080', description: 'Multi-LLM Chat Interface', license: 'MIT', sourceUrl: 'https://github.com/danny-avila/LibreChat' },
|
||||
{ type: 'service', name: 'RAGFlow', version: 'latest', category: 'ai', port: '9380', description: 'RAG Pipeline Service', license: 'Apache-2.0', sourceUrl: 'https://github.com/infiniflow/ragflow' },
|
||||
|
||||
// ===== ERP =====
|
||||
{ type: 'service', name: 'ERPNext', version: 'v15', category: 'erp', port: '8090', description: 'Open Source ERP System', license: 'GPL-3.0', sourceUrl: 'https://github.com/frappe/erpnext' },
|
||||
|
||||
// ===== CI/CD & VERSION CONTROL =====
|
||||
{ type: 'service', name: 'Woodpecker CI', version: '2.x', category: 'cicd', port: '8082', description: 'Self-hosted CI/CD Pipeline (Drone Fork)', license: 'Apache-2.0', sourceUrl: 'https://github.com/woodpecker-ci/woodpecker' },
|
||||
{ type: 'service', name: 'Gitea', version: '1.21', category: 'cicd', port: '3003', description: 'Self-hosted Git Service', license: 'MIT', sourceUrl: 'https://github.com/go-gitea/gitea' },
|
||||
{ type: 'service', name: 'Dokploy', version: '0.26.7', category: 'cicd', port: '3000', description: 'Self-hosted PaaS (Vercel/Heroku Alternative)', license: 'Apache-2.0', sourceUrl: 'https://github.com/Dokploy/dokploy' },
|
||||
|
||||
// ===== DEVELOPMENT =====
|
||||
{ type: 'service', name: 'Mailpit', version: 'latest', category: 'development', port: '8025/1025', description: 'E-Mail Testing (SMTP Catch-All)', license: 'MIT', sourceUrl: 'https://github.com/axllent/mailpit' },
|
||||
|
||||
// ===== GAME (Breakpilot Drive) =====
|
||||
{ type: 'service', name: 'Breakpilot Drive (Unity WebGL)', version: '6000.0', category: 'game', port: '3001', description: 'Lernspiel fuer Schueler (Klasse 2-6)', license: 'Proprietary', sourceUrl: '-' },
|
||||
|
||||
// ===== VOICE SERVICE =====
|
||||
{ type: 'service', name: 'Voice Service (FastAPI)', version: '1.0', category: 'voice', port: '8091', description: 'Voice-First Interface mit PersonaPlex-7B & TaskOrchestrator', license: 'Proprietary', sourceUrl: '-' },
|
||||
{ type: 'service', name: 'PersonaPlex-7B (NVIDIA)', version: '7B', category: 'voice', port: '8998', description: 'Full-Duplex Speech-to-Speech (Produktion)', license: 'MIT/NVIDIA Open Model', sourceUrl: 'https://developer.nvidia.com' },
|
||||
{ type: 'service', name: 'TaskOrchestrator', version: '1.0', category: 'voice', port: '-', description: 'Agent-Orchestrierung mit Task State Machine', license: 'Proprietary', sourceUrl: '-' },
|
||||
{ type: 'service', name: 'Mimi Audio Codec', version: '1.0', category: 'voice', port: '-', description: 'Audio Streaming (24kHz, 80ms Frames)', license: 'MIT', sourceUrl: '-' },
|
||||
|
||||
// ===== BQAS (Quality Assurance) =====
|
||||
{ type: 'service', name: 'BQAS Local Scheduler', version: '1.0', category: 'qa', port: '-', description: 'Lokale GitHub Actions Alternative (launchd)', license: 'Proprietary', sourceUrl: '-' },
|
||||
{ type: 'service', name: 'BQAS LLM Judge', version: '1.0', category: 'qa', port: '-', description: 'Qwen2.5-32B basierte Test-Bewertung', license: 'Proprietary', sourceUrl: '-' },
|
||||
{ type: 'service', name: 'BQAS RAG Judge', version: '1.0', category: 'qa', port: '-', description: 'RAG/Korrektur Evaluierung', license: 'Proprietary', sourceUrl: '-' },
|
||||
{ type: 'service', name: 'BQAS Notifier', version: '1.0', category: 'qa', port: '-', description: 'Desktop/Slack/Email Benachrichtigungen', license: 'Proprietary', sourceUrl: '-' },
|
||||
{ type: 'service', name: 'BQAS Regression Tracker', version: '1.0', category: 'qa', port: '-', description: 'Score-Historie und Regression-Erkennung', license: 'Proprietary', sourceUrl: '-' },
|
||||
]
|
||||
|
||||
// Security Tools discovered in project
|
||||
const SECURITY_TOOLS: Component[] = [
|
||||
{ type: 'tool', name: 'Trivy', version: 'latest', category: 'security-tool', description: 'Container Vulnerability Scanner', license: 'Apache-2.0', sourceUrl: 'https://github.com/aquasecurity/trivy' },
|
||||
{ type: 'tool', name: 'Grype', version: 'latest', category: 'security-tool', description: 'SBOM Vulnerability Scanner', license: 'Apache-2.0', sourceUrl: 'https://github.com/anchore/grype' },
|
||||
{ type: 'tool', name: 'Syft', version: 'latest', category: 'security-tool', description: 'SBOM Generator', license: 'Apache-2.0', sourceUrl: 'https://github.com/anchore/syft' },
|
||||
{ type: 'tool', name: 'Gitleaks', version: 'latest', category: 'security-tool', description: 'Secrets Detection in Git', license: 'MIT', sourceUrl: 'https://github.com/gitleaks/gitleaks' },
|
||||
{ type: 'tool', name: 'TruffleHog', version: '3.x', category: 'security-tool', description: 'Secrets Scanner (Regex/Entropy)', license: 'AGPL-3.0', sourceUrl: 'https://github.com/trufflesecurity/trufflehog' },
|
||||
{ type: 'tool', name: 'Semgrep', version: 'latest', category: 'security-tool', description: 'SAST - Static Analysis', license: 'LGPL-2.1', sourceUrl: 'https://github.com/semgrep/semgrep' },
|
||||
{ type: 'tool', name: 'Bandit', version: 'latest', category: 'security-tool', description: 'Python Security Linter', license: 'Apache-2.0', sourceUrl: 'https://github.com/PyCQA/bandit' },
|
||||
{ type: 'tool', name: 'Gosec', version: 'latest', category: 'security-tool', description: 'Go Security Scanner', license: 'Apache-2.0', sourceUrl: 'https://github.com/securego/gosec' },
|
||||
{ type: 'tool', name: 'govulncheck', version: 'latest', category: 'security-tool', description: 'Go Vulnerability Check', license: 'BSD-3-Clause', sourceUrl: 'https://pkg.go.dev/golang.org/x/vuln/cmd/govulncheck' },
|
||||
{ type: 'tool', name: 'golangci-lint', version: 'latest', category: 'security-tool', description: 'Go Linter (Security Rules)', license: 'GPL-3.0', sourceUrl: 'https://github.com/golangci/golangci-lint' },
|
||||
{ type: 'tool', name: 'npm audit', version: 'built-in', category: 'security-tool', description: 'Node.js Vulnerability Check', license: 'Artistic-2.0', sourceUrl: 'https://docs.npmjs.com/cli/commands/npm-audit' },
|
||||
{ type: 'tool', name: 'pip-audit', version: 'latest', category: 'security-tool', description: 'Python Dependency Audit', license: 'Apache-2.0', sourceUrl: 'https://github.com/pypa/pip-audit' },
|
||||
{ type: 'tool', name: 'safety', version: 'latest', category: 'security-tool', description: 'Python Safety Check', license: 'MIT', sourceUrl: 'https://github.com/pyupio/safety' },
|
||||
{ type: 'tool', name: 'CodeQL', version: 'latest', category: 'security-tool', description: 'GitHub Security Analysis', license: 'MIT', sourceUrl: 'https://github.com/github/codeql' },
|
||||
]
|
||||
|
||||
// Key Python packages (from requirements.txt)
|
||||
const PYTHON_PACKAGES: Component[] = [
|
||||
{ type: 'library', name: 'FastAPI', version: '0.109+', category: 'python', description: 'Web Framework', license: 'MIT', sourceUrl: 'https://github.com/tiangolo/fastapi' },
|
||||
{ type: 'library', name: 'Uvicorn', version: '0.38+', category: 'python', description: 'ASGI Server', license: 'BSD-3-Clause', sourceUrl: 'https://github.com/encode/uvicorn' },
|
||||
{ type: 'library', name: 'Starlette', version: '0.49+', category: 'python', description: 'ASGI Framework (FastAPI Basis)', license: 'BSD-3-Clause', sourceUrl: 'https://github.com/encode/starlette' },
|
||||
{ type: 'library', name: 'Pydantic', version: '2.x', category: 'python', description: 'Data Validation', license: 'MIT', sourceUrl: 'https://github.com/pydantic/pydantic' },
|
||||
{ type: 'library', name: 'SQLAlchemy', version: '2.x', category: 'python', description: 'ORM', license: 'MIT', sourceUrl: 'https://github.com/sqlalchemy/sqlalchemy' },
|
||||
{ type: 'library', name: 'Alembic', version: '1.14+', category: 'python', description: 'DB Migrations (Classroom, Feedback Tables)', license: 'MIT', sourceUrl: 'https://github.com/sqlalchemy/alembic' },
|
||||
{ type: 'library', name: 'psycopg2-binary', version: '2.9+', category: 'python', description: 'PostgreSQL Driver', license: 'LGPL-3.0', sourceUrl: 'https://github.com/psycopg/psycopg2' },
|
||||
{ type: 'library', name: 'httpx', version: 'latest', category: 'python', description: 'Async HTTP Client', license: 'BSD-3-Clause', sourceUrl: 'https://github.com/encode/httpx' },
|
||||
{ type: 'library', name: 'PyJWT', version: 'latest', category: 'python', description: 'JWT Handling', license: 'MIT', sourceUrl: 'https://github.com/jpadilla/pyjwt' },
|
||||
{ type: 'library', name: 'hvac', version: 'latest', category: 'python', description: 'Vault Client', license: 'Apache-2.0', sourceUrl: 'https://github.com/hvac/hvac' },
|
||||
{ type: 'library', name: 'python-multipart', version: 'latest', category: 'python', description: 'File Uploads', license: 'Apache-2.0', sourceUrl: 'https://github.com/andrew-d/python-multipart' },
|
||||
{ type: 'library', name: 'aiofiles', version: 'latest', category: 'python', description: 'Async File I/O', license: 'Apache-2.0', sourceUrl: 'https://github.com/Tinche/aiofiles' },
|
||||
{ type: 'library', name: 'openai', version: 'latest', category: 'python', description: 'OpenAI SDK', license: 'MIT', sourceUrl: 'https://github.com/openai/openai-python' },
|
||||
{ type: 'library', name: 'anthropic', version: 'latest', category: 'python', description: 'Anthropic Claude SDK', license: 'MIT', sourceUrl: 'https://github.com/anthropics/anthropic-sdk-python' },
|
||||
{ type: 'library', name: 'langchain', version: 'latest', category: 'python', description: 'LLM Framework', license: 'MIT', sourceUrl: 'https://github.com/langchain-ai/langchain' },
|
||||
{ type: 'library', name: 'aioimaplib', version: 'latest', category: 'python', description: 'Async IMAP Client (Unified Inbox)', license: 'MIT', sourceUrl: 'https://github.com/bamthomas/aioimaplib' },
|
||||
{ type: 'library', name: 'aiosmtplib', version: 'latest', category: 'python', description: 'Async SMTP Client (Mail Sending)', license: 'MIT', sourceUrl: 'https://github.com/cole/aiosmtplib' },
|
||||
{ type: 'library', name: 'email-validator', version: 'latest', category: 'python', description: 'Email Validation', license: 'CC0-1.0', sourceUrl: 'https://github.com/JoshData/python-email-validator' },
|
||||
{ type: 'library', name: 'cryptography', version: 'latest', category: 'python', description: 'Encryption (Mail Credentials)', license: 'BSD-3-Clause', sourceUrl: 'https://github.com/pyca/cryptography' },
|
||||
{ type: 'library', name: 'asyncpg', version: 'latest', category: 'python', description: 'Async PostgreSQL Driver', license: 'Apache-2.0', sourceUrl: 'https://github.com/MagicStack/asyncpg' },
|
||||
{ type: 'library', name: 'python-dateutil', version: 'latest', category: 'python', description: 'Date Parsing (Deadline Extraction)', license: 'Apache-2.0', sourceUrl: 'https://github.com/dateutil/dateutil' },
|
||||
{ type: 'library', name: 'faster-whisper', version: '1.0+', category: 'python', description: 'CTranslate2 Whisper (GPU-optimiert)', license: 'MIT', sourceUrl: 'https://github.com/SYSTRAN/faster-whisper' },
|
||||
{ type: 'library', name: 'pyannote.audio', version: '3.x', category: 'python', description: 'Speaker Diarization', license: 'MIT', sourceUrl: 'https://github.com/pyannote/pyannote-audio' },
|
||||
{ type: 'library', name: 'rq', version: '1.x', category: 'python', description: 'Redis Queue (Task Processing)', license: 'BSD-2-Clause', sourceUrl: 'https://github.com/rq/rq' },
|
||||
{ type: 'library', name: 'ffmpeg-python', version: '0.2+', category: 'python', description: 'FFmpeg Python Bindings', license: 'Apache-2.0', sourceUrl: 'https://github.com/kkroening/ffmpeg-python' },
|
||||
{ type: 'library', name: 'webvtt-py', version: '0.4+', category: 'python', description: 'WebVTT Subtitle Export', license: 'MIT', sourceUrl: 'https://github.com/glut23/webvtt-py' },
|
||||
{ type: 'library', name: 'minio', version: '7.x', category: 'python', description: 'MinIO S3 Client', license: 'Apache-2.0', sourceUrl: 'https://github.com/minio/minio-py' },
|
||||
{ type: 'library', name: 'structlog', version: '24.x', category: 'python', description: 'Structured Logging', license: 'Apache-2.0', sourceUrl: 'https://github.com/hynek/structlog' },
|
||||
{ type: 'library', name: 'feedparser', version: '6.x', category: 'python', description: 'RSS/Atom Feed Parser (Alerts Agent)', license: 'BSD-2-Clause', sourceUrl: 'https://github.com/kurtmckee/feedparser' },
|
||||
{ type: 'library', name: 'APScheduler', version: '3.x', category: 'python', description: 'AsyncIO Job Scheduler (Alerts Agent)', license: 'MIT', sourceUrl: 'https://github.com/agronholm/apscheduler' },
|
||||
{ type: 'library', name: 'beautifulsoup4', version: '4.x', category: 'python', description: 'HTML Parser (Email Parsing, Compliance Scraper)', license: 'MIT', sourceUrl: 'https://code.launchpad.net/beautifulsoup' },
|
||||
{ type: 'library', name: 'lxml', version: '5.x', category: 'python', description: 'XML/HTML Parser (EUR-Lex Scraping)', license: 'BSD-3-Clause', sourceUrl: 'https://github.com/lxml/lxml' },
|
||||
{ type: 'library', name: 'PyMuPDF', version: '1.24+', category: 'python', description: 'PDF Parser (BSI-TR Extraction)', license: 'AGPL-3.0', sourceUrl: 'https://github.com/pymupdf/PyMuPDF' },
|
||||
{ type: 'library', name: 'pdfplumber', version: '0.11+', category: 'python', description: 'PDF Table Extraction (Compliance Docs)', license: 'MIT', sourceUrl: 'https://github.com/jsvine/pdfplumber' },
|
||||
{ type: 'library', name: 'websockets', version: '14.x', category: 'python', description: 'WebSocket Support (Voice Streaming)', license: 'BSD-3-Clause', sourceUrl: 'https://github.com/python-websockets/websockets' },
|
||||
{ type: 'library', name: 'soundfile', version: '0.13+', category: 'python', description: 'Audio File Processing (Voice Service)', license: 'BSD-3-Clause', sourceUrl: 'https://github.com/bastibe/python-soundfile' },
|
||||
{ type: 'library', name: 'scipy', version: '1.14+', category: 'python', description: 'Signal Processing (Audio)', license: 'BSD-3-Clause', sourceUrl: 'https://github.com/scipy/scipy' },
|
||||
{ type: 'library', name: 'redis', version: '5.x', category: 'python', description: 'Valkey/Redis Client (Voice Sessions)', license: 'MIT', sourceUrl: 'https://github.com/redis/redis-py' },
|
||||
{ type: 'library', name: 'pydantic-settings', version: '2.x', category: 'python', description: 'Settings Management (Voice Config)', license: 'MIT', sourceUrl: 'https://github.com/pydantic/pydantic-settings' },
|
||||
]
|
||||
|
||||
// Key Go modules (from go.mod files)
|
||||
const GO_MODULES: Component[] = [
|
||||
{ type: 'library', name: 'gin-gonic/gin', version: '1.9+', category: 'go', description: 'Web Framework', license: 'MIT', sourceUrl: 'https://github.com/gin-gonic/gin' },
|
||||
{ type: 'library', name: 'gorm.io/gorm', version: '1.25+', category: 'go', description: 'ORM', license: 'MIT', sourceUrl: 'https://github.com/go-gorm/gorm' },
|
||||
{ type: 'library', name: 'golang-jwt/jwt', version: 'v5', category: 'go', description: 'JWT Library', license: 'MIT', sourceUrl: 'https://github.com/golang-jwt/jwt' },
|
||||
{ type: 'library', name: 'stripe/stripe-go', version: 'v76', category: 'go', description: 'Stripe SDK', license: 'MIT', sourceUrl: 'https://github.com/stripe/stripe-go' },
|
||||
{ type: 'library', name: 'spf13/viper', version: 'latest', category: 'go', description: 'Configuration', license: 'MIT', sourceUrl: 'https://github.com/spf13/viper' },
|
||||
{ type: 'library', name: 'uber-go/zap', version: 'latest', category: 'go', description: 'Structured Logging', license: 'MIT', sourceUrl: 'https://github.com/uber-go/zap' },
|
||||
{ type: 'library', name: 'swaggo/swag', version: 'latest', category: 'go', description: 'Swagger Docs', license: 'MIT', sourceUrl: 'https://github.com/swaggo/swag' },
|
||||
]
|
||||
|
||||
// Key Node.js packages (from package.json files)
|
||||
const NODE_PACKAGES: Component[] = [
|
||||
{ type: 'library', name: 'Next.js', version: '15.1', category: 'nodejs', description: 'React Framework', license: 'MIT', sourceUrl: 'https://github.com/vercel/next.js' },
|
||||
{ type: 'library', name: 'React', version: '19', category: 'nodejs', description: 'UI Library', license: 'MIT', sourceUrl: 'https://github.com/facebook/react' },
|
||||
{ type: 'library', name: 'Vue.js', version: '3.4', category: 'nodejs', description: 'UI Framework (Creator Studio)', license: 'MIT', sourceUrl: 'https://github.com/vuejs/core' },
|
||||
{ type: 'library', name: 'Angular', version: '17', category: 'nodejs', description: 'UI Framework (Policy Vault)', license: 'MIT', sourceUrl: 'https://github.com/angular/angular' },
|
||||
{ type: 'library', name: 'NestJS', version: '10', category: 'nodejs', description: 'Node.js Framework', license: 'MIT', sourceUrl: 'https://github.com/nestjs/nest' },
|
||||
{ type: 'library', name: 'TypeScript', version: '5.x', category: 'nodejs', description: 'Type System', license: 'Apache-2.0', sourceUrl: 'https://github.com/microsoft/TypeScript' },
|
||||
{ type: 'library', name: 'Tailwind CSS', version: '3.4', category: 'nodejs', description: 'Utility CSS', license: 'MIT', sourceUrl: 'https://github.com/tailwindlabs/tailwindcss' },
|
||||
{ type: 'library', name: 'Prisma', version: '5.x', category: 'nodejs', description: 'ORM (Policy Vault)', license: 'Apache-2.0', sourceUrl: 'https://github.com/prisma/prisma' },
|
||||
{ type: 'library', name: 'Material Design Icons', version: 'latest', category: 'nodejs', description: 'Icon-System (Companion UI, Studio)', license: 'Apache-2.0', sourceUrl: 'https://github.com/google/material-design-icons' },
|
||||
{ type: 'library', name: 'Recharts', version: '2.12', category: 'nodejs', description: 'React Charts (Compliance Dashboard)', license: 'MIT', sourceUrl: 'https://github.com/recharts/recharts' },
|
||||
{ type: 'library', name: 'React Flow', version: '11.x', category: 'nodejs', description: 'Node-basierte Flow-Diagramme (Screen Flow)', license: 'MIT', sourceUrl: 'https://github.com/xyflow/xyflow' },
|
||||
{ type: 'library', name: 'Playwright', version: '1.50', category: 'nodejs', description: 'E2E Testing Framework (SDK Tests)', license: 'Apache-2.0', sourceUrl: 'https://github.com/microsoft/playwright' },
|
||||
{ type: 'library', name: 'Vitest', version: '4.x', category: 'nodejs', description: 'Unit Testing Framework', license: 'MIT', sourceUrl: 'https://github.com/vitest-dev/vitest' },
|
||||
{ type: 'library', name: 'jsPDF', version: '4.x', category: 'nodejs', description: 'PDF Generation (SDK Export)', license: 'MIT', sourceUrl: 'https://github.com/parallax/jsPDF' },
|
||||
{ type: 'library', name: 'JSZip', version: '3.x', category: 'nodejs', description: 'ZIP File Creation (SDK Export)', license: 'MIT/GPL-3.0', sourceUrl: 'https://github.com/Stuk/jszip' },
|
||||
{ type: 'library', name: 'Lucide React', version: '0.468', category: 'nodejs', description: 'Icon Library', license: 'ISC', sourceUrl: 'https://github.com/lucide-icons/lucide' },
|
||||
]
|
||||
|
||||
// Unity packages (Breakpilot Drive game engine)
|
||||
const UNITY_PACKAGES: Component[] = [
|
||||
{ type: 'library', name: 'Unity Engine', version: '6000.0 (Unity 6)', category: 'unity', description: 'Game Engine', license: 'Unity EULA', sourceUrl: 'https://unity.com' },
|
||||
{ type: 'library', name: 'Universal Render Pipeline (URP)', version: '17.x', category: 'unity', description: 'Render Pipeline', license: 'Unity Companion', sourceUrl: 'https://docs.unity3d.com/Packages/com.unity.render-pipelines.universal@17.0' },
|
||||
{ type: 'library', name: 'TextMeshPro', version: '3.2', category: 'unity', description: 'Advanced Text Rendering', license: 'Unity Companion', sourceUrl: 'https://docs.unity3d.com/Packages/com.unity.textmeshpro@3.2' },
|
||||
{ type: 'library', name: 'Unity Mathematics', version: '1.3', category: 'unity', description: 'Math Library (SIMD)', license: 'Unity Companion', sourceUrl: 'https://docs.unity3d.com/Packages/com.unity.mathematics@1.3' },
|
||||
{ type: 'library', name: 'Newtonsoft.Json (Unity)', version: '3.2', category: 'unity', description: 'JSON Serialization', license: 'MIT', sourceUrl: 'https://docs.unity3d.com/Packages/com.unity.nuget.newtonsoft-json@3.2' },
|
||||
{ type: 'library', name: 'Unity UI', version: '2.0', category: 'unity', description: 'UI System', license: 'Unity Companion', sourceUrl: 'https://docs.unity3d.com/Packages/com.unity.ugui@2.0' },
|
||||
{ type: 'library', name: 'Unity Input System', version: '1.8', category: 'unity', description: 'New Input System', license: 'Unity Companion', sourceUrl: 'https://docs.unity3d.com/Packages/com.unity.inputsystem@1.8' },
|
||||
]
|
||||
|
||||
// C# dependencies (Breakpilot Drive)
|
||||
const CSHARP_PACKAGES: Component[] = [
|
||||
{ type: 'library', name: '.NET Standard', version: '2.1', category: 'csharp', description: 'Runtime', license: 'MIT', sourceUrl: 'https://github.com/dotnet/standard' },
|
||||
{ type: 'library', name: 'UnityWebRequest', version: 'built-in', category: 'csharp', description: 'HTTP Client', license: 'Unity Companion', sourceUrl: '-' },
|
||||
{ type: 'library', name: 'System.Text.Json', version: 'built-in', category: 'csharp', description: 'JSON Parsing', license: 'MIT', sourceUrl: 'https://github.com/dotnet/runtime' },
|
||||
]
|
||||
|
||||
export default function SBOMPage() {
|
||||
const [sbomData, setSbomData] = useState<SBOMData | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [activeCategory, setActiveCategory] = useState<CategoryType>('all')
|
||||
const [searchTerm, setSearchTerm] = useState('')
|
||||
const [activeInfoTab, setActiveInfoTab] = useState<InfoTabType>('audit')
|
||||
const [showFullDocs, setShowFullDocs] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
loadSBOM()
|
||||
}, [])
|
||||
|
||||
const loadSBOM = async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const res = await fetch('/api/v1/security/sbom')
|
||||
if (res.ok) {
|
||||
const data = await res.json()
|
||||
setSbomData(data)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load SBOM:', error)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const getAllComponents = (): Component[] => {
|
||||
const infraComponents = INFRASTRUCTURE_COMPONENTS.map(c => ({
|
||||
...c,
|
||||
category: c.category || 'infrastructure'
|
||||
}))
|
||||
|
||||
const securityToolsComponents = SECURITY_TOOLS.map(c => ({
|
||||
...c,
|
||||
category: c.category || 'security-tool'
|
||||
}))
|
||||
|
||||
const pythonComponents = PYTHON_PACKAGES.map(c => ({
|
||||
...c,
|
||||
category: 'python'
|
||||
}))
|
||||
|
||||
const goComponents = GO_MODULES.map(c => ({
|
||||
...c,
|
||||
category: 'go'
|
||||
}))
|
||||
|
||||
const nodeComponents = NODE_PACKAGES.map(c => ({
|
||||
...c,
|
||||
category: 'nodejs'
|
||||
}))
|
||||
|
||||
const unityComponents = UNITY_PACKAGES.map(c => ({
|
||||
...c,
|
||||
category: 'unity'
|
||||
}))
|
||||
|
||||
const csharpComponents = CSHARP_PACKAGES.map(c => ({
|
||||
...c,
|
||||
category: 'csharp'
|
||||
}))
|
||||
|
||||
// Add dynamic SBOM data from backend if available
|
||||
const dynamicPython = (sbomData?.components || []).map(c => ({
|
||||
...c,
|
||||
category: 'python'
|
||||
}))
|
||||
|
||||
return [...infraComponents, ...securityToolsComponents, ...pythonComponents, ...goComponents, ...nodeComponents, ...unityComponents, ...csharpComponents, ...dynamicPython]
|
||||
}
|
||||
|
||||
const getFilteredComponents = () => {
|
||||
let components = getAllComponents()
|
||||
|
||||
if (activeCategory !== 'all') {
|
||||
if (activeCategory === 'infrastructure') {
|
||||
components = INFRASTRUCTURE_COMPONENTS
|
||||
} else if (activeCategory === 'security-tools') {
|
||||
components = SECURITY_TOOLS
|
||||
} else if (activeCategory === 'python') {
|
||||
components = [...PYTHON_PACKAGES, ...(sbomData?.components || [])]
|
||||
} else if (activeCategory === 'go') {
|
||||
components = GO_MODULES
|
||||
} else if (activeCategory === 'nodejs') {
|
||||
components = NODE_PACKAGES
|
||||
} else if (activeCategory === 'unity') {
|
||||
components = UNITY_PACKAGES
|
||||
} else if (activeCategory === 'csharp') {
|
||||
components = CSHARP_PACKAGES
|
||||
}
|
||||
}
|
||||
|
||||
if (searchTerm) {
|
||||
components = components.filter(c =>
|
||||
c.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
c.version.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
(c.description?.toLowerCase().includes(searchTerm.toLowerCase()))
|
||||
)
|
||||
}
|
||||
|
||||
return components
|
||||
}
|
||||
|
||||
const getCategoryColor = (category?: string) => {
|
||||
switch (category) {
|
||||
case 'database': return 'bg-blue-100 text-blue-800'
|
||||
case 'security': return 'bg-purple-100 text-purple-800'
|
||||
case 'security-tool': return 'bg-red-100 text-red-800'
|
||||
case 'application': return 'bg-green-100 text-green-800'
|
||||
case 'communication': return 'bg-yellow-100 text-yellow-800'
|
||||
case 'storage': return 'bg-orange-100 text-orange-800'
|
||||
case 'search': return 'bg-pink-100 text-pink-800'
|
||||
case 'erp': return 'bg-indigo-100 text-indigo-800'
|
||||
case 'cache': return 'bg-cyan-100 text-cyan-800'
|
||||
case 'ai': return 'bg-violet-100 text-violet-800'
|
||||
case 'development': return 'bg-gray-100 text-gray-800'
|
||||
case 'cicd': return 'bg-orange-100 text-orange-800'
|
||||
case 'python': return 'bg-emerald-100 text-emerald-800'
|
||||
case 'go': return 'bg-sky-100 text-sky-800'
|
||||
case 'nodejs': return 'bg-lime-100 text-lime-800'
|
||||
case 'unity': return 'bg-amber-100 text-amber-800'
|
||||
case 'csharp': return 'bg-fuchsia-100 text-fuchsia-800'
|
||||
case 'game': return 'bg-rose-100 text-rose-800'
|
||||
case 'voice': return 'bg-teal-100 text-teal-800'
|
||||
case 'qa': return 'bg-blue-100 text-blue-800'
|
||||
default: return 'bg-slate-100 text-slate-800'
|
||||
}
|
||||
}
|
||||
|
||||
const getLicenseColor = (license?: string) => {
|
||||
if (!license) return 'bg-gray-100 text-gray-600'
|
||||
if (license.includes('MIT')) return 'bg-green-100 text-green-700'
|
||||
if (license.includes('Apache')) return 'bg-blue-100 text-blue-700'
|
||||
if (license.includes('BSD')) return 'bg-cyan-100 text-cyan-700'
|
||||
if (license.includes('GPL') || license.includes('LGPL')) return 'bg-orange-100 text-orange-700'
|
||||
return 'bg-gray-100 text-gray-600'
|
||||
}
|
||||
|
||||
const stats = {
|
||||
totalInfra: INFRASTRUCTURE_COMPONENTS.length,
|
||||
totalSecurityTools: SECURITY_TOOLS.length,
|
||||
totalPython: PYTHON_PACKAGES.length + (sbomData?.components?.length || 0),
|
||||
totalGo: GO_MODULES.length,
|
||||
totalNode: NODE_PACKAGES.length,
|
||||
totalUnity: UNITY_PACKAGES.length,
|
||||
totalCsharp: CSHARP_PACKAGES.length,
|
||||
totalAll: INFRASTRUCTURE_COMPONENTS.length + SECURITY_TOOLS.length + PYTHON_PACKAGES.length + GO_MODULES.length + NODE_PACKAGES.length + UNITY_PACKAGES.length + CSHARP_PACKAGES.length + (sbomData?.components?.length || 0),
|
||||
databases: INFRASTRUCTURE_COMPONENTS.filter(c => c.category === 'database').length,
|
||||
services: INFRASTRUCTURE_COMPONENTS.filter(c => c.category === 'application').length,
|
||||
communication: INFRASTRUCTURE_COMPONENTS.filter(c => c.category === 'communication').length,
|
||||
game: INFRASTRUCTURE_COMPONENTS.filter(c => c.category === 'game').length,
|
||||
}
|
||||
|
||||
const categories = [
|
||||
{ id: 'all', name: 'Alle', count: stats.totalAll },
|
||||
{ id: 'infrastructure', name: 'Infrastruktur', count: stats.totalInfra },
|
||||
{ id: 'security-tools', name: 'Security Tools', count: stats.totalSecurityTools },
|
||||
{ id: 'python', name: 'Python', count: stats.totalPython },
|
||||
{ id: 'go', name: 'Go', count: stats.totalGo },
|
||||
{ id: 'nodejs', name: 'Node.js', count: stats.totalNode },
|
||||
{ id: 'unity', name: 'Unity', count: stats.totalUnity },
|
||||
{ id: 'csharp', name: 'C#', count: stats.totalCsharp },
|
||||
]
|
||||
|
||||
const filteredComponents = getFilteredComponents()
|
||||
|
||||
return (
|
||||
<div>
|
||||
<PagePurpose
|
||||
title="SBOM"
|
||||
purpose="Software Bill of Materials - Alle Komponenten & Abhaengigkeiten der Breakpilot-Plattform. Wichtig fuer Supply-Chain-Security, Compliance-Audits und Lizenz-Pruefung."
|
||||
audience={['DevOps', 'Compliance', 'Security', 'Auditoren']}
|
||||
gdprArticles={['Art. 32 (Sicherheit der Verarbeitung)']}
|
||||
architecture={{
|
||||
services: ['Syft (SBOM Generator)', 'Trivy (CVE Scanner)'],
|
||||
databases: ['CycloneDX JSON'],
|
||||
}}
|
||||
relatedPages={[
|
||||
{ name: 'Security', href: '/infrastructure/security', description: 'DevSecOps Dashboard' },
|
||||
{ name: 'Controls', href: '/sdk/controls', description: 'Security Controls' },
|
||||
]}
|
||||
collapsible={true}
|
||||
defaultCollapsed={true}
|
||||
/>
|
||||
|
||||
{/* DevOps Pipeline Sidebar */}
|
||||
<DevOpsPipelineSidebarResponsive currentTool="sbom" />
|
||||
|
||||
{/* Wizard Link */}
|
||||
<div className="mb-6 flex justify-end">
|
||||
<Link
|
||||
href="/infrastructure/sbom/wizard"
|
||||
className="flex items-center gap-2 px-4 py-2 rounded-lg font-medium bg-purple-100 text-purple-700 border border-purple-200 hover:bg-purple-200 transition-colors"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9.663 17h4.673M12 3v1m6.364 1.636l-.707.707M21 12h-1M4 12H3m3.343-5.657l-.707-.707m2.828 9.9a5 5 0 117.072 0l-.548.547A3.374 3.374 0 0014 18.469V19a2 2 0 11-4 0v-.531c0-.895-.356-1.754-.988-2.386l-.548-.547z" />
|
||||
</svg>
|
||||
Lern-Wizard
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* Stats Cards */}
|
||||
<div className="grid grid-cols-2 md:grid-cols-5 lg:grid-cols-10 gap-4 mb-6">
|
||||
<div className="bg-white rounded-lg shadow p-4">
|
||||
<div className="text-3xl font-bold text-slate-800">{stats.totalAll}</div>
|
||||
<div className="text-sm text-slate-500">Komponenten Total</div>
|
||||
</div>
|
||||
<div className="bg-white rounded-lg shadow p-4">
|
||||
<div className="text-3xl font-bold text-purple-600">{stats.totalInfra}</div>
|
||||
<div className="text-sm text-slate-500">Docker Services</div>
|
||||
</div>
|
||||
<div className="bg-white rounded-lg shadow p-4">
|
||||
<div className="text-3xl font-bold text-red-600">{stats.totalSecurityTools}</div>
|
||||
<div className="text-sm text-slate-500">Security Tools</div>
|
||||
</div>
|
||||
<div className="bg-white rounded-lg shadow p-4">
|
||||
<div className="text-3xl font-bold text-emerald-600">{stats.totalPython}</div>
|
||||
<div className="text-sm text-slate-500">Python</div>
|
||||
</div>
|
||||
<div className="bg-white rounded-lg shadow p-4">
|
||||
<div className="text-3xl font-bold text-sky-600">{stats.totalGo}</div>
|
||||
<div className="text-sm text-slate-500">Go</div>
|
||||
</div>
|
||||
<div className="bg-white rounded-lg shadow p-4">
|
||||
<div className="text-3xl font-bold text-lime-600">{stats.totalNode}</div>
|
||||
<div className="text-sm text-slate-500">Node.js</div>
|
||||
</div>
|
||||
<div className="bg-white rounded-lg shadow p-4">
|
||||
<div className="text-3xl font-bold text-amber-600">{stats.totalUnity}</div>
|
||||
<div className="text-sm text-slate-500">Unity</div>
|
||||
</div>
|
||||
<div className="bg-white rounded-lg shadow p-4">
|
||||
<div className="text-3xl font-bold text-fuchsia-600">{stats.totalCsharp}</div>
|
||||
<div className="text-sm text-slate-500">C#</div>
|
||||
</div>
|
||||
<div className="bg-white rounded-lg shadow p-4">
|
||||
<div className="text-3xl font-bold text-blue-600">{stats.databases}</div>
|
||||
<div className="text-sm text-slate-500">Datenbanken</div>
|
||||
</div>
|
||||
<div className="bg-white rounded-lg shadow p-4">
|
||||
<div className="text-3xl font-bold text-rose-600">{stats.game}</div>
|
||||
<div className="text-sm text-slate-500">Game</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Filters */}
|
||||
<div className="bg-white rounded-lg shadow p-4 mb-6">
|
||||
<div className="flex flex-col md:flex-row gap-4 items-center justify-between">
|
||||
{/* Category Tabs */}
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{categories.map((cat) => (
|
||||
<button
|
||||
key={cat.id}
|
||||
onClick={() => setActiveCategory(cat.id as CategoryType)}
|
||||
className={`px-4 py-2 rounded-lg text-sm font-medium transition-colors ${
|
||||
activeCategory === cat.id
|
||||
? 'bg-orange-600 text-white'
|
||||
: 'bg-gray-100 text-gray-700 hover:bg-gray-200'
|
||||
}`}
|
||||
>
|
||||
{cat.name} ({cat.count})
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Search */}
|
||||
<div className="relative w-full md:w-64">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Suchen..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
className="w-full pl-10 pr-4 py-2 border border-gray-200 rounded-lg focus:ring-2 focus:ring-orange-500 focus:border-transparent"
|
||||
/>
|
||||
<svg className="absolute left-3 top-2.5 w-5 h-5 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* SBOM Metadata */}
|
||||
{sbomData?.metadata && (
|
||||
<div className="bg-slate-50 rounded-lg p-4 mb-6 text-sm">
|
||||
<div className="flex flex-wrap gap-6">
|
||||
<div>
|
||||
<span className="text-slate-500">Format:</span>
|
||||
<span className="ml-2 font-medium">{sbomData.bomFormat} {sbomData.specVersion}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-slate-500">Generiert:</span>
|
||||
<span className="ml-2 font-medium">
|
||||
{sbomData.metadata.timestamp ? new Date(sbomData.metadata.timestamp).toLocaleString('de-DE') : '-'}
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-slate-500">Anwendung:</span>
|
||||
<span className="ml-2 font-medium">
|
||||
{sbomData.metadata.component?.name} v{sbomData.metadata.component?.version}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Components Table */}
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-orange-600"></div>
|
||||
<span className="ml-3 text-gray-600">Lade SBOM...</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="bg-white rounded-lg shadow overflow-hidden">
|
||||
<table className="min-w-full divide-y divide-gray-200">
|
||||
<thead className="bg-gray-50">
|
||||
<tr>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Komponente</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Version</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Kategorie</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider min-w-[300px]">Verwendungszweck</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Port</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Lizenz</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Source</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="bg-white divide-y divide-gray-200">
|
||||
{filteredComponents.map((component, idx) => {
|
||||
// Get license from either the new license field or the old licenses array
|
||||
const licenseId = component.license || component.licenses?.[0]?.license?.id
|
||||
|
||||
return (
|
||||
<tr key={idx} className="hover:bg-gray-50">
|
||||
<td className="px-4 py-4">
|
||||
<div className="text-sm font-medium text-gray-900">{component.name}</div>
|
||||
</td>
|
||||
<td className="px-4 py-4 whitespace-nowrap">
|
||||
<span className="text-sm font-mono text-gray-900">{component.version}</span>
|
||||
</td>
|
||||
<td className="px-4 py-4 whitespace-nowrap">
|
||||
<span className={`px-2 py-1 text-xs font-medium rounded ${getCategoryColor(component.category)}`}>
|
||||
{component.category || component.type}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-4">
|
||||
{component.description ? (
|
||||
<div className="text-sm text-gray-600 leading-relaxed">{component.description}</div>
|
||||
) : (
|
||||
<span className="text-sm text-gray-400 italic">Keine Beschreibung</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-4 whitespace-nowrap">
|
||||
{component.port ? (
|
||||
<span className="text-sm font-mono text-gray-600">{component.port}</span>
|
||||
) : (
|
||||
<span className="text-sm text-gray-400">-</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-4 whitespace-nowrap">
|
||||
{licenseId ? (
|
||||
<span className={`px-2 py-1 text-xs font-medium rounded ${getLicenseColor(licenseId)}`}>
|
||||
{licenseId}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-sm text-gray-400">-</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-4 whitespace-nowrap">
|
||||
{component.sourceUrl && component.sourceUrl !== '-' ? (
|
||||
<a
|
||||
href={component.sourceUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-orange-600 hover:text-orange-800 text-sm flex items-center gap-1"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
|
||||
</svg>
|
||||
GitHub
|
||||
</a>
|
||||
) : (
|
||||
<span className="text-sm text-gray-400">-</span>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
{filteredComponents.length === 0 && (
|
||||
<div className="p-8 text-center text-gray-500">
|
||||
Keine Komponenten gefunden.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Export Button */}
|
||||
<div className="mt-6 flex justify-end">
|
||||
<button
|
||||
onClick={() => {
|
||||
const data = JSON.stringify({
|
||||
...sbomData,
|
||||
infrastructure: INFRASTRUCTURE_COMPONENTS
|
||||
}, null, 2)
|
||||
const blob = new Blob([data], { type: 'application/json' })
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = `breakpilot-sbom-${new Date().toISOString().split('T')[0]}.json`
|
||||
a.click()
|
||||
}}
|
||||
className="px-4 py-2 bg-orange-600 text-white rounded-lg hover:bg-orange-700 transition-colors flex items-center gap-2"
|
||||
>
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" />
|
||||
</svg>
|
||||
SBOM exportieren (JSON)
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Info Tabs Section */}
|
||||
<div className="mt-8">
|
||||
<div className="bg-white rounded-lg shadow">
|
||||
{/* Tab Headers */}
|
||||
<div className="border-b border-gray-200">
|
||||
<nav className="flex -mb-px">
|
||||
<button
|
||||
onClick={() => setActiveInfoTab('audit')}
|
||||
className={`px-6 py-4 text-sm font-medium border-b-2 transition-colors ${
|
||||
activeInfoTab === 'audit'
|
||||
? 'border-orange-500 text-orange-600'
|
||||
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'
|
||||
}`}
|
||||
>
|
||||
<span className="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="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
Audit
|
||||
</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveInfoTab('documentation')}
|
||||
className={`px-6 py-4 text-sm font-medium border-b-2 transition-colors ${
|
||||
activeInfoTab === 'documentation'
|
||||
? 'border-orange-500 text-orange-600'
|
||||
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'
|
||||
}`}
|
||||
>
|
||||
<span className="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="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>
|
||||
Dokumentation
|
||||
</span>
|
||||
</button>
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
{/* Tab Content */}
|
||||
<div className="p-6">
|
||||
{/* Audit Tab */}
|
||||
{activeInfoTab === 'audit' && (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
{/* SBOM Status */}
|
||||
<div className="bg-slate-50 rounded-lg p-4">
|
||||
<h3 className="text-lg font-semibold text-slate-800 mb-4 flex items-center gap-2">
|
||||
<svg className="w-5 h-5 text-blue-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2" />
|
||||
</svg>
|
||||
SBOM Status
|
||||
</h3>
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm text-slate-600">Letzte Generierung</span>
|
||||
<span className="flex items-center gap-2">
|
||||
<span className="w-2 h-2 rounded-full bg-green-500"></span>
|
||||
<span className="text-sm font-medium">CI/CD</span>
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm text-slate-600">Format</span>
|
||||
<span className="flex items-center gap-2">
|
||||
<span className="w-2 h-2 rounded-full bg-green-500"></span>
|
||||
<span className="text-sm font-medium">CycloneDX 1.5</span>
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm text-slate-600">Komponenten</span>
|
||||
<span className="flex items-center gap-2">
|
||||
<span className="w-2 h-2 rounded-full bg-green-500"></span>
|
||||
<span className="text-sm font-medium">Alle erfasst</span>
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm text-slate-600">Transitive Deps</span>
|
||||
<span className="flex items-center gap-2">
|
||||
<span className="w-2 h-2 rounded-full bg-green-500"></span>
|
||||
<span className="text-sm font-medium">Inkludiert</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* License Compliance */}
|
||||
<div className="bg-slate-50 rounded-lg p-4">
|
||||
<h3 className="text-lg font-semibold text-slate-800 mb-4 flex items-center gap-2">
|
||||
<svg className="w-5 h-5 text-purple-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 6l3 1m0 0l-3 9a5.002 5.002 0 006.001 0M6 7l3 9M6 7l6-2m6 2l3-1m-3 1l-3 9a5.002 5.002 0 006.001 0M18 7l3 9m-3-9l-6-2m0-2v2m0 16V5m0 16H9m3 0h3" />
|
||||
</svg>
|
||||
License Compliance
|
||||
</h3>
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm text-slate-600">Erlaubte Lizenzen</span>
|
||||
<span className="flex items-center gap-2">
|
||||
<span className="w-2 h-2 rounded-full bg-green-500"></span>
|
||||
<span className="text-sm font-medium">MIT, Apache, BSD</span>
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm text-slate-600">Copyleft (GPL)</span>
|
||||
<span className="flex items-center gap-2">
|
||||
<span className="w-2 h-2 rounded-full bg-green-500"></span>
|
||||
<span className="text-sm font-medium">0</span>
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm text-slate-600">Unbekannte Lizenzen</span>
|
||||
<span className="flex items-center gap-2">
|
||||
<span className="w-2 h-2 rounded-full bg-green-500"></span>
|
||||
<span className="text-sm font-medium">0</span>
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm text-slate-600">Kommerzielle</span>
|
||||
<span className="flex items-center gap-2">
|
||||
<span className="w-2 h-2 rounded-full bg-yellow-500"></span>
|
||||
<span className="text-sm font-medium">Review erforderlich</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Documentation Tab */}
|
||||
{activeInfoTab === 'documentation' && (
|
||||
<div>
|
||||
<div className="flex justify-between items-center mb-4">
|
||||
<h3 className="text-lg font-semibold text-slate-800">SBOM Dokumentation</h3>
|
||||
<button
|
||||
onClick={() => setShowFullDocs(!showFullDocs)}
|
||||
className="px-4 py-2 bg-slate-100 text-slate-700 rounded-lg hover:bg-slate-200 transition-colors flex items-center gap-2"
|
||||
>
|
||||
<svg className={`w-4 h-4 transition-transform ${showFullDocs ? 'rotate-180' : ''}`} fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
|
||||
</svg>
|
||||
{showFullDocs ? 'Weniger anzeigen' : 'Vollstaendige Dokumentation'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{!showFullDocs ? (
|
||||
<div className="prose prose-slate max-w-none">
|
||||
<p className="text-slate-600">
|
||||
Das SBOM-Modul generiert und analysiert die vollstaendige Komponentenliste aller Software-Abhaengigkeiten.
|
||||
Es dient der Compliance, Sicherheit und Supply-Chain-Transparenz.
|
||||
</p>
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 mt-4">
|
||||
<div className="bg-blue-50 p-4 rounded-lg">
|
||||
<h4 className="font-medium text-blue-800 mb-2">Generator</h4>
|
||||
<p className="text-sm text-blue-600">Syft (Primary), Trivy (Validation)</p>
|
||||
</div>
|
||||
<div className="bg-purple-50 p-4 rounded-lg">
|
||||
<h4 className="font-medium text-purple-800 mb-2">Format</h4>
|
||||
<p className="text-sm text-purple-600">CycloneDX 1.5, SPDX</p>
|
||||
</div>
|
||||
<div className="bg-green-50 p-4 rounded-lg">
|
||||
<h4 className="font-medium text-green-800 mb-2">Retention</h4>
|
||||
<p className="text-sm text-green-600">5 Jahre (Compliance)</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="prose prose-slate max-w-none bg-slate-50 p-6 rounded-lg overflow-auto max-h-[600px]">
|
||||
<h2>Software Bill of Materials (SBOM)</h2>
|
||||
|
||||
<h3>1. Uebersicht</h3>
|
||||
<p>Das SBOM-Modul generiert und analysiert die vollstaendige Komponentenliste aller Software-Abhaengigkeiten. Es dient der Compliance, Sicherheit und Supply-Chain-Transparenz.</p>
|
||||
|
||||
<h3>2. SBOM-Generierung</h3>
|
||||
<pre className="bg-slate-800 text-slate-100 p-4 rounded-lg overflow-x-auto text-sm">
|
||||
{`Source Code
|
||||
│
|
||||
v
|
||||
┌───────────────────────────────────────────────────────────────┐
|
||||
│ SBOM Generators │
|
||||
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────────────┐ │
|
||||
│ │ Syft │ │ Trivy │ │ Native Tooling │ │
|
||||
│ │ (Primary) │ │ (Validation)│ │ (npm, go mod, pip) │ │
|
||||
│ └──────┬──────┘ └──────┬──────┘ └──────────┬──────────┘ │
|
||||
└─────────┼────────────────┼────────────────────┼───────────────┘
|
||||
│ │ │
|
||||
└────────────────┴────────────────────┘
|
||||
│
|
||||
v
|
||||
┌────────────────┐
|
||||
│ CycloneDX │
|
||||
│ Format │
|
||||
└────────────────┘`}
|
||||
</pre>
|
||||
|
||||
<h3>3. Erfasste Komponenten</h3>
|
||||
<table className="min-w-full">
|
||||
<thead>
|
||||
<tr>
|
||||
<th className="text-left">Typ</th>
|
||||
<th className="text-left">Quelle</th>
|
||||
<th className="text-left">Beispiele</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr><td>npm packages</td><td>package-lock.json</td><td>react, next, tailwindcss</td></tr>
|
||||
<tr><td>Go modules</td><td>go.sum</td><td>gin, gorm, jwt-go</td></tr>
|
||||
<tr><td>Python packages</td><td>requirements.txt</td><td>fastapi, pydantic, httpx</td></tr>
|
||||
<tr><td>Container Images</td><td>Dockerfile</td><td>node:20-alpine, postgres:16</td></tr>
|
||||
<tr><td>OS Packages</td><td>apk, apt</td><td>openssl, libpq</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<h3>4. License Compliance</h3>
|
||||
<table className="min-w-full">
|
||||
<thead>
|
||||
<tr>
|
||||
<th className="text-left">Kategorie</th>
|
||||
<th className="text-left">Lizenzen</th>
|
||||
<th className="text-left">Status</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr><td>Permissive (erlaubt)</td><td>MIT, Apache 2.0, BSD, ISC</td><td className="text-green-600">OK</td></tr>
|
||||
<tr><td>Weak Copyleft</td><td>LGPL, MPL</td><td className="text-yellow-600">Review</td></tr>
|
||||
<tr><td>Strong Copyleft</td><td>GPL, AGPL</td><td className="text-red-600">Nicht erlaubt</td></tr>
|
||||
<tr><td>Proprietaer</td><td>Commercial</td><td className="text-yellow-600">Genehmigung</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<h3>5. Aufbewahrung & Compliance</h3>
|
||||
<ul>
|
||||
<li><strong>Retention:</strong> 5 Jahre (Compliance)</li>
|
||||
<li><strong>Format:</strong> JSON + PDF Report</li>
|
||||
<li><strong>Signierung:</strong> Digital signiert</li>
|
||||
<li><strong>Audit:</strong> Jederzeit abrufbar</li>
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,558 @@
|
||||
'use client'
|
||||
|
||||
/**
|
||||
* SBOM (Software Bill of Materials) - Lern-Wizard
|
||||
*
|
||||
* Migriert von /admin/sbom/wizard (website) nach /infrastructure/sbom/wizard (admin-v2)
|
||||
*
|
||||
* Interaktiver Wizard zum Verstehen der SBOM:
|
||||
* - Was ist eine SBOM?
|
||||
* - Warum ist sie wichtig?
|
||||
* - Kategorien erklaert
|
||||
* - Breakpilot Drive (Unity/C#/Game) Komponenten
|
||||
* - Lizenzen und Compliance
|
||||
*/
|
||||
|
||||
import { useState } from 'react'
|
||||
import Link from 'next/link'
|
||||
|
||||
// ==============================================
|
||||
// Types
|
||||
// ==============================================
|
||||
|
||||
type StepStatus = 'pending' | 'active' | 'completed'
|
||||
|
||||
interface WizardStep {
|
||||
id: string
|
||||
name: string
|
||||
icon: string
|
||||
status: StepStatus
|
||||
}
|
||||
|
||||
// ==============================================
|
||||
// Constants
|
||||
// ==============================================
|
||||
|
||||
const STEPS: WizardStep[] = [
|
||||
{ id: 'welcome', name: 'Willkommen', icon: '📋', status: 'pending' },
|
||||
{ id: 'what-is-sbom', name: 'Was ist SBOM?', icon: '❓', status: 'pending' },
|
||||
{ id: 'why-important', name: 'Warum wichtig?', icon: '⚠️', status: 'pending' },
|
||||
{ id: 'categories', name: 'Kategorien', icon: '📁', status: 'pending' },
|
||||
{ id: 'infrastructure', name: 'Infrastruktur', icon: '🏗️', status: 'pending' },
|
||||
{ id: 'unity-game', name: 'Unity & Game', icon: '🎮', status: 'pending' },
|
||||
{ id: 'licenses', name: 'Lizenzen', icon: '📜', status: 'pending' },
|
||||
{ id: 'summary', name: 'Zusammenfassung', icon: '✅', status: 'pending' },
|
||||
]
|
||||
|
||||
const EDUCATION_CONTENT: Record<string, { title: string; content: string[]; tips?: string[] }> = {
|
||||
'welcome': {
|
||||
title: 'Willkommen zum SBOM-Wizard!',
|
||||
content: [
|
||||
'Eine **Software Bill of Materials (SBOM)** ist wie ein Zutaten-Etikett fuer Software.',
|
||||
'Sie listet alle Komponenten auf, aus denen eine Anwendung besteht:',
|
||||
'• Open-Source-Bibliotheken',
|
||||
'• Frameworks und Engines',
|
||||
'• Infrastruktur-Dienste',
|
||||
'• Entwicklungs-Tools',
|
||||
'In diesem Wizard lernst du, warum SBOMs wichtig sind und welche Komponenten BreakPilot verwendet - inklusive der neuen **Breakpilot Drive** (Unity) Komponenten.',
|
||||
],
|
||||
tips: [
|
||||
'SBOMs sind seit 2021 fuer US-Regierungsauftraege Pflicht',
|
||||
'Die EU plant aehnliche Vorschriften im Cyber Resilience Act',
|
||||
],
|
||||
},
|
||||
'what-is-sbom': {
|
||||
title: 'Was ist eine SBOM?',
|
||||
content: [
|
||||
'**SBOM = Software Bill of Materials**',
|
||||
'Eine SBOM ist eine vollstaendige Liste aller Software-Komponenten:',
|
||||
'**Enthaltene Informationen:**',
|
||||
'• Name der Komponente',
|
||||
'• Version',
|
||||
'• Lizenz (MIT, Apache, GPL, etc.)',
|
||||
'• Herkunft (Source URL)',
|
||||
'• Typ (Library, Service, Tool)',
|
||||
'**Formate:**',
|
||||
'• SPDX (Linux Foundation Standard)',
|
||||
'• CycloneDX (OWASP Standard)',
|
||||
'• SWID Tags (ISO Standard)',
|
||||
'BreakPilot verwendet eine eigene Darstellung im Admin-Panel, die alle relevanten Infos zeigt.',
|
||||
],
|
||||
tips: [
|
||||
'Eine SBOM ist wie ein Beipackzettel fuer Medikamente',
|
||||
'Sie ermoeglicht schnelle Reaktion bei Sicherheitsluecken',
|
||||
],
|
||||
},
|
||||
'why-important': {
|
||||
title: 'Warum sind SBOMs wichtig?',
|
||||
content: [
|
||||
'**1. Sicherheit (Security)**',
|
||||
'Wenn eine Sicherheitsluecke in einer Bibliothek entdeckt wird (z.B. Log4j), kannst du sofort pruefen ob du betroffen bist.',
|
||||
'**2. Compliance (Lizenz-Einhaltung)**',
|
||||
'Verschiedene Lizenzen haben verschiedene Anforderungen:',
|
||||
'• MIT: Fast keine Einschraenkungen',
|
||||
'• GPL: Copyleft - abgeleitete Werke muessen auch GPL sein',
|
||||
'• Proprietary: Kommerzielle Nutzung eingeschraenkt',
|
||||
'**3. Supply Chain Security**',
|
||||
'Moderne Software besteht aus hunderten Abhaengigkeiten. Eine SBOM macht diese Kette transparent.',
|
||||
'**4. Regulatorische Anforderungen**',
|
||||
'US Executive Order 14028 verlangt SBOMs fuer Regierungssoftware.',
|
||||
],
|
||||
tips: [
|
||||
'Log4Shell (2021) betraf Millionen von Systemen',
|
||||
'Mit SBOM: Betroffenheit in Minuten geprueft',
|
||||
],
|
||||
},
|
||||
'categories': {
|
||||
title: 'SBOM-Kategorien in BreakPilot',
|
||||
content: [
|
||||
'Die BreakPilot SBOM ist in Kategorien unterteilt:',
|
||||
'**infrastructure** (Blau)',
|
||||
'→ Kern-Infrastruktur: PostgreSQL, Valkey, Keycloak, Docker',
|
||||
'**security-tools** (Rot)',
|
||||
'→ Sicherheits-Tools: Trivy, Gitleaks, Semgrep',
|
||||
'**python** (Gelb)',
|
||||
'→ Python-Backend: FastAPI, Pydantic, httpx',
|
||||
'**go** (Cyan)',
|
||||
'→ Go-Services: Gin, GORM, JWT',
|
||||
'**nodejs** (Gruen)',
|
||||
'→ Frontend: Next.js, React, Tailwind',
|
||||
'**unity** (Amber) ← NEU!',
|
||||
'→ Game Engine: Unity 6, URP, TextMeshPro',
|
||||
'**csharp** (Fuchsia) ← NEU!',
|
||||
'→ C#/.NET: .NET Standard, UnityWebRequest',
|
||||
'**game** (Rose) ← NEU!',
|
||||
'→ Breakpilot Drive Service',
|
||||
],
|
||||
tips: [
|
||||
'Klicke auf eine Kategorie um zu filtern',
|
||||
'Die neuen Unity/Game-Kategorien wurden fuer Breakpilot Drive hinzugefuegt',
|
||||
],
|
||||
},
|
||||
'infrastructure': {
|
||||
title: 'Infrastruktur-Komponenten',
|
||||
content: [
|
||||
'BreakPilot basiert auf robuster Infrastruktur:',
|
||||
'**Datenbanken:**',
|
||||
'• PostgreSQL 16 - Relationale Datenbank',
|
||||
'• Valkey 8 - In-Memory Cache (Redis-Fork)',
|
||||
'• ChromaDB - Vector Store fuer RAG',
|
||||
'**Auth & Security:**',
|
||||
'• Keycloak 23 - Identity & Access Management',
|
||||
'• HashiCorp Vault - Secrets Management',
|
||||
'**Container & Orchestrierung:**',
|
||||
'• Docker - Container Runtime',
|
||||
'• Traefik - Reverse Proxy',
|
||||
'**Kommunikation:**',
|
||||
'• Matrix Synapse - Chat/Messaging',
|
||||
'• Jitsi Meet - Video-Konferenzen',
|
||||
],
|
||||
tips: [
|
||||
'Alle Services laufen in Docker-Containern',
|
||||
'Ports sind in docker-compose.yml definiert',
|
||||
],
|
||||
},
|
||||
'unity-game': {
|
||||
title: 'Unity & Breakpilot Drive',
|
||||
content: [
|
||||
'**Neu hinzugefuegt fuer Breakpilot Drive:**',
|
||||
'**Unity Engine (6000.0)**',
|
||||
'→ Die Game Engine fuer das Lernspiel',
|
||||
'→ Lizenz: Unity EULA (kostenlos bis 100k Revenue)',
|
||||
'**Universal Render Pipeline (17.x)**',
|
||||
'→ Optimierte Grafik-Pipeline fuer WebGL',
|
||||
'→ Lizenz: Unity Companion License',
|
||||
'**TextMeshPro (3.2)**',
|
||||
'→ Fortgeschrittenes Text-Rendering',
|
||||
'**Unity Mathematics (1.3)**',
|
||||
'→ SIMD-optimierte Mathe-Bibliothek',
|
||||
'**Newtonsoft.Json (3.2)**',
|
||||
'→ JSON-Serialisierung fuer API-Kommunikation',
|
||||
'**C# Abhaengigkeiten:**',
|
||||
'• .NET Standard 2.1',
|
||||
'• UnityWebRequest (HTTP Client)',
|
||||
'• System.Text.Json',
|
||||
],
|
||||
tips: [
|
||||
'Unity 6 ist die neueste LTS-Version',
|
||||
'WebGL-Builds sind ~30-50 MB gross',
|
||||
],
|
||||
},
|
||||
'licenses': {
|
||||
title: 'Lizenz-Compliance',
|
||||
content: [
|
||||
'**Lizenz-Typen in BreakPilot:**',
|
||||
'**Permissive (Unkompliziert):**',
|
||||
'• MIT - Die meisten JS/Python Libs',
|
||||
'• Apache 2.0 - FastAPI, Keycloak',
|
||||
'• BSD - PostgreSQL',
|
||||
'**Copyleft (Vorsicht bei Aenderungen):**',
|
||||
'• GPL - Wenige Komponenten',
|
||||
'• AGPL - Jitsi (Server-Side OK)',
|
||||
'**Proprietary:**',
|
||||
'• Unity EULA - Kostenlos bis 100k Revenue',
|
||||
'• Unity Companion - Packages an Engine gebunden',
|
||||
'**Wichtig:**',
|
||||
'Alle verwendeten Lizenzen sind mit kommerziellem Einsatz kompatibel. Bei Fragen: Rechtsabteilung konsultieren.',
|
||||
],
|
||||
tips: [
|
||||
'MIT und Apache 2.0 sind am unproblematischsten',
|
||||
'AGPL erfordert Source-Code-Freigabe bei Modifikation',
|
||||
],
|
||||
},
|
||||
'summary': {
|
||||
title: 'Zusammenfassung',
|
||||
content: [
|
||||
'Du hast die SBOM von BreakPilot kennengelernt:',
|
||||
'✅ Was eine SBOM ist und warum sie wichtig ist',
|
||||
'✅ Die verschiedenen Kategorien (8 Stueck)',
|
||||
'✅ Infrastruktur-Komponenten',
|
||||
'✅ Die neuen Unity/Game-Komponenten fuer Breakpilot Drive',
|
||||
'✅ Lizenz-Typen und Compliance',
|
||||
'**Im SBOM-Dashboard kannst du:**',
|
||||
'• Nach Kategorie filtern',
|
||||
'• Nach Namen suchen',
|
||||
'• Lizenzen pruefen',
|
||||
'• Komponenten-Details ansehen',
|
||||
'**180+ Komponenten** sind dokumentiert und nachverfolgbar.',
|
||||
],
|
||||
tips: [
|
||||
'Pruefe regelmaessig auf veraltete Komponenten',
|
||||
'Bei neuen Abhaengigkeiten: SBOM aktualisieren',
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
// ==============================================
|
||||
// Components
|
||||
// ==============================================
|
||||
|
||||
function WizardStepper({
|
||||
steps,
|
||||
currentStep,
|
||||
onStepClick
|
||||
}: {
|
||||
steps: WizardStep[]
|
||||
currentStep: number
|
||||
onStepClick: (index: number) => void
|
||||
}) {
|
||||
return (
|
||||
<div className="flex items-center justify-between mb-8 overflow-x-auto pb-4">
|
||||
{steps.map((step, index) => (
|
||||
<div key={step.id} className="flex items-center">
|
||||
<button
|
||||
onClick={() => onStepClick(index)}
|
||||
className={`flex flex-col items-center min-w-[80px] p-2 rounded-lg transition-colors ${
|
||||
index === currentStep
|
||||
? 'bg-orange-100 text-orange-700'
|
||||
: step.status === 'completed'
|
||||
? 'bg-green-100 text-green-700 cursor-pointer hover:bg-green-200'
|
||||
: 'text-slate-400 hover:bg-slate-100'
|
||||
}`}
|
||||
>
|
||||
<span className="text-2xl mb-1">{step.icon}</span>
|
||||
<span className="text-xs font-medium text-center">{step.name}</span>
|
||||
{step.status === 'completed' && <span className="text-xs text-green-600">✓</span>}
|
||||
</button>
|
||||
{index < steps.length - 1 && (
|
||||
<div className={`h-0.5 w-8 mx-1 ${
|
||||
index < currentStep ? 'bg-green-400' : 'bg-slate-200'
|
||||
}`} />
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function EducationCard({ stepId }: { stepId: string }) {
|
||||
const content = EDUCATION_CONTENT[stepId]
|
||||
if (!content) return null
|
||||
|
||||
return (
|
||||
<div className="bg-orange-50 border border-orange-200 rounded-lg p-6 mb-6">
|
||||
<h3 className="text-lg font-semibold text-orange-800 mb-4 flex items-center">
|
||||
<span className="mr-2">📖</span>
|
||||
{content.title}
|
||||
</h3>
|
||||
<div className="space-y-2 text-orange-900">
|
||||
{content.content.map((line, index) => (
|
||||
<p
|
||||
key={index}
|
||||
className={`${line.startsWith('•') ? 'ml-4' : ''} ${line.startsWith('**') ? 'font-semibold mt-3' : ''}`}
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: line
|
||||
.replace(/\*\*(.*?)\*\*/g, '<strong>$1</strong>')
|
||||
.replace(/→/g, '<span class="text-orange-600">→</span>')
|
||||
.replace(/← NEU!/g, '<span class="bg-amber-200 text-amber-800 px-1 rounded text-sm font-bold">← NEU!</span>')
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
{content.tips && content.tips.length > 0 && (
|
||||
<div className="mt-4 pt-4 border-t border-orange-200">
|
||||
<p className="text-sm font-semibold text-orange-700 mb-2">💡 Tipps:</p>
|
||||
{content.tips.map((tip, index) => (
|
||||
<p key={index} className="text-sm text-orange-700 ml-4">• {tip}</p>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function CategoryDemo({ stepId }: { stepId: string }) {
|
||||
if (stepId === 'categories') {
|
||||
const categories = [
|
||||
{ name: 'infrastructure', color: 'blue', count: 45 },
|
||||
{ name: 'security-tools', color: 'red', count: 12 },
|
||||
{ name: 'python', color: 'yellow', count: 35 },
|
||||
{ name: 'go', color: 'cyan', count: 18 },
|
||||
{ name: 'nodejs', color: 'green', count: 55 },
|
||||
{ name: 'unity', color: 'amber', count: 7, isNew: true },
|
||||
{ name: 'csharp', color: 'fuchsia', count: 3, isNew: true },
|
||||
{ name: 'game', color: 'rose', count: 1, isNew: true },
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="bg-white border border-slate-200 rounded-lg p-4 mb-4">
|
||||
<h4 className="font-semibold text-slate-800 mb-3">Live-Vorschau: Kategorien</h4>
|
||||
<div className="grid grid-cols-4 gap-2">
|
||||
{categories.map((cat) => (
|
||||
<div
|
||||
key={cat.name}
|
||||
className={`bg-${cat.color}-100 text-${cat.color}-800 px-3 py-2 rounded-lg text-center text-sm relative`}
|
||||
>
|
||||
<p className="font-medium">{cat.name}</p>
|
||||
<p className="text-xs opacity-70">{cat.count} Komponenten</p>
|
||||
{cat.isNew && (
|
||||
<span className="absolute -top-1 -right-1 bg-amber-500 text-white text-xs px-1 rounded">NEU</span>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (stepId === 'unity-game') {
|
||||
const unityComponents = [
|
||||
{ name: 'Unity Engine', version: '6000.0', license: 'Unity EULA' },
|
||||
{ name: 'URP', version: '17.x', license: 'Unity Companion' },
|
||||
{ name: 'TextMeshPro', version: '3.2', license: 'Unity Companion' },
|
||||
{ name: 'Mathematics', version: '1.3', license: 'Unity Companion' },
|
||||
{ name: 'Newtonsoft.Json', version: '3.2', license: 'MIT' },
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="bg-white border border-slate-200 rounded-lg p-4 mb-4">
|
||||
<h4 className="font-semibold text-slate-800 mb-3">Unity Packages (Breakpilot Drive)</h4>
|
||||
<div className="space-y-2">
|
||||
{unityComponents.map((comp) => (
|
||||
<div key={comp.name} className="flex items-center justify-between py-2 border-b border-slate-100 last:border-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="bg-amber-100 text-amber-800 text-xs px-2 py-0.5 rounded">unity</span>
|
||||
<span className="font-medium">{comp.name}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-4 text-sm text-slate-600">
|
||||
<span>{comp.version}</span>
|
||||
<span className="bg-slate-100 px-2 py-0.5 rounded text-xs">{comp.license}</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (stepId === 'licenses') {
|
||||
const licenses = [
|
||||
{ name: 'MIT', count: 85, color: 'green', risk: 'Niedrig' },
|
||||
{ name: 'Apache 2.0', count: 45, color: 'green', risk: 'Niedrig' },
|
||||
{ name: 'BSD', count: 12, color: 'green', risk: 'Niedrig' },
|
||||
{ name: 'Unity EULA', count: 1, color: 'yellow', risk: 'Mittel' },
|
||||
{ name: 'Unity Companion', count: 6, color: 'yellow', risk: 'Mittel' },
|
||||
{ name: 'AGPL', count: 2, color: 'orange', risk: 'Hoch' },
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="bg-white border border-slate-200 rounded-lg p-4 mb-4">
|
||||
<h4 className="font-semibold text-slate-800 mb-3">Lizenz-Uebersicht</h4>
|
||||
<div className="space-y-2">
|
||||
{licenses.map((lic) => (
|
||||
<div key={lic.name} className="flex items-center justify-between py-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium">{lic.name}</span>
|
||||
<span className="text-sm text-slate-500">({lic.count})</span>
|
||||
</div>
|
||||
<span className={`text-xs px-2 py-1 rounded ${
|
||||
lic.risk === 'Niedrig' ? 'bg-green-100 text-green-700' :
|
||||
lic.risk === 'Mittel' ? 'bg-yellow-100 text-yellow-700' :
|
||||
'bg-orange-100 text-orange-700'
|
||||
}`}>
|
||||
Risiko: {lic.risk}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
// ==============================================
|
||||
// Main Component
|
||||
// ==============================================
|
||||
|
||||
export default function SBOMWizardPage() {
|
||||
const [currentStep, setCurrentStep] = useState(0)
|
||||
const [steps, setSteps] = useState<WizardStep[]>(STEPS)
|
||||
|
||||
const currentStepData = steps[currentStep]
|
||||
const isWelcome = currentStepData?.id === 'welcome'
|
||||
const isSummary = currentStepData?.id === 'summary'
|
||||
|
||||
const goToNext = () => {
|
||||
if (currentStep < steps.length - 1) {
|
||||
setSteps(prev => prev.map((step, idx) =>
|
||||
idx === currentStep && step.status === 'pending'
|
||||
? { ...step, status: 'completed' }
|
||||
: step
|
||||
))
|
||||
setCurrentStep(prev => prev + 1)
|
||||
}
|
||||
}
|
||||
|
||||
const goToPrev = () => {
|
||||
if (currentStep > 0) {
|
||||
setCurrentStep(prev => prev - 1)
|
||||
}
|
||||
}
|
||||
|
||||
const handleStepClick = (index: number) => {
|
||||
setCurrentStep(index)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-slate-100 py-8">
|
||||
<div className="max-w-4xl mx-auto px-4">
|
||||
{/* Header */}
|
||||
<div className="bg-white rounded-lg shadow-lg p-6 mb-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-slate-800">📋 SBOM Lern-Wizard</h1>
|
||||
<p className="text-slate-600 mt-1">
|
||||
Software Bill of Materials verstehen
|
||||
</p>
|
||||
</div>
|
||||
<Link
|
||||
href="/infrastructure/sbom"
|
||||
className="text-orange-600 hover:text-orange-800 text-sm"
|
||||
>
|
||||
← Zurueck zur SBOM
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Stepper */}
|
||||
<div className="bg-white rounded-lg shadow-lg p-6 mb-6">
|
||||
<WizardStepper
|
||||
steps={steps}
|
||||
currentStep={currentStep}
|
||||
onStepClick={handleStepClick}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="bg-white rounded-lg shadow-lg p-6">
|
||||
{/* Step Header */}
|
||||
<div className="flex items-center mb-6">
|
||||
<span className="text-4xl mr-4">{currentStepData?.icon}</span>
|
||||
<div>
|
||||
<h2 className="text-xl font-bold text-slate-800">
|
||||
Schritt {currentStep + 1}: {currentStepData?.name}
|
||||
</h2>
|
||||
<p className="text-slate-500 text-sm">
|
||||
{currentStep + 1} von {steps.length}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Education Card */}
|
||||
<EducationCard stepId={currentStepData?.id || ''} />
|
||||
|
||||
{/* Category Demo */}
|
||||
<CategoryDemo stepId={currentStepData?.id || ''} />
|
||||
|
||||
{/* Welcome Start Button */}
|
||||
{isWelcome && (
|
||||
<div className="text-center py-8">
|
||||
<button
|
||||
onClick={goToNext}
|
||||
className="bg-orange-600 text-white px-8 py-3 rounded-lg font-medium hover:bg-orange-700 transition-colors"
|
||||
>
|
||||
🚀 Lern-Tour starten
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Summary Actions */}
|
||||
{isSummary && (
|
||||
<div className="text-center py-6 space-y-4">
|
||||
<div className="flex justify-center gap-4">
|
||||
<Link
|
||||
href="/infrastructure/sbom"
|
||||
className="px-6 py-3 bg-orange-600 text-white rounded-lg font-medium hover:bg-orange-700 transition-colors"
|
||||
>
|
||||
📋 Zur SBOM
|
||||
</Link>
|
||||
<button
|
||||
onClick={() => {
|
||||
setCurrentStep(0)
|
||||
setSteps(STEPS.map(s => ({ ...s, status: 'pending' })))
|
||||
}}
|
||||
className="px-6 py-3 bg-slate-200 text-slate-700 rounded-lg font-medium hover:bg-slate-300 transition-colors"
|
||||
>
|
||||
🔄 Wizard neu starten
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Navigation */}
|
||||
{!isWelcome && (
|
||||
<div className="flex justify-between mt-8 pt-6 border-t">
|
||||
<button
|
||||
onClick={goToPrev}
|
||||
disabled={currentStep === 0}
|
||||
className={`px-6 py-2 rounded-lg transition-colors ${
|
||||
currentStep === 0
|
||||
? 'bg-slate-200 text-slate-400 cursor-not-allowed'
|
||||
: 'bg-slate-200 text-slate-700 hover:bg-slate-300'
|
||||
}`}
|
||||
>
|
||||
← Zurueck
|
||||
</button>
|
||||
|
||||
{!isSummary && (
|
||||
<button
|
||||
onClick={goToNext}
|
||||
className="bg-orange-600 text-white px-6 py-2 rounded-lg hover:bg-orange-700 transition-colors"
|
||||
>
|
||||
Weiter →
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer Info */}
|
||||
<div className="text-center text-slate-500 text-sm mt-6">
|
||||
BreakPilot SBOM - 180+ Komponenten dokumentiert
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,192 @@
|
||||
/**
|
||||
* TypeScript Types for Test Dashboard
|
||||
*/
|
||||
|
||||
export type TestFramework =
|
||||
| 'go_test'
|
||||
| 'pytest'
|
||||
| 'jest'
|
||||
| 'playwright'
|
||||
| 'bqas_golden'
|
||||
| 'bqas_rag'
|
||||
| 'bqas_synthetic'
|
||||
|
||||
export type TestCategory = 'unit' | 'integration' | 'e2e' | 'bqas' | 'security' | 'performance'
|
||||
|
||||
export type TestStatus = 'pending' | 'running' | 'passed' | 'failed' | 'skipped' | 'error'
|
||||
|
||||
export type RunStatus = 'queued' | 'running' | 'completed' | 'failed' | 'cancelled'
|
||||
|
||||
export interface TestCase {
|
||||
id: string
|
||||
name: string
|
||||
file_path: string
|
||||
line_number?: number
|
||||
framework: TestFramework
|
||||
category: TestCategory
|
||||
duration_ms?: number
|
||||
status: TestStatus
|
||||
error_message?: string
|
||||
output?: string
|
||||
}
|
||||
|
||||
export interface ServiceTestInfo {
|
||||
service: string
|
||||
display_name: string
|
||||
port?: number
|
||||
language: string
|
||||
total_tests: number
|
||||
passed_tests: number
|
||||
failed_tests: number
|
||||
skipped_tests: number
|
||||
pass_rate: number
|
||||
coverage_percent?: number
|
||||
last_run?: string
|
||||
status: TestStatus
|
||||
}
|
||||
|
||||
export interface TestRun {
|
||||
id: string
|
||||
suite_id: string
|
||||
service: string
|
||||
started_at: string
|
||||
completed_at?: string
|
||||
status: RunStatus
|
||||
total_tests: number
|
||||
passed_tests: number
|
||||
failed_tests: number
|
||||
skipped_tests: number
|
||||
duration_seconds: number
|
||||
git_commit?: string
|
||||
git_branch?: string
|
||||
coverage_percent?: number
|
||||
triggered_by: string
|
||||
output?: string
|
||||
failed_test_ids: string[]
|
||||
}
|
||||
|
||||
export interface TestRegistryStats {
|
||||
total_tests: number
|
||||
total_passed: number
|
||||
total_failed: number
|
||||
total_skipped: number
|
||||
overall_pass_rate: number
|
||||
average_coverage?: number
|
||||
services_count: number
|
||||
last_full_run?: string
|
||||
by_category: Record<string, number>
|
||||
by_framework: Record<string, number>
|
||||
}
|
||||
|
||||
export interface RegistryResponse {
|
||||
services: ServiceTestInfo[]
|
||||
stats: TestRegistryStats
|
||||
last_updated: string
|
||||
}
|
||||
|
||||
export interface CoverageData {
|
||||
service: string
|
||||
display_name: string
|
||||
coverage_percent: number
|
||||
language: string
|
||||
}
|
||||
|
||||
export interface CoverageResponse {
|
||||
services: CoverageData[]
|
||||
average_coverage: number
|
||||
total_services: number
|
||||
}
|
||||
|
||||
export type TabType = 'overview' | 'unit' | 'integration' | 'bqas' | 'history' | 'backlog' | 'guide'
|
||||
|
||||
export type BacklogStatus = 'open' | 'in_progress' | 'fixed' | 'wont_fix' | 'flaky'
|
||||
export type BacklogPriority = 'critical' | 'high' | 'medium' | 'low'
|
||||
|
||||
export interface FailedTest {
|
||||
id: string
|
||||
name: string
|
||||
service: string
|
||||
file_path: string
|
||||
line_number?: number
|
||||
error_message: string
|
||||
error_type: string
|
||||
suggestion: string
|
||||
run_id: string
|
||||
last_failed: string
|
||||
status: BacklogStatus
|
||||
}
|
||||
|
||||
export interface FailedTestsResponse {
|
||||
total_failed: number
|
||||
by_service: Record<string, FailedTest[]>
|
||||
tests: FailedTest[]
|
||||
last_updated: string
|
||||
}
|
||||
|
||||
// Neue PostgreSQL-basierte Backlog-Typen
|
||||
export interface BacklogItem {
|
||||
id: number
|
||||
test_name: string
|
||||
test_file: string | null
|
||||
service: string
|
||||
framework: string | null
|
||||
error_message: string | null
|
||||
error_type: string | null
|
||||
first_failed_at: string
|
||||
last_failed_at: string
|
||||
failure_count: number
|
||||
status: BacklogStatus
|
||||
priority: BacklogPriority
|
||||
assigned_to: string | null
|
||||
fix_suggestion: string | null
|
||||
notes: string | null
|
||||
created_at: string
|
||||
updated_at: string
|
||||
fixes?: FixAttempt[]
|
||||
}
|
||||
|
||||
export interface FixAttempt {
|
||||
id: number
|
||||
backlog_id: number
|
||||
fix_type: 'manual' | 'auto_claude' | 'auto_script'
|
||||
fix_description: string | null
|
||||
commit_hash: string | null
|
||||
success: boolean
|
||||
created_at: string
|
||||
}
|
||||
|
||||
export interface BacklogResponse {
|
||||
total: number
|
||||
items: BacklogItem[]
|
||||
by_service: Record<string, BacklogItem[]>
|
||||
filters: {
|
||||
status: string | null
|
||||
service: string | null
|
||||
priority: string | null
|
||||
}
|
||||
pagination: {
|
||||
limit: number
|
||||
offset: number
|
||||
}
|
||||
}
|
||||
|
||||
export interface TrendDataPoint {
|
||||
date: string
|
||||
total_tests: number
|
||||
passed: number
|
||||
failed: number
|
||||
runs: number
|
||||
pass_rate: number
|
||||
}
|
||||
|
||||
export interface TrendsResponse {
|
||||
trends: TrendDataPoint[]
|
||||
days: number
|
||||
service: string | null
|
||||
}
|
||||
|
||||
export interface Toast {
|
||||
id: number
|
||||
type: 'success' | 'error' | 'info' | 'loading'
|
||||
message: string
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { Sidebar } from '@/components/layout/Sidebar'
|
||||
import { Header } from '@/components/layout/Header'
|
||||
import { Breadcrumbs } from '@/components/common/Breadcrumbs'
|
||||
import { getStoredRole } from '@/lib/roles'
|
||||
|
||||
export default function AdminLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
const router = useRouter()
|
||||
const [sidebarKey, setSidebarKey] = useState(0)
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
useEffect(() => {
|
||||
// Check if role is stored
|
||||
const role = getStoredRole()
|
||||
if (!role) {
|
||||
// Redirect to role selection
|
||||
router.replace('/')
|
||||
} else {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [router])
|
||||
|
||||
const handleRoleChange = () => {
|
||||
// Force sidebar to re-render
|
||||
setSidebarKey(prev => prev + 1)
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="min-h-screen bg-slate-50 flex items-center justify-center">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-primary-600"></div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-slate-50 flex">
|
||||
{/* Sidebar */}
|
||||
<Sidebar key={sidebarKey} onRoleChange={handleRoleChange} />
|
||||
|
||||
{/* Main Content */}
|
||||
<div className="flex-1 ml-64 transition-all duration-300">
|
||||
{/* Header */}
|
||||
<Header />
|
||||
|
||||
{/* Page Content */}
|
||||
<main className="p-6">
|
||||
<Breadcrumbs />
|
||||
{children}
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
'use client'
|
||||
|
||||
import { getModuleByHref, metaModules } from '@/lib/navigation'
|
||||
import { PagePurpose } from '@/components/common/PagePurpose'
|
||||
import { Compass, Construction } from 'lucide-react'
|
||||
|
||||
export default function OnboardingPage() {
|
||||
const module = metaModules.find(m => m.id === 'onboarding')
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{module && (
|
||||
<PagePurpose
|
||||
title={module.name}
|
||||
purpose={module.purpose}
|
||||
audience={module.audience}
|
||||
collapsible={true}
|
||||
defaultCollapsed={true}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="bg-white border border-slate-200 rounded-xl p-8 text-center">
|
||||
<div className="flex justify-center mb-4">
|
||||
<div className="p-4 bg-slate-100 rounded-full">
|
||||
<Compass className="w-12 h-12 text-slate-400" />
|
||||
</div>
|
||||
</div>
|
||||
<h2 className="text-xl font-semibold text-slate-800 mb-2">Onboarding</h2>
|
||||
<p className="text-slate-600 mb-4">
|
||||
Gefuehrte Tutorials und Lern-Wizards fuer neue Benutzer.
|
||||
</p>
|
||||
<div className="inline-flex items-center gap-2 px-4 py-2 bg-amber-50 border border-amber-200 rounded-lg text-amber-700">
|
||||
<Construction className="w-4 h-4" />
|
||||
<span className="text-sm font-medium">In Entwicklung</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,670 @@
|
||||
'use client'
|
||||
|
||||
/**
|
||||
* Website Manager - CMS Dashboard
|
||||
*
|
||||
* Visual CMS dashboard for the BreakPilot website (macmini:3000).
|
||||
* 60/40 split: Section cards with inline editors | Live iframe preview.
|
||||
* Status bar, content stats, reset, save.
|
||||
*/
|
||||
|
||||
import { useState, useEffect, useRef, useCallback } from 'react'
|
||||
import type {
|
||||
WebsiteContent,
|
||||
HeroContent,
|
||||
FeatureContent,
|
||||
FAQItem,
|
||||
PricingPlan,
|
||||
} from '@/lib/content-types'
|
||||
|
||||
const ADMIN_KEY = 'breakpilot-admin-2024'
|
||||
|
||||
// Section metadata for cards
|
||||
const SECTIONS = [
|
||||
{ key: 'hero', name: 'Hero Section', icon: '🎯', scrollTo: 'hero' },
|
||||
{ key: 'features', name: 'Features', icon: '⚡', scrollTo: 'features' },
|
||||
{ key: 'faq', name: 'FAQ', icon: '❓', scrollTo: 'faq' },
|
||||
{ key: 'pricing', name: 'Pricing', icon: '💰', scrollTo: 'pricing' },
|
||||
{ key: 'trust', name: 'Trust Indicators', icon: '🛡️', scrollTo: 'trust' },
|
||||
{ key: 'testimonial', name: 'Testimonial', icon: '💬', scrollTo: 'trust' },
|
||||
] as const
|
||||
|
||||
type SectionKey = (typeof SECTIONS)[number]['key']
|
||||
|
||||
// ─── Helpers ───────────────────────────────────────────────────────────────
|
||||
|
||||
function countWords(content: WebsiteContent): number {
|
||||
const texts: string[] = []
|
||||
// Hero
|
||||
texts.push(content.hero.badge, content.hero.title, content.hero.titleHighlight1, content.hero.titleHighlight2, content.hero.subtitle, content.hero.ctaPrimary, content.hero.ctaSecondary, content.hero.ctaHint)
|
||||
// Features
|
||||
content.features.forEach(f => { texts.push(f.title, f.description) })
|
||||
// FAQ
|
||||
content.faq.forEach(f => { texts.push(f.question, ...f.answer) })
|
||||
// Pricing
|
||||
content.pricing.forEach(p => { texts.push(p.name, p.description, p.features.tasks, p.features.taskDescription, ...p.features.included) })
|
||||
// Trust
|
||||
texts.push(content.trust.item1.value, content.trust.item1.label, content.trust.item2.value, content.trust.item2.label, content.trust.item3.value, content.trust.item3.label)
|
||||
// Testimonial
|
||||
texts.push(content.testimonial.quote, content.testimonial.author, content.testimonial.role)
|
||||
return texts.filter(Boolean).join(' ').split(/\s+/).filter(Boolean).length
|
||||
}
|
||||
|
||||
function sectionComplete(content: WebsiteContent, section: SectionKey): boolean {
|
||||
switch (section) {
|
||||
case 'hero':
|
||||
return !!(content.hero.title && content.hero.subtitle && content.hero.ctaPrimary)
|
||||
case 'features':
|
||||
return content.features.length > 0 && content.features.every(f => f.title && f.description)
|
||||
case 'faq':
|
||||
return content.faq.length > 0 && content.faq.every(f => f.question && f.answer.length > 0)
|
||||
case 'pricing':
|
||||
return content.pricing.length > 0 && content.pricing.every(p => p.name && p.price > 0)
|
||||
case 'trust':
|
||||
return !!(content.trust.item1.value && content.trust.item2.value && content.trust.item3.value)
|
||||
case 'testimonial':
|
||||
return !!(content.testimonial.quote && content.testimonial.author)
|
||||
}
|
||||
}
|
||||
|
||||
function sectionSummary(content: WebsiteContent, section: SectionKey): string {
|
||||
switch (section) {
|
||||
case 'hero':
|
||||
return `"${content.hero.title} ${content.hero.titleHighlight1}"`.slice(0, 50)
|
||||
case 'features':
|
||||
return `${content.features.length} Features`
|
||||
case 'faq':
|
||||
return `${content.faq.length} Fragen`
|
||||
case 'pricing':
|
||||
return `${content.pricing.length} Plaene`
|
||||
case 'trust':
|
||||
return `${content.trust.item1.value}, ${content.trust.item2.value}, ${content.trust.item3.value}`
|
||||
case 'testimonial':
|
||||
return `"${content.testimonial.quote.slice(0, 40)}..."`
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Main Component ────────────────────────────────────────────────────────
|
||||
|
||||
export default function WebsiteManagerPage() {
|
||||
const [content, setContent] = useState<WebsiteContent | null>(null)
|
||||
const [originalContent, setOriginalContent] = useState<WebsiteContent | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [message, setMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null)
|
||||
const [expandedSection, setExpandedSection] = useState<SectionKey | null>(null)
|
||||
const [websiteStatus, setWebsiteStatus] = useState<{ online: boolean; responseTime: number } | null>(null)
|
||||
const iframeRef = useRef<HTMLIFrameElement>(null)
|
||||
|
||||
// Load content
|
||||
useEffect(() => {
|
||||
loadContent()
|
||||
checkWebsiteStatus()
|
||||
}, [])
|
||||
|
||||
// Auto-dismiss messages
|
||||
useEffect(() => {
|
||||
if (message) {
|
||||
const t = setTimeout(() => setMessage(null), 4000)
|
||||
return () => clearTimeout(t)
|
||||
}
|
||||
}, [message])
|
||||
|
||||
async function loadContent() {
|
||||
try {
|
||||
const res = await fetch('/api/website/content')
|
||||
if (res.ok) {
|
||||
const data = await res.json()
|
||||
setContent(data)
|
||||
setOriginalContent(JSON.parse(JSON.stringify(data)))
|
||||
} else {
|
||||
setMessage({ type: 'error', text: 'Fehler beim Laden des Contents' })
|
||||
}
|
||||
} catch {
|
||||
setMessage({ type: 'error', text: 'Verbindungsfehler beim Laden' })
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function checkWebsiteStatus() {
|
||||
try {
|
||||
const res = await fetch('/api/website/status')
|
||||
if (res.ok) {
|
||||
const data = await res.json()
|
||||
setWebsiteStatus(data)
|
||||
}
|
||||
} catch {
|
||||
setWebsiteStatus({ online: false, responseTime: 0 })
|
||||
}
|
||||
}
|
||||
|
||||
async function saveChanges() {
|
||||
if (!content) return
|
||||
setSaving(true)
|
||||
setMessage(null)
|
||||
try {
|
||||
const res = await fetch('/api/website/content', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', 'x-admin-key': ADMIN_KEY },
|
||||
body: JSON.stringify(content),
|
||||
})
|
||||
if (res.ok) {
|
||||
setMessage({ type: 'success', text: 'Erfolgreich gespeichert!' })
|
||||
setOriginalContent(JSON.parse(JSON.stringify(content)))
|
||||
// Reload iframe to reflect changes
|
||||
if (iframeRef.current) {
|
||||
iframeRef.current.src = iframeRef.current.src
|
||||
}
|
||||
} else {
|
||||
const err = await res.json()
|
||||
setMessage({ type: 'error', text: err.error || 'Fehler beim Speichern' })
|
||||
}
|
||||
} catch {
|
||||
setMessage({ type: 'error', text: 'Verbindungsfehler beim Speichern' })
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
function resetContent() {
|
||||
if (originalContent) {
|
||||
setContent(JSON.parse(JSON.stringify(originalContent)))
|
||||
setMessage({ type: 'success', text: 'Zurueckgesetzt auf letzten gespeicherten Stand' })
|
||||
}
|
||||
}
|
||||
|
||||
// Scroll iframe to section
|
||||
const scrollPreview = useCallback((scrollTo: string) => {
|
||||
if (!iframeRef.current?.contentWindow) return
|
||||
try {
|
||||
iframeRef.current.contentWindow.postMessage(
|
||||
{ type: 'scrollTo', section: scrollTo },
|
||||
'*'
|
||||
)
|
||||
} catch {
|
||||
// cross-origin fallback
|
||||
}
|
||||
}, [])
|
||||
|
||||
function toggleSection(key: SectionKey) {
|
||||
const newExpanded = expandedSection === key ? null : key
|
||||
setExpandedSection(newExpanded)
|
||||
if (newExpanded) {
|
||||
const section = SECTIONS.find(s => s.key === newExpanded)
|
||||
if (section) scrollPreview(section.scrollTo)
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Render ────────────────────────────────────────────────────────────────
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-20">
|
||||
<div className="flex items-center gap-3 text-slate-500">
|
||||
<svg className="w-5 h-5 animate-spin" fill="none" viewBox="0 0 24 24">
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
|
||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
|
||||
</svg>
|
||||
Lade Website-Content...
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!content) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-20">
|
||||
<div className="text-red-600">Content konnte nicht geladen werden.</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const wordCount = countWords(content)
|
||||
const completeSections = SECTIONS.filter(s => sectionComplete(content, s.key)).length
|
||||
const completionPct = Math.round((completeSections / SECTIONS.length) * 100)
|
||||
const hasChanges = JSON.stringify(content) !== JSON.stringify(originalContent)
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* ── Status Bar ───────────────────────────────────────────────────── */}
|
||||
<div className="bg-white rounded-xl border border-slate-200 px-5 py-3 flex items-center justify-between">
|
||||
<div className="flex items-center gap-4">
|
||||
{/* Website status */}
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={`w-2.5 h-2.5 rounded-full ${websiteStatus?.online ? 'bg-green-500' : 'bg-red-500'}`} />
|
||||
<span className="text-sm text-slate-700">
|
||||
Website {websiteStatus?.online ? 'online' : 'offline'}
|
||||
{websiteStatus?.online && websiteStatus.responseTime > 0 && (
|
||||
<span className="text-slate-400 ml-1">({websiteStatus.responseTime}ms)</span>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
{/* Link */}
|
||||
<a
|
||||
href="https://macmini:3000"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-sm text-sky-600 hover:text-sky-700 flex items-center gap-1"
|
||||
>
|
||||
Zur Website
|
||||
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
|
||||
</svg>
|
||||
</a>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
{message && (
|
||||
<span className={`px-3 py-1 rounded-lg text-sm font-medium ${
|
||||
message.type === 'success' ? 'bg-green-50 text-green-700' : 'bg-red-50 text-red-700'
|
||||
}`}>
|
||||
{message.text}
|
||||
</span>
|
||||
)}
|
||||
<button
|
||||
onClick={resetContent}
|
||||
disabled={!hasChanges}
|
||||
className="px-4 py-2 text-sm font-medium text-slate-600 bg-slate-100 rounded-lg hover:bg-slate-200 disabled:opacity-40 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
Reset
|
||||
</button>
|
||||
<button
|
||||
onClick={saveChanges}
|
||||
disabled={saving || !hasChanges}
|
||||
className="px-5 py-2 text-sm font-medium text-white bg-sky-600 rounded-lg hover:bg-sky-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
{saving ? 'Speichern...' : 'Speichern'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Stats Bar ────────────────────────────────────────────────────── */}
|
||||
<div className="grid grid-cols-4 gap-3">
|
||||
{[
|
||||
{ label: 'Sektionen', value: `${SECTIONS.length}`, icon: '📄' },
|
||||
{ label: 'Woerter', value: wordCount.toLocaleString('de-DE'), icon: '📝' },
|
||||
{ label: 'Vollstaendig', value: `${completionPct}%`, icon: completionPct === 100 ? '✅' : '🔧' },
|
||||
{ label: 'Aenderungen', value: hasChanges ? 'Ungespeichert' : 'Aktuell', icon: hasChanges ? '🟡' : '🟢' },
|
||||
].map((stat) => (
|
||||
<div key={stat.label} className="bg-white rounded-xl border border-slate-200 px-4 py-3 flex items-center gap-3">
|
||||
<span className="text-xl">{stat.icon}</span>
|
||||
<div>
|
||||
<div className="text-sm font-semibold text-slate-900">{stat.value}</div>
|
||||
<div className="text-xs text-slate-500">{stat.label}</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* ── Main Layout: 60/40 ───────────────────────────────────────────── */}
|
||||
<div className="grid grid-cols-5 gap-4" style={{ height: 'calc(100vh - 300px)' }}>
|
||||
{/* ── Left: Section Cards (3/5 = 60%) ──────────────────────────── */}
|
||||
<div className="col-span-3 overflow-y-auto pr-1 space-y-3">
|
||||
{SECTIONS.map((section) => {
|
||||
const isExpanded = expandedSection === section.key
|
||||
const isComplete = sectionComplete(content, section.key)
|
||||
return (
|
||||
<div
|
||||
key={section.key}
|
||||
className={`bg-white rounded-xl border transition-all ${
|
||||
isExpanded ? 'border-sky-300 shadow-md' : 'border-slate-200 hover:border-slate-300'
|
||||
}`}
|
||||
>
|
||||
{/* Card Header */}
|
||||
<button
|
||||
onClick={() => toggleSection(section.key)}
|
||||
className="w-full px-5 py-4 flex items-center justify-between text-left"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-xl">{section.icon}</span>
|
||||
<div>
|
||||
<div className="font-medium text-slate-900">{section.name}</div>
|
||||
<div className="text-xs text-slate-500 mt-0.5">{sectionSummary(content, section.key)}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
{isComplete ? (
|
||||
<span className="w-6 h-6 rounded-full bg-green-100 text-green-600 flex items-center justify-center text-xs">
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" /></svg>
|
||||
</span>
|
||||
) : (
|
||||
<span className="w-6 h-6 rounded-full bg-amber-100 text-amber-600 flex items-center justify-center text-xs">!</span>
|
||||
)}
|
||||
<svg
|
||||
className={`w-5 h-5 text-slate-400 transition-transform ${isExpanded ? 'rotate-180' : ''}`}
|
||||
fill="none" viewBox="0 0 24 24" stroke="currentColor"
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
|
||||
</svg>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{/* Inline Editor */}
|
||||
{isExpanded && (
|
||||
<div className="px-5 pb-5 border-t border-slate-100 pt-4">
|
||||
{section.key === 'hero' && <HeroEditor content={content} setContent={setContent} />}
|
||||
{section.key === 'features' && <FeaturesEditor content={content} setContent={setContent} />}
|
||||
{section.key === 'faq' && <FAQEditor content={content} setContent={setContent} />}
|
||||
{section.key === 'pricing' && <PricingEditor content={content} setContent={setContent} />}
|
||||
{section.key === 'trust' && <TrustEditor content={content} setContent={setContent} />}
|
||||
{section.key === 'testimonial' && <TestimonialEditor content={content} setContent={setContent} />}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* ── Right: Live Preview (2/5 = 40%) ──────────────────────────── */}
|
||||
<div className="col-span-2 bg-white rounded-xl border border-slate-200 overflow-hidden flex flex-col">
|
||||
{/* Preview Header */}
|
||||
<div className="bg-slate-50 border-b border-slate-200 px-4 py-2.5 flex items-center justify-between flex-shrink-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex gap-1.5">
|
||||
<div className="w-2.5 h-2.5 rounded-full bg-red-400" />
|
||||
<div className="w-2.5 h-2.5 rounded-full bg-yellow-400" />
|
||||
<div className="w-2.5 h-2.5 rounded-full bg-green-400" />
|
||||
</div>
|
||||
<span className="text-xs text-slate-500 ml-2">macmini:3000</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => { if (iframeRef.current) iframeRef.current.src = iframeRef.current.src }}
|
||||
className="p-1 text-slate-400 hover:text-slate-600 rounded transition-colors"
|
||||
title="Preview neu laden"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
{/* iframe */}
|
||||
<div className="flex-1 relative bg-slate-100">
|
||||
<iframe
|
||||
ref={iframeRef}
|
||||
src="https://macmini:3000/?preview=true"
|
||||
className="absolute inset-0 w-full h-full border-0"
|
||||
style={{
|
||||
width: '166.67%',
|
||||
height: '166.67%',
|
||||
transform: 'scale(0.6)',
|
||||
transformOrigin: 'top left',
|
||||
}}
|
||||
title="Website Preview"
|
||||
sandbox="allow-same-origin allow-scripts"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── Section Editors ─────────────────────────────────────────────────────────
|
||||
|
||||
interface EditorProps {
|
||||
content: WebsiteContent
|
||||
setContent: React.Dispatch<React.SetStateAction<WebsiteContent | null>>
|
||||
}
|
||||
|
||||
const inputCls = 'w-full px-3 py-2 border border-slate-300 rounded-lg text-sm focus:ring-2 focus:ring-sky-500 focus:border-sky-500 transition-colors'
|
||||
const labelCls = 'block text-xs font-medium text-slate-600 mb-1'
|
||||
|
||||
// ─── Hero Editor ─────────────────────────────────────────────────────────────
|
||||
|
||||
function HeroEditor({ content, setContent }: EditorProps) {
|
||||
function update(field: keyof HeroContent, value: string) {
|
||||
setContent(c => c ? { ...c, hero: { ...c.hero, [field]: value } } : c)
|
||||
}
|
||||
return (
|
||||
<div className="grid gap-3">
|
||||
<div>
|
||||
<label className={labelCls}>Badge</label>
|
||||
<input className={inputCls} value={content.hero.badge} onChange={e => update('badge', e.target.value)} />
|
||||
</div>
|
||||
<div>
|
||||
<label className={labelCls}>Titel</label>
|
||||
<input className={inputCls} value={content.hero.title} onChange={e => update('title', e.target.value)} />
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className={labelCls}>Highlight 1</label>
|
||||
<input className={inputCls} value={content.hero.titleHighlight1} onChange={e => update('titleHighlight1', e.target.value)} />
|
||||
</div>
|
||||
<div>
|
||||
<label className={labelCls}>Highlight 2</label>
|
||||
<input className={inputCls} value={content.hero.titleHighlight2} onChange={e => update('titleHighlight2', e.target.value)} />
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className={labelCls}>Untertitel</label>
|
||||
<textarea className={inputCls} rows={2} value={content.hero.subtitle} onChange={e => update('subtitle', e.target.value)} />
|
||||
</div>
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
<div>
|
||||
<label className={labelCls}>CTA Primaer</label>
|
||||
<input className={inputCls} value={content.hero.ctaPrimary} onChange={e => update('ctaPrimary', e.target.value)} />
|
||||
</div>
|
||||
<div>
|
||||
<label className={labelCls}>CTA Sekundaer</label>
|
||||
<input className={inputCls} value={content.hero.ctaSecondary} onChange={e => update('ctaSecondary', e.target.value)} />
|
||||
</div>
|
||||
<div>
|
||||
<label className={labelCls}>CTA Hinweis</label>
|
||||
<input className={inputCls} value={content.hero.ctaHint} onChange={e => update('ctaHint', e.target.value)} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── Features Editor ─────────────────────────────────────────────────────────
|
||||
|
||||
function FeaturesEditor({ content, setContent }: EditorProps) {
|
||||
function update(index: number, field: keyof FeatureContent, value: string) {
|
||||
setContent(c => {
|
||||
if (!c) return c
|
||||
const features = [...c.features]
|
||||
features[index] = { ...features[index], [field]: value }
|
||||
return { ...c, features }
|
||||
})
|
||||
}
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{content.features.map((feature, i) => (
|
||||
<div key={feature.id} className="bg-slate-50 rounded-lg p-3 space-y-2">
|
||||
<div className="grid grid-cols-6 gap-2">
|
||||
<div>
|
||||
<label className={labelCls}>Icon</label>
|
||||
<input className={`${inputCls} text-center text-lg`} value={feature.icon} onChange={e => update(i, 'icon', e.target.value)} />
|
||||
</div>
|
||||
<div className="col-span-5">
|
||||
<label className={labelCls}>Titel</label>
|
||||
<input className={inputCls} value={feature.title} onChange={e => update(i, 'title', e.target.value)} />
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className={labelCls}>Beschreibung</label>
|
||||
<textarea className={inputCls} rows={2} value={feature.description} onChange={e => update(i, 'description', e.target.value)} />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── FAQ Editor ──────────────────────────────────────────────────────────────
|
||||
|
||||
function FAQEditor({ content, setContent }: EditorProps) {
|
||||
function updateItem(index: number, field: 'question' | 'answer', value: string) {
|
||||
setContent(c => {
|
||||
if (!c) return c
|
||||
const faq = [...c.faq]
|
||||
if (field === 'answer') {
|
||||
faq[index] = { ...faq[index], answer: value.split('\n') }
|
||||
} else {
|
||||
faq[index] = { ...faq[index], question: value }
|
||||
}
|
||||
return { ...c, faq }
|
||||
})
|
||||
}
|
||||
function addItem() {
|
||||
setContent(c => c ? { ...c, faq: [...c.faq, { question: 'Neue Frage?', answer: ['Antwort hier...'] }] } : c)
|
||||
}
|
||||
function removeItem(index: number) {
|
||||
setContent(c => c ? { ...c, faq: c.faq.filter((_, i) => i !== index) } : c)
|
||||
}
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{content.faq.map((item, i) => (
|
||||
<div key={i} className="bg-slate-50 rounded-lg p-3 space-y-2 relative group">
|
||||
<button
|
||||
onClick={() => removeItem(i)}
|
||||
className="absolute top-2 right-2 p-1 text-red-400 hover:text-red-600 opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
title="Entfernen"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
<div>
|
||||
<label className={labelCls}>Frage {i + 1}</label>
|
||||
<input className={inputCls} value={item.question} onChange={e => updateItem(i, 'question', e.target.value)} />
|
||||
</div>
|
||||
<div>
|
||||
<label className={labelCls}>Antwort</label>
|
||||
<textarea className={`${inputCls} font-mono`} rows={3} value={item.answer.join('\n')} onChange={e => updateItem(i, 'answer', e.target.value)} />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
<button onClick={addItem} className="w-full py-2 border-2 border-dashed border-slate-300 rounded-lg text-sm text-slate-500 hover:border-sky-400 hover:text-sky-600 transition-colors">
|
||||
+ Frage hinzufuegen
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── Pricing Editor ──────────────────────────────────────────────────────────
|
||||
|
||||
function PricingEditor({ content, setContent }: EditorProps) {
|
||||
function update(index: number, field: string, value: string | number | boolean) {
|
||||
setContent(c => {
|
||||
if (!c) return c
|
||||
const pricing = [...c.pricing]
|
||||
if (field === 'price') {
|
||||
pricing[index] = { ...pricing[index], price: Number(value) }
|
||||
} else if (field === 'popular') {
|
||||
pricing[index] = { ...pricing[index], popular: Boolean(value) }
|
||||
} else if (field.startsWith('features.')) {
|
||||
const sub = field.replace('features.', '')
|
||||
if (sub === 'included' && typeof value === 'string') {
|
||||
pricing[index] = { ...pricing[index], features: { ...pricing[index].features, included: value.split('\n') } }
|
||||
} else {
|
||||
pricing[index] = { ...pricing[index], features: { ...pricing[index].features, [sub]: value } }
|
||||
}
|
||||
} else {
|
||||
pricing[index] = { ...pricing[index], [field]: value }
|
||||
}
|
||||
return { ...c, pricing }
|
||||
})
|
||||
}
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{content.pricing.map((plan, i) => (
|
||||
<div key={plan.id} className="bg-slate-50 rounded-lg p-3 space-y-2">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<span className="text-sm font-semibold text-slate-800">{plan.name}</span>
|
||||
{plan.popular && <span className="text-xs bg-sky-100 text-sky-700 px-1.5 py-0.5 rounded">Beliebt</span>}
|
||||
</div>
|
||||
<div className="grid grid-cols-4 gap-2">
|
||||
<div>
|
||||
<label className={labelCls}>Name</label>
|
||||
<input className={inputCls} value={plan.name} onChange={e => update(i, 'name', e.target.value)} />
|
||||
</div>
|
||||
<div>
|
||||
<label className={labelCls}>Preis (EUR)</label>
|
||||
<input className={inputCls} type="number" step="0.01" value={plan.price} onChange={e => update(i, 'price', e.target.value)} />
|
||||
</div>
|
||||
<div>
|
||||
<label className={labelCls}>Intervall</label>
|
||||
<input className={inputCls} value={plan.interval} onChange={e => update(i, 'interval', e.target.value)} />
|
||||
</div>
|
||||
<div className="flex items-end pb-1">
|
||||
<label className="flex items-center gap-2 cursor-pointer">
|
||||
<input type="checkbox" checked={plan.popular || false} onChange={e => update(i, 'popular', e.target.checked)} className="w-4 h-4 text-sky-600 rounded" />
|
||||
<span className="text-xs text-slate-600">Beliebt</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className={labelCls}>Beschreibung</label>
|
||||
<input className={inputCls} value={plan.description} onChange={e => update(i, 'description', e.target.value)} />
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div>
|
||||
<label className={labelCls}>Aufgaben</label>
|
||||
<input className={inputCls} value={plan.features.tasks} onChange={e => update(i, 'features.tasks', e.target.value)} />
|
||||
</div>
|
||||
<div>
|
||||
<label className={labelCls}>Aufgaben-Beschreibung</label>
|
||||
<input className={inputCls} value={plan.features.taskDescription} onChange={e => update(i, 'features.taskDescription', e.target.value)} />
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className={labelCls}>Features (eine pro Zeile)</label>
|
||||
<textarea className={`${inputCls} font-mono`} rows={3} value={plan.features.included.join('\n')} onChange={e => update(i, 'features.included', e.target.value)} />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── Trust Editor ────────────────────────────────────────────────────────────
|
||||
|
||||
function TrustEditor({ content, setContent }: EditorProps) {
|
||||
function update(key: 'item1' | 'item2' | 'item3', field: 'value' | 'label', val: string) {
|
||||
setContent(c => c ? { ...c, trust: { ...c.trust, [key]: { ...c.trust[key], [field]: val } } } : c)
|
||||
}
|
||||
return (
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
{(['item1', 'item2', 'item3'] as const).map((key, i) => (
|
||||
<div key={key} className="bg-slate-50 rounded-lg p-3 space-y-2">
|
||||
<div>
|
||||
<label className={labelCls}>Wert {i + 1}</label>
|
||||
<input className={inputCls} value={content.trust[key].value} onChange={e => update(key, 'value', e.target.value)} />
|
||||
</div>
|
||||
<div>
|
||||
<label className={labelCls}>Label {i + 1}</label>
|
||||
<input className={inputCls} value={content.trust[key].label} onChange={e => update(key, 'label', e.target.value)} />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── Testimonial Editor ──────────────────────────────────────────────────────
|
||||
|
||||
function TestimonialEditor({ content, setContent }: EditorProps) {
|
||||
function update(field: 'quote' | 'author' | 'role', value: string) {
|
||||
setContent(c => c ? { ...c, testimonial: { ...c.testimonial, [field]: value } } : c)
|
||||
}
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<label className={labelCls}>Zitat</label>
|
||||
<textarea className={inputCls} rows={3} value={content.testimonial.quote} onChange={e => update('quote', e.target.value)} />
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className={labelCls}>Autor</label>
|
||||
<input className={inputCls} value={content.testimonial.author} onChange={e => update('author', e.target.value)} />
|
||||
</div>
|
||||
<div>
|
||||
<label className={labelCls}>Rolle</label>
|
||||
<input className={inputCls} value={content.testimonial.role} onChange={e => update('role', e.target.value)} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
'use client'
|
||||
|
||||
import { getCategoryById } from '@/lib/navigation'
|
||||
import { ModuleCard } from '@/components/common/ModuleCard'
|
||||
import { PagePurpose } from '@/components/common/PagePurpose'
|
||||
|
||||
export default function WebsitePage() {
|
||||
const category = getCategoryById('website')
|
||||
|
||||
if (!category) {
|
||||
return <div>Kategorie nicht gefunden</div>
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* Page Purpose */}
|
||||
<PagePurpose
|
||||
title={category.name}
|
||||
purpose="Website Content & Management. Verwalten Sie Inhalte, Uebersetzungen und das CMS."
|
||||
audience={['Content Manager', 'Entwickler']}
|
||||
architecture={{
|
||||
services: ['website (Next.js)'],
|
||||
databases: [],
|
||||
}}
|
||||
collapsible={true}
|
||||
defaultCollapsed={false}
|
||||
/>
|
||||
|
||||
{/* Modules Grid */}
|
||||
<h2 className="text-lg font-semibold text-slate-900 mb-4">Module</h2>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{category.modules.map((module) => (
|
||||
<ModuleCard key={module.id} module={module} category={category} />
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Info Section */}
|
||||
<div className="mt-8 bg-sky-50 border border-sky-200 rounded-xl p-6">
|
||||
<h3 className="font-semibold text-sky-800 flex items-center gap-2">
|
||||
<span>🌐</span>
|
||||
Website CMS
|
||||
</h3>
|
||||
<p className="text-sm text-sky-700 mt-2">
|
||||
Die BreakPilot Website wird ueber ein visuelles CMS verwaltet.
|
||||
Inhalte koennen direkt bearbeitet und in mehrere Sprachen uebersetzt werden.
|
||||
Aenderungen werden nach dem Speichern sofort auf der Website sichtbar.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,769 @@
|
||||
'use client'
|
||||
|
||||
/**
|
||||
* Uebersetzungen - Website Content Editor
|
||||
*
|
||||
* Allows editing all website texts:
|
||||
* - Hero Section
|
||||
* - Features
|
||||
* - FAQ
|
||||
* - Pricing
|
||||
* - Trust Indicators
|
||||
* - Testimonial
|
||||
*
|
||||
* Includes Live-Preview of website
|
||||
*/
|
||||
|
||||
import { useState, useEffect, useRef, useCallback } from 'react'
|
||||
import { WebsiteContent, HeroContent, FeatureContent } from '@/lib/content-types'
|
||||
|
||||
// Admin Key (in production via login)
|
||||
const ADMIN_KEY = 'breakpilot-admin-2024'
|
||||
|
||||
// Mapping tabs to website sections
|
||||
const SECTION_MAP: Record<string, { selector: string; scrollTo: string }> = {
|
||||
hero: { selector: '#hero', scrollTo: 'hero' },
|
||||
features: { selector: '#features', scrollTo: 'features' },
|
||||
faq: { selector: '#faq', scrollTo: 'faq' },
|
||||
pricing: { selector: '#pricing', scrollTo: 'pricing' },
|
||||
other: { selector: '#trust', scrollTo: 'trust' },
|
||||
}
|
||||
|
||||
export default function UebersetzungenPage() {
|
||||
const [content, setContent] = useState<WebsiteContent | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [message, setMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null)
|
||||
const [activeTab, setActiveTab] = useState<'hero' | 'features' | 'faq' | 'pricing' | 'other'>('hero')
|
||||
const [showPreview, setShowPreview] = useState(true)
|
||||
const iframeRef = useRef<HTMLIFrameElement>(null)
|
||||
|
||||
// Scroll preview to section
|
||||
const scrollToSection = useCallback((tab: string) => {
|
||||
if (!iframeRef.current?.contentWindow) return
|
||||
const section = SECTION_MAP[tab]
|
||||
if (section) {
|
||||
try {
|
||||
iframeRef.current.contentWindow.postMessage(
|
||||
{ type: 'scrollTo', section: section.scrollTo },
|
||||
'*'
|
||||
)
|
||||
} catch {
|
||||
// Same-origin policy - fallback
|
||||
}
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Scroll to section on tab change
|
||||
useEffect(() => {
|
||||
scrollToSection(activeTab)
|
||||
}, [activeTab, scrollToSection])
|
||||
|
||||
// Load content
|
||||
useEffect(() => {
|
||||
loadContent()
|
||||
}, [])
|
||||
|
||||
async function loadContent() {
|
||||
try {
|
||||
const res = await fetch('/api/website/content')
|
||||
if (res.ok) {
|
||||
const data = await res.json()
|
||||
setContent(data)
|
||||
} else {
|
||||
setMessage({ type: 'error', text: 'Fehler beim Laden' })
|
||||
}
|
||||
} catch {
|
||||
setMessage({ type: 'error', text: 'Fehler beim Laden' })
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function saveChanges() {
|
||||
if (!content) return
|
||||
|
||||
setSaving(true)
|
||||
setMessage(null)
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/website/content', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'x-admin-key': ADMIN_KEY,
|
||||
},
|
||||
body: JSON.stringify(content),
|
||||
})
|
||||
|
||||
if (res.ok) {
|
||||
setMessage({ type: 'success', text: 'Gespeichert!' })
|
||||
} else {
|
||||
const error = await res.json()
|
||||
setMessage({ type: 'error', text: error.error || 'Fehler beim Speichern' })
|
||||
}
|
||||
} catch {
|
||||
setMessage({ type: 'error', text: 'Fehler beim Speichern' })
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
// Hero Section update
|
||||
function updateHero(field: keyof HeroContent, value: string) {
|
||||
if (!content) return
|
||||
setContent({
|
||||
...content,
|
||||
hero: { ...content.hero, [field]: value },
|
||||
})
|
||||
}
|
||||
|
||||
// Feature update
|
||||
function updateFeature(index: number, field: keyof FeatureContent, value: string) {
|
||||
if (!content) return
|
||||
const newFeatures = [...content.features]
|
||||
newFeatures[index] = { ...newFeatures[index], [field]: value }
|
||||
setContent({ ...content, features: newFeatures })
|
||||
}
|
||||
|
||||
// FAQ update
|
||||
function updateFAQ(index: number, field: 'question' | 'answer', value: string | string[]) {
|
||||
if (!content) return
|
||||
const newFAQ = [...content.faq]
|
||||
if (field === 'answer' && typeof value === 'string') {
|
||||
newFAQ[index] = { ...newFAQ[index], answer: value.split('\n') }
|
||||
} else if (field === 'question' && typeof value === 'string') {
|
||||
newFAQ[index] = { ...newFAQ[index], question: value }
|
||||
}
|
||||
setContent({ ...content, faq: newFAQ })
|
||||
}
|
||||
|
||||
// Add FAQ
|
||||
function addFAQ() {
|
||||
if (!content) return
|
||||
setContent({
|
||||
...content,
|
||||
faq: [...content.faq, { question: 'Neue Frage?', answer: ['Antwort hier...'] }],
|
||||
})
|
||||
}
|
||||
|
||||
// Remove FAQ
|
||||
function removeFAQ(index: number) {
|
||||
if (!content) return
|
||||
const newFAQ = content.faq.filter((_, i) => i !== index)
|
||||
setContent({ ...content, faq: newFAQ })
|
||||
}
|
||||
|
||||
// Pricing update
|
||||
function updatePricing(index: number, field: string, value: string | number | boolean) {
|
||||
if (!content) return
|
||||
const newPricing = [...content.pricing]
|
||||
if (field === 'price') {
|
||||
newPricing[index] = { ...newPricing[index], price: Number(value) }
|
||||
} else if (field === 'popular') {
|
||||
newPricing[index] = { ...newPricing[index], popular: Boolean(value) }
|
||||
} else if (field.startsWith('features.')) {
|
||||
const subField = field.replace('features.', '')
|
||||
if (subField === 'included' && typeof value === 'string') {
|
||||
newPricing[index] = {
|
||||
...newPricing[index],
|
||||
features: {
|
||||
...newPricing[index].features,
|
||||
included: value.split('\n'),
|
||||
},
|
||||
}
|
||||
} else {
|
||||
newPricing[index] = {
|
||||
...newPricing[index],
|
||||
features: {
|
||||
...newPricing[index].features,
|
||||
[subField]: value,
|
||||
},
|
||||
}
|
||||
}
|
||||
} else {
|
||||
newPricing[index] = { ...newPricing[index], [field]: value }
|
||||
}
|
||||
setContent({ ...content, pricing: newPricing })
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<div className="text-xl text-slate-600">Laden...</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!content) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<div className="text-xl text-red-600">Fehler beim Laden</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* Toolbar */}
|
||||
<div className="bg-white rounded-xl border border-slate-200 p-4 mb-6 flex items-center justify-between">
|
||||
<div className="flex items-center gap-4">
|
||||
<h1 className="text-lg font-semibold text-slate-900">Uebersetzungen</h1>
|
||||
{/* Preview Toggle */}
|
||||
<button
|
||||
onClick={() => setShowPreview(!showPreview)}
|
||||
className={`flex items-center gap-2 px-3 py-2 rounded-lg text-sm font-medium transition-colors ${
|
||||
showPreview
|
||||
? 'bg-blue-100 text-blue-700'
|
||||
: 'bg-slate-100 text-slate-600 hover:bg-slate-200'
|
||||
}`}
|
||||
title={showPreview ? 'Preview ausblenden' : 'Preview einblenden'}
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z" />
|
||||
</svg>
|
||||
Live-Preview
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
{message && (
|
||||
<span
|
||||
className={`px-3 py-1 rounded text-sm ${
|
||||
message.type === 'success'
|
||||
? 'bg-green-100 text-green-800'
|
||||
: 'bg-red-100 text-red-800'
|
||||
}`}
|
||||
>
|
||||
{message.text}
|
||||
</span>
|
||||
)}
|
||||
<button
|
||||
onClick={saveChanges}
|
||||
disabled={saving}
|
||||
className="bg-blue-600 text-white px-6 py-2 rounded-lg font-medium hover:bg-blue-700 disabled:opacity-50 transition-colors"
|
||||
>
|
||||
{saving ? 'Speichern...' : 'Speichern'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tabs */}
|
||||
<div className="mb-6">
|
||||
<div className="flex gap-1 bg-slate-100 p-1 rounded-lg w-fit">
|
||||
{(['hero', 'features', 'faq', 'pricing', 'other'] as const).map((tab) => (
|
||||
<button
|
||||
key={tab}
|
||||
onClick={() => setActiveTab(tab)}
|
||||
className={`px-4 py-2 text-sm font-medium rounded-md transition-colors ${
|
||||
activeTab === tab
|
||||
? 'bg-white text-slate-900 shadow-sm'
|
||||
: 'text-slate-600 hover:text-slate-900'
|
||||
}`}
|
||||
>
|
||||
{tab === 'hero' && 'Hero'}
|
||||
{tab === 'features' && 'Features'}
|
||||
{tab === 'faq' && 'FAQ'}
|
||||
{tab === 'pricing' && 'Preise'}
|
||||
{tab === 'other' && 'Sonstige'}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Split Layout: Editor + Preview */}
|
||||
<div className={`grid gap-6 ${showPreview ? 'grid-cols-2' : 'grid-cols-1'}`}>
|
||||
{/* Editor Panel */}
|
||||
<div className="bg-white rounded-xl border border-slate-200 shadow-sm p-6 max-h-[calc(100vh-280px)] overflow-y-auto">
|
||||
{/* Hero Tab */}
|
||||
{activeTab === 'hero' && (
|
||||
<div className="space-y-6">
|
||||
<h2 className="text-xl font-semibold text-slate-900">Hero Section</h2>
|
||||
|
||||
<div className="grid gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1">Badge</label>
|
||||
<input
|
||||
type="text"
|
||||
value={content.hero.badge}
|
||||
onChange={(e) => updateHero('badge', 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-blue-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1">
|
||||
Titel (vor Highlight)
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={content.hero.title}
|
||||
onChange={(e) => updateHero('title', 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-blue-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1">
|
||||
Highlight 1
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={content.hero.titleHighlight1}
|
||||
onChange={(e) => updateHero('titleHighlight1', 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-blue-500"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1">
|
||||
Highlight 2
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={content.hero.titleHighlight2}
|
||||
onChange={(e) => updateHero('titleHighlight2', 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-blue-500"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1">Untertitel</label>
|
||||
<textarea
|
||||
value={content.hero.subtitle}
|
||||
onChange={(e) => updateHero('subtitle', e.target.value)}
|
||||
rows={3}
|
||||
className="w-full px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1">
|
||||
CTA Primaer
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={content.hero.ctaPrimary}
|
||||
onChange={(e) => updateHero('ctaPrimary', 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-blue-500"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1">
|
||||
CTA Sekundaer
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={content.hero.ctaSecondary}
|
||||
onChange={(e) => updateHero('ctaSecondary', 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-blue-500"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1">CTA Hinweis</label>
|
||||
<input
|
||||
type="text"
|
||||
value={content.hero.ctaHint}
|
||||
onChange={(e) => updateHero('ctaHint', 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-blue-500"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Features Tab */}
|
||||
{activeTab === 'features' && (
|
||||
<div className="space-y-6">
|
||||
<h2 className="text-xl font-semibold text-slate-900">Features</h2>
|
||||
|
||||
{content.features.map((feature, index) => (
|
||||
<div key={feature.id} className="border border-slate-200 rounded-lg p-4">
|
||||
<div className="grid gap-4">
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1">Icon</label>
|
||||
<input
|
||||
type="text"
|
||||
value={feature.icon}
|
||||
onChange={(e) => updateFeature(index, 'icon', e.target.value)}
|
||||
className="w-full px-3 py-2 border border-slate-300 rounded-lg text-2xl text-center"
|
||||
/>
|
||||
</div>
|
||||
<div className="col-span-2">
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1">Titel</label>
|
||||
<input
|
||||
type="text"
|
||||
value={feature.title}
|
||||
onChange={(e) => updateFeature(index, 'title', 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-blue-500"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1">
|
||||
Beschreibung
|
||||
</label>
|
||||
<textarea
|
||||
value={feature.description}
|
||||
onChange={(e) => updateFeature(index, 'description', e.target.value)}
|
||||
rows={2}
|
||||
className="w-full px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* FAQ Tab */}
|
||||
{activeTab === 'faq' && (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-xl font-semibold text-slate-900">FAQ</h2>
|
||||
<button
|
||||
onClick={addFAQ}
|
||||
className="px-4 py-2 bg-slate-100 text-slate-700 rounded-lg hover:bg-slate-200 transition-colors"
|
||||
>
|
||||
+ Frage hinzufuegen
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{content.faq.map((item, index) => (
|
||||
<div key={index} className="border border-slate-200 rounded-lg p-4">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="flex-1 space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1">
|
||||
Frage {index + 1}
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={item.question}
|
||||
onChange={(e) => updateFAQ(index, 'question', 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-blue-500"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1">
|
||||
Antwort
|
||||
</label>
|
||||
<textarea
|
||||
value={item.answer.join('\n')}
|
||||
onChange={(e) => updateFAQ(index, 'answer', e.target.value)}
|
||||
rows={4}
|
||||
className="w-full px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 font-mono text-sm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => removeFAQ(index)}
|
||||
className="p-2 text-red-600 hover:bg-red-50 rounded-lg transition-colors"
|
||||
title="Frage entfernen"
|
||||
>
|
||||
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Pricing Tab */}
|
||||
{activeTab === 'pricing' && (
|
||||
<div className="space-y-6">
|
||||
<h2 className="text-xl font-semibold text-slate-900">Preise</h2>
|
||||
|
||||
{content.pricing.map((plan, index) => (
|
||||
<div key={plan.id} className="border border-slate-200 rounded-lg p-4">
|
||||
<div className="grid gap-4">
|
||||
<div className="grid grid-cols-4 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1">Name</label>
|
||||
<input
|
||||
type="text"
|
||||
value={plan.name}
|
||||
onChange={(e) => updatePricing(index, 'name', 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-blue-500"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1">
|
||||
Preis (EUR)
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
step="0.01"
|
||||
value={plan.price}
|
||||
onChange={(e) => updatePricing(index, 'price', 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-blue-500"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1">
|
||||
Intervall
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={plan.interval}
|
||||
onChange={(e) => updatePricing(index, 'interval', 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-blue-500"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-end">
|
||||
<label className="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={plan.popular || false}
|
||||
onChange={(e) => updatePricing(index, 'popular', e.target.checked)}
|
||||
className="w-4 h-4 text-blue-600 rounded"
|
||||
/>
|
||||
<span className="text-sm text-slate-700">Beliebt</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1">
|
||||
Beschreibung
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={plan.description}
|
||||
onChange={(e) => updatePricing(index, 'description', 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-blue-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1">
|
||||
Aufgaben
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={plan.features.tasks}
|
||||
onChange={(e) => updatePricing(index, 'features.tasks', 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-blue-500"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1">
|
||||
Aufgaben-Beschreibung
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={plan.features.taskDescription}
|
||||
onChange={(e) =>
|
||||
updatePricing(index, 'features.taskDescription', 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-blue-500"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1">
|
||||
Features (eine pro Zeile)
|
||||
</label>
|
||||
<textarea
|
||||
value={plan.features.included.join('\n')}
|
||||
onChange={(e) => updatePricing(index, 'features.included', e.target.value)}
|
||||
rows={4}
|
||||
className="w-full px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 font-mono text-sm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Other Tab */}
|
||||
{activeTab === 'other' && (
|
||||
<div className="space-y-8">
|
||||
{/* Trust Indicators */}
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold text-slate-900 mb-4">Trust Indicators</h2>
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
{(['item1', 'item2', 'item3'] as const).map((key, index) => (
|
||||
<div key={key} className="border border-slate-200 rounded-lg p-4">
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1">
|
||||
Wert {index + 1}
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={content.trust[key].value}
|
||||
onChange={(e) =>
|
||||
setContent({
|
||||
...content,
|
||||
trust: {
|
||||
...content.trust,
|
||||
[key]: { ...content.trust[key], value: 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-blue-500"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1">
|
||||
Label {index + 1}
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={content.trust[key].label}
|
||||
onChange={(e) =>
|
||||
setContent({
|
||||
...content,
|
||||
trust: {
|
||||
...content.trust,
|
||||
[key]: { ...content.trust[key], label: 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-blue-500"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Testimonial */}
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold text-slate-900 mb-4">Testimonial</h2>
|
||||
<div className="border border-slate-200 rounded-lg p-4 space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1">Zitat</label>
|
||||
<textarea
|
||||
value={content.testimonial.quote}
|
||||
onChange={(e) =>
|
||||
setContent({
|
||||
...content,
|
||||
testimonial: { ...content.testimonial, quote: e.target.value },
|
||||
})
|
||||
}
|
||||
rows={3}
|
||||
className="w-full px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||||
/>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1">Autor</label>
|
||||
<input
|
||||
type="text"
|
||||
value={content.testimonial.author}
|
||||
onChange={(e) =>
|
||||
setContent({
|
||||
...content,
|
||||
testimonial: { ...content.testimonial, author: 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-blue-500"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1">Rolle</label>
|
||||
<input
|
||||
type="text"
|
||||
value={content.testimonial.role}
|
||||
onChange={(e) =>
|
||||
setContent({
|
||||
...content,
|
||||
testimonial: { ...content.testimonial, role: 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-blue-500"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Live Preview Panel */}
|
||||
{showPreview && (
|
||||
<div className="bg-white rounded-xl border border-slate-200 shadow-sm overflow-hidden">
|
||||
{/* Preview Header */}
|
||||
<div className="bg-slate-50 border-b border-slate-200 px-4 py-3 flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex gap-1.5">
|
||||
<div className="w-3 h-3 rounded-full bg-red-400"></div>
|
||||
<div className="w-3 h-3 rounded-full bg-yellow-400"></div>
|
||||
<div className="w-3 h-3 rounded-full bg-green-400"></div>
|
||||
</div>
|
||||
<span className="text-xs text-slate-500 ml-2">breakpilot.app</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs font-medium text-slate-600 bg-slate-200 px-2 py-1 rounded">
|
||||
{activeTab === 'hero' && 'Hero Section'}
|
||||
{activeTab === 'features' && 'Features'}
|
||||
{activeTab === 'faq' && 'FAQ'}
|
||||
{activeTab === 'pricing' && 'Pricing'}
|
||||
{activeTab === 'other' && 'Trust & Testimonial'}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => iframeRef.current?.contentWindow?.location.reload()}
|
||||
className="p-1.5 text-slate-500 hover:text-slate-700 hover:bg-slate-200 rounded transition-colors"
|
||||
title="Preview neu laden"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{/* Preview Frame */}
|
||||
<div className="relative h-[calc(100vh-340px)] bg-slate-100">
|
||||
<iframe
|
||||
ref={iframeRef}
|
||||
src={`https://macmini:3000/?preview=true§ion=${activeTab}#${activeTab}`}
|
||||
className="w-full h-full border-0 scale-75 origin-top-left"
|
||||
style={{
|
||||
width: '133.33%',
|
||||
height: '133.33%',
|
||||
transform: 'scale(0.75)',
|
||||
transformOrigin: 'top left',
|
||||
}}
|
||||
title="Website Preview"
|
||||
sandbox="allow-same-origin allow-scripts"
|
||||
/>
|
||||
{/* Section Indicator */}
|
||||
<div className="absolute bottom-4 left-4 right-4 bg-blue-600 text-white px-4 py-2 rounded-lg shadow-lg flex items-center gap-2 text-sm">
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<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>
|
||||
<span>
|
||||
Du bearbeitest: <strong>
|
||||
{activeTab === 'hero' && 'Hero Section (Startbereich)'}
|
||||
{activeTab === 'features' && 'Features (Funktionen)'}
|
||||
{activeTab === 'faq' && 'FAQ (Haeufige Fragen)'}
|
||||
{activeTab === 'pricing' && 'Pricing (Preise)'}
|
||||
{activeTab === 'other' && 'Trust & Testimonial'}
|
||||
</strong>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,275 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import * as fs from 'fs/promises'
|
||||
import * as path from 'path'
|
||||
|
||||
/**
|
||||
* Individual Agent API
|
||||
*
|
||||
* GET - Get agent details including SOUL content
|
||||
* PUT - Update agent configuration
|
||||
* DELETE - Delete agent
|
||||
*/
|
||||
|
||||
const AGENT_CORE_PATH = process.env.AGENT_CORE_PATH || '/app/agent-core'
|
||||
const SOUL_PATH = path.join(AGENT_CORE_PATH, 'soul')
|
||||
|
||||
interface AgentDetails {
|
||||
id: string
|
||||
name: string
|
||||
description: string
|
||||
soulFile: string
|
||||
soulContent: string
|
||||
color: string
|
||||
icon: string
|
||||
status: 'running' | 'paused' | 'stopped' | 'error'
|
||||
activeSessions: number
|
||||
totalProcessed: number
|
||||
avgResponseTime: number
|
||||
errorRate: number
|
||||
lastActivity: string
|
||||
version: string
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
}
|
||||
|
||||
// Agent metadata (in production, store in database)
|
||||
const agentMetadata: Record<string, Partial<AgentDetails>> = {
|
||||
'tutor-agent': {
|
||||
name: 'TutorAgent',
|
||||
description: 'Lernbegleitung und Fragen beantworten',
|
||||
color: '#3b82f6',
|
||||
icon: 'brain'
|
||||
},
|
||||
'grader-agent': {
|
||||
name: 'GraderAgent',
|
||||
description: 'Klausur-Korrektur und Bewertung',
|
||||
color: '#10b981',
|
||||
icon: 'bot'
|
||||
},
|
||||
'quality-judge': {
|
||||
name: 'QualityJudge',
|
||||
description: 'BQAS Qualitaetspruefung',
|
||||
color: '#f59e0b',
|
||||
icon: 'settings'
|
||||
},
|
||||
'alert-agent': {
|
||||
name: 'AlertAgent',
|
||||
description: 'Monitoring und Benachrichtigungen',
|
||||
color: '#ef4444',
|
||||
icon: 'alert'
|
||||
},
|
||||
'orchestrator': {
|
||||
name: 'Orchestrator',
|
||||
description: 'Task-Koordination und Routing',
|
||||
color: '#8b5cf6',
|
||||
icon: 'message'
|
||||
}
|
||||
}
|
||||
|
||||
// Read SOUL file content
|
||||
async function readSoulFile(agentId: string): Promise<string | null> {
|
||||
const soulFile = `${agentId}.soul.md`
|
||||
const soulFilePath = path.join(SOUL_PATH, soulFile)
|
||||
|
||||
try {
|
||||
const content = await fs.readFile(soulFilePath, 'utf-8')
|
||||
return content
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
// Get file stats
|
||||
async function getFileStats(agentId: string): Promise<{ createdAt: string; updatedAt: string } | null> {
|
||||
const soulFile = `${agentId}.soul.md`
|
||||
const soulFilePath = path.join(SOUL_PATH, soulFile)
|
||||
|
||||
try {
|
||||
const stats = await fs.stat(soulFilePath)
|
||||
return {
|
||||
createdAt: stats.birthtime.toISOString(),
|
||||
updatedAt: stats.mtime.toISOString()
|
||||
}
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
// Mock agent stats (in production, query from Redis/PostgreSQL)
|
||||
function getMockStats(agentId: string) {
|
||||
const mockStats: Record<string, { activeSessions: number; totalProcessed: number; avgResponseTime: number; errorRate: number }> = {
|
||||
'tutor-agent': { activeSessions: 12, totalProcessed: 1847, avgResponseTime: 234, errorRate: 0.3 },
|
||||
'grader-agent': { activeSessions: 3, totalProcessed: 456, avgResponseTime: 1205, errorRate: 0.5 },
|
||||
'quality-judge': { activeSessions: 8, totalProcessed: 3291, avgResponseTime: 89, errorRate: 0.1 },
|
||||
'alert-agent': { activeSessions: 1, totalProcessed: 892, avgResponseTime: 45, errorRate: 0.0 },
|
||||
'orchestrator': { activeSessions: 24, totalProcessed: 8934, avgResponseTime: 12, errorRate: 0.2 }
|
||||
}
|
||||
|
||||
return mockStats[agentId] || { activeSessions: 0, totalProcessed: 0, avgResponseTime: 0, errorRate: 0 }
|
||||
}
|
||||
|
||||
// GET - Get agent details
|
||||
export async function GET(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ agentId: string }> }
|
||||
) {
|
||||
try {
|
||||
const { agentId } = await params
|
||||
|
||||
const soulContent = await readSoulFile(agentId)
|
||||
if (!soulContent) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Agent not found' },
|
||||
{ status: 404 }
|
||||
)
|
||||
}
|
||||
|
||||
const fileStats = await getFileStats(agentId)
|
||||
const metadata = agentMetadata[agentId] || {}
|
||||
const stats = getMockStats(agentId)
|
||||
|
||||
const agent: AgentDetails = {
|
||||
id: agentId,
|
||||
name: metadata.name || agentId.split('-').map(w => w.charAt(0).toUpperCase() + w.slice(1)).join(''),
|
||||
description: metadata.description || 'Custom agent',
|
||||
soulFile: `${agentId}.soul.md`,
|
||||
soulContent,
|
||||
color: metadata.color || '#6b7280',
|
||||
icon: metadata.icon || 'bot',
|
||||
status: 'running',
|
||||
...stats,
|
||||
lastActivity: 'just now',
|
||||
version: '1.0.0',
|
||||
createdAt: fileStats?.createdAt || new Date().toISOString(),
|
||||
updatedAt: fileStats?.updatedAt || new Date().toISOString()
|
||||
}
|
||||
|
||||
return NextResponse.json(agent)
|
||||
} catch (error) {
|
||||
console.error('Error fetching agent:', error)
|
||||
return NextResponse.json(
|
||||
{ error: error instanceof Error ? error.message : 'Failed to fetch agent' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// PUT - Update agent
|
||||
export async function PUT(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ agentId: string }> }
|
||||
) {
|
||||
try {
|
||||
const { agentId } = await params
|
||||
const body = await request.json()
|
||||
const { soulContent, name, description, color, icon } = body
|
||||
|
||||
const soulFile = `${agentId}.soul.md`
|
||||
const soulFilePath = path.join(SOUL_PATH, soulFile)
|
||||
|
||||
// Check if agent exists
|
||||
try {
|
||||
await fs.access(soulFilePath)
|
||||
} catch {
|
||||
return NextResponse.json(
|
||||
{ error: 'Agent not found' },
|
||||
{ status: 404 }
|
||||
)
|
||||
}
|
||||
|
||||
// Update SOUL file if content provided
|
||||
if (soulContent) {
|
||||
// Create backup before updating
|
||||
const backupPath = path.join(SOUL_PATH, '.backups', `${agentId}-${Date.now()}.soul.md`)
|
||||
try {
|
||||
await fs.mkdir(path.dirname(backupPath), { recursive: true })
|
||||
const currentContent = await fs.readFile(soulFilePath, 'utf-8')
|
||||
await fs.writeFile(backupPath, currentContent, 'utf-8')
|
||||
} catch {
|
||||
// Backup failed, continue anyway
|
||||
}
|
||||
|
||||
// Write new content
|
||||
await fs.writeFile(soulFilePath, soulContent, 'utf-8')
|
||||
}
|
||||
|
||||
// In production, update metadata in database
|
||||
// For now, just return success
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: `Agent ${agentId} updated successfully`,
|
||||
agent: {
|
||||
id: agentId,
|
||||
name: name || agentMetadata[agentId]?.name || agentId,
|
||||
description: description || agentMetadata[agentId]?.description || '',
|
||||
soulFile,
|
||||
color: color || agentMetadata[agentId]?.color || '#6b7280',
|
||||
icon: icon || agentMetadata[agentId]?.icon || 'bot',
|
||||
updatedAt: new Date().toISOString()
|
||||
}
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Error updating agent:', error)
|
||||
return NextResponse.json(
|
||||
{ error: error instanceof Error ? error.message : 'Failed to update agent' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// DELETE - Delete agent
|
||||
export async function DELETE(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ agentId: string }> }
|
||||
) {
|
||||
try {
|
||||
const { agentId } = await params
|
||||
|
||||
// Don't allow deleting core agents
|
||||
const coreAgents = ['tutor-agent', 'grader-agent', 'quality-judge', 'alert-agent', 'orchestrator']
|
||||
if (coreAgents.includes(agentId)) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Cannot delete core system agent' },
|
||||
{ status: 403 }
|
||||
)
|
||||
}
|
||||
|
||||
const soulFile = `${agentId}.soul.md`
|
||||
const soulFilePath = path.join(SOUL_PATH, soulFile)
|
||||
|
||||
// Check if agent exists
|
||||
try {
|
||||
await fs.access(soulFilePath)
|
||||
} catch {
|
||||
return NextResponse.json(
|
||||
{ error: 'Agent not found' },
|
||||
{ status: 404 }
|
||||
)
|
||||
}
|
||||
|
||||
// Create backup before deleting
|
||||
const backupPath = path.join(SOUL_PATH, '.deleted', `${agentId}-${Date.now()}.soul.md`)
|
||||
try {
|
||||
await fs.mkdir(path.dirname(backupPath), { recursive: true })
|
||||
const content = await fs.readFile(soulFilePath, 'utf-8')
|
||||
await fs.writeFile(backupPath, content, 'utf-8')
|
||||
} catch {
|
||||
// Backup failed, continue anyway
|
||||
}
|
||||
|
||||
// Delete the file
|
||||
await fs.unlink(soulFilePath)
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: `Agent ${agentId} deleted successfully`
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Error deleting agent:', error)
|
||||
return NextResponse.json(
|
||||
{ error: error instanceof Error ? error.message : 'Failed to delete agent' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,187 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import * as fs from 'fs/promises'
|
||||
import * as path from 'path'
|
||||
|
||||
/**
|
||||
* Agent SOUL File API
|
||||
*
|
||||
* GET - Get SOUL file content
|
||||
* PUT - Update SOUL file content
|
||||
* GET /history - Get version history
|
||||
*/
|
||||
|
||||
const AGENT_CORE_PATH = process.env.AGENT_CORE_PATH || '/app/agent-core'
|
||||
const SOUL_PATH = path.join(AGENT_CORE_PATH, 'soul')
|
||||
|
||||
interface SoulVersion {
|
||||
version: string
|
||||
timestamp: string
|
||||
content: string
|
||||
author: string
|
||||
changes: string
|
||||
}
|
||||
|
||||
// Read SOUL file content
|
||||
async function readSoulFile(agentId: string): Promise<string | null> {
|
||||
const soulFile = `${agentId}.soul.md`
|
||||
const soulFilePath = path.join(SOUL_PATH, soulFile)
|
||||
|
||||
try {
|
||||
const content = await fs.readFile(soulFilePath, 'utf-8')
|
||||
return content
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
// Get version history from backup directory
|
||||
async function getVersionHistory(agentId: string): Promise<SoulVersion[]> {
|
||||
const backupDir = path.join(SOUL_PATH, '.backups')
|
||||
const versions: SoulVersion[] = []
|
||||
|
||||
try {
|
||||
const files = await fs.readdir(backupDir)
|
||||
const agentBackups = files.filter(f => f.startsWith(`${agentId}-`) && f.endsWith('.soul.md'))
|
||||
|
||||
for (const file of agentBackups.slice(-10)) { // Last 10 versions
|
||||
const filePath = path.join(backupDir, file)
|
||||
const stats = await fs.stat(filePath)
|
||||
const content = await fs.readFile(filePath, 'utf-8')
|
||||
|
||||
// Extract timestamp from filename
|
||||
const match = file.match(/-(\d+)\.soul\.md$/)
|
||||
const timestamp = match ? new Date(parseInt(match[1])).toISOString() : stats.mtime.toISOString()
|
||||
|
||||
versions.push({
|
||||
version: file.replace('.soul.md', ''),
|
||||
timestamp,
|
||||
content,
|
||||
author: 'Admin',
|
||||
changes: 'Manual update'
|
||||
})
|
||||
}
|
||||
|
||||
// Sort by timestamp descending
|
||||
versions.sort((a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime())
|
||||
} catch {
|
||||
// No backup directory or can't read
|
||||
}
|
||||
|
||||
return versions
|
||||
}
|
||||
|
||||
// GET - Get SOUL file content
|
||||
export async function GET(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ agentId: string }> }
|
||||
) {
|
||||
try {
|
||||
const { agentId } = await params
|
||||
const url = new URL(request.url)
|
||||
const includeHistory = url.searchParams.get('history') === 'true'
|
||||
|
||||
const content = await readSoulFile(agentId)
|
||||
if (!content) {
|
||||
return NextResponse.json(
|
||||
{ error: 'SOUL file not found' },
|
||||
{ status: 404 }
|
||||
)
|
||||
}
|
||||
|
||||
const soulFile = `${agentId}.soul.md`
|
||||
const soulFilePath = path.join(SOUL_PATH, soulFile)
|
||||
const stats = await fs.stat(soulFilePath)
|
||||
|
||||
const response: {
|
||||
agentId: string
|
||||
soulFile: string
|
||||
content: string
|
||||
updatedAt: string
|
||||
size: number
|
||||
history?: SoulVersion[]
|
||||
} = {
|
||||
agentId,
|
||||
soulFile,
|
||||
content,
|
||||
updatedAt: stats.mtime.toISOString(),
|
||||
size: stats.size
|
||||
}
|
||||
|
||||
if (includeHistory) {
|
||||
response.history = await getVersionHistory(agentId)
|
||||
}
|
||||
|
||||
return NextResponse.json(response)
|
||||
} catch (error) {
|
||||
console.error('Error fetching SOUL file:', error)
|
||||
return NextResponse.json(
|
||||
{ error: error instanceof Error ? error.message : 'Failed to fetch SOUL file' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// PUT - Update SOUL file content
|
||||
export async function PUT(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ agentId: string }> }
|
||||
) {
|
||||
try {
|
||||
const { agentId } = await params
|
||||
const body = await request.json()
|
||||
const { content, author, changeDescription } = body
|
||||
|
||||
if (!content) {
|
||||
return NextResponse.json(
|
||||
{ error: 'content is required' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
const soulFile = `${agentId}.soul.md`
|
||||
const soulFilePath = path.join(SOUL_PATH, soulFile)
|
||||
|
||||
// Check if file exists
|
||||
try {
|
||||
await fs.access(soulFilePath)
|
||||
} catch {
|
||||
return NextResponse.json(
|
||||
{ error: 'SOUL file not found' },
|
||||
{ status: 404 }
|
||||
)
|
||||
}
|
||||
|
||||
// Create backup before updating
|
||||
const backupDir = path.join(SOUL_PATH, '.backups')
|
||||
const backupPath = path.join(backupDir, `${agentId}-${Date.now()}.soul.md`)
|
||||
|
||||
try {
|
||||
await fs.mkdir(backupDir, { recursive: true })
|
||||
const currentContent = await fs.readFile(soulFilePath, 'utf-8')
|
||||
await fs.writeFile(backupPath, currentContent, 'utf-8')
|
||||
} catch (backupError) {
|
||||
console.warn('Failed to create backup:', backupError)
|
||||
}
|
||||
|
||||
// Write new content
|
||||
await fs.writeFile(soulFilePath, content, 'utf-8')
|
||||
const stats = await fs.stat(soulFilePath)
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: `SOUL file for ${agentId} updated successfully`,
|
||||
agentId,
|
||||
soulFile,
|
||||
updatedAt: stats.mtime.toISOString(),
|
||||
size: stats.size,
|
||||
author: author || 'Admin',
|
||||
changeDescription: changeDescription || 'Manual update'
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Error updating SOUL file:', error)
|
||||
return NextResponse.json(
|
||||
{ error: error instanceof Error ? error.message : 'Failed to update SOUL file' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user