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:
Sharang Parnerkar
2026-04-16 13:14:28 +02:00
parent 8044ddb776
commit 87f2ce9692
19 changed files with 1294 additions and 1404 deletions

View File

@@ -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 &lt; 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 &gt; 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>
)
}

View File

@@ -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>
)
}

View File

@@ -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>
)
}

View File

@@ -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>
)
}

View File

@@ -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>
)
}

View File

@@ -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
}

View File

@@ -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 &lt; 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 &gt; 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>

View File

@@ -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>
)
}

View File

@@ -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>
)
}

View File

@@ -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>
)
}

View File

@@ -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>
)
}

View File

@@ -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>
)
}

View File

@@ -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>
)
}

View File

@@ -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>
)
}

View File

@@ -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>
)
}

View 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',
}

View 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()
}

View File

@@ -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 }
}

View File

@@ -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)