refactor(admin): split workshop, vendor-compliance, advisory-board/documentation pages
Each page.tsx was >500 LOC (610/602/596). Extracted React components to _components/ and custom hook to _hooks/ per-route, reducing all three page.tsx orchestrators to 107/229/120 LOC respectively. Zero behavior changes. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,118 @@
|
||||
export function ArchitectureTab() {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="bg-white rounded-xl border border-slate-200 p-6">
|
||||
<h3 className="font-semibold text-slate-800 text-lg mb-4">Systemarchitektur</h3>
|
||||
|
||||
<div className="bg-slate-900 text-green-400 p-6 rounded-lg font-mono text-sm overflow-x-auto">
|
||||
<pre>{`
|
||||
┌─────────────────────────────────────────────────────────────────────┐
|
||||
│ Frontend (Next.js) │
|
||||
│ admin-v2:3000/sdk/advisory-board │
|
||||
└───────────────────────────────────┬─────────────────────────────────┘
|
||||
│ HTTPS
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────────────────────┐
|
||||
│ AI Compliance SDK (Go) │
|
||||
│ Port 8090 │
|
||||
│ ┌─────────────────────────────────────────────────────────────┐ │
|
||||
│ │ Policy Engine │ │
|
||||
│ │ ┌───────────────────────────────────────────────────────┐ │ │
|
||||
│ │ │ YAML-basierte Regeln (ucca_policy_v1.yaml) │ │ │
|
||||
│ │ │ ~45 Regeln in 7 Kategorien │ │ │
|
||||
│ │ │ Deterministisch - Kein LLM in Entscheidungslogik │ │ │
|
||||
│ │ └───────────────────────────────────────────────────────┘ │ │
|
||||
│ │ │ │ │
|
||||
│ │ ▼ │ │
|
||||
│ │ ┌────────────────┐ ┌────────────────┐ ┌────────────────┐ │ │
|
||||
│ │ │ Controls │ │ Patterns │ │ Examples │ │ │
|
||||
│ │ │ Library │ │ Library │ │ Library │ │ │
|
||||
│ │ └────────────────┘ └────────────────┘ └────────────────┘ │ │
|
||||
│ └─────────────────────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ ┌──────────────────┐ ┌──────────────────┐ │
|
||||
│ │ LLM Integration │ │ Legal RAG │──────┐ │
|
||||
│ │ (nur Explain) │ │ Client │ │ │
|
||||
│ └──────────────────┘ └──────────────────┘ │ │
|
||||
└─────────────────────────────┬────────────────────┼──────────────────┘
|
||||
│ │
|
||||
▼ ▼
|
||||
┌─────────────────────────────────────────────────────────────────────┐
|
||||
│ Datenschicht │
|
||||
│ ┌────────────────────┐ ┌────────────────────┐ │
|
||||
│ │ PostgreSQL │ │ Qdrant │ │
|
||||
│ │ (Assessments, │ │ (Legal Corpus, │ │
|
||||
│ │ Escalations) │ │ 2,274 Chunks) │ │
|
||||
│ └────────────────────┘ └────────────────────┘ │
|
||||
└─────────────────────────────────────────────────────────────────────┘
|
||||
`}</pre>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div className="p-4 bg-blue-50 rounded-lg border border-blue-200">
|
||||
<h4 className="font-medium text-blue-800 mb-2">Datenfluss</h4>
|
||||
<ol className="text-sm text-blue-700 list-decimal list-inside space-y-1">
|
||||
<li>Benutzer beschreibt Use Case im Frontend</li>
|
||||
<li>Policy Engine evaluiert gegen alle Regeln</li>
|
||||
<li>Ergebnis mit Controls + Patterns zurueck</li>
|
||||
<li>Optional: LLM erklaert das Ergebnis</li>
|
||||
<li>Bei Risiko: Automatische Eskalation</li>
|
||||
</ol>
|
||||
</div>
|
||||
<div className="p-4 bg-green-50 rounded-lg border border-green-200">
|
||||
<h4 className="font-medium text-green-800 mb-2">Sicherheitsmerkmale</h4>
|
||||
<ul className="text-sm text-green-700 list-disc list-inside space-y-1">
|
||||
<li>TLS 1.3 Verschluesselung</li>
|
||||
<li>RBAC mit Tenant-Isolation</li>
|
||||
<li>JWT-basierte Authentifizierung</li>
|
||||
<li>Audit-Trail aller Aktionen</li>
|
||||
<li>Keine Rohtext-Speicherung (nur Hash)</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-xl border border-slate-200 p-6">
|
||||
<h3 className="font-semibold text-slate-800 text-lg mb-4">Eskalations-Workflow</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">Level</th>
|
||||
<th className="text-left py-2 px-3 font-medium text-slate-600">Ausloeser</th>
|
||||
<th className="text-left py-2 px-3 font-medium text-slate-600">Pruefer</th>
|
||||
<th className="text-left py-2 px-3 font-medium text-slate-600">SLA</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr className="border-b border-slate-100 bg-green-50">
|
||||
<td className="py-2 px-3 font-medium text-green-700">E0</td>
|
||||
<td className="py-2 px-3 text-slate-600">Nur INFO-Regeln, Risiko < 20</td>
|
||||
<td className="py-2 px-3 text-slate-600">Automatisch</td>
|
||||
<td className="py-2 px-3 text-slate-600">-</td>
|
||||
</tr>
|
||||
<tr className="border-b border-slate-100 bg-yellow-50">
|
||||
<td className="py-2 px-3 font-medium text-yellow-700">E1</td>
|
||||
<td className="py-2 px-3 text-slate-600">WARN-Regeln, Risiko 20-40</td>
|
||||
<td className="py-2 px-3 text-slate-600">Team-Lead</td>
|
||||
<td className="py-2 px-3 text-slate-600">24h / 72h</td>
|
||||
</tr>
|
||||
<tr className="border-b border-slate-100 bg-orange-50">
|
||||
<td className="py-2 px-3 font-medium text-orange-700">E2</td>
|
||||
<td className="py-2 px-3 text-slate-600">Art. 9 Daten, DSFA empfohlen, Risiko 40-60</td>
|
||||
<td className="py-2 px-3 text-slate-600">DSB</td>
|
||||
<td className="py-2 px-3 text-slate-600">8h / 48h</td>
|
||||
</tr>
|
||||
<tr className="bg-red-50">
|
||||
<td className="py-2 px-3 font-medium text-red-700">E3</td>
|
||||
<td className="py-2 px-3 text-slate-600">BLOCK-Regeln, Art. 22, Risiko > 60</td>
|
||||
<td className="py-2 px-3 text-slate-600">DSB + Legal</td>
|
||||
<td className="py-2 px-3 text-slate-600">4h / 24h</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
export function AuditorTab() {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="bg-white rounded-xl border border-slate-200 p-6">
|
||||
<h3 className="font-semibold text-slate-800 text-lg mb-4">
|
||||
Dokumentation fuer externe Auditoren
|
||||
</h3>
|
||||
<p className="text-slate-600 mb-4">
|
||||
Diese Dokumentation erfuellt die Anforderungen nach Art. 30 DSGVO (Verzeichnis von
|
||||
Verarbeitungstaetigkeiten) und dient als Grundlage fuer Audits nach Art. 32 DSGVO.
|
||||
</p>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="p-4 bg-slate-50 rounded-lg border border-slate-200">
|
||||
<h4 className="font-medium text-slate-800 mb-2">1. Zweck des Systems</h4>
|
||||
<p className="text-sm text-slate-600">
|
||||
UCCA ist ein Compliance-Pruefwerkzeug zur Bewertung geplanter KI-Anwendungsfaelle
|
||||
hinsichtlich ihrer datenschutzrechtlichen Zulaessigkeit.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="p-4 bg-slate-50 rounded-lg border border-slate-200">
|
||||
<h4 className="font-medium text-slate-800 mb-2">2. Rechtsgrundlage</h4>
|
||||
<ul className="text-sm text-slate-600 list-disc list-inside space-y-1">
|
||||
<li><strong>Art. 6 Abs. 1 lit. c DSGVO</strong> - Erfuellung rechtlicher Verpflichtungen</li>
|
||||
<li><strong>Art. 6 Abs. 1 lit. f DSGVO</strong> - Berechtigte Interessen (Compliance-Management)</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div className="p-4 bg-slate-50 rounded-lg border border-slate-200">
|
||||
<h4 className="font-medium text-slate-800 mb-2">3. Verarbeitete Datenkategorien</h4>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm mt-2">
|
||||
<thead>
|
||||
<tr className="border-b border-slate-200">
|
||||
<th className="text-left py-2 px-2 font-medium text-slate-600">Kategorie</th>
|
||||
<th className="text-left py-2 px-2 font-medium text-slate-600">Speicherung</th>
|
||||
<th className="text-left py-2 px-2 font-medium text-slate-600">Aufbewahrung</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="text-slate-600">
|
||||
<tr className="border-b border-slate-100">
|
||||
<td className="py-2 px-2">Use-Case-Beschreibung</td>
|
||||
<td className="py-2 px-2">Nur Hash (SHA-256)</td>
|
||||
<td className="py-2 px-2">10 Jahre</td>
|
||||
</tr>
|
||||
<tr className="border-b border-slate-100">
|
||||
<td className="py-2 px-2">Bewertungsergebnis</td>
|
||||
<td className="py-2 px-2">Vollstaendig</td>
|
||||
<td className="py-2 px-2">10 Jahre</td>
|
||||
</tr>
|
||||
<tr className="border-b border-slate-100">
|
||||
<td className="py-2 px-2">Audit-Trail</td>
|
||||
<td className="py-2 px-2">Vollstaendig</td>
|
||||
<td className="py-2 px-2">10 Jahre</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="py-2 px-2">Eskalations-Historie</td>
|
||||
<td className="py-2 px-2">Vollstaendig</td>
|
||||
<td className="py-2 px-2">10 Jahre</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-4 bg-slate-50 rounded-lg border border-slate-200">
|
||||
<h4 className="font-medium text-slate-800 mb-2">4. Keine autonomen KI-Entscheidungen</h4>
|
||||
<p className="text-sm text-slate-600">
|
||||
Das System trifft <strong>KEINE automatisierten Einzelentscheidungen</strong> im Sinne
|
||||
von Art. 22 DSGVO, da:
|
||||
</p>
|
||||
<ul className="text-sm text-slate-600 list-disc list-inside mt-2 space-y-1">
|
||||
<li>Regelauswertung ist keine rechtlich bindende Entscheidung</li>
|
||||
<li>Alle kritischen Faelle werden menschlich geprueft (E1-E3)</li>
|
||||
<li>BLOCK-Entscheidungen erfordern immer menschliche Freigabe</li>
|
||||
<li>Betroffene haben Anfechtungsmoeglichkeit ueber Eskalation</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div className="p-4 bg-green-50 rounded-lg border border-green-200">
|
||||
<h4 className="font-medium text-green-800 mb-2">5. Technische und Organisatorische Massnahmen</h4>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 text-sm">
|
||||
<div>
|
||||
<strong className="text-green-700">Vertraulichkeit</strong>
|
||||
<ul className="text-green-700 list-disc list-inside mt-1">
|
||||
<li>RBAC mit Tenant-Isolation</li>
|
||||
<li>TLS 1.3 Verschluesselung</li>
|
||||
<li>AES-256 at rest</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div>
|
||||
<strong className="text-green-700">Integritaet</strong>
|
||||
<ul className="text-green-700 list-disc list-inside mt-1">
|
||||
<li>Unveraenderlicher Audit-Trail</li>
|
||||
<li>Policy-Versionierung</li>
|
||||
<li>Input-Validierung</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
export function LegalCorpusTab() {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="bg-white rounded-xl border border-slate-200 p-6">
|
||||
<h3 className="font-semibold text-slate-800 text-lg mb-4">Legal RAG Corpus</h3>
|
||||
<p className="text-slate-600 mb-4">
|
||||
Das System verwendet einen semantischen Suchindex mit 2.274 Chunks aus 19 EU-Regulierungen
|
||||
fuer rechtsgrundlagenbasierte Erklaerungen.
|
||||
</p>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div className="p-4 bg-blue-50 rounded-lg border border-blue-200">
|
||||
<h4 className="font-medium text-blue-800 mb-2">Indexierte Regulierungen</h4>
|
||||
<ul className="text-sm text-blue-700 space-y-1">
|
||||
<li>DSGVO - Datenschutz-Grundverordnung</li>
|
||||
<li>AI Act - EU KI-Verordnung</li>
|
||||
<li>NIS2 - Cybersicherheits-Richtlinie</li>
|
||||
<li>CRA - Cyber Resilience Act</li>
|
||||
<li>Data Act - Datengesetz</li>
|
||||
<li>DSA/DMA - Digital Services/Markets Act</li>
|
||||
<li>DPF - EU-US Data Privacy Framework</li>
|
||||
<li>BSI-TR-03161 - Digitale Identitaeten</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div className="p-4 bg-green-50 rounded-lg border border-green-200">
|
||||
<h4 className="font-medium text-green-800 mb-2">RAG-Funktionalitaet</h4>
|
||||
<ul className="text-sm text-green-700 space-y-1">
|
||||
<li>Hybride Suche (Dense + BM25)</li>
|
||||
<li>Semantisches Chunking</li>
|
||||
<li>Cross-Encoder Reranking</li>
|
||||
<li>Artikel-Referenz-Extraktion</li>
|
||||
<li>Mehrsprachig (DE/EN)</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-xl border border-slate-200 p-6">
|
||||
<h3 className="font-semibold text-slate-800 mb-4">Verwendung im System</h3>
|
||||
<div className="space-y-3">
|
||||
{[
|
||||
{
|
||||
step: 1,
|
||||
title: 'Benutzer fordert Erklaerung an',
|
||||
text: 'Nach der Bewertung kann eine LLM-basierte Erklaerung generiert werden.',
|
||||
},
|
||||
{
|
||||
step: 2,
|
||||
title: 'Legal RAG Client sucht relevante Artikel',
|
||||
text: 'Basierend auf den ausgeloesten Regeln werden passende Gesetzestexte gefunden.',
|
||||
},
|
||||
{
|
||||
step: 3,
|
||||
title: 'LLM generiert Erklaerung mit Rechtsgrundlage',
|
||||
text: 'Die Erklaerung referenziert konkrete Artikel aus DSGVO, AI Act etc.',
|
||||
},
|
||||
].map(({ step, title, text }) => (
|
||||
<div key={step} className="flex items-start gap-3">
|
||||
<div className="w-8 h-8 bg-purple-100 text-purple-700 rounded-full flex items-center justify-center flex-shrink-0">
|
||||
{step}
|
||||
</div>
|
||||
<div>
|
||||
<div className="font-medium text-slate-800">{title}</div>
|
||||
<div className="text-sm text-slate-600">{text}</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import { Rule, Pattern, Control } from './types'
|
||||
|
||||
export function OverviewTab({ rules, patterns, controls }: {
|
||||
rules: Rule[]
|
||||
patterns: Pattern[]
|
||||
controls: Control[]
|
||||
}) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<div className="bg-white rounded-xl border border-slate-200 p-6 shadow-sm">
|
||||
<h3 className="font-semibold text-slate-800 mb-2">Deterministische Regeln</h3>
|
||||
<div className="text-3xl font-bold text-purple-600">{rules.length}</div>
|
||||
<p className="text-sm text-slate-500 mt-2">
|
||||
Alle Entscheidungen basieren auf transparenten, nachvollziehbaren Regeln.
|
||||
</p>
|
||||
</div>
|
||||
<div className="bg-white rounded-xl border border-slate-200 p-6 shadow-sm">
|
||||
<h3 className="font-semibold text-slate-800 mb-2">Architektur-Patterns</h3>
|
||||
<div className="text-3xl font-bold text-green-600">{patterns.length}</div>
|
||||
<p className="text-sm text-slate-500 mt-2">
|
||||
Best-Practice-Loesungen fuer datenschutzkonforme KI-Systeme.
|
||||
</p>
|
||||
</div>
|
||||
<div className="bg-white rounded-xl border border-slate-200 p-6 shadow-sm">
|
||||
<h3 className="font-semibold text-slate-800 mb-2">Compliance-Kontrollen</h3>
|
||||
<div className="text-3xl font-bold text-blue-600">{controls.length}</div>
|
||||
<p className="text-sm text-slate-500 mt-2">
|
||||
Technische und organisatorische Massnahmen.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-gradient-to-br from-purple-50 to-blue-50 rounded-xl border border-purple-200 p-6">
|
||||
<h3 className="font-semibold text-purple-800 text-lg mb-4">Was ist UCCA?</h3>
|
||||
<div className="prose prose-sm max-w-none text-slate-700">
|
||||
<p>
|
||||
<strong>UCCA (Use-Case Compliance & Feasibility Advisor)</strong> ist ein deterministisches
|
||||
Compliance-Pruefwerkzeug, das Organisationen bei der Bewertung geplanter KI-Anwendungsfaelle
|
||||
hinsichtlich ihrer datenschutzrechtlichen Zulaessigkeit unterstuetzt.
|
||||
</p>
|
||||
<h4 className="text-purple-700 mt-4">Kernprinzipien</h4>
|
||||
<ul className="space-y-2">
|
||||
<li><strong>Determinismus:</strong> Alle Entscheidungen basieren auf transparenten Regeln. Die KI trifft KEINE autonomen Entscheidungen.</li>
|
||||
<li><strong>Transparenz:</strong> Alle Regeln, Kontrollen und Patterns sind einsehbar.</li>
|
||||
<li><strong>Human-in-the-Loop:</strong> Kritische Entscheidungen erfordern immer menschliche Pruefung durch DSB oder Legal.</li>
|
||||
<li><strong>Rechtsgrundlage:</strong> Jede Regel referenziert konkrete DSGVO-Artikel.</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-amber-50 border border-amber-200 rounded-xl p-6">
|
||||
<h3 className="font-semibold text-amber-800 mb-3">Wichtiger Hinweis zur KI-Nutzung</h3>
|
||||
<p className="text-amber-700">
|
||||
Das System verwendet KI (LLM) <strong>ausschliesslich zur Erklaerung</strong> bereits
|
||||
getroffener Regelentscheidungen. Die eigentliche Compliance-Bewertung erfolgt
|
||||
<strong> rein deterministisch</strong> durch die Policy Engine. BLOCK-Entscheidungen
|
||||
koennen NICHT durch KI ueberschrieben werden.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import { Rule } from './types'
|
||||
|
||||
export function RulesTab({ rules, policyVersion, loading }: {
|
||||
rules: Rule[]
|
||||
policyVersion: string
|
||||
loading: boolean
|
||||
}) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h3 className="font-semibold text-slate-800 text-lg">Regel-Katalog</h3>
|
||||
<p className="text-sm text-slate-500">Policy Version: {policyVersion}</p>
|
||||
</div>
|
||||
<div className="text-sm text-slate-500">{rules.length} Regeln insgesamt</div>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className="text-center py-8 text-slate-500">Lade Regeln...</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{Array.from(new Set(rules.map(r => r.category))).map(category => (
|
||||
<div key={category} className="bg-white rounded-xl border border-slate-200 overflow-hidden">
|
||||
<div className="px-4 py-3 bg-slate-50 border-b border-slate-200">
|
||||
<h4 className="font-medium text-slate-800">{category}</h4>
|
||||
<p className="text-xs text-slate-500">
|
||||
{rules.filter(r => r.category === category).length} Regeln
|
||||
</p>
|
||||
</div>
|
||||
<div className="divide-y divide-slate-100">
|
||||
{rules.filter(r => r.category === category).map(rule => (
|
||||
<div key={rule.code} className="p-4">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-mono text-sm text-slate-500">{rule.code}</span>
|
||||
<span className={`text-xs px-2 py-0.5 rounded ${
|
||||
rule.severity === 'BLOCK' ? 'bg-red-100 text-red-700' :
|
||||
rule.severity === 'WARN' ? 'bg-yellow-100 text-yellow-700' :
|
||||
'bg-blue-100 text-blue-700'
|
||||
}`}>
|
||||
{rule.severity}
|
||||
</span>
|
||||
</div>
|
||||
<div className="font-medium text-slate-800 mt-1">{rule.title}</div>
|
||||
<div className="text-sm text-slate-600 mt-1">{rule.description}</div>
|
||||
{rule.gdpr_ref && (
|
||||
<div className="text-xs text-slate-500 mt-2">{rule.gdpr_ref}</div>
|
||||
)}
|
||||
</div>
|
||||
{rule.risk_add && (
|
||||
<div className="text-sm font-medium text-red-600">+{rule.risk_add}</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
export type DocTab = 'overview' | 'architecture' | 'auditor' | 'rules' | 'legal-corpus'
|
||||
|
||||
export interface Rule {
|
||||
code: string
|
||||
category: string
|
||||
title: string
|
||||
description: string
|
||||
severity: string
|
||||
gdpr_ref: string
|
||||
rationale?: string
|
||||
risk_add?: number
|
||||
}
|
||||
|
||||
export interface Pattern {
|
||||
id: string
|
||||
title: string
|
||||
description: string
|
||||
benefit?: string
|
||||
effort?: string
|
||||
risk_reduction?: number
|
||||
}
|
||||
|
||||
export interface Control {
|
||||
id: string
|
||||
title: string
|
||||
description: string
|
||||
gdpr_ref?: string
|
||||
effort?: string
|
||||
}
|
||||
@@ -9,50 +9,22 @@
|
||||
|
||||
import { useState, useEffect } from 'react'
|
||||
import Link from 'next/link'
|
||||
|
||||
// ============================================================================
|
||||
// Types
|
||||
// ============================================================================
|
||||
|
||||
type DocTab = 'overview' | 'architecture' | 'auditor' | 'rules' | 'legal-corpus'
|
||||
|
||||
interface Rule {
|
||||
code: string
|
||||
category: string
|
||||
title: string
|
||||
description: string
|
||||
severity: string
|
||||
gdpr_ref: string
|
||||
rationale?: string
|
||||
risk_add?: number
|
||||
}
|
||||
|
||||
interface Pattern {
|
||||
id: string
|
||||
title: string
|
||||
description: string
|
||||
benefit?: string
|
||||
effort?: string
|
||||
risk_reduction?: number
|
||||
}
|
||||
|
||||
interface Control {
|
||||
id: string
|
||||
title: string
|
||||
description: string
|
||||
gdpr_ref?: string
|
||||
effort?: string
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// API Configuration
|
||||
// ============================================================================
|
||||
import { DocTab, Rule, Pattern, Control } from './_components/types'
|
||||
import { OverviewTab } from './_components/OverviewTab'
|
||||
import { ArchitectureTab } from './_components/ArchitectureTab'
|
||||
import { AuditorTab } from './_components/AuditorTab'
|
||||
import { RulesTab } from './_components/RulesTab'
|
||||
import { LegalCorpusTab } from './_components/LegalCorpusTab'
|
||||
|
||||
const API_BASE = process.env.NEXT_PUBLIC_SDK_API_URL || 'https://macmini:8090'
|
||||
|
||||
// ============================================================================
|
||||
// Main Component
|
||||
// ============================================================================
|
||||
const tabs: { id: DocTab; label: string }[] = [
|
||||
{ id: 'overview', label: 'Uebersicht' },
|
||||
{ id: 'architecture', label: 'Architektur' },
|
||||
{ id: 'auditor', label: 'Fuer Auditoren' },
|
||||
{ id: 'rules', label: 'Regel-Katalog' },
|
||||
{ id: 'legal-corpus', label: 'Legal RAG' },
|
||||
]
|
||||
|
||||
export default function DocumentationPage() {
|
||||
const [activeTab, setActiveTab] = useState<DocTab>('overview')
|
||||
@@ -100,454 +72,6 @@ export default function DocumentationPage() {
|
||||
fetchData()
|
||||
}, [])
|
||||
|
||||
// ============================================================================
|
||||
// Tab Content Renderers
|
||||
// ============================================================================
|
||||
|
||||
const renderOverview = () => (
|
||||
<div className="space-y-6">
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<div className="bg-white rounded-xl border border-slate-200 p-6 shadow-sm">
|
||||
<h3 className="font-semibold text-slate-800 mb-2">Deterministische Regeln</h3>
|
||||
<div className="text-3xl font-bold text-purple-600">{rules.length}</div>
|
||||
<p className="text-sm text-slate-500 mt-2">
|
||||
Alle Entscheidungen basieren auf transparenten, nachvollziehbaren Regeln.
|
||||
</p>
|
||||
</div>
|
||||
<div className="bg-white rounded-xl border border-slate-200 p-6 shadow-sm">
|
||||
<h3 className="font-semibold text-slate-800 mb-2">Architektur-Patterns</h3>
|
||||
<div className="text-3xl font-bold text-green-600">{patterns.length}</div>
|
||||
<p className="text-sm text-slate-500 mt-2">
|
||||
Best-Practice-Loesungen fuer datenschutzkonforme KI-Systeme.
|
||||
</p>
|
||||
</div>
|
||||
<div className="bg-white rounded-xl border border-slate-200 p-6 shadow-sm">
|
||||
<h3 className="font-semibold text-slate-800 mb-2">Compliance-Kontrollen</h3>
|
||||
<div className="text-3xl font-bold text-blue-600">{controls.length}</div>
|
||||
<p className="text-sm text-slate-500 mt-2">
|
||||
Technische und organisatorische Massnahmen.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-gradient-to-br from-purple-50 to-blue-50 rounded-xl border border-purple-200 p-6">
|
||||
<h3 className="font-semibold text-purple-800 text-lg mb-4">Was ist UCCA?</h3>
|
||||
<div className="prose prose-sm max-w-none text-slate-700">
|
||||
<p>
|
||||
<strong>UCCA (Use-Case Compliance & Feasibility Advisor)</strong> ist ein deterministisches
|
||||
Compliance-Pruefwerkzeug, das Organisationen bei der Bewertung geplanter KI-Anwendungsfaelle
|
||||
hinsichtlich ihrer datenschutzrechtlichen Zulaessigkeit unterstuetzt.
|
||||
</p>
|
||||
<h4 className="text-purple-700 mt-4">Kernprinzipien</h4>
|
||||
<ul className="space-y-2">
|
||||
<li>
|
||||
<strong>Determinismus:</strong> Alle Entscheidungen basieren auf transparenten Regeln.
|
||||
Die KI trifft KEINE autonomen Entscheidungen.
|
||||
</li>
|
||||
<li>
|
||||
<strong>Transparenz:</strong> Alle Regeln, Kontrollen und Patterns sind einsehbar.
|
||||
</li>
|
||||
<li>
|
||||
<strong>Human-in-the-Loop:</strong> Kritische Entscheidungen erfordern immer
|
||||
menschliche Pruefung durch DSB oder Legal.
|
||||
</li>
|
||||
<li>
|
||||
<strong>Rechtsgrundlage:</strong> Jede Regel referenziert konkrete DSGVO-Artikel.
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-amber-50 border border-amber-200 rounded-xl p-6">
|
||||
<h3 className="font-semibold text-amber-800 mb-3">
|
||||
Wichtiger Hinweis zur KI-Nutzung
|
||||
</h3>
|
||||
<p className="text-amber-700">
|
||||
Das System verwendet KI (LLM) <strong>ausschliesslich zur Erklaerung</strong> bereits
|
||||
getroffener Regelentscheidungen. Die eigentliche Compliance-Bewertung erfolgt
|
||||
<strong> rein deterministisch</strong> durch die Policy Engine. BLOCK-Entscheidungen
|
||||
koennen NICHT durch KI ueberschrieben werden.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
const renderArchitecture = () => (
|
||||
<div className="space-y-6">
|
||||
<div className="bg-white rounded-xl border border-slate-200 p-6">
|
||||
<h3 className="font-semibold text-slate-800 text-lg mb-4">Systemarchitektur</h3>
|
||||
|
||||
<div className="bg-slate-900 text-green-400 p-6 rounded-lg font-mono text-sm overflow-x-auto">
|
||||
<pre>{`
|
||||
┌─────────────────────────────────────────────────────────────────────┐
|
||||
│ Frontend (Next.js) │
|
||||
│ admin-v2:3000/sdk/advisory-board │
|
||||
└───────────────────────────────────┬─────────────────────────────────┘
|
||||
│ HTTPS
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────────────────────┐
|
||||
│ AI Compliance SDK (Go) │
|
||||
│ Port 8090 │
|
||||
│ ┌─────────────────────────────────────────────────────────────┐ │
|
||||
│ │ Policy Engine │ │
|
||||
│ │ ┌───────────────────────────────────────────────────────┐ │ │
|
||||
│ │ │ YAML-basierte Regeln (ucca_policy_v1.yaml) │ │ │
|
||||
│ │ │ ~45 Regeln in 7 Kategorien │ │ │
|
||||
│ │ │ Deterministisch - Kein LLM in Entscheidungslogik │ │ │
|
||||
│ │ └───────────────────────────────────────────────────────┘ │ │
|
||||
│ │ │ │ │
|
||||
│ │ ▼ │ │
|
||||
│ │ ┌────────────────┐ ┌────────────────┐ ┌────────────────┐ │ │
|
||||
│ │ │ Controls │ │ Patterns │ │ Examples │ │ │
|
||||
│ │ │ Library │ │ Library │ │ Library │ │ │
|
||||
│ │ └────────────────┘ └────────────────┘ └────────────────┘ │ │
|
||||
│ └─────────────────────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ ┌──────────────────┐ ┌──────────────────┐ │
|
||||
│ │ LLM Integration │ │ Legal RAG │──────┐ │
|
||||
│ │ (nur Explain) │ │ Client │ │ │
|
||||
│ └──────────────────┘ └──────────────────┘ │ │
|
||||
└─────────────────────────────┬────────────────────┼──────────────────┘
|
||||
│ │
|
||||
▼ ▼
|
||||
┌─────────────────────────────────────────────────────────────────────┐
|
||||
│ Datenschicht │
|
||||
│ ┌────────────────────┐ ┌────────────────────┐ │
|
||||
│ │ PostgreSQL │ │ Qdrant │ │
|
||||
│ │ (Assessments, │ │ (Legal Corpus, │ │
|
||||
│ │ Escalations) │ │ 2,274 Chunks) │ │
|
||||
│ └────────────────────┘ └────────────────────┘ │
|
||||
└─────────────────────────────────────────────────────────────────────┘
|
||||
`}</pre>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div className="p-4 bg-blue-50 rounded-lg border border-blue-200">
|
||||
<h4 className="font-medium text-blue-800 mb-2">Datenfluss</h4>
|
||||
<ol className="text-sm text-blue-700 list-decimal list-inside space-y-1">
|
||||
<li>Benutzer beschreibt Use Case im Frontend</li>
|
||||
<li>Policy Engine evaluiert gegen alle Regeln</li>
|
||||
<li>Ergebnis mit Controls + Patterns zurueck</li>
|
||||
<li>Optional: LLM erklaert das Ergebnis</li>
|
||||
<li>Bei Risiko: Automatische Eskalation</li>
|
||||
</ol>
|
||||
</div>
|
||||
<div className="p-4 bg-green-50 rounded-lg border border-green-200">
|
||||
<h4 className="font-medium text-green-800 mb-2">Sicherheitsmerkmale</h4>
|
||||
<ul className="text-sm text-green-700 list-disc list-inside space-y-1">
|
||||
<li>TLS 1.3 Verschluesselung</li>
|
||||
<li>RBAC mit Tenant-Isolation</li>
|
||||
<li>JWT-basierte Authentifizierung</li>
|
||||
<li>Audit-Trail aller Aktionen</li>
|
||||
<li>Keine Rohtext-Speicherung (nur Hash)</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-xl border border-slate-200 p-6">
|
||||
<h3 className="font-semibold text-slate-800 text-lg mb-4">Eskalations-Workflow</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">Level</th>
|
||||
<th className="text-left py-2 px-3 font-medium text-slate-600">Ausloeser</th>
|
||||
<th className="text-left py-2 px-3 font-medium text-slate-600">Pruefer</th>
|
||||
<th className="text-left py-2 px-3 font-medium text-slate-600">SLA</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr className="border-b border-slate-100 bg-green-50">
|
||||
<td className="py-2 px-3 font-medium text-green-700">E0</td>
|
||||
<td className="py-2 px-3 text-slate-600">Nur INFO-Regeln, Risiko < 20</td>
|
||||
<td className="py-2 px-3 text-slate-600">Automatisch</td>
|
||||
<td className="py-2 px-3 text-slate-600">-</td>
|
||||
</tr>
|
||||
<tr className="border-b border-slate-100 bg-yellow-50">
|
||||
<td className="py-2 px-3 font-medium text-yellow-700">E1</td>
|
||||
<td className="py-2 px-3 text-slate-600">WARN-Regeln, Risiko 20-40</td>
|
||||
<td className="py-2 px-3 text-slate-600">Team-Lead</td>
|
||||
<td className="py-2 px-3 text-slate-600">24h / 72h</td>
|
||||
</tr>
|
||||
<tr className="border-b border-slate-100 bg-orange-50">
|
||||
<td className="py-2 px-3 font-medium text-orange-700">E2</td>
|
||||
<td className="py-2 px-3 text-slate-600">Art. 9 Daten, DSFA empfohlen, Risiko 40-60</td>
|
||||
<td className="py-2 px-3 text-slate-600">DSB</td>
|
||||
<td className="py-2 px-3 text-slate-600">8h / 48h</td>
|
||||
</tr>
|
||||
<tr className="bg-red-50">
|
||||
<td className="py-2 px-3 font-medium text-red-700">E3</td>
|
||||
<td className="py-2 px-3 text-slate-600">BLOCK-Regeln, Art. 22, Risiko > 60</td>
|
||||
<td className="py-2 px-3 text-slate-600">DSB + Legal</td>
|
||||
<td className="py-2 px-3 text-slate-600">4h / 24h</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
const renderAuditorInfo = () => (
|
||||
<div className="space-y-6">
|
||||
<div className="bg-white rounded-xl border border-slate-200 p-6">
|
||||
<h3 className="font-semibold text-slate-800 text-lg mb-4">
|
||||
Dokumentation fuer externe Auditoren
|
||||
</h3>
|
||||
<p className="text-slate-600 mb-4">
|
||||
Diese Dokumentation erfuellt die Anforderungen nach Art. 30 DSGVO (Verzeichnis von
|
||||
Verarbeitungstaetigkeiten) und dient als Grundlage fuer Audits nach Art. 32 DSGVO.
|
||||
</p>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="p-4 bg-slate-50 rounded-lg border border-slate-200">
|
||||
<h4 className="font-medium text-slate-800 mb-2">1. Zweck des Systems</h4>
|
||||
<p className="text-sm text-slate-600">
|
||||
UCCA ist ein Compliance-Pruefwerkzeug zur Bewertung geplanter KI-Anwendungsfaelle
|
||||
hinsichtlich ihrer datenschutzrechtlichen Zulaessigkeit.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="p-4 bg-slate-50 rounded-lg border border-slate-200">
|
||||
<h4 className="font-medium text-slate-800 mb-2">2. Rechtsgrundlage</h4>
|
||||
<ul className="text-sm text-slate-600 list-disc list-inside space-y-1">
|
||||
<li><strong>Art. 6 Abs. 1 lit. c DSGVO</strong> - Erfuellung rechtlicher Verpflichtungen</li>
|
||||
<li><strong>Art. 6 Abs. 1 lit. f DSGVO</strong> - Berechtigte Interessen (Compliance-Management)</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div className="p-4 bg-slate-50 rounded-lg border border-slate-200">
|
||||
<h4 className="font-medium text-slate-800 mb-2">3. Verarbeitete Datenkategorien</h4>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm mt-2">
|
||||
<thead>
|
||||
<tr className="border-b border-slate-200">
|
||||
<th className="text-left py-2 px-2 font-medium text-slate-600">Kategorie</th>
|
||||
<th className="text-left py-2 px-2 font-medium text-slate-600">Speicherung</th>
|
||||
<th className="text-left py-2 px-2 font-medium text-slate-600">Aufbewahrung</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="text-slate-600">
|
||||
<tr className="border-b border-slate-100">
|
||||
<td className="py-2 px-2">Use-Case-Beschreibung</td>
|
||||
<td className="py-2 px-2">Nur Hash (SHA-256)</td>
|
||||
<td className="py-2 px-2">10 Jahre</td>
|
||||
</tr>
|
||||
<tr className="border-b border-slate-100">
|
||||
<td className="py-2 px-2">Bewertungsergebnis</td>
|
||||
<td className="py-2 px-2">Vollstaendig</td>
|
||||
<td className="py-2 px-2">10 Jahre</td>
|
||||
</tr>
|
||||
<tr className="border-b border-slate-100">
|
||||
<td className="py-2 px-2">Audit-Trail</td>
|
||||
<td className="py-2 px-2">Vollstaendig</td>
|
||||
<td className="py-2 px-2">10 Jahre</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="py-2 px-2">Eskalations-Historie</td>
|
||||
<td className="py-2 px-2">Vollstaendig</td>
|
||||
<td className="py-2 px-2">10 Jahre</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-4 bg-slate-50 rounded-lg border border-slate-200">
|
||||
<h4 className="font-medium text-slate-800 mb-2">4. Keine autonomen KI-Entscheidungen</h4>
|
||||
<p className="text-sm text-slate-600">
|
||||
Das System trifft <strong>KEINE automatisierten Einzelentscheidungen</strong> im Sinne
|
||||
von Art. 22 DSGVO, da:
|
||||
</p>
|
||||
<ul className="text-sm text-slate-600 list-disc list-inside mt-2 space-y-1">
|
||||
<li>Regelauswertung ist keine rechtlich bindende Entscheidung</li>
|
||||
<li>Alle kritischen Faelle werden menschlich geprueft (E1-E3)</li>
|
||||
<li>BLOCK-Entscheidungen erfordern immer menschliche Freigabe</li>
|
||||
<li>Betroffene haben Anfechtungsmoeglichkeit ueber Eskalation</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div className="p-4 bg-green-50 rounded-lg border border-green-200">
|
||||
<h4 className="font-medium text-green-800 mb-2">5. Technische und Organisatorische Massnahmen</h4>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 text-sm">
|
||||
<div>
|
||||
<strong className="text-green-700">Vertraulichkeit</strong>
|
||||
<ul className="text-green-700 list-disc list-inside mt-1">
|
||||
<li>RBAC mit Tenant-Isolation</li>
|
||||
<li>TLS 1.3 Verschluesselung</li>
|
||||
<li>AES-256 at rest</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div>
|
||||
<strong className="text-green-700">Integritaet</strong>
|
||||
<ul className="text-green-700 list-disc list-inside mt-1">
|
||||
<li>Unveraenderlicher Audit-Trail</li>
|
||||
<li>Policy-Versionierung</li>
|
||||
<li>Input-Validierung</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
const renderRulesTab = () => (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h3 className="font-semibold text-slate-800 text-lg">Regel-Katalog</h3>
|
||||
<p className="text-sm text-slate-500">Policy Version: {policyVersion}</p>
|
||||
</div>
|
||||
<div className="text-sm text-slate-500">
|
||||
{rules.length} Regeln insgesamt
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className="text-center py-8 text-slate-500">Lade Regeln...</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{Array.from(new Set(rules.map(r => r.category))).map(category => (
|
||||
<div key={category} className="bg-white rounded-xl border border-slate-200 overflow-hidden">
|
||||
<div className="px-4 py-3 bg-slate-50 border-b border-slate-200">
|
||||
<h4 className="font-medium text-slate-800">{category}</h4>
|
||||
<p className="text-xs text-slate-500">
|
||||
{rules.filter(r => r.category === category).length} Regeln
|
||||
</p>
|
||||
</div>
|
||||
<div className="divide-y divide-slate-100">
|
||||
{rules.filter(r => r.category === category).map(rule => (
|
||||
<div key={rule.code} className="p-4">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-mono text-sm text-slate-500">{rule.code}</span>
|
||||
<span className={`text-xs px-2 py-0.5 rounded ${
|
||||
rule.severity === 'BLOCK' ? 'bg-red-100 text-red-700' :
|
||||
rule.severity === 'WARN' ? 'bg-yellow-100 text-yellow-700' :
|
||||
'bg-blue-100 text-blue-700'
|
||||
}`}>
|
||||
{rule.severity}
|
||||
</span>
|
||||
</div>
|
||||
<div className="font-medium text-slate-800 mt-1">{rule.title}</div>
|
||||
<div className="text-sm text-slate-600 mt-1">{rule.description}</div>
|
||||
{rule.gdpr_ref && (
|
||||
<div className="text-xs text-slate-500 mt-2">{rule.gdpr_ref}</div>
|
||||
)}
|
||||
</div>
|
||||
{rule.risk_add && (
|
||||
<div className="text-sm font-medium text-red-600">
|
||||
+{rule.risk_add}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
|
||||
const renderLegalCorpus = () => (
|
||||
<div className="space-y-6">
|
||||
<div className="bg-white rounded-xl border border-slate-200 p-6">
|
||||
<h3 className="font-semibold text-slate-800 text-lg mb-4">Legal RAG Corpus</h3>
|
||||
<p className="text-slate-600 mb-4">
|
||||
Das System verwendet einen semantischen Suchindex mit 2.274 Chunks aus 19 EU-Regulierungen
|
||||
fuer rechtsgrundlagenbasierte Erklaerungen.
|
||||
</p>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div className="p-4 bg-blue-50 rounded-lg border border-blue-200">
|
||||
<h4 className="font-medium text-blue-800 mb-2">Indexierte Regulierungen</h4>
|
||||
<ul className="text-sm text-blue-700 space-y-1">
|
||||
<li>DSGVO - Datenschutz-Grundverordnung</li>
|
||||
<li>AI Act - EU KI-Verordnung</li>
|
||||
<li>NIS2 - Cybersicherheits-Richtlinie</li>
|
||||
<li>CRA - Cyber Resilience Act</li>
|
||||
<li>Data Act - Datengesetz</li>
|
||||
<li>DSA/DMA - Digital Services/Markets Act</li>
|
||||
<li>DPF - EU-US Data Privacy Framework</li>
|
||||
<li>BSI-TR-03161 - Digitale Identitaeten</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div className="p-4 bg-green-50 rounded-lg border border-green-200">
|
||||
<h4 className="font-medium text-green-800 mb-2">RAG-Funktionalitaet</h4>
|
||||
<ul className="text-sm text-green-700 space-y-1">
|
||||
<li>Hybride Suche (Dense + BM25)</li>
|
||||
<li>Semantisches Chunking</li>
|
||||
<li>Cross-Encoder Reranking</li>
|
||||
<li>Artikel-Referenz-Extraktion</li>
|
||||
<li>Mehrsprachig (DE/EN)</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-xl border border-slate-200 p-6">
|
||||
<h3 className="font-semibold text-slate-800 mb-4">Verwendung im System</h3>
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="w-8 h-8 bg-purple-100 text-purple-700 rounded-full flex items-center justify-center flex-shrink-0">
|
||||
1
|
||||
</div>
|
||||
<div>
|
||||
<div className="font-medium text-slate-800">Benutzer fordert Erklaerung an</div>
|
||||
<div className="text-sm text-slate-600">
|
||||
Nach der Bewertung kann eine LLM-basierte Erklaerung generiert werden.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="w-8 h-8 bg-purple-100 text-purple-700 rounded-full flex items-center justify-center flex-shrink-0">
|
||||
2
|
||||
</div>
|
||||
<div>
|
||||
<div className="font-medium text-slate-800">Legal RAG Client sucht relevante Artikel</div>
|
||||
<div className="text-sm text-slate-600">
|
||||
Basierend auf den ausgeloesten Regeln werden passende Gesetzestexte gefunden.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="w-8 h-8 bg-purple-100 text-purple-700 rounded-full flex items-center justify-center flex-shrink-0">
|
||||
3
|
||||
</div>
|
||||
<div>
|
||||
<div className="font-medium text-slate-800">LLM generiert Erklaerung mit Rechtsgrundlage</div>
|
||||
<div className="text-sm text-slate-600">
|
||||
Die Erklaerung referenziert konkrete Artikel aus DSGVO, AI Act etc.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
// ============================================================================
|
||||
// Tabs Configuration
|
||||
// ============================================================================
|
||||
|
||||
const tabs: { id: DocTab; label: string }[] = [
|
||||
{ id: 'overview', label: 'Uebersicht' },
|
||||
{ id: 'architecture', label: 'Architektur' },
|
||||
{ id: 'auditor', label: 'Fuer Auditoren' },
|
||||
{ id: 'rules', label: 'Regel-Katalog' },
|
||||
{ id: 'legal-corpus', label: 'Legal RAG' },
|
||||
]
|
||||
|
||||
// ============================================================================
|
||||
// Main Render
|
||||
// ============================================================================
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
@@ -584,11 +108,11 @@ export default function DocumentationPage() {
|
||||
</div>
|
||||
|
||||
<div className="p-6">
|
||||
{activeTab === 'overview' && renderOverview()}
|
||||
{activeTab === 'architecture' && renderArchitecture()}
|
||||
{activeTab === 'auditor' && renderAuditorInfo()}
|
||||
{activeTab === 'rules' && renderRulesTab()}
|
||||
{activeTab === 'legal-corpus' && renderLegalCorpus()}
|
||||
{activeTab === 'overview' && <OverviewTab rules={rules} patterns={patterns} controls={controls} />}
|
||||
{activeTab === 'architecture' && <ArchitectureTab />}
|
||||
{activeTab === 'auditor' && <AuditorTab />}
|
||||
{activeTab === 'rules' && <RulesTab rules={rules} policyVersion={policyVersion} loading={loading} />}
|
||||
{activeTab === 'legal-corpus' && <LegalCorpusTab />}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
import React from 'react'
|
||||
import Link from 'next/link'
|
||||
|
||||
export function QuickActionCard({
|
||||
title,
|
||||
description,
|
||||
href,
|
||||
onClick,
|
||||
icon,
|
||||
}: {
|
||||
title: string
|
||||
description: string
|
||||
href?: string
|
||||
onClick?: () => void
|
||||
icon: React.ReactNode
|
||||
}) {
|
||||
const inner = (
|
||||
<>
|
||||
<div className="flex-shrink-0 p-3 bg-blue-50 dark:bg-blue-900/20 rounded-lg text-blue-600 dark:text-blue-400">
|
||||
{icon}
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-medium text-gray-900 dark:text-white">{title}</h3>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">{description}</p>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
|
||||
if (onClick) {
|
||||
return (
|
||||
<button
|
||||
onClick={onClick}
|
||||
className="bg-white dark:bg-gray-800 rounded-lg shadow p-6 hover:shadow-md transition-shadow flex items-start gap-4 w-full text-left"
|
||||
>
|
||||
{inner}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Link
|
||||
href={href!}
|
||||
className="bg-white dark:bg-gray-800 rounded-lg shadow p-6 hover:shadow-md transition-shadow flex items-start gap-4"
|
||||
>
|
||||
{inner}
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
export function RiskBar({
|
||||
label,
|
||||
count,
|
||||
total,
|
||||
color,
|
||||
}: {
|
||||
label: string
|
||||
count: number
|
||||
total: number
|
||||
color: string
|
||||
}) {
|
||||
const percentage = total > 0 ? (count / total) * 100 : 0
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex justify-between text-sm mb-1">
|
||||
<span className="text-gray-600 dark:text-gray-400">{label}</span>
|
||||
<span className="font-medium text-gray-900 dark:text-white">{count}</span>
|
||||
</div>
|
||||
<div className="w-full bg-gray-200 dark:bg-gray-700 rounded-full h-2">
|
||||
<div
|
||||
className={`${color} h-2 rounded-full transition-all`}
|
||||
style={{ width: `${percentage}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import Link from 'next/link'
|
||||
|
||||
export function StatCard({
|
||||
title,
|
||||
value,
|
||||
description,
|
||||
href,
|
||||
color,
|
||||
}: {
|
||||
title: string
|
||||
value: number
|
||||
description: string
|
||||
href: string
|
||||
color: 'blue' | 'purple' | 'green' | 'red'
|
||||
}) {
|
||||
const colors = {
|
||||
blue: 'bg-blue-50 dark:bg-blue-900/20',
|
||||
purple: 'bg-purple-50 dark:bg-purple-900/20',
|
||||
green: 'bg-green-50 dark:bg-green-900/20',
|
||||
red: 'bg-red-50 dark:bg-red-900/20',
|
||||
}
|
||||
|
||||
return (
|
||||
<Link href={href} className={`${colors[color]} rounded-lg p-6 hover:opacity-80 transition-opacity`}>
|
||||
<p className="text-sm font-medium text-gray-600 dark:text-gray-400">{title}</p>
|
||||
<p className="mt-2 text-3xl font-bold text-gray-900 dark:text-white">{value}</p>
|
||||
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400">{description}</p>
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
|
||||
export function VendorCreateModal({
|
||||
onClose,
|
||||
onSuccess
|
||||
}: {
|
||||
onClose: () => void
|
||||
onSuccess: () => void
|
||||
}) {
|
||||
const [name, setName] = useState('')
|
||||
const [serviceDescription, setServiceDescription] = useState('')
|
||||
const [category, setCategory] = useState('data_processor')
|
||||
const [country, setCountry] = useState('Germany')
|
||||
const [riskLevel, setRiskLevel] = useState('MEDIUM')
|
||||
const [dpaStatus, setDpaStatus] = useState('PENDING')
|
||||
const [contractUrl, setContractUrl] = useState('')
|
||||
const [isSaving, setIsSaving] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!name.trim()) {
|
||||
setError('Name ist erforderlich.')
|
||||
return
|
||||
}
|
||||
setIsSaving(true)
|
||||
setError(null)
|
||||
try {
|
||||
const res = await fetch('/api/sdk/v1/vendor-compliance/vendors', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
name,
|
||||
serviceDescription,
|
||||
category,
|
||||
country,
|
||||
riskLevel,
|
||||
dpaStatus,
|
||||
contractUrl
|
||||
})
|
||||
})
|
||||
if (!res.ok) {
|
||||
const data = await res.json().catch(() => ({}))
|
||||
throw new Error(data.detail || data.message || `Fehler: ${res.status}`)
|
||||
}
|
||||
onSuccess()
|
||||
} catch (err: unknown) {
|
||||
setError(err instanceof Error ? err.message : 'Unbekannter Fehler')
|
||||
} finally {
|
||||
setIsSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center">
|
||||
<div className="absolute inset-0 bg-black/50" onClick={onClose} />
|
||||
<div className="relative bg-white rounded-xl shadow-2xl w-full max-w-lg mx-4 p-6 z-10 max-h-[90vh] overflow-y-auto">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<h2 className="text-xl font-semibold text-gray-900">Neuen Vendor anlegen</h2>
|
||||
<button onClick={onClose} className="p-1 text-gray-400 hover:text-gray-600 rounded 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="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="mb-4 p-3 bg-red-50 border border-red-200 rounded-lg text-sm text-red-700">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Name <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<input
|
||||
type="text" value={name} onChange={e => setName(e.target.value)}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 text-sm"
|
||||
placeholder="Name des Vendors / Dienstleisters"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Leistungsbeschreibung</label>
|
||||
<input
|
||||
type="text" value={serviceDescription} onChange={e => setServiceDescription(e.target.value)}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 text-sm"
|
||||
placeholder="Kurze Beschreibung der erbrachten Leistung"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Kategorie</label>
|
||||
<select value={category} onChange={e => setCategory(e.target.value)}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 text-sm">
|
||||
<option value="data_processor">Auftragsverarbeiter</option>
|
||||
<option value="cloud_provider">Cloud-Anbieter</option>
|
||||
<option value="saas">SaaS-Anbieter</option>
|
||||
<option value="analytics">Analytics</option>
|
||||
<option value="payment">Zahlungsabwicklung</option>
|
||||
<option value="other">Sonstiges</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Land</label>
|
||||
<input
|
||||
type="text" value={country} onChange={e => setCountry(e.target.value)}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 text-sm"
|
||||
placeholder="z.B. Germany, USA, Netherlands"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Risikostufe</label>
|
||||
<select value={riskLevel} onChange={e => setRiskLevel(e.target.value)}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 text-sm">
|
||||
<option value="LOW">Niedrig</option>
|
||||
<option value="MEDIUM">Mittel</option>
|
||||
<option value="HIGH">Hoch</option>
|
||||
<option value="CRITICAL">Kritisch</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">AVV-Status</label>
|
||||
<select value={dpaStatus} onChange={e => setDpaStatus(e.target.value)}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 text-sm">
|
||||
<option value="SIGNED">Unterzeichnet</option>
|
||||
<option value="PENDING">Ausstehend</option>
|
||||
<option value="EXPIRED">Abgelaufen</option>
|
||||
<option value="NOT_REQUIRED">Nicht erforderlich</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">AVV-Link (URL)</label>
|
||||
<input
|
||||
type="text" value={contractUrl} onChange={e => setContractUrl(e.target.value)}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 text-sm"
|
||||
placeholder="https://..."
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-end gap-3 mt-6 pt-4 border-t border-gray-200">
|
||||
<button onClick={onClose} className="px-4 py-2 text-sm text-gray-700 hover:bg-gray-100 rounded-lg transition-colors">
|
||||
Abbrechen
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSave} disabled={isSaving}
|
||||
className="px-4 py-2 text-sm bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-2"
|
||||
>
|
||||
{isSaving && (
|
||||
<svg className="animate-spin w-4 h-4" fill="none" viewBox="0 0 24 24">
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
|
||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" />
|
||||
</svg>
|
||||
)}
|
||||
Speichern
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -3,217 +3,10 @@
|
||||
import { useState } from 'react'
|
||||
import { useVendorCompliance } from '@/lib/sdk/vendor-compliance'
|
||||
import Link from 'next/link'
|
||||
|
||||
// =============================================================================
|
||||
// VENDOR CREATE MODAL
|
||||
// =============================================================================
|
||||
|
||||
function VendorCreateModal({
|
||||
onClose,
|
||||
onSuccess
|
||||
}: {
|
||||
onClose: () => void
|
||||
onSuccess: () => void
|
||||
}) {
|
||||
const [name, setName] = useState('')
|
||||
const [serviceDescription, setServiceDescription] = useState('')
|
||||
const [category, setCategory] = useState('data_processor')
|
||||
const [country, setCountry] = useState('Germany')
|
||||
const [riskLevel, setRiskLevel] = useState('MEDIUM')
|
||||
const [dpaStatus, setDpaStatus] = useState('PENDING')
|
||||
const [contractUrl, setContractUrl] = useState('')
|
||||
const [isSaving, setIsSaving] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!name.trim()) {
|
||||
setError('Name ist erforderlich.')
|
||||
return
|
||||
}
|
||||
setIsSaving(true)
|
||||
setError(null)
|
||||
try {
|
||||
const res = await fetch('/api/sdk/v1/vendor-compliance/vendors', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
name,
|
||||
serviceDescription,
|
||||
category,
|
||||
country,
|
||||
riskLevel,
|
||||
dpaStatus,
|
||||
contractUrl
|
||||
})
|
||||
})
|
||||
if (!res.ok) {
|
||||
const data = await res.json().catch(() => ({}))
|
||||
throw new Error(data.detail || data.message || `Fehler: ${res.status}`)
|
||||
}
|
||||
onSuccess()
|
||||
} catch (err: unknown) {
|
||||
setError(err instanceof Error ? err.message : 'Unbekannter Fehler')
|
||||
} finally {
|
||||
setIsSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center">
|
||||
{/* Backdrop */}
|
||||
<div
|
||||
className="absolute inset-0 bg-black/50"
|
||||
onClick={onClose}
|
||||
/>
|
||||
{/* Modal */}
|
||||
<div className="relative bg-white rounded-xl shadow-2xl w-full max-w-lg mx-4 p-6 z-10 max-h-[90vh] overflow-y-auto">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<h2 className="text-xl font-semibold text-gray-900">Neuen Vendor anlegen</h2>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="p-1 text-gray-400 hover:text-gray-600 rounded 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="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="mb-4 p-3 bg-red-50 border border-red-200 rounded-lg text-sm text-red-700">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-4">
|
||||
{/* Name */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Name <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={name}
|
||||
onChange={e => setName(e.target.value)}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 text-sm"
|
||||
placeholder="Name des Vendors / Dienstleisters"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Service Description */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Leistungsbeschreibung</label>
|
||||
<input
|
||||
type="text"
|
||||
value={serviceDescription}
|
||||
onChange={e => setServiceDescription(e.target.value)}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 text-sm"
|
||||
placeholder="Kurze Beschreibung der erbrachten Leistung"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Category */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Kategorie</label>
|
||||
<select
|
||||
value={category}
|
||||
onChange={e => setCategory(e.target.value)}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 text-sm"
|
||||
>
|
||||
<option value="data_processor">Auftragsverarbeiter</option>
|
||||
<option value="cloud_provider">Cloud-Anbieter</option>
|
||||
<option value="saas">SaaS-Anbieter</option>
|
||||
<option value="analytics">Analytics</option>
|
||||
<option value="payment">Zahlungsabwicklung</option>
|
||||
<option value="other">Sonstiges</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Country */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Land</label>
|
||||
<input
|
||||
type="text"
|
||||
value={country}
|
||||
onChange={e => setCountry(e.target.value)}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 text-sm"
|
||||
placeholder="z.B. Germany, USA, Netherlands"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Risk Level */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Risikostufe</label>
|
||||
<select
|
||||
value={riskLevel}
|
||||
onChange={e => setRiskLevel(e.target.value)}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 text-sm"
|
||||
>
|
||||
<option value="LOW">Niedrig</option>
|
||||
<option value="MEDIUM">Mittel</option>
|
||||
<option value="HIGH">Hoch</option>
|
||||
<option value="CRITICAL">Kritisch</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* DPA Status */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">AVV-Status</label>
|
||||
<select
|
||||
value={dpaStatus}
|
||||
onChange={e => setDpaStatus(e.target.value)}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 text-sm"
|
||||
>
|
||||
<option value="SIGNED">Unterzeichnet</option>
|
||||
<option value="PENDING">Ausstehend</option>
|
||||
<option value="EXPIRED">Abgelaufen</option>
|
||||
<option value="NOT_REQUIRED">Nicht erforderlich</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Contract URL */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">AVV-Link (URL)</label>
|
||||
<input
|
||||
type="text"
|
||||
value={contractUrl}
|
||||
onChange={e => setContractUrl(e.target.value)}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 text-sm"
|
||||
placeholder="https://..."
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Buttons */}
|
||||
<div className="flex items-center justify-end gap-3 mt-6 pt-4 border-t border-gray-200">
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="px-4 py-2 text-sm text-gray-700 hover:bg-gray-100 rounded-lg transition-colors"
|
||||
>
|
||||
Abbrechen
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSave}
|
||||
disabled={isSaving}
|
||||
className="px-4 py-2 text-sm bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-2"
|
||||
>
|
||||
{isSaving && (
|
||||
<svg className="animate-spin w-4 h-4" fill="none" viewBox="0 0 24 24">
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
|
||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" />
|
||||
</svg>
|
||||
)}
|
||||
Speichern
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// MAIN PAGE
|
||||
// =============================================================================
|
||||
import { VendorCreateModal } from './_components/VendorCreateModal'
|
||||
import { StatCard } from './_components/StatCard'
|
||||
import { RiskBar } from './_components/RiskBar'
|
||||
import { QuickActionCard } from './_components/QuickActionCard'
|
||||
|
||||
export default function VendorComplianceDashboard() {
|
||||
const {
|
||||
@@ -300,47 +93,19 @@ export default function VendorComplianceDashboard() {
|
||||
Vendor Risiko-Verteilung
|
||||
</h2>
|
||||
<div className="space-y-4">
|
||||
<RiskBar
|
||||
label="Kritisch"
|
||||
count={vendorStats.byRiskLevel?.CRITICAL || 0}
|
||||
total={vendorStats.total}
|
||||
color="bg-red-500"
|
||||
/>
|
||||
<RiskBar
|
||||
label="Hoch"
|
||||
count={vendorStats.byRiskLevel?.HIGH || 0}
|
||||
total={vendorStats.total}
|
||||
color="bg-orange-500"
|
||||
/>
|
||||
<RiskBar
|
||||
label="Mittel"
|
||||
count={vendorStats.byRiskLevel?.MEDIUM || 0}
|
||||
total={vendorStats.total}
|
||||
color="bg-yellow-500"
|
||||
/>
|
||||
<RiskBar
|
||||
label="Niedrig"
|
||||
count={vendorStats.byRiskLevel?.LOW || 0}
|
||||
total={vendorStats.total}
|
||||
color="bg-green-500"
|
||||
/>
|
||||
<RiskBar label="Kritisch" count={vendorStats.byRiskLevel?.CRITICAL || 0} total={vendorStats.total} color="bg-red-500" />
|
||||
<RiskBar label="Hoch" count={vendorStats.byRiskLevel?.HIGH || 0} total={vendorStats.total} color="bg-orange-500" />
|
||||
<RiskBar label="Mittel" count={vendorStats.byRiskLevel?.MEDIUM || 0} total={vendorStats.total} color="bg-yellow-500" />
|
||||
<RiskBar label="Niedrig" count={vendorStats.byRiskLevel?.LOW || 0} total={vendorStats.total} color="bg-green-500" />
|
||||
</div>
|
||||
<div className="mt-4 pt-4 border-t border-gray-200 dark:border-gray-700">
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-gray-500 dark:text-gray-400">
|
||||
Durchschn. Inherent Risk
|
||||
</span>
|
||||
<span className="font-medium text-gray-900 dark:text-white">
|
||||
{Math.round(riskOverview.averageInherentRisk)}%
|
||||
</span>
|
||||
<span className="text-gray-500 dark:text-gray-400">Durchschn. Inherent Risk</span>
|
||||
<span className="font-medium text-gray-900 dark:text-white">{Math.round(riskOverview.averageInherentRisk)}%</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-sm mt-1">
|
||||
<span className="text-gray-500 dark:text-gray-400">
|
||||
Durchschn. Residual Risk
|
||||
</span>
|
||||
<span className="font-medium text-gray-900 dark:text-white">
|
||||
{Math.round(riskOverview.averageResidualRisk)}%
|
||||
</span>
|
||||
<span className="text-gray-500 dark:text-gray-400">Durchschn. Residual Risk</span>
|
||||
<span className="font-medium text-gray-900 dark:text-white">{Math.round(riskOverview.averageResidualRisk)}%</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -353,23 +118,11 @@ export default function VendorComplianceDashboard() {
|
||||
<div className="flex items-center justify-center mb-6">
|
||||
<div className="relative w-32 h-32">
|
||||
<svg className="w-full h-full transform -rotate-90" viewBox="0 0 36 36">
|
||||
<path
|
||||
d="M18 2.0845
|
||||
a 15.9155 15.9155 0 0 1 0 31.831
|
||||
a 15.9155 15.9155 0 0 1 0 -31.831"
|
||||
fill="none"
|
||||
stroke="#E5E7EB"
|
||||
strokeWidth="3"
|
||||
/>
|
||||
<path
|
||||
d="M18 2.0845
|
||||
a 15.9155 15.9155 0 0 1 0 31.831
|
||||
a 15.9155 15.9155 0 0 1 0 -31.831"
|
||||
fill="none"
|
||||
stroke="#3B82F6"
|
||||
strokeWidth="3"
|
||||
strokeDasharray={`${complianceStats.averageComplianceScore}, 100`}
|
||||
/>
|
||||
<path d="M18 2.0845 a 15.9155 15.9155 0 0 1 0 31.831 a 15.9155 15.9155 0 0 1 0 -31.831"
|
||||
fill="none" stroke="#E5E7EB" strokeWidth="3" />
|
||||
<path d="M18 2.0845 a 15.9155 15.9155 0 0 1 0 31.831 a 15.9155 15.9155 0 0 1 0 -31.831"
|
||||
fill="none" stroke="#3B82F6" strokeWidth="3"
|
||||
strokeDasharray={`${complianceStats.averageComplianceScore}, 100`} />
|
||||
</svg>
|
||||
<div className="absolute inset-0 flex items-center justify-center">
|
||||
<span className="text-2xl font-bold text-gray-900 dark:text-white">
|
||||
@@ -380,30 +133,18 @@ export default function VendorComplianceDashboard() {
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="text-center">
|
||||
<div className="text-2xl font-bold text-green-600">
|
||||
{complianceStats.resolvedFindings}
|
||||
</div>
|
||||
<div className="text-sm text-gray-500 dark:text-gray-400">
|
||||
Behoben
|
||||
</div>
|
||||
<div className="text-2xl font-bold text-green-600">{complianceStats.resolvedFindings}</div>
|
||||
<div className="text-sm text-gray-500 dark:text-gray-400">Behoben</div>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<div className="text-2xl font-bold text-red-600">
|
||||
{complianceStats.openFindings}
|
||||
</div>
|
||||
<div className="text-sm text-gray-500 dark:text-gray-400">
|
||||
Offen
|
||||
</div>
|
||||
<div className="text-2xl font-bold text-red-600">{complianceStats.openFindings}</div>
|
||||
<div className="text-sm text-gray-500 dark:text-gray-400">Offen</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-4 pt-4 border-t border-gray-200 dark:border-gray-700">
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-gray-500 dark:text-gray-400">
|
||||
Control Pass Rate
|
||||
</span>
|
||||
<span className="font-medium text-gray-900 dark:text-white">
|
||||
{Math.round(complianceStats.controlPassRate)}%
|
||||
</span>
|
||||
<span className="text-gray-500 dark:text-gray-400">Control Pass Rate</span>
|
||||
<span className="font-medium text-gray-900 dark:text-white">{Math.round(complianceStats.controlPassRate)}%</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -443,12 +184,10 @@ export default function VendorComplianceDashboard() {
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Recent Activity */}
|
||||
{/* Faellige Reviews */}
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow">
|
||||
<div className="px-6 py-4 border-b border-gray-200 dark:border-gray-700">
|
||||
<h2 className="text-lg font-semibold text-gray-900 dark:text-white">
|
||||
Fällige Reviews
|
||||
</h2>
|
||||
<h2 className="text-lg font-semibold text-gray-900 dark:text-white">Fällige Reviews</h2>
|
||||
</div>
|
||||
<div className="divide-y divide-gray-200 dark:divide-gray-700">
|
||||
{vendors
|
||||
@@ -462,12 +201,8 @@ export default function VendorComplianceDashboard() {
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-gray-900 dark:text-white">
|
||||
{vendor.name}
|
||||
</p>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||
{vendor.serviceDescription}
|
||||
</p>
|
||||
<p className="text-sm font-medium text-gray-900 dark:text-white">{vendor.name}</p>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">{vendor.serviceDescription}</p>
|
||||
</div>
|
||||
<span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200">
|
||||
Review fällig
|
||||
@@ -483,7 +218,6 @@ export default function VendorComplianceDashboard() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Vendor Create Modal */}
|
||||
{showVendorCreate && (
|
||||
<VendorCreateModal
|
||||
onClose={() => setShowVendorCreate(false)}
|
||||
@@ -493,110 +227,3 @@ export default function VendorComplianceDashboard() {
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function StatCard({
|
||||
title,
|
||||
value,
|
||||
description,
|
||||
href,
|
||||
color,
|
||||
}: {
|
||||
title: string
|
||||
value: number
|
||||
description: string
|
||||
href: string
|
||||
color: 'blue' | 'purple' | 'green' | 'red'
|
||||
}) {
|
||||
const colors = {
|
||||
blue: 'bg-blue-50 dark:bg-blue-900/20',
|
||||
purple: 'bg-purple-50 dark:bg-purple-900/20',
|
||||
green: 'bg-green-50 dark:bg-green-900/20',
|
||||
red: 'bg-red-50 dark:bg-red-900/20',
|
||||
}
|
||||
|
||||
return (
|
||||
<Link
|
||||
href={href}
|
||||
className={`${colors[color]} rounded-lg p-6 hover:opacity-80 transition-opacity`}
|
||||
>
|
||||
<p className="text-sm font-medium text-gray-600 dark:text-gray-400">{title}</p>
|
||||
<p className="mt-2 text-3xl font-bold text-gray-900 dark:text-white">{value}</p>
|
||||
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400">{description}</p>
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
|
||||
function RiskBar({
|
||||
label,
|
||||
count,
|
||||
total,
|
||||
color,
|
||||
}: {
|
||||
label: string
|
||||
count: number
|
||||
total: number
|
||||
color: string
|
||||
}) {
|
||||
const percentage = total > 0 ? (count / total) * 100 : 0
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex justify-between text-sm mb-1">
|
||||
<span className="text-gray-600 dark:text-gray-400">{label}</span>
|
||||
<span className="font-medium text-gray-900 dark:text-white">{count}</span>
|
||||
</div>
|
||||
<div className="w-full bg-gray-200 dark:bg-gray-700 rounded-full h-2">
|
||||
<div
|
||||
className={`${color} h-2 rounded-full transition-all`}
|
||||
style={{ width: `${percentage}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function QuickActionCard({
|
||||
title,
|
||||
description,
|
||||
href,
|
||||
onClick,
|
||||
icon,
|
||||
}: {
|
||||
title: string
|
||||
description: string
|
||||
href?: string
|
||||
onClick?: () => void
|
||||
icon: React.ReactNode
|
||||
}) {
|
||||
const inner = (
|
||||
<>
|
||||
<div className="flex-shrink-0 p-3 bg-blue-50 dark:bg-blue-900/20 rounded-lg text-blue-600 dark:text-blue-400">
|
||||
{icon}
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-medium text-gray-900 dark:text-white">{title}</h3>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">{description}</p>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
|
||||
if (onClick) {
|
||||
return (
|
||||
<button
|
||||
onClick={onClick}
|
||||
className="bg-white dark:bg-gray-800 rounded-lg shadow p-6 hover:shadow-md transition-shadow flex items-start gap-4 w-full text-left"
|
||||
>
|
||||
{inner}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Link
|
||||
href={href!}
|
||||
className="bg-white dark:bg-gray-800 rounded-lg shadow p-6 hover:shadow-md transition-shadow flex items-start gap-4"
|
||||
>
|
||||
{inner}
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { api } from './workshopApi'
|
||||
|
||||
export function CreateSessionModal({ onClose, onCreated }: {
|
||||
onClose: () => void
|
||||
onCreated: () => void
|
||||
}) {
|
||||
const [title, setTitle] = useState('')
|
||||
const [description, setDescription] = useState('')
|
||||
const [sessionType, setSessionType] = useState<'ucca' | 'dsfa' | 'custom'>('custom')
|
||||
const [totalSteps, setTotalSteps] = useState(5)
|
||||
const [saving, setSaving] = useState(false)
|
||||
|
||||
const handleCreate = async () => {
|
||||
if (!title.trim()) return
|
||||
setSaving(true)
|
||||
try {
|
||||
await api('', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
title: title.trim(),
|
||||
description: description.trim(),
|
||||
session_type: sessionType,
|
||||
total_steps: totalSteps,
|
||||
}),
|
||||
})
|
||||
onCreated()
|
||||
} catch (err) {
|
||||
console.error('Create session error:', err)
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50" onClick={onClose}>
|
||||
<div className="bg-white rounded-2xl p-6 w-full max-w-lg" onClick={e => e.stopPropagation()}>
|
||||
<h3 className="text-lg font-bold text-gray-900 mb-4">Neuer Workshop</h3>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Titel *</label>
|
||||
<input
|
||||
type="text" value={title} onChange={e => setTitle(e.target.value)}
|
||||
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent"
|
||||
placeholder="z.B. DSFA Workshop Q1"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Beschreibung</label>
|
||||
<textarea
|
||||
value={description} onChange={e => setDescription(e.target.value)}
|
||||
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent"
|
||||
rows={3} placeholder="Beschreibung des Workshops..."
|
||||
/>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Typ</label>
|
||||
<select value={sessionType} onChange={e => setSessionType(e.target.value as 'ucca' | 'dsfa' | 'custom')}
|
||||
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-purple-500">
|
||||
<option value="custom">Benutzerdefiniert</option>
|
||||
<option value="ucca">UCCA Assessment</option>
|
||||
<option value="dsfa">DSFA Workshop</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Schritte</label>
|
||||
<input type="number" value={totalSteps} onChange={e => setTotalSteps(Number(e.target.value))}
|
||||
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-purple-500" min={1} max={50}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex justify-end gap-3 mt-6">
|
||||
<button onClick={onClose} className="px-4 py-2 text-sm text-gray-600 hover:bg-gray-100 rounded-lg">Abbrechen</button>
|
||||
<button onClick={handleCreate} disabled={!title.trim() || saving}
|
||||
className="px-4 py-2 text-sm bg-purple-600 text-white rounded-lg hover:bg-purple-700 disabled:opacity-50">
|
||||
{saving ? 'Erstelle...' : 'Erstellen'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
'use client'
|
||||
|
||||
import { WorkshopSession, statusColors, statusLabels, typeLabels } from './types'
|
||||
|
||||
export function SessionCard({ session, onSelect, onDelete }: {
|
||||
session: WorkshopSession
|
||||
onSelect: (s: WorkshopSession) => void
|
||||
onDelete: (id: string) => void
|
||||
}) {
|
||||
const progress = session.total_steps > 0
|
||||
? Math.round((session.current_step / session.total_steps) * 100)
|
||||
: 0
|
||||
|
||||
return (
|
||||
<div className="bg-white rounded-xl border-2 border-gray-200 p-6 hover:border-purple-300 transition-colors cursor-pointer"
|
||||
onClick={() => onSelect(session)}>
|
||||
<div className="flex items-start justify-between mb-3">
|
||||
<div>
|
||||
<h4 className="font-semibold text-gray-900">{session.title}</h4>
|
||||
<span className="text-xs text-gray-500">{typeLabels[session.session_type] || session.session_type}</span>
|
||||
</div>
|
||||
<span className={`px-2 py-1 text-xs rounded-full ${statusColors[session.status] || 'bg-gray-100 text-gray-700'}`}>
|
||||
{statusLabels[session.status] || session.status}
|
||||
</span>
|
||||
</div>
|
||||
{session.description && (
|
||||
<p className="text-sm text-gray-600 mb-3 line-clamp-2">{session.description}</p>
|
||||
)}
|
||||
<div className="flex items-center gap-4 text-xs text-gray-500 mb-3">
|
||||
<span>Code: <code className="bg-gray-100 px-1 rounded">{session.join_code}</code></span>
|
||||
<span>Schritt {session.current_step}/{session.total_steps}</span>
|
||||
</div>
|
||||
<div className="w-full h-2 bg-gray-100 rounded-full overflow-hidden mb-3">
|
||||
<div className="h-full bg-purple-500 rounded-full transition-all" style={{ width: `${progress}%` }} />
|
||||
</div>
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-xs text-gray-400">
|
||||
{new Date(session.created_at).toLocaleDateString('de-DE')}
|
||||
</span>
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); onDelete(session.id) }}
|
||||
className="text-xs text-red-500 hover:text-red-700 hover:bg-red-50 px-2 py-1 rounded"
|
||||
>
|
||||
Loeschen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,237 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import {
|
||||
WorkshopSession, Participant, WorkshopResponse, WorkshopComment,
|
||||
SessionStats, statusColors, statusLabels, typeLabels,
|
||||
} from './types'
|
||||
import { api } from './workshopApi'
|
||||
|
||||
export function SessionDetailView({ session, onBack, onRefresh }: {
|
||||
session: WorkshopSession
|
||||
onBack: () => void
|
||||
onRefresh: () => void
|
||||
}) {
|
||||
const [participants, setParticipants] = useState<Participant[]>([])
|
||||
const [responses, setResponses] = useState<WorkshopResponse[]>([])
|
||||
const [comments, setComments] = useState<WorkshopComment[]>([])
|
||||
const [stats, setStats] = useState<SessionStats | null>(null)
|
||||
const [activeTab, setActiveTab] = useState<'participants' | 'responses' | 'comments'>('participants')
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
const loadDetails = useCallback(async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const [p, r, c, s] = await Promise.all([
|
||||
api<Participant[]>(`/${session.id}/participants`).catch(() => []),
|
||||
api<WorkshopResponse[]>(`/${session.id}/responses`).catch(() => []),
|
||||
api<WorkshopComment[]>(`/${session.id}/comments`).catch(() => []),
|
||||
api<SessionStats>(`/${session.id}/stats`).catch(() => null),
|
||||
])
|
||||
setParticipants(Array.isArray(p) ? p : [])
|
||||
setResponses(Array.isArray(r) ? r : [])
|
||||
setComments(Array.isArray(c) ? c : [])
|
||||
setStats(s)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [session.id])
|
||||
|
||||
useEffect(() => { loadDetails() }, [loadDetails])
|
||||
|
||||
const handleLifecycle = async (action: 'start' | 'pause' | 'complete') => {
|
||||
try {
|
||||
await api(`/${session.id}/${action}`, { method: 'POST' })
|
||||
onRefresh()
|
||||
} catch (err) {
|
||||
console.error(`${action} error:`, err)
|
||||
}
|
||||
}
|
||||
|
||||
const handleExport = async () => {
|
||||
try {
|
||||
const data = await api(`/${session.id}/export`)
|
||||
const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' })
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url; a.download = `workshop-${session.id}.json`; a.click()
|
||||
URL.revokeObjectURL(url)
|
||||
} catch (err) {
|
||||
console.error('Export error:', err)
|
||||
}
|
||||
}
|
||||
|
||||
const roleColors: Record<string, string> = {
|
||||
FACILITATOR: 'bg-purple-100 text-purple-700',
|
||||
EXPERT: 'bg-blue-100 text-blue-700',
|
||||
STAKEHOLDER: 'bg-green-100 text-green-700',
|
||||
OBSERVER: 'bg-gray-100 text-gray-700',
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<button onClick={onBack} className="flex items-center gap-2 text-sm text-gray-600 hover:text-gray-900 mb-4">
|
||||
<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
|
||||
</button>
|
||||
|
||||
<div className="bg-white rounded-xl border-2 border-gray-200 p-6 mb-6">
|
||||
<div className="flex items-start justify-between mb-4">
|
||||
<div>
|
||||
<h2 className="text-xl font-bold text-gray-900">{session.title}</h2>
|
||||
<p className="text-sm text-gray-500 mt-1">{session.description}</p>
|
||||
</div>
|
||||
<span className={`px-3 py-1 text-sm rounded-full ${statusColors[session.status]}`}>
|
||||
{statusLabels[session.status]}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{stats && (
|
||||
<div className="grid grid-cols-4 gap-4 mb-4">
|
||||
<div className="bg-gray-50 rounded-lg p-3 text-center">
|
||||
<div className="text-2xl font-bold text-gray-900">{stats.total_participants}</div>
|
||||
<div className="text-xs text-gray-500">Teilnehmer</div>
|
||||
</div>
|
||||
<div className="bg-gray-50 rounded-lg p-3 text-center">
|
||||
<div className="text-2xl font-bold text-gray-900">{stats.active_participants}</div>
|
||||
<div className="text-xs text-gray-500">Aktiv</div>
|
||||
</div>
|
||||
<div className="bg-gray-50 rounded-lg p-3 text-center">
|
||||
<div className="text-2xl font-bold text-gray-900">{stats.total_responses}</div>
|
||||
<div className="text-xs text-gray-500">Antworten</div>
|
||||
</div>
|
||||
<div className="bg-gray-50 rounded-lg p-3 text-center">
|
||||
<div className="text-2xl font-bold text-purple-600">{stats.progress}%</div>
|
||||
<div className="text-xs text-gray-500">Fortschritt</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<span className="text-sm text-gray-500">Join-Code: <code className="bg-gray-100 px-2 py-0.5 rounded font-mono">{session.join_code}</code></span>
|
||||
<span className="text-sm text-gray-500">Typ: {typeLabels[session.session_type]}</span>
|
||||
<span className="text-sm text-gray-500">Schritt {session.current_step}/{session.total_steps}</span>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2">
|
||||
{session.status === 'DRAFT' && (
|
||||
<button onClick={() => handleLifecycle('start')} className="px-3 py-1.5 text-sm bg-green-600 text-white rounded-lg hover:bg-green-700">Starten</button>
|
||||
)}
|
||||
{session.status === 'ACTIVE' && (
|
||||
<button onClick={() => handleLifecycle('pause')} className="px-3 py-1.5 text-sm bg-yellow-600 text-white rounded-lg hover:bg-yellow-700">Pausieren</button>
|
||||
)}
|
||||
{(session.status === 'ACTIVE' || session.status === 'PAUSED') && (
|
||||
<button onClick={() => handleLifecycle('complete')} className="px-3 py-1.5 text-sm bg-purple-600 text-white rounded-lg hover:bg-purple-700">Abschliessen</button>
|
||||
)}
|
||||
<button onClick={handleExport} className="px-3 py-1.5 text-sm border border-gray-300 text-gray-700 rounded-lg hover:bg-gray-50">Exportieren</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-1 mb-4 bg-gray-100 p-1 rounded-lg">
|
||||
{(['participants', 'responses', 'comments'] as const).map(tab => (
|
||||
<button key={tab} onClick={() => setActiveTab(tab)}
|
||||
className={`flex-1 px-4 py-2 text-sm rounded-md transition-colors ${activeTab === tab ? 'bg-white text-gray-900 shadow-sm' : 'text-gray-600 hover:text-gray-900'}`}>
|
||||
{tab === 'participants' ? `Teilnehmer (${participants.length})` :
|
||||
tab === 'responses' ? `Antworten (${responses.length})` :
|
||||
`Kommentare (${comments.length})`}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className="text-center py-8 text-gray-500">Laden...</div>
|
||||
) : (
|
||||
<>
|
||||
{activeTab === 'participants' && (
|
||||
<div className="bg-white rounded-xl border border-gray-200 overflow-hidden">
|
||||
<table className="w-full">
|
||||
<thead className="bg-gray-50">
|
||||
<tr>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Name</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Rolle</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Abteilung</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Status</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Beigetreten</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-200">
|
||||
{participants.map(p => (
|
||||
<tr key={p.id} className="hover:bg-gray-50">
|
||||
<td className="px-4 py-3">
|
||||
<div className="font-medium text-gray-900">{p.name}</div>
|
||||
<div className="text-xs text-gray-500">{p.email}</div>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<span className={`px-2 py-0.5 text-xs rounded-full ${roleColors[p.role] || 'bg-gray-100 text-gray-700'}`}>
|
||||
{p.role}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sm text-gray-600">{p.department || '-'}</td>
|
||||
<td className="px-4 py-3">
|
||||
<span className={`inline-block w-2 h-2 rounded-full ${p.is_active ? 'bg-green-500' : 'bg-gray-300'}`} />
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sm text-gray-500">
|
||||
{new Date(p.joined_at).toLocaleDateString('de-DE')}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
{participants.length === 0 && (
|
||||
<tr><td colSpan={5} className="px-4 py-8 text-center text-gray-500">Keine Teilnehmer</td></tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'responses' && (
|
||||
<div className="space-y-3">
|
||||
{responses.map(r => (
|
||||
<div key={r.id} className="bg-white rounded-lg border border-gray-200 p-4">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-sm font-medium text-gray-900">Schritt {r.step_number} / {r.field_id}</span>
|
||||
<span className={`px-2 py-0.5 text-xs rounded-full ${
|
||||
r.response_status === 'SUBMITTED' ? 'bg-green-100 text-green-700' :
|
||||
r.response_status === 'REVIEWED' ? 'bg-purple-100 text-purple-700' :
|
||||
'bg-gray-100 text-gray-700'
|
||||
}`}>{r.response_status}</span>
|
||||
</div>
|
||||
<pre className="text-sm text-gray-600 bg-gray-50 p-2 rounded overflow-auto max-h-32">
|
||||
{typeof r.value === 'string' ? r.value : JSON.stringify(r.value, null, 2)}
|
||||
</pre>
|
||||
<div className="text-xs text-gray-400 mt-2">
|
||||
{new Date(r.created_at).toLocaleString('de-DE')}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{responses.length === 0 && (
|
||||
<div className="text-center py-8 text-gray-500">Keine Antworten</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'comments' && (
|
||||
<div className="space-y-3">
|
||||
{comments.map(c => (
|
||||
<div key={c.id} className={`bg-white rounded-lg border p-4 ${c.is_resolved ? 'border-green-200' : 'border-gray-200'}`}>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
{c.step_number != null && <span className="text-xs text-gray-500">Schritt {c.step_number}</span>}
|
||||
{c.is_resolved && <span className="text-xs text-green-600">Geloest</span>}
|
||||
</div>
|
||||
<p className="text-sm text-gray-700">{c.text}</p>
|
||||
<div className="text-xs text-gray-400 mt-2">
|
||||
{new Date(c.created_at).toLocaleString('de-DE')}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{comments.length === 0 && (
|
||||
<div className="text-center py-8 text-gray-500">Keine Kommentare</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
93
admin-compliance/app/sdk/workshop/_components/types.ts
Normal file
93
admin-compliance/app/sdk/workshop/_components/types.ts
Normal file
@@ -0,0 +1,93 @@
|
||||
export interface WorkshopSession {
|
||||
id: string
|
||||
title: string
|
||||
description: string
|
||||
session_type: 'ucca' | 'dsfa' | 'custom'
|
||||
status: 'DRAFT' | 'SCHEDULED' | 'ACTIVE' | 'PAUSED' | 'COMPLETED' | 'CANCELLED'
|
||||
current_step: number
|
||||
total_steps: number
|
||||
join_code: string
|
||||
require_auth: boolean
|
||||
allow_anonymous: boolean
|
||||
scheduled_start: string | null
|
||||
scheduled_end: string | null
|
||||
actual_start: string | null
|
||||
actual_end: string | null
|
||||
assessment_id: string | null
|
||||
roadmap_id: string | null
|
||||
portfolio_id: string | null
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
export interface Participant {
|
||||
id: string
|
||||
session_id: string
|
||||
user_id: string | null
|
||||
name: string
|
||||
email: string
|
||||
role: 'FACILITATOR' | 'EXPERT' | 'STAKEHOLDER' | 'OBSERVER'
|
||||
department: string
|
||||
is_active: boolean
|
||||
last_active_at: string | null
|
||||
joined_at: string
|
||||
can_edit: boolean
|
||||
can_comment: boolean
|
||||
can_approve: boolean
|
||||
}
|
||||
|
||||
export interface WorkshopResponse {
|
||||
id: string
|
||||
session_id: string
|
||||
participant_id: string
|
||||
step_number: number
|
||||
field_id: string
|
||||
value: unknown
|
||||
value_type: string
|
||||
response_status: 'PENDING' | 'DRAFT' | 'SUBMITTED' | 'REVIEWED'
|
||||
created_at: string
|
||||
}
|
||||
|
||||
export interface WorkshopComment {
|
||||
id: string
|
||||
session_id: string
|
||||
participant_id: string
|
||||
step_number: number | null
|
||||
field_id: string | null
|
||||
text: string
|
||||
is_resolved: boolean
|
||||
created_at: string
|
||||
}
|
||||
|
||||
export interface SessionStats {
|
||||
total_participants: number
|
||||
active_participants: number
|
||||
total_responses: number
|
||||
completed_steps: number
|
||||
total_steps: number
|
||||
progress: number
|
||||
}
|
||||
|
||||
export const statusColors: Record<string, string> = {
|
||||
DRAFT: 'bg-gray-100 text-gray-700',
|
||||
SCHEDULED: 'bg-blue-100 text-blue-700',
|
||||
ACTIVE: 'bg-green-100 text-green-700',
|
||||
PAUSED: 'bg-yellow-100 text-yellow-700',
|
||||
COMPLETED: 'bg-purple-100 text-purple-700',
|
||||
CANCELLED: 'bg-red-100 text-red-700',
|
||||
}
|
||||
|
||||
export const statusLabels: Record<string, string> = {
|
||||
DRAFT: 'Entwurf',
|
||||
SCHEDULED: 'Geplant',
|
||||
ACTIVE: 'Aktiv',
|
||||
PAUSED: 'Pausiert',
|
||||
COMPLETED: 'Abgeschlossen',
|
||||
CANCELLED: 'Abgebrochen',
|
||||
}
|
||||
|
||||
export const typeLabels: Record<string, string> = {
|
||||
ucca: 'UCCA Assessment',
|
||||
dsfa: 'DSFA Workshop',
|
||||
custom: 'Benutzerdefiniert',
|
||||
}
|
||||
13
admin-compliance/app/sdk/workshop/_components/workshopApi.ts
Normal file
13
admin-compliance/app/sdk/workshop/_components/workshopApi.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
export const API_BASE = '/api/sdk/v1/workshops'
|
||||
|
||||
export async function api<T>(path: string, options?: RequestInit): Promise<T> {
|
||||
const res = await fetch(`${API_BASE}${path}`, {
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
...options,
|
||||
})
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({ error: res.statusText }))
|
||||
throw new Error(err.error || err.message || `HTTP ${res.status}`)
|
||||
}
|
||||
return res.json()
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { WorkshopSession } from '../_components/types'
|
||||
import { api } from '../_components/workshopApi'
|
||||
|
||||
export function useWorkshopSessions() {
|
||||
const [sessions, setSessions] = useState<WorkshopSession[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
const loadSessions = useCallback(async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const data = await api<WorkshopSession[] | { sessions: WorkshopSession[] }>('')
|
||||
const list = Array.isArray(data) ? data : (data.sessions || [])
|
||||
setSessions(list)
|
||||
} catch (err) {
|
||||
console.error('Load sessions error:', err)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => { loadSessions() }, [loadSessions])
|
||||
|
||||
const handleDelete = async (id: string) => {
|
||||
if (!confirm('Workshop wirklich loeschen?')) return
|
||||
try {
|
||||
await api(`/${id}`, { method: 'DELETE' })
|
||||
setSessions(prev => prev.filter(s => s.id !== id))
|
||||
} catch (err) {
|
||||
console.error('Delete error:', err)
|
||||
}
|
||||
}
|
||||
|
||||
return { sessions, loading, loadSessions, handleDelete }
|
||||
}
|
||||
@@ -1,521 +1,18 @@
|
||||
'use client'
|
||||
|
||||
import React, { useState, useEffect, useCallback } from 'react'
|
||||
|
||||
// =============================================================================
|
||||
// TYPES
|
||||
// =============================================================================
|
||||
|
||||
interface WorkshopSession {
|
||||
id: string
|
||||
title: string
|
||||
description: string
|
||||
session_type: 'ucca' | 'dsfa' | 'custom'
|
||||
status: 'DRAFT' | 'SCHEDULED' | 'ACTIVE' | 'PAUSED' | 'COMPLETED' | 'CANCELLED'
|
||||
current_step: number
|
||||
total_steps: number
|
||||
join_code: string
|
||||
require_auth: boolean
|
||||
allow_anonymous: boolean
|
||||
scheduled_start: string | null
|
||||
scheduled_end: string | null
|
||||
actual_start: string | null
|
||||
actual_end: string | null
|
||||
assessment_id: string | null
|
||||
roadmap_id: string | null
|
||||
portfolio_id: string | null
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
interface Participant {
|
||||
id: string
|
||||
session_id: string
|
||||
user_id: string | null
|
||||
name: string
|
||||
email: string
|
||||
role: 'FACILITATOR' | 'EXPERT' | 'STAKEHOLDER' | 'OBSERVER'
|
||||
department: string
|
||||
is_active: boolean
|
||||
last_active_at: string | null
|
||||
joined_at: string
|
||||
can_edit: boolean
|
||||
can_comment: boolean
|
||||
can_approve: boolean
|
||||
}
|
||||
|
||||
interface WorkshopResponse {
|
||||
id: string
|
||||
session_id: string
|
||||
participant_id: string
|
||||
step_number: number
|
||||
field_id: string
|
||||
value: unknown
|
||||
value_type: string
|
||||
response_status: 'PENDING' | 'DRAFT' | 'SUBMITTED' | 'REVIEWED'
|
||||
created_at: string
|
||||
}
|
||||
|
||||
interface WorkshopComment {
|
||||
id: string
|
||||
session_id: string
|
||||
participant_id: string
|
||||
step_number: number | null
|
||||
field_id: string | null
|
||||
text: string
|
||||
is_resolved: boolean
|
||||
created_at: string
|
||||
}
|
||||
|
||||
interface SessionStats {
|
||||
total_participants: number
|
||||
active_participants: number
|
||||
total_responses: number
|
||||
completed_steps: number
|
||||
total_steps: number
|
||||
progress: number
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// API
|
||||
// =============================================================================
|
||||
|
||||
const API_BASE = '/api/sdk/v1/workshops'
|
||||
|
||||
async function api<T>(path: string, options?: RequestInit): Promise<T> {
|
||||
const res = await fetch(`${API_BASE}${path}`, {
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
...options,
|
||||
})
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({ error: res.statusText }))
|
||||
throw new Error(err.error || err.message || `HTTP ${res.status}`)
|
||||
}
|
||||
return res.json()
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// COMPONENTS
|
||||
// =============================================================================
|
||||
|
||||
const statusColors: Record<string, string> = {
|
||||
DRAFT: 'bg-gray-100 text-gray-700',
|
||||
SCHEDULED: 'bg-blue-100 text-blue-700',
|
||||
ACTIVE: 'bg-green-100 text-green-700',
|
||||
PAUSED: 'bg-yellow-100 text-yellow-700',
|
||||
COMPLETED: 'bg-purple-100 text-purple-700',
|
||||
CANCELLED: 'bg-red-100 text-red-700',
|
||||
}
|
||||
|
||||
const statusLabels: Record<string, string> = {
|
||||
DRAFT: 'Entwurf',
|
||||
SCHEDULED: 'Geplant',
|
||||
ACTIVE: 'Aktiv',
|
||||
PAUSED: 'Pausiert',
|
||||
COMPLETED: 'Abgeschlossen',
|
||||
CANCELLED: 'Abgebrochen',
|
||||
}
|
||||
|
||||
const typeLabels: Record<string, string> = {
|
||||
ucca: 'UCCA Assessment',
|
||||
dsfa: 'DSFA Workshop',
|
||||
custom: 'Benutzerdefiniert',
|
||||
}
|
||||
|
||||
function SessionCard({ session, onSelect, onDelete }: {
|
||||
session: WorkshopSession
|
||||
onSelect: (s: WorkshopSession) => void
|
||||
onDelete: (id: string) => void
|
||||
}) {
|
||||
const progress = session.total_steps > 0
|
||||
? Math.round((session.current_step / session.total_steps) * 100)
|
||||
: 0
|
||||
|
||||
return (
|
||||
<div className="bg-white rounded-xl border-2 border-gray-200 p-6 hover:border-purple-300 transition-colors cursor-pointer"
|
||||
onClick={() => onSelect(session)}>
|
||||
<div className="flex items-start justify-between mb-3">
|
||||
<div>
|
||||
<h4 className="font-semibold text-gray-900">{session.title}</h4>
|
||||
<span className="text-xs text-gray-500">{typeLabels[session.session_type] || session.session_type}</span>
|
||||
</div>
|
||||
<span className={`px-2 py-1 text-xs rounded-full ${statusColors[session.status] || 'bg-gray-100 text-gray-700'}`}>
|
||||
{statusLabels[session.status] || session.status}
|
||||
</span>
|
||||
</div>
|
||||
{session.description && (
|
||||
<p className="text-sm text-gray-600 mb-3 line-clamp-2">{session.description}</p>
|
||||
)}
|
||||
<div className="flex items-center gap-4 text-xs text-gray-500 mb-3">
|
||||
<span>Code: <code className="bg-gray-100 px-1 rounded">{session.join_code}</code></span>
|
||||
<span>Schritt {session.current_step}/{session.total_steps}</span>
|
||||
</div>
|
||||
<div className="w-full h-2 bg-gray-100 rounded-full overflow-hidden mb-3">
|
||||
<div className="h-full bg-purple-500 rounded-full transition-all" style={{ width: `${progress}%` }} />
|
||||
</div>
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-xs text-gray-400">
|
||||
{new Date(session.created_at).toLocaleDateString('de-DE')}
|
||||
</span>
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); onDelete(session.id) }}
|
||||
className="text-xs text-red-500 hover:text-red-700 hover:bg-red-50 px-2 py-1 rounded"
|
||||
>
|
||||
Loeschen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function CreateSessionModal({ onClose, onCreated }: {
|
||||
onClose: () => void
|
||||
onCreated: () => void
|
||||
}) {
|
||||
const [title, setTitle] = useState('')
|
||||
const [description, setDescription] = useState('')
|
||||
const [sessionType, setSessionType] = useState<'ucca' | 'dsfa' | 'custom'>('custom')
|
||||
const [totalSteps, setTotalSteps] = useState(5)
|
||||
const [saving, setSaving] = useState(false)
|
||||
|
||||
const handleCreate = async () => {
|
||||
if (!title.trim()) return
|
||||
setSaving(true)
|
||||
try {
|
||||
await api('', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
title: title.trim(),
|
||||
description: description.trim(),
|
||||
session_type: sessionType,
|
||||
total_steps: totalSteps,
|
||||
}),
|
||||
})
|
||||
onCreated()
|
||||
} catch (err) {
|
||||
console.error('Create session error:', err)
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50" onClick={onClose}>
|
||||
<div className="bg-white rounded-2xl p-6 w-full max-w-lg" onClick={e => e.stopPropagation()}>
|
||||
<h3 className="text-lg font-bold text-gray-900 mb-4">Neuer Workshop</h3>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Titel *</label>
|
||||
<input
|
||||
type="text" value={title} onChange={e => setTitle(e.target.value)}
|
||||
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent"
|
||||
placeholder="z.B. DSFA Workshop Q1"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Beschreibung</label>
|
||||
<textarea
|
||||
value={description} onChange={e => setDescription(e.target.value)}
|
||||
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent"
|
||||
rows={3} placeholder="Beschreibung des Workshops..."
|
||||
/>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Typ</label>
|
||||
<select value={sessionType} onChange={e => setSessionType(e.target.value as 'ucca' | 'dsfa' | 'custom')}
|
||||
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-purple-500">
|
||||
<option value="custom">Benutzerdefiniert</option>
|
||||
<option value="ucca">UCCA Assessment</option>
|
||||
<option value="dsfa">DSFA Workshop</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Schritte</label>
|
||||
<input type="number" value={totalSteps} onChange={e => setTotalSteps(Number(e.target.value))}
|
||||
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-purple-500" min={1} max={50}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex justify-end gap-3 mt-6">
|
||||
<button onClick={onClose} className="px-4 py-2 text-sm text-gray-600 hover:bg-gray-100 rounded-lg">Abbrechen</button>
|
||||
<button onClick={handleCreate} disabled={!title.trim() || saving}
|
||||
className="px-4 py-2 text-sm bg-purple-600 text-white rounded-lg hover:bg-purple-700 disabled:opacity-50">
|
||||
{saving ? 'Erstelle...' : 'Erstellen'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function SessionDetailView({ session, onBack, onRefresh }: {
|
||||
session: WorkshopSession
|
||||
onBack: () => void
|
||||
onRefresh: () => void
|
||||
}) {
|
||||
const [participants, setParticipants] = useState<Participant[]>([])
|
||||
const [responses, setResponses] = useState<WorkshopResponse[]>([])
|
||||
const [comments, setComments] = useState<WorkshopComment[]>([])
|
||||
const [stats, setStats] = useState<SessionStats | null>(null)
|
||||
const [activeTab, setActiveTab] = useState<'participants' | 'responses' | 'comments'>('participants')
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
const loadDetails = useCallback(async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const [p, r, c, s] = await Promise.all([
|
||||
api<Participant[]>(`/${session.id}/participants`).catch(() => []),
|
||||
api<WorkshopResponse[]>(`/${session.id}/responses`).catch(() => []),
|
||||
api<WorkshopComment[]>(`/${session.id}/comments`).catch(() => []),
|
||||
api<SessionStats>(`/${session.id}/stats`).catch(() => null),
|
||||
])
|
||||
setParticipants(Array.isArray(p) ? p : [])
|
||||
setResponses(Array.isArray(r) ? r : [])
|
||||
setComments(Array.isArray(c) ? c : [])
|
||||
setStats(s)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [session.id])
|
||||
|
||||
useEffect(() => { loadDetails() }, [loadDetails])
|
||||
|
||||
const handleLifecycle = async (action: 'start' | 'pause' | 'complete') => {
|
||||
try {
|
||||
await api(`/${session.id}/${action}`, { method: 'POST' })
|
||||
onRefresh()
|
||||
} catch (err) {
|
||||
console.error(`${action} error:`, err)
|
||||
}
|
||||
}
|
||||
|
||||
const handleExport = async () => {
|
||||
try {
|
||||
const data = await api(`/${session.id}/export`)
|
||||
const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' })
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url; a.download = `workshop-${session.id}.json`; a.click()
|
||||
URL.revokeObjectURL(url)
|
||||
} catch (err) {
|
||||
console.error('Export error:', err)
|
||||
}
|
||||
}
|
||||
|
||||
const roleColors: Record<string, string> = {
|
||||
FACILITATOR: 'bg-purple-100 text-purple-700',
|
||||
EXPERT: 'bg-blue-100 text-blue-700',
|
||||
STAKEHOLDER: 'bg-green-100 text-green-700',
|
||||
OBSERVER: 'bg-gray-100 text-gray-700',
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<button onClick={onBack} className="flex items-center gap-2 text-sm text-gray-600 hover:text-gray-900 mb-4">
|
||||
<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
|
||||
</button>
|
||||
|
||||
<div className="bg-white rounded-xl border-2 border-gray-200 p-6 mb-6">
|
||||
<div className="flex items-start justify-between mb-4">
|
||||
<div>
|
||||
<h2 className="text-xl font-bold text-gray-900">{session.title}</h2>
|
||||
<p className="text-sm text-gray-500 mt-1">{session.description}</p>
|
||||
</div>
|
||||
<span className={`px-3 py-1 text-sm rounded-full ${statusColors[session.status]}`}>
|
||||
{statusLabels[session.status]}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{stats && (
|
||||
<div className="grid grid-cols-4 gap-4 mb-4">
|
||||
<div className="bg-gray-50 rounded-lg p-3 text-center">
|
||||
<div className="text-2xl font-bold text-gray-900">{stats.total_participants}</div>
|
||||
<div className="text-xs text-gray-500">Teilnehmer</div>
|
||||
</div>
|
||||
<div className="bg-gray-50 rounded-lg p-3 text-center">
|
||||
<div className="text-2xl font-bold text-gray-900">{stats.active_participants}</div>
|
||||
<div className="text-xs text-gray-500">Aktiv</div>
|
||||
</div>
|
||||
<div className="bg-gray-50 rounded-lg p-3 text-center">
|
||||
<div className="text-2xl font-bold text-gray-900">{stats.total_responses}</div>
|
||||
<div className="text-xs text-gray-500">Antworten</div>
|
||||
</div>
|
||||
<div className="bg-gray-50 rounded-lg p-3 text-center">
|
||||
<div className="text-2xl font-bold text-purple-600">{stats.progress}%</div>
|
||||
<div className="text-xs text-gray-500">Fortschritt</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<span className="text-sm text-gray-500">Join-Code: <code className="bg-gray-100 px-2 py-0.5 rounded font-mono">{session.join_code}</code></span>
|
||||
<span className="text-sm text-gray-500">Typ: {typeLabels[session.session_type]}</span>
|
||||
<span className="text-sm text-gray-500">Schritt {session.current_step}/{session.total_steps}</span>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2">
|
||||
{session.status === 'DRAFT' && (
|
||||
<button onClick={() => handleLifecycle('start')} className="px-3 py-1.5 text-sm bg-green-600 text-white rounded-lg hover:bg-green-700">Starten</button>
|
||||
)}
|
||||
{session.status === 'ACTIVE' && (
|
||||
<button onClick={() => handleLifecycle('pause')} className="px-3 py-1.5 text-sm bg-yellow-600 text-white rounded-lg hover:bg-yellow-700">Pausieren</button>
|
||||
)}
|
||||
{(session.status === 'ACTIVE' || session.status === 'PAUSED') && (
|
||||
<button onClick={() => handleLifecycle('complete')} className="px-3 py-1.5 text-sm bg-purple-600 text-white rounded-lg hover:bg-purple-700">Abschliessen</button>
|
||||
)}
|
||||
<button onClick={handleExport} className="px-3 py-1.5 text-sm border border-gray-300 text-gray-700 rounded-lg hover:bg-gray-50">Exportieren</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tabs */}
|
||||
<div className="flex gap-1 mb-4 bg-gray-100 p-1 rounded-lg">
|
||||
{(['participants', 'responses', 'comments'] as const).map(tab => (
|
||||
<button key={tab} onClick={() => setActiveTab(tab)}
|
||||
className={`flex-1 px-4 py-2 text-sm rounded-md transition-colors ${activeTab === tab ? 'bg-white text-gray-900 shadow-sm' : 'text-gray-600 hover:text-gray-900'}`}>
|
||||
{tab === 'participants' ? `Teilnehmer (${participants.length})` :
|
||||
tab === 'responses' ? `Antworten (${responses.length})` :
|
||||
`Kommentare (${comments.length})`}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className="text-center py-8 text-gray-500">Laden...</div>
|
||||
) : (
|
||||
<>
|
||||
{activeTab === 'participants' && (
|
||||
<div className="bg-white rounded-xl border border-gray-200 overflow-hidden">
|
||||
<table className="w-full">
|
||||
<thead className="bg-gray-50">
|
||||
<tr>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Name</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Rolle</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Abteilung</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Status</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Beigetreten</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-200">
|
||||
{participants.map(p => (
|
||||
<tr key={p.id} className="hover:bg-gray-50">
|
||||
<td className="px-4 py-3">
|
||||
<div className="font-medium text-gray-900">{p.name}</div>
|
||||
<div className="text-xs text-gray-500">{p.email}</div>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<span className={`px-2 py-0.5 text-xs rounded-full ${roleColors[p.role] || 'bg-gray-100 text-gray-700'}`}>
|
||||
{p.role}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sm text-gray-600">{p.department || '-'}</td>
|
||||
<td className="px-4 py-3">
|
||||
<span className={`inline-block w-2 h-2 rounded-full ${p.is_active ? 'bg-green-500' : 'bg-gray-300'}`} />
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sm text-gray-500">
|
||||
{new Date(p.joined_at).toLocaleDateString('de-DE')}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
{participants.length === 0 && (
|
||||
<tr><td colSpan={5} className="px-4 py-8 text-center text-gray-500">Keine Teilnehmer</td></tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'responses' && (
|
||||
<div className="space-y-3">
|
||||
{responses.map(r => (
|
||||
<div key={r.id} className="bg-white rounded-lg border border-gray-200 p-4">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-sm font-medium text-gray-900">Schritt {r.step_number} / {r.field_id}</span>
|
||||
<span className={`px-2 py-0.5 text-xs rounded-full ${
|
||||
r.response_status === 'SUBMITTED' ? 'bg-green-100 text-green-700' :
|
||||
r.response_status === 'REVIEWED' ? 'bg-purple-100 text-purple-700' :
|
||||
'bg-gray-100 text-gray-700'
|
||||
}`}>{r.response_status}</span>
|
||||
</div>
|
||||
<pre className="text-sm text-gray-600 bg-gray-50 p-2 rounded overflow-auto max-h-32">
|
||||
{typeof r.value === 'string' ? r.value : JSON.stringify(r.value, null, 2)}
|
||||
</pre>
|
||||
<div className="text-xs text-gray-400 mt-2">
|
||||
{new Date(r.created_at).toLocaleString('de-DE')}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{responses.length === 0 && (
|
||||
<div className="text-center py-8 text-gray-500">Keine Antworten</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'comments' && (
|
||||
<div className="space-y-3">
|
||||
{comments.map(c => (
|
||||
<div key={c.id} className={`bg-white rounded-lg border p-4 ${c.is_resolved ? 'border-green-200' : 'border-gray-200'}`}>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
{c.step_number != null && <span className="text-xs text-gray-500">Schritt {c.step_number}</span>}
|
||||
{c.is_resolved && <span className="text-xs text-green-600">Geloest</span>}
|
||||
</div>
|
||||
<p className="text-sm text-gray-700">{c.text}</p>
|
||||
<div className="text-xs text-gray-400 mt-2">
|
||||
{new Date(c.created_at).toLocaleString('de-DE')}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{comments.length === 0 && (
|
||||
<div className="text-center py-8 text-gray-500">Keine Kommentare</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// MAIN PAGE
|
||||
// =============================================================================
|
||||
import { useState } from 'react'
|
||||
import { WorkshopSession, statusLabels } from './_components/types'
|
||||
import { SessionCard } from './_components/SessionCard'
|
||||
import { CreateSessionModal } from './_components/CreateSessionModal'
|
||||
import { SessionDetailView } from './_components/SessionDetailView'
|
||||
import { useWorkshopSessions } from './_hooks/useWorkshopSessions'
|
||||
|
||||
export default function WorkshopPage() {
|
||||
const [sessions, setSessions] = useState<WorkshopSession[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const { sessions, loading, loadSessions, handleDelete } = useWorkshopSessions()
|
||||
const [showCreate, setShowCreate] = useState(false)
|
||||
const [selectedSession, setSelectedSession] = useState<WorkshopSession | null>(null)
|
||||
const [filter, setFilter] = useState<string>('all')
|
||||
|
||||
const loadSessions = useCallback(async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const data = await api<WorkshopSession[] | { sessions: WorkshopSession[] }>('')
|
||||
const list = Array.isArray(data) ? data : (data.sessions || [])
|
||||
setSessions(list)
|
||||
} catch (err) {
|
||||
console.error('Load sessions error:', err)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => { loadSessions() }, [loadSessions])
|
||||
|
||||
const handleDelete = async (id: string) => {
|
||||
if (!confirm('Workshop wirklich loeschen?')) return
|
||||
try {
|
||||
await api(`/${id}`, { method: 'DELETE' })
|
||||
setSessions(prev => prev.filter(s => s.id !== id))
|
||||
} catch (err) {
|
||||
console.error('Delete error:', err)
|
||||
}
|
||||
}
|
||||
|
||||
const filteredSessions = filter === 'all'
|
||||
? sessions
|
||||
: sessions.filter(s => s.status === filter)
|
||||
|
||||
Reference in New Issue
Block a user